diff --git a/shelfmark/core/queue.py b/shelfmark/core/queue.py
index e25cf26c2..89ffdd13b 100644
--- a/shelfmark/core/queue.py
+++ b/shelfmark/core/queue.py
@@ -198,6 +198,11 @@ def handoffs_since(self, since: float) -> list[DownloadTask]:
with self._lock:
return [task for at, task in self._handoffs.values() if at >= since]
+ def handoff_times_since(self, since: float) -> list[tuple[float, DownloadTask]]:
+ """`handoffs_since`, with when each task was handed off."""
+ with self._lock:
+ return [(at, task) for at, task in self._handoffs.values() if at >= since]
+
def forget_handoff(self, task_id: str) -> None:
"""Drop a task's handoff: it never reached the indexer (refused at grab)."""
with self._lock:
diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py
index 7a30a8a72..61281f2a2 100644
--- a/shelfmark/download/orchestrator.py
+++ b/shelfmark/download/orchestrator.py
@@ -1113,8 +1113,11 @@ def _finalize_download_failure(task_id: str) -> None:
# marked as waiting, and is released oldest first once a slot is free: checked before
# each MAM grab, and after a grab fails while the account is at the limit.
-UNSAT_WAIT_MESSAGE = "Waiting for an unsatisfied slot on MyAnonamouse"
-_UNSAT_RECHECK_SECONDS = 5 * 60
+_UNSAT_RECHECK_SECONDS = 2 * 60
+UNSAT_WAIT_MESSAGE = (
+ "Waiting for an unsatisfied slot on MyAnonamouse (checked every "
+ f"{_UNSAT_RECHECK_SECONDS // 60} minutes)"
+)
_held_lock = Lock()
_held_for_slot: dict[str, float] = {} # task id -> when it started waiting
_slot_watcher: threading.Thread | None = None
diff --git a/shelfmark/release_sources/prowlarr/mam_account.py b/shelfmark/release_sources/prowlarr/mam_account.py
index ae05228d5..8c57bd54b 100644
--- a/shelfmark/release_sources/prowlarr/mam_account.py
+++ b/shelfmark/release_sources/prowlarr/mam_account.py
@@ -45,6 +45,11 @@
# How far behind MAM's unsatisfied count may be: snatches this long before the stats
# were read are counted as Shelfmark's own too.
_UNSAT_LAG_SECONDS = 15 * 60
+# MAM's unsatisfied count as read, (when, count), oldest first: to tell how many of
+# Shelfmark's recent snatches it already shows (see `_uncounted_snatches`).
+_UNSAT_SAMPLE_SECONDS = 2 * 3600
+_unsat_samples: list[tuple[float, int]] = []
+_unsat_lock = threading.Lock()
_GIB = 1024**3
_SIZE_RE = re.compile(r"^\s*([-+]?\d+(?:\.\d+)?)\s*([KMGTP]?i?B)?\s*$", re.IGNORECASE)
_SIZE_UNITS = {"": _GIB, "B": 1, "K": 1024, "M": 1024**2, "G": _GIB, "T": 1024**4, "P": 1024**5}
@@ -234,12 +239,48 @@ def get_stats(*, refresh: bool = False) -> MamStats:
stats = parse_stats(client.get_user_data())
with _cache_lock:
_cached = (mam_id, stats)
+ _record_unsat(stats)
from shelfmark.release_sources.prowlarr import mam_points
mam_points.record_sample(stats.seedbonus, at=stats.fetched_at)
return stats
+def _record_unsat(stats: MamStats) -> None:
+ if stats.unsat_count is None:
+ return
+ with _unsat_lock:
+ _unsat_samples.append((stats.fetched_at, stats.unsat_count))
+ cutoff = stats.fetched_at - _UNSAT_SAMPLE_SECONDS
+ _unsat_samples[:] = [sample for sample in _unsat_samples if sample[0] >= cutoff]
+
+
+def _unsat_count_before(at: float) -> int | None:
+ """MAM's unsatisfied count as last read at or before `at`, if it was."""
+ with _unsat_lock:
+ earlier = [count for read_at, count in _unsat_samples if read_at <= at]
+ return earlier[-1] if earlier else None
+
+
+def _uncounted_snatches(handoffs: list[float], unsat_count: int | None) -> int:
+ """How many of Shelfmark's recent snatches (handed off at `handoffs`) MAM's
+ `unsat_count` doesn't show yet.
+
+ MAM's count lags its snatches, so recent ones are added to it, but once it has
+ caught up that counts them twice: a run that filled the slots then saw them full for
+ the whole lag allowance (15 minutes), with slots free. So: however far MAM's count
+ has risen since just before the first of them, that many are in it already. Counted
+ in full when there's no reading from before them (as after a restart). Torrents
+ leaving the count meanwhile (satisfied) only make this count more, never fewer.
+ """
+ if not handoffs:
+ return 0
+ baseline = _unsat_count_before(min(handoffs)) if unsat_count is not None else None
+ if unsat_count is None or baseline is None:
+ return len(handoffs)
+ return max(0, len(handoffs) - max(0, unsat_count - baseline))
+
+
def invalidate_stats() -> None:
"""Drop cached stats so the next read asks MAM again."""
global _cached
@@ -518,7 +559,9 @@ def pending_charge_bytes() -> int:
return sum(task.mam_charge_bytes or 0 for _status, task in _active_mam_tasks())
-def pending_unsat_count(stats_fetched_at: float | None = None) -> int:
+def pending_unsat_count(
+ stats_fetched_at: float | None = None, unsat_count: int | None = None
+) -> int:
"""Shelfmark's MAM torrents that the account's unsatisfied count may not show yet.
MAM counts a torrent as unsatisfied from when it is snatched, but the stats are read
@@ -538,8 +581,12 @@ def pending_unsat_count(stats_fetched_at: float | None = None) -> int:
if task.mam_torrent_id and status in not_started
}
since = (stats_fetched_at if stats_fetched_at is not None else time.time()) - _UNSAT_LAG_SECONDS
- counted |= {task.task_id for task in book_queue.handoffs_since(since) if task.mam_torrent_id}
- return len(counted)
+ snatched = [
+ at
+ for at, task in book_queue.handoff_times_since(since)
+ if task.mam_torrent_id and task.task_id not in counted
+ ]
+ return len(counted) + _uncounted_snatches(snatched, unsat_count)
def unsat_free_slots(
@@ -564,12 +611,13 @@ def unsat_free_slots(
if stats.unsat_count is None or stats.unsat_limit is None:
return None
since = stats.fetched_at - _UNSAT_LAG_SECONDS
- snatched = {
- task.task_id
- for task in book_queue.handoffs_since(since)
+ snatched = [
+ at
+ for at, task in book_queue.handoff_times_since(since)
if task.mam_torrent_id and task.task_id not in exclude
- }
- return stats.unsat_limit - unsat_reserve_slots() - stats.unsat_count - len(snatched)
+ ]
+ uncounted = _uncounted_snatches(snatched, stats.unsat_count)
+ return stats.unsat_limit - unsat_reserve_slots() - stats.unsat_count - uncounted
def recommended_purchase_gb(missing_bytes: int) -> int:
@@ -641,7 +689,7 @@ def check_buffer(releases: list[dict[str, Any]]) -> BufferCheck:
missing = request_bytes + pending - stats.buffer_bytes if check_buffer_size else 0
recommended = recommended_purchase_gb(missing) if missing > 0 else 0
- unsat_pending = pending_unsat_count(stats.fetched_at)
+ unsat_pending = pending_unsat_count(stats.fetched_at, stats.unsat_count)
reserve = unsat_reserve_slots()
unsat_ok = True
if check_unsat and stats.unsat_count is not None and stats.unsat_limit is not None:
@@ -722,6 +770,6 @@ def ratio_snapshot() -> dict[str, Any]:
"warning_ratio": warning_ratio(),
"unsat_count": stats.unsat_count,
"unsat_limit": stats.unsat_limit,
- "unsat_pending": pending_unsat_count(stats.fetched_at),
+ "unsat_pending": pending_unsat_count(stats.fetched_at, stats.unsat_count),
"unsat_reserve": unsat_reserve_slots(),
}
diff --git a/src/frontend/src/components/activity/ActivityItemParts.tsx b/src/frontend/src/components/activity/ActivityItemParts.tsx
index f6e61dd4e..89aa7ae85 100644
--- a/src/frontend/src/components/activity/ActivityItemParts.tsx
+++ b/src/frontend/src/components/activity/ActivityItemParts.tsx
@@ -176,6 +176,36 @@ export const sourcePageName = (url: string): string => {
}
};
+/** A release's page at its source (the MAM torrent listing), opened in a new tab. */
+export const SourcePageLink = ({ url, size = 'md' }: { url: string; size?: 'sm' | 'md' }) => (
+
+
+
+
+
+);
+
/** The status badges, with a download's progress filling its badge. */
export const ActivityStatusBadges = ({
badges,
@@ -314,32 +344,7 @@ export const ActivityLinkButtons = ({
)}
- {item.infoUrl && (
-
-
-
-
-
- )}
+ {item.infoUrl && }
{canShowDownloadLink && item.downloadBookId && (
- {lines.map((line, index) => (
- // Lines can repeat ("Audiobook" twice): the index tells them apart.
- // eslint-disable-next-line react/no-array-index-key
-
- {line}
-
- ))}
+ {lines.map((line, index) => {
+ // Lines line up with the picks; each links to its release's page at the source.
+ const url = item.releases[index]?.release.info_url;
+ return (
+ // Lines can repeat ("Audiobook" twice): the index tells them apart.
+ // eslint-disable-next-line react/no-array-index-key
+
+ {line}
+ {url && }
+
+ );
+ })}
);
};
diff --git a/tests/prowlarr/test_mam_account.py b/tests/prowlarr/test_mam_account.py
index aff2cf651..676cb4d49 100644
--- a/tests/prowlarr/test_mam_account.py
+++ b/tests/prowlarr/test_mam_account.py
@@ -504,6 +504,12 @@ def __init__(self, task_id: str, mam_torrent_id: int | None) -> None:
self.mam_torrent_id = mam_torrent_id
+@pytest.fixture(autouse=True)
+def no_unsat_readings(monkeypatch):
+ """Each test starts with no record of MAM's unsatisfied count."""
+ monkeypatch.setattr(mam_account, "_unsat_samples", [])
+
+
class TestUnsatisfied:
@pytest.mark.parametrize("nested", [True, False])
def test_parsed_from_either_shape(self, nested):
@@ -556,7 +562,7 @@ def task(task_id: str, mam_id: int | None) -> DownloadTask:
status[QueueStatus.RESOLVING] = {"c": task("c", 2)}
status[QueueStatus.DOWNLOADING] = {"d": task("d", 3)} # already in MAM's count
monkeypatch.setattr(book_queue, "get_status", lambda: status)
- monkeypatch.setattr(book_queue, "handoffs_since", lambda _since: [])
+ monkeypatch.setattr(book_queue, "handoff_times_since", lambda _since: [])
assert mam_account.pending_unsat_count() == 2
@@ -592,7 +598,9 @@ def test_free_slots_count_recent_snatches_but_not_the_asker(self, mam, monkeypat
SimpleTask("asker", 2),
SimpleTask("not-mam", None),
]
- monkeypatch.setattr(book_queue, "handoffs_since", lambda _since: snatched)
+ monkeypatch.setattr(
+ book_queue, "handoff_times_since", lambda _since: [(time.time(), t) for t in snatched]
+ )
# 100 - 3 kept free - 90 - "a" (the asker doesn't count against itself)
assert mam_account.unsat_free_slots(exclude={"asker"}) == 6
monkeypatch.setattr(mam_account, "unsat_check_enabled", lambda: False)
@@ -611,3 +619,36 @@ def test_download_route_queues_a_download_that_waits_for_a_slot(
assert resp.status_code == 200
assert resp.get_json()["waiting_for_slot"] is True
qr.assert_called_once()
+
+
+class TestRecentSnatchesAlreadyCounted:
+ """Recent snatches MAM's count already shows aren't counted twice."""
+
+ def _snatch(self, monkeypatch, count: int, *, at: float):
+ from shelfmark.core.queue import book_queue
+
+ tasks = [(at, SimpleTask(f"t{i}", i + 1)) for i in range(count)]
+ monkeypatch.setattr(book_queue, "handoff_times_since", lambda _since: tasks)
+
+ def test_counts_only_the_snatches_mam_does_not_show_yet(self, mam, monkeypatch):
+ monkeypatch.setattr(mam_account, "unsat_reserve_slots", lambda: 5)
+ mam.user = _unsat_user(119, 150)
+ before = mam_account.get_stats(refresh=True) # Read before the snatches
+ self._snatch(monkeypatch, 13, at=before.fetched_at)
+
+ # MAM's count hasn't moved yet: all 13 take slots. 150 - 5 - 119 - 13.
+ assert mam_account.unsat_free_slots(refresh=True) == 13
+ # It shows 7 of them now: the other 6 still take slots.
+ mam.user = _unsat_user(126, 150)
+ assert mam_account.unsat_free_slots(refresh=True) == 150 - 5 - 126 - 6
+ # It shows all 13 (132 of 150): 13 slots free, not 0.
+ mam.user = _unsat_user(132, 150)
+ stats = mam_account.get_stats(refresh=True)
+ assert mam_account.unsat_free_slots() == 13
+ assert mam_account.pending_unsat_count(stats.fetched_at, stats.unsat_count) == 0
+
+ def test_without_an_earlier_reading_every_snatch_counts(self, mam, monkeypatch):
+ monkeypatch.setattr(mam_account, "unsat_reserve_slots", lambda: 5)
+ self._snatch(monkeypatch, 13, at=time.time() - 60)
+ mam.user = _unsat_user(132, 150)
+ assert mam_account.unsat_free_slots(refresh=True) == 0