From a93769237165b0c16c40ee7ef628209b10d57178 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:18:41 +0000 Subject: [PATCH 1/2] feat: link queued items to their source page Items on the Queued page get the same "Open on MyAnonamouse" button as History, opening the release's page at its source (the MAM torrent listing) in a new tab: - Downloads up next have it beside Remove. - Saved picks waiting for room have one beside each release line, since a book can have several releases picked. The Wanted page gets them too, for books whose releases are already picked. The button is now a shared SourcePageLink, used by History as well. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V88DbAixT45r3AJsyFJchY --- .../components/activity/ActivityItemParts.tsx | 57 ++++++++++--------- .../src/components/activity/DownloadQueue.tsx | 22 ++++--- .../src/components/activity/SavedPanel.tsx | 20 ++++--- 3 files changed, 57 insertions(+), 42 deletions(-) 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 && ( - {onCancel && bookId && ( - - )} +
+ {item.infoUrl && } + {onCancel && bookId && ( + + )} +
); diff --git a/src/frontend/src/components/activity/SavedPanel.tsx b/src/frontend/src/components/activity/SavedPanel.tsx index dc7c72d80..74e8630a6 100644 --- a/src/frontend/src/components/activity/SavedPanel.tsx +++ b/src/frontend/src/components/activity/SavedPanel.tsx @@ -26,6 +26,7 @@ import { tableClassName, type SortState, } from '../shared/DataTable'; +import { SourcePageLink } from './ActivityItemParts'; import { DragHandle, QueueMoveButtons, @@ -110,13 +111,18 @@ const ReleaseLines = ({ item }: { item: SavedItem }) => { const full = savedPickLines(item, { full: true }); return (
    - {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 && } +
  • + ); + })}
); }; From 360d4a43a51163bf1e2490afa1df45857e96d978 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:25:18 +0000 Subject: [PATCH 2/2] fix: don't count recent MAM snatches twice when finding free slots MAM's unsatisfied count lags its snatches, so Shelfmark adds the torrents it handed off in the last 15 minutes to it. Once MAM's count had caught up, they were counted twice. A run that filled the free slots then saw the account as full for the rest of those 15 minutes: at 132 of 150 with 5 kept free, it worked out 150 - 5 - 132 - 13 = 0, and the downloads up next waited although 13 slots were free. They were only re-checked every 5 minutes, so the queue moved in bursts with long stalls. Shelfmark now keeps the readings of MAM's unsatisfied count it takes. However far the count has risen since the reading just before the recent snatches, that many of them are already in it, and only the rest are added. With no earlier reading (after a restart), all of them still count. Torrents leaving the count meanwhile only make it count more, and the kept-free slots cover snatches made outside Shelfmark. Downloads waiting for a slot are re-checked every 2 minutes instead of 5, and their status says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V88DbAixT45r3AJsyFJchY --- shelfmark/core/queue.py | 5 ++ shelfmark/download/orchestrator.py | 7 +- .../release_sources/prowlarr/mam_account.py | 68 ++++++++++++++++--- tests/prowlarr/test_mam_account.py | 45 +++++++++++- 4 files changed, 111 insertions(+), 14 deletions(-) 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/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