From 371cd8b30f9753637e191e13f3f3a9c8c0097411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:43:54 +0000 Subject: [PATCH 1/2] fix: non-freeleech audiobooks wait for the target ratio A non-freeleech download that would take the ratio below "Keep Ratio At Least" went anyway if it lowered the ratio by less than 0.01, an allowance meant for ebooks. But the drop scales with the account: at 92.27 GiB up and 73.35 GiB down (1.26), a 539 MB audiobook lowers the ratio by only 0.009, and anything up to about 600 MiB got through. Each was checked against the ratio the ones before it had already lowered, so queued audiobooks went one after another while the ratio sank below the target. The allowance now also needs the download to be ebook-sized (100 MB or less). Larger non-freeleech downloads wait until the ratio would stay at the target, or they turn freeleech. The setting's description says so. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V88DbAixT45r3AJsyFJchY --- shelfmark/core/saved_autoget.py | 16 +++++++++++----- .../release_sources/prowlarr/settings.py | 3 ++- tests/core/test_saved_autoget.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/shelfmark/core/saved_autoget.py b/shelfmark/core/saved_autoget.py index d02b604ed..d9504d002 100644 --- a/shelfmark/core/saved_autoget.py +++ b/shelfmark/core/saved_autoget.py @@ -11,9 +11,9 @@ - the ratio stays healthy, by one rule for every item rather than per-item settings: freeleech torrents (checked live on MAM) never touch the ratio, so they go as soon as there is room; anything else goes when the ratio afterwards is still at the target - (``SAVED_AUTO_GET_TARGET_RATIO``), or when it is small enough to barely move the ratio - (an ebook, say). The rest waits until the ratio recovers or the torrent turns - freeleech, whichever comes first. + (``SAVED_AUTO_GET_TARGET_RATIO``), or when it is an ebook-sized download (up to + ``SMALL_DOWNLOAD_BYTES``) that barely moves the ratio. The rest waits until the ratio + recovers or the torrent turns freeleech, whichever comes first. Items wait in order of saving, so earlier ones get the free slots first. Every check records why an item is still waiting, shown in the Saved tab. @@ -49,7 +49,11 @@ _STARTUP_DELAY_SECONDS = 90 _SOON_DELAY_SECONDS = 5 _DEFAULT_TARGET_RATIO = 2.0 -# A download that lowers the ratio by less than this goes even below the target. +# A small download (an ebook) that lowers the ratio by less than SMALL_RATIO_DROP goes even +# below the target. Measured by size too, not by the drop alone: at 92 GiB up and 73 GiB +# down (1.26), a 539 MB audiobook lowers the ratio by only 0.009, so each one went, one +# after another, while the ratio sank below the target. +SMALL_DOWNLOAD_BYTES = 100 * 1024**2 SMALL_RATIO_DROP = 0.01 @@ -132,7 +136,9 @@ def _ratio_wait(stats: mam_account.MamStats, charge: int) -> str | None: downloaded = stats.downloaded_bytes + mam_account.pending_charge_bytes() before = _ratio(stats.uploaded_bytes, downloaded) after = _ratio(stats.uploaded_bytes, downloaded + charge) - if after >= target or before - after < SMALL_RATIO_DROP: + if after >= target: + return None + if charge <= SMALL_DOWNLOAD_BYTES and before - after < SMALL_RATIO_DROP: return None return ( f"Waiting for freeleech or ratio {target:.2f}: {_size(charge)} would take it " diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 01e22ecb0..701c0998e 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -350,7 +350,8 @@ def prowlarr_config_settings() -> list[SettingsField]: label="Keep Ratio At Least", description=( "Non-freeleech downloads that would take the ratio below this wait, unless " - "they'd lower it by less than 0.01. 0 turns the ratio check off." + "they're ebook-sized (100 MB or less) and lower it by less than 0.01. " + "0 turns the ratio check off." ), default=2.0, min_value=0, diff --git a/tests/core/test_saved_autoget.py b/tests/core/test_saved_autoget.py index c425b1328..b24e7f610 100644 --- a/tests/core/test_saved_autoget.py +++ b/tests/core/test_saved_autoget.py @@ -224,6 +224,25 @@ def test_a_small_download_goes_even_below_the_target_ratio(service, mam): assert SavedAutoGetter(service, recorder.hooks()).run_check().queued == [item_id] +def test_an_audiobook_waits_below_the_target_though_it_barely_moves_the_ratio(service, mam): + # 92.27 GiB up / 73.35 GiB down (1.26): a 539 MB audiobook lowers that by only 0.009, + # but it isn't an ebook, so it waits for the ratio (or freeleech) like any other. + mam.stats = _stats(uploaded=int(92.27 * GIB), downloaded=int(73.35 * GIB)) + recorder = Recorder() + item_id = _mark(service, [_payload("audiobook", size=539 * 1000**2)]) + report = SavedAutoGetter(service, recorder.hooks()).run_check() + assert report.queued == [] + assert report.waiting[item_id].startswith("Waiting for freeleech or ratio 2.00") + assert recorder.queued == [] + # An ebook still goes: 5 MB moves it by well under 0.01. + ebook_id = _mark( + service, + [_payload("ebook", torrent_id=9, size=5 * 1024**2)], + book={**BOOK, "provider_id": "43", "title": "Ebook"}, + ) + assert SavedAutoGetter(service, recorder.hooks()).run_check().queued == [ebook_id] + + def test_a_large_download_waits_for_the_ratio_or_freeleech(service, mam): # 300 up / 100 down; a 60 GB download leaves 300 / 160 = 1.88. mam.stats = _stats(uploaded=300 * GIB, downloaded=100 * GIB) From 94db2302d5aba2e22b8665e94669e89e49d20c49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:51:54 +0000 Subject: [PATCH 2/2] feat: show audiobook narrators on Downloads, History and Up next Two audiobooks of the same book (Martin Jarvis's and the full cast's Good Omens) looked the same in the lists. Each now has a "Narrated by" line under its author, e.g. "Narrated by Bill Nighy +2", with every name on hover. Ebooks, which have no narrators, are unchanged. The queue status now includes a download's narrators. History takes them from the data kept for a retry, so downloads recorded before this show them too. Saved picks already name their narrators in the Releases column. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01V88DbAixT45r3AJsyFJchY --- shelfmark/core/download_history_service.py | 10 ++++++++++ shelfmark/download/orchestrator.py | 1 + .../components/activity/ActivityItemParts.tsx | 9 +++++++++ .../src/components/activity/ActivityTable.tsx | 2 ++ .../src/components/activity/DownloadQueue.tsx | 3 ++- .../components/activity/activityMappers.ts | 2 ++ .../src/components/activity/activityTypes.ts | 1 + .../src/tests/activityNarrators.test.ts | 20 +++++++++++++++++++ src/frontend/src/types/index.ts | 1 + tests/core/test_download_history_service.py | 12 +++++++++++ tests/download/test_orchestrator_retry.py | 9 +++++++++ 11 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/tests/activityNarrators.test.ts diff --git a/shelfmark/core/download_history_service.py b/shelfmark/core/download_history_service.py index 48f637cd4..875603077 100644 --- a/shelfmark/core/download_history_service.py +++ b/shelfmark/core/download_history_service.py @@ -201,6 +201,7 @@ def to_download_payload(row: dict[str, Any]) -> dict[str, Any]: "id": row.get("task_id"), "title": row.get("title"), "author": row.get("author"), + "narrators": DownloadHistoryService._narrators(row), "format": row.get("format"), "size": row.get("size"), "preview": row.get("preview"), @@ -221,6 +222,15 @@ def to_download_payload(row: dict[str, Any]) -> dict[str, Any]: "retry_available": DownloadHistoryService.is_retry_available(row), } + @classmethod + def _narrators(cls, row: dict[str, Any]) -> list[str]: + """The release's narrators, as kept with the download for a retry.""" + payload = cls._deserialize_retry_payload(row.get("retry_payload")) + narrators = payload.get("narrators") if payload else None + if not isinstance(narrators, list): + return [] + return [str(name) for name in narrators if isinstance(name, str) and name.strip()] + @classmethod def _mam_page(cls, row: dict[str, Any]) -> str | None: """The MyAnonamouse page of a download recorded before pages were kept.""" diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 61281f2a2..26c2a0b91 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -737,6 +737,7 @@ def _task_to_dict( "id": task.task_id, "title": task.title, "author": task.author, + "narrators": list(task.narrators or []), "format": task.format, "size": task.size, "downloads": task.downloads, diff --git a/src/frontend/src/components/activity/ActivityItemParts.tsx b/src/frontend/src/components/activity/ActivityItemParts.tsx index 89aa7ae85..8112e2631 100644 --- a/src/frontend/src/components/activity/ActivityItemParts.tsx +++ b/src/frontend/src/components/activity/ActivityItemParts.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react'; import { useLayoutEffect, useRef, useState } from 'react'; import { withBasePath } from '../../utils/basePath'; +import { shortNarrators } from '../../utils/narrators'; import { LibraryAppIcon } from '../shared/LibraryAppIcon'; import { Tooltip } from '../shared/Tooltip'; import type { ActivityCardAction, ActivityCardBadge } from './activityCardModel'; @@ -176,6 +177,14 @@ export const sourcePageName = (url: string): string => { } }; +/** "Narrated by Tony Robinson +2" under an audiobook's author; nothing without narrators. */ +export const NarratorLine = ({ narrators }: { narrators?: readonly string[] }) => + narrators && narrators.length > 0 ? ( +

+ Narrated by {shortNarrators(narrators)} +

+ ) : null; + /** 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' }) => ( diff --git a/src/frontend/src/components/activity/ActivityTable.tsx b/src/frontend/src/components/activity/ActivityTable.tsx index 92192c3b4..7466d0b5e 100644 --- a/src/frontend/src/components/activity/ActivityTable.tsx +++ b/src/frontend/src/components/activity/ActivityTable.tsx @@ -19,6 +19,7 @@ import { ActivityActionButton, ActivityLinkButtons, ActivityStatusBadges, + NarratorLine, actionKey, } from './ActivityItemParts'; import type { ActivityItem } from './activityTypes'; @@ -133,6 +134,7 @@ const ActivityRow = ({ {item.author}

)} + {model.noteLine && (

{model.noteLine} diff --git a/src/frontend/src/components/activity/DownloadQueue.tsx b/src/frontend/src/components/activity/DownloadQueue.tsx index 240173b35..594064f78 100644 --- a/src/frontend/src/components/activity/DownloadQueue.tsx +++ b/src/frontend/src/components/activity/DownloadQueue.tsx @@ -10,7 +10,7 @@ import { rowClassName, tableClassName, } from '../shared/DataTable'; -import { SourcePageLink } from './ActivityItemParts'; +import { NarratorLine, SourcePageLink } from './ActivityItemParts'; import type { ActivityItem } from './activityTypes'; import { DragHandle, @@ -149,6 +149,7 @@ export const DownloadQueue = ({ {item.author}

)} + {item.statusDetail || 'Waiting to start'} diff --git a/src/frontend/src/components/activity/activityMappers.ts b/src/frontend/src/components/activity/activityMappers.ts index cf1987435..9209e0e91 100644 --- a/src/frontend/src/components/activity/activityMappers.ts +++ b/src/frontend/src/components/activity/activityMappers.ts @@ -1,5 +1,6 @@ import type { Book, RequestRecord, StatusData } from '../../types'; import { getDownloadsCount } from '../../types'; +import { narratorNames } from '../../utils/narrators'; import { STATUS_LABELS, isActiveDownloadStatus } from './activityStyles.js'; import type { ActivityItem, ActivityVisualStatus } from './activityTypes'; @@ -110,6 +111,7 @@ export const downloadToActivityItem = (book: Book, statusKey: DownloadStatusKey) visualStatus, title: toText(book.title, 'Unknown title'), author: toText(book.author, 'Unknown author'), + narrators: narratorNames(book.narrators), preview: toOptionalText(book.preview), metaLine, statusLabel: STATUS_LABELS[visualStatus], diff --git a/src/frontend/src/components/activity/activityTypes.ts b/src/frontend/src/components/activity/activityTypes.ts index 2fa77c2aa..76c23a970 100644 --- a/src/frontend/src/components/activity/activityTypes.ts +++ b/src/frontend/src/components/activity/activityTypes.ts @@ -21,6 +21,7 @@ export interface ActivityItem { title: string; author: string; + narrators?: string[]; // An audiobook's narrators, to tell editions apart preview?: string; metaLine: string; diff --git a/src/frontend/src/tests/activityNarrators.test.ts b/src/frontend/src/tests/activityNarrators.test.ts new file mode 100644 index 000000000..bfb1382f8 --- /dev/null +++ b/src/frontend/src/tests/activityNarrators.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { downloadToActivityItem } from '../components/activity/activityMappers'; +import type { Book } from '../types'; + +const book = (fields: Partial): Book => ({ + id: 'b', + title: 'Good Omens', + author: 'A', + ...fields, +}); + +describe('download narrators', () => { + it("carries an audiobook's narrators, to tell editions apart", () => { + expect( + downloadToActivityItem(book({ narrators: ['Martin Jarvis'] }), 'queued').narrators, + ).toEqual(['Martin Jarvis']); + expect(downloadToActivityItem(book({}), 'complete').narrators).toEqual([]); + }); +}); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 03ae9e8c4..db61292e3 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -32,6 +32,7 @@ export interface Book { status_message?: string; // Detailed status message (e.g., "Trying Libgen (2/5)") added_time?: number; // Timestamp when added to queue priority?: number; // Downloads: place in the queue, lower first + narrators?: string[] | null; // Downloads: the release's narrators (audiobooks) content_type?: string; // "ebook", "audiobook", or related book subtype book_key?: string | null; // Downloads: the metadata book it was got for ("hardcover:42") info_url?: string | null; // Downloads: the release's page at its source (MAM torrent page) diff --git a/tests/core/test_download_history_service.py b/tests/core/test_download_history_service.py index 7b29ec7d3..c4427b1a9 100644 --- a/tests/core/test_download_history_service.py +++ b/tests/core/test_download_history_service.py @@ -50,3 +50,15 @@ def test_record_download_stores_utc_iso_timestamps(): assert row is not None assert "+00:00" in row["queued_at"] assert "+00:00" in row["terminal_at"] + + +def test_history_rows_carry_the_narrators_kept_for_a_retry(): + import json + + row = { + "task_id": "t1", + "title": "Good Omens", + "retry_payload": json.dumps({"narrators": ["Martin Jarvis", " "]}), + } + assert DownloadHistoryService.to_download_payload(row)["narrators"] == ["Martin Jarvis"] + assert DownloadHistoryService.to_download_payload({"task_id": "t2"})["narrators"] == [] diff --git a/tests/download/test_orchestrator_retry.py b/tests/download/test_orchestrator_retry.py index f1def1fe4..5ebf3cc8c 100644 --- a/tests/download/test_orchestrator_retry.py +++ b/tests/download/test_orchestrator_retry.py @@ -348,3 +348,12 @@ def test_os_error_message_names_the_reason(): orchestrator._format_download_exception_message(RuntimeError("boom")) == "Download failed: RuntimeError" ) + + +def test_queue_status_names_the_narrators(): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="n1", source="prowlarr", title="Good Omens", narrators=["Martin Jarvis"] + ) + assert orchestrator._task_to_dict(task, QueueStatus.QUEUED)["narrators"] == ["Martin Jarvis"]