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 && ( - {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 && } +
  • + ); + })}
); }; 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