diff --git a/shelfmark/core/saved_autoget.py b/shelfmark/core/saved_autoget.py index db8cf1d5c..b8564fa0d 100644 --- a/shelfmark/core/saved_autoget.py +++ b/shelfmark/core/saved_autoget.py @@ -24,6 +24,7 @@ import copy import math import threading +import time from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -239,6 +240,10 @@ def __init__(self, service: SavedItemsService, hooks: AutoGetHooks) -> None: self._thread: threading.Thread | None = None self._thread_lock = threading.Lock() self._soon: threading.Timer | None = None + # When the scheduled check and a pending quick check (`check_soon`) run, in epoch + # seconds, for the Queued page. + self._next_check_at: float | None = None + self._soon_at: float | None = None def run_check(self) -> RunReport: """Check every item marked for automatic download, oldest first.""" @@ -302,6 +307,7 @@ def _check_item(self, item: dict[str, Any], run: _RunContext, report: RunReport) def _loop(self) -> None: delay: float = _STARTUP_DELAY_SECONDS while True: + self._next_check_at = time.time() + delay self._wake.wait(delay) self._wake.clear() try: @@ -310,6 +316,15 @@ def _loop(self) -> None: logger.exception("Saved items automatic download check failed") delay = interval_minutes() * 60 + def next_check_at(self) -> float | None: + """When the next check is due (epoch seconds), or None when none is scheduled.""" + if not enabled(): + return None + with self._thread_lock: + running = self._thread is not None and self._thread.is_alive() + due = [at for at in (self._next_check_at, self._soon_at) if at is not None] + return min(due) if running and due else None + def start(self) -> None: """Start the background schedule (idempotent).""" with self._thread_lock: @@ -325,11 +340,14 @@ def check_soon(self) -> None: with self._thread_lock: if self._soon is not None and self._soon.is_alive(): return + self._soon_at = time.time() + _SOON_DELAY_SECONDS self._soon = threading.Timer(_SOON_DELAY_SECONDS, self._run_soon) self._soon.daemon = True self._soon.start() def _run_soon(self) -> None: + with self._thread_lock: + self._soon_at = None # Running now: the schedule says when the next one is try: self.run_check() except Exception: # a background check must not raise diff --git a/shelfmark/core/saved_routes.py b/shelfmark/core/saved_routes.py index 438640191..b6876e7da 100644 --- a/shelfmark/core/saved_routes.py +++ b/shelfmark/core/saved_routes.py @@ -46,8 +46,13 @@ def register_saved_routes( login_required: Callable[..., Any], resolve_auth_mode: Callable[[], str], on_auto_get: Callable[[], None] | None = None, + next_check_at: Callable[[], float | None] | None = None, ) -> None: - """Register the saved items API; `on_auto_get` runs when an item is marked.""" + """Register the saved items API. + + `on_auto_get` runs when an item is marked; `next_check_at` says when the automatic + check next runs (epoch seconds), shown beside the queued items. + """ @app.route("/api/saved", methods=["GET"]) @login_required @@ -55,7 +60,12 @@ def api_saved_list() -> Response | tuple[Response, int]: owner = _owner(resolve_auth_mode) if owner is None: return _no_owner() - return jsonify({"items": service.list_items(owner)}) + return jsonify( + { + "items": service.list_items(owner), + "next_check_at": next_check_at() if next_check_at is not None else None, + } + ) @app.route("/api/saved", methods=["POST"]) @login_required diff --git a/shelfmark/main.py b/shelfmark/main.py index d9fa0e651..91f76f0bf 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1248,6 +1248,7 @@ def _saved_notify(item: dict[str, Any], owner: SavedOwner) -> None: login_required, get_auth_mode, on_auto_get=saved_autogetter.check_soon, + next_check_at=saved_autogetter.next_check_at, ) saved_autogetter.start() diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 57c4fa822..7c095c2d8 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,5 +1,6 @@ """Prowlarr download handler - resolves releases and delegates lifecycle to shared clients.""" +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from urllib.parse import urlparse @@ -7,8 +8,9 @@ from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.naming import derive_primary_title from shelfmark.core.request_helpers import normalize_optional_text -from shelfmark.core.search_plan import build_release_search_plan +from shelfmark.core.search_plan import build_release_search_plan, pick_search_author from shelfmark.core.utils import normalize_http_url from shelfmark.download.clients import ( DownloadClient, @@ -89,6 +91,15 @@ def _coerce_positive_minutes(raw_minutes: object) -> int | None: return minutes if minutes > 0 else None +@dataclass(frozen=True) +class _RefreshSearch: + """One Prowlarr search for re-finding an expired release.""" + + book: BookMetadata + query: str | None = None # Searched as typed, like a manual search + expand: bool = False # Without the content-type categories + + @register_handler("prowlarr") class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" @@ -306,7 +317,14 @@ def _resolve_download( ) def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None: - """Re-query Prowlarr and cache the exact original release if it still exists.""" + """Re-query Prowlarr and cache the exact original release if it still exists. + + Searched the way the user would find it again (see `_refresh_searches`). A + search built only from the download's title can miss a release that is still + there: an indexer that ANDs its terms finds nothing for a long title with its + subtitle, and a short title ("Eric") brings back so many other books that the + release isn't among the results. That left the user to search by hand. + """ title = normalize_optional_text(task.title) if title is None: return None @@ -314,37 +332,103 @@ def _refresh_release(self, task: DownloadTask) -> dict[str, Any] | None: context = getattr(task, "retry_source_context", None) if not isinstance(context, dict): context = {} - indexer = normalize_optional_text(context.get("indexer")) + content_type = task.content_type or "ebook" + + source = ProwlarrSource() + for search in self._refresh_searches(task, title): + # No language default here on purpose: this re-finds one exact release by + # its guid, and Prowlarr does not filter on plan.languages anyway. + plan = build_release_search_plan( + search.book, + manual_query=search.query, + indexers=[indexer] if indexer is not None else None, + ) + results = source.search( + search.book, plan, expand_search=search.expand, content_type=content_type + ) + for release in results: + raw_release = get_release(release.source_id) + if raw_release is None: + continue + if not self._raw_release_matches_task(raw_release, task.task_id): + continue + + cache_release(task.task_id, raw_release) + logger.info("Refreshed Prowlarr release: %s", task.task_id) + return raw_release + + return None + + def _refresh_searches(self, task: DownloadTask, title: str) -> list[_RefreshSearch]: + """The searches that re-find a release, in order, stopping at the first hit. + + With the metadata provider's record of the book (when the download noted it): + the book's own release search, then its title and author as one query (for a + title so common the release is lost among other books), then without the + content-type categories. Without it: the download's title without its + subtitle, then title and author. + """ + provider_book = self._provider_book(task) + if provider_book is not None: + query = self._title_author_query(provider_book) + return [ + _RefreshSearch(provider_book), + *([_RefreshSearch(provider_book, query=query)] if query else []), + _RefreshSearch(provider_book, expand=True), + ] + + authors = [a.strip() for a in (task.author or "").split(",") if a.strip()] book = BookMetadata( provider="shelfmark", provider_id=task.task_id, title=title, - authors=[task.author] if task.author else [], - search_title=title, - search_author=task.author, - ) - # No language default here on purpose: this re-finds one exact release by its - # guid, and Prowlarr does not filter on plan.languages anyway. - plan = build_release_search_plan( - book, - indexers=[indexer] if indexer is not None else None, + authors=authors, + search_title=derive_primary_title(title, task.subtitle) or title, + search_author=authors[0] if authors else None, ) + query = self._title_author_query(book) + return [_RefreshSearch(book), *([_RefreshSearch(book, query=query)] if query else [])] - source = ProwlarrSource() - results = source.search(book, plan, content_type=task.content_type or "ebook") - for release in results: - raw_release = get_release(release.source_id) - if raw_release is None: - continue - if not self._raw_release_matches_task(raw_release, task.task_id): - continue - - cache_release(task.task_id, raw_release) - logger.info("Refreshed Prowlarr release: %s", task.task_id) - return raw_release + @staticmethod + def _title_author_query(book: BookMetadata) -> str | None: + """ "Eric Terry Pratchett": the search title and first author, or None without one.""" + author = pick_search_author(book) + search_title = book.search_title or book.title + if not author or not search_title: + return None + return f"{search_title} {author}" - return None + @staticmethod + def _provider_book(task: DownloadTask) -> BookMetadata | None: + """The metadata provider's record of the book the download was for, if known.""" + book_key = normalize_optional_text(getattr(task, "book_key", None)) + if book_key is None or ":" not in book_key: + return None + provider, _, provider_id = book_key.partition(":") + if not provider or not provider_id or provider in {"manual", "id"}: + return None + + from shelfmark.metadata_providers import ( + get_provider, + get_provider_kwargs, + is_provider_registered, + ) + + if not is_provider_registered(provider): + return None + try: + return get_provider(provider, **get_provider_kwargs(provider)).get_book(provider_id) + except ( + OSError, + RuntimeError, + ValueError, + KeyError, + TypeError, + requests.exceptions.RequestException, + ) as exc: + logger.debug("Could not load %s to refresh %s: %s", book_key, task.task_id, exc) + return None @staticmethod def _raw_release_matches_task(raw_release: dict[str, Any], task_id: str) -> bool: diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f83d70fde..1876ee7b1 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -2064,25 +2064,39 @@ function App() { } }; + // Wanted: pick releases for the book; saving them there moves it into the queue. + const handleSavedChoose = async (item: SavedItem): Promise => { + try { + if (effectiveSearchMode === 'universal') { + await handleGetReleases(item.book); + } else { + await handleDownload(item.book); + } + } catch (error) { + console.warn('Could not open releases for saved item:', error); + } + }; + const savedPanelFor = (stage: SavedStage) => ( savedStore.remove(item)} onRefresh={savedStore.refresh} - onAutoGet={savedStore.setAutoGet} onOpenDetails={(item) => { setDetailsNotice(null); void showBookDetails(item.book, item.book.id); }} + onChooseReleases={handleSavedChoose} // Queued: numbered on from the downloads up next, which go first. onReorder={stage === 'queued' ? savedStore.reorder : undefined} positionOffset={stage === 'queued' ? statusCounts.queued : 0} + nextCheckAt={stage === 'queued' ? savedStore.nextCheckAt : null} /> ); + const savedCounts: Record = { queued: savedStore.items.filter((item) => savedStage(item) === 'queued').length, later: savedStore.items.filter((item) => savedStage(item) === 'later').length, diff --git a/src/frontend/src/components/activity/SavedPanel.tsx b/src/frontend/src/components/activity/SavedPanel.tsx index dadbd6d8e..f50ac1ef6 100644 --- a/src/frontend/src/components/activity/SavedPanel.tsx +++ b/src/frontend/src/components/activity/SavedPanel.tsx @@ -1,15 +1,17 @@ import { useState } from 'react'; -import { useMountEffect } from '../../hooks/useMountEffect'; +import { useDependencyEffect, useMountEffect } from '../../hooks/useMountEffect'; import { useRowDrag } from '../../hooks/useRowDrag'; import { withBasePath } from '../../utils/basePath'; +import { formatCountdown } from '../../utils/mamAccount'; import { formatDateTime, isoTimeAgo } from '../../utils/relativeTime'; import { SAVED_STAGE_LABELS, describeSavedPick, - hasMamPick, queueOrder, + savedPickLines, savedStage, + waitingFor, type SavedItem, type SavedStage, } from '../../utils/savedItems'; @@ -36,19 +38,21 @@ import { interface SavedPanelProps { items: SavedItem[]; loaded: boolean; - // Whether items can be marked to download on their own (the global setting). - autoGetAvailable: boolean; + // Downloads the picked releases now. onGet: (item: SavedItem) => Promise; onRemove: (item: SavedItem) => Promise; // Re-reads the list, for what the background checks found since. onRefresh: () => Promise; - onAutoGet: (item: SavedItem, changes: { auto_get: boolean }) => Promise; // Opens the book's details, as clicking a book does on the other pages. onOpenDetails?: (item: SavedItem) => void; + // Wanted: opens the release picker; picks saved there move the book to the queue. + onChooseReleases?: (item: SavedItem) => Promise; // Queued only: put the items in this order (ids, first claim on room first). onReorder?: (ids: number[]) => Promise; // Queued only: how many downloads are ahead of these, to number on from. positionOffset?: number; + // Queued only: when the automatic check next runs (ms since epoch), or null. + nextCheckAt?: number | null; } // A queued row's place in line and its move buttons. @@ -62,55 +66,10 @@ interface RowQueue { row?: ReturnType['rowProps']>; } -const AutoGetControls = ({ - item, - onAutoGet, -}: { - item: SavedItem; - onAutoGet: SavedPanelProps['onAutoGet']; -}) => { - const [busy, setBusy] = useState(false); - const toggle = async (autoGet: boolean) => { - setBusy(true); - try { - await onAutoGet(item, { auto_get: autoGet }); - } finally { - setBusy(false); - } - }; - const checkedAgo = item.auto_checked_at ? isoTimeAgo(item.auto_checked_at) : null; - let status = 'Waiting for the next check'; - if (item.auto_status) { - status = checkedAgo ? `${item.auto_status} · checked ${checkedAgo}` : item.auto_status; - } - - return ( -
- - {item.auto_get && ( -

- {status} -

- )} -
- ); -}; +type RowProps = Omit< + SavedPanelProps, + 'items' | 'loaded' | 'onRefresh' | 'onReorder' | 'positionOffset' +> & { item: SavedItem; queue?: RowQueue }; const coverUrl = (preview: string | undefined): string | null => { if (!preview) return null; @@ -126,31 +85,66 @@ const savedSortValue = (item: SavedItem, key: SavedSortKey): string | number | u return Number.isNaN(saved) ? undefined : saved; }; +const smallButton = + 'rounded-full px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors disabled:opacity-40'; +const greenButton = `${smallButton} bg-emerald-600 text-white hover:bg-emerald-700`; +const quietButton = `${smallButton} opacity-70 hover:bg-(--hover-surface) hover:opacity-100`; + +/** "in 4:32" to the next automatic check, ticking; "Checking now" once it's due. */ +const NextCheck = ({ at }: { at: number }) => { + const [now, setNow] = useState(() => Date.now()); + useDependencyEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, [at]); + const seconds = (at - now) / 1000; + return ( + + {seconds > 0 ? `in ${formatCountdown(seconds)}` : 'Checking now'} + + ); +}; + +const ReleaseLines = ({ item }: { item: SavedItem }) => { + const lines = savedPickLines(item); + 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} +
  • + ))} +
+ ); +}; + const SavedRow = ({ item, - autoGetAvailable, onGet, onRemove, - onAutoGet, onOpenDetails, + onChooseReleases, + nextCheckAt, queue, -}: { item: SavedItem; queue?: RowQueue } & Omit< - SavedPanelProps, - 'items' | 'loaded' | 'onRefresh' | 'onReorder' | 'positionOffset' ->) => { - const [busy, setBusy] = useState<'get' | 'remove' | null>(null); - const run = async (action: 'get' | 'remove') => { +}: RowProps) => { + const [busy, setBusy] = useState<'get' | 'choose' | 'remove' | null>(null); + const run = async (action: 'get' | 'choose' | 'remove') => { setBusy(action); try { - await (action === 'get' ? onGet(item) : onRemove(item)); + if (action === 'get') await onGet(item); + else if (action === 'choose') await onChooseReleases?.(item); + else await onRemove(item); } finally { setBusy(null); } }; - const picks = describeSavedPick(item); - const allPicks = describeSavedPick(item, { full: true }); const saved = Date.parse(item.created_at); const openDetails = onOpenDetails ? () => onOpenDetails(item) : undefined; + const hasPicks = item.kind !== 'book' && item.releases.length > 0; + const reason = waitingFor(item); return ( )} - -

- {picks} -

+ + {hasPicks && } {item.last_error && (

{item.last_error}

)} + {!queue && ( +
+ + {hasPicks && ( + + )} +
+ )} - {autoGetAvailable && ( - - {item.releases.length > 0 ? ( - - ) : ( - Pick a release first - )} + {queue ? ( + <> + + {reason ?? First check pending} + + + {item.auto_checked_at ? isoTimeAgo(item.auto_checked_at) : 'Not yet'} + + + {typeof nextCheckAt === 'number' ? : '—'} + + + ) : ( + + {isoTimeAgo(item.created_at)} )} - - {isoTimeAgo(item.created_at)} -
- + {queue && ( + + )} @@ -247,30 +272,25 @@ const SavedRow = ({ const EMPTY: Record = { queued: { title: 'Nothing queued.', - hint: "Pick a release and save it (or tick Queue for download on a saved pick) and it downloads on its own once there's room.", + hint: "Pick a release and save it, and it downloads on its own once there's room.", }, later: { title: 'Nothing saved for later.', - hint: "Use the bookmark on a book or release, or Save for later when you're out of room, to keep it here.", + hint: "Use the bookmark on a book, or Save for later when you're out of room, to keep it here.", }, }; /** * One of the two saved tabs: Queued (releases picked, downloading on their own once - * there's room) or Saved for later (to get by hand). + * there's room) or Wanted (books to choose releases for). */ export const SavedPanel = ({ stage, items, loaded, - autoGetAvailable, - onGet, - onRemove, onRefresh, - onAutoGet, - onOpenDetails, - onReorder, positionOffset = 0, + ...rest }: SavedPanelProps & { stage: SavedStage }) => { useMountEffect(() => { void onRefresh(); @@ -294,28 +314,36 @@ export const SavedPanel = ({ ); }; +// How long after a check is due to read the list again: what it found, and the next one. +const AFTER_CHECK_MS = 4000; + const SavedTable = ({ stage, items, - autoGetAvailable, - onGet, - onRemove, - onAutoGet, - onOpenDetails, onReorder, positionOffset = 0, -}: Omit & { stage: SavedStage }) => { + onCheckDue, + ...rowHandlers +}: Omit & { + stage: SavedStage; + onCheckDue: () => Promise; +}) => { + const { nextCheckAt } = rowHandlers; + useDependencyEffect(() => { + if (stage !== 'queued' || typeof nextCheckAt !== 'number') return undefined; + const timer = window.setTimeout( + () => void onCheckDue(), + Math.max(0, nextCheckAt - Date.now()) + AFTER_CHECK_MS, + ); + return () => window.clearTimeout(timer); + }, [stage, nextCheckAt]); const [sort, setSort] = useState>({ key: 'saved', direction: 'desc' }); const [moving, setMoving] = useState(false); // Queued items are in line: shown in the order they get room, not sortable. @@ -339,9 +367,9 @@ const SavedTable = ({ return (

- {stage === 'queued' + {inLine ? "Each downloads on its own once there's room, checked in this order: one that doesn't fit yet doesn't hold up the ones after it." - : 'Get one with + Get whenever you want it.'} + : 'Choose releases for a book and it joins the queue, to download once there’s room.'}

@@ -355,26 +383,24 @@ const SavedTable = ({ <> + + + ) : ( <> + )} - {autoGetAvailable && } - {inLine ? ( - - ) : ( - - )} @@ -400,11 +426,7 @@ const SavedTable = ({ } : undefined } - autoGetAvailable={autoGetAvailable} - onGet={onGet} - onRemove={onRemove} - onAutoGet={onAutoGet} - onOpenDetails={onOpenDetails} + {...rowHandlers} /> ))} diff --git a/src/frontend/src/contexts/SavedItemsContext.tsx b/src/frontend/src/contexts/SavedItemsContext.tsx index 2649415b7..64ccf8290 100644 --- a/src/frontend/src/contexts/SavedItemsContext.tsx +++ b/src/frontend/src/contexts/SavedItemsContext.tsx @@ -34,6 +34,8 @@ export interface SavedItemsContextValue { remove: (item: SavedItem, options?: { quiet?: boolean }) => Promise; /** Turn automatic downloading on or off. */ setAutoGet: (item: SavedItem, changes: { auto_get: boolean }) => Promise; + /** When the automatic check next runs (ms since epoch), or null when none is due. */ + nextCheckAt: number | null; /** Put the queued items in this order (ids, first claim on room first). */ reorder: (ids: number[]) => Promise; refresh: () => Promise; @@ -64,12 +66,16 @@ export const useSavedItemsStore = ({ }): SavedItemsContextValue => { const [items, setItems] = useState([]); const [loaded, setLoaded] = useState(false); + const [nextCheckAt, setNextCheckAt] = useState(null); + // Wanted items already sent to the queue, so a failed move isn't retried each refresh. + const promoted = useRef(new Set()); // What the last refresh saw, to tell when an automatic download took an item. const lastSeen = useRef(null); const refresh = useCallback(async () => { try { - const next = await getSavedItems(); + const { items: next, next_check_at } = await getSavedItems(); + setNextCheckAt(next_check_at === null ? null : next_check_at * 1000); const previous = lastSeen.current; if (previous) { const remaining = new Set(next.map((item) => item.id)); @@ -79,12 +85,37 @@ export const useSavedItemsStore = ({ } lastSeen.current = next; setItems(next); + if (autoGetAvailable) { + // Picked releases belong in the queue: Wanted is for books still to pick for. + // One whose last automatic download failed stays, so its error is seen first. + next + .filter( + (item) => + savedStage(item) === 'later' && + item.releases.length > 0 && + !item.last_error && + !promoted.current.has(item.id), + ) + .forEach((item) => { + promoted.current.add(item.id); + void updateSavedItem(item.id, { + auto_get: true, + payloads: savedPayloads(item.book, item.releases), + }) + .then((updated) => + setItems((current) => + current.map((existing) => (existing.id === updated.id ? updated : existing)), + ), + ) + .catch((error: unknown) => console.warn('Could not queue saved item:', error)); + }); + } } catch (error) { console.warn('Could not load saved items:', error); } finally { setLoaded(true); } - }, [onShowToast]); + }, [autoGetAvailable, onShowToast]); const replaceItem = useCallback((saved: SavedItem) => { const apply = (current: SavedItem[]) => [ @@ -196,10 +227,11 @@ export const useSavedItemsStore = ({ savePicks: store, remove, setAutoGet, + nextCheckAt, reorder, refresh, }; - }, [contentType, items, loaded, refresh, remove, reorder, setAutoGet, store]); + }, [contentType, items, loaded, nextCheckAt, refresh, remove, reorder, setAutoGet, store]); }; // Re-read now and then, so items got automatically leave the list (with a toast). diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 0b701bb45..9286dc635 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -715,8 +715,16 @@ export const getMamRatio = async (): Promise => export const getBookActivity = async (): Promise => (await fetchJSON<{ downloads: BookDownloadEntry[] }>(`${API_BASE}/activity/books`)).downloads; -export const getSavedItems = async (): Promise => - (await fetchJSON<{ items: SavedItem[] }>(`${API_BASE}/saved`)).items; +/** The saved list, and when the automatic check next runs (epoch seconds, or null). */ +export const getSavedItems = async (): Promise<{ + items: SavedItem[]; + next_check_at: number | null; +}> => { + const body = await fetchJSON<{ items: SavedItem[]; next_check_at?: number | null }>( + `${API_BASE}/saved`, + ); + return { items: body.items, next_check_at: body.next_check_at ?? null }; +}; export const saveForLater = async (payload: { book: Book; diff --git a/src/frontend/src/tests/savedItems.test.ts b/src/frontend/src/tests/savedItems.test.ts index d9fcde990..a007dd7b7 100644 --- a/src/frontend/src/tests/savedItems.test.ts +++ b/src/frontend/src/tests/savedItems.test.ts @@ -7,7 +7,9 @@ import { hasMamPick, savedBookKey, savedPayloads, + savedPickLines, savedStage, + waitingFor, } from '../utils/savedItems'; const release = (extra: Partial): Release => ({ @@ -138,3 +140,44 @@ describe('savedStage', () => { expect(savedStage({ releases: [], auto_get: false })).toBe('later'); }); }); + +describe('savedPickLines', () => { + it('puts each release on its own line', () => { + const narrated = (...names: string[]) => release({ extra: { narrators: names } }); + expect( + savedPickLines({ + kind: 'combined', + releases: [ + { content_type: 'ebook', release: release({ format: 'epub' }) }, + { content_type: 'audiobook', release: narrated('Tony Robinson') }, + { content_type: 'audiobook', release: narrated('Bill Nighy', 'A', 'B') }, + { content_type: 'audiobook', release: narrated('Nigel Planer') }, + ], + }), + ).toEqual([ + 'Ebook, EPUB', + 'Audiobook, Tony Robinson', + 'Audiobook, Bill Nighy +2', + 'Audiobook, Nigel Planer', + ]); + }); + + it('has no lines for a book with nothing picked', () => { + expect(savedPickLines({ kind: 'book', releases: [] })).toEqual([]); + }); +}); + +describe('waitingFor', () => { + it('says what a queued item waits for, from the check status', () => { + expect(waitingFor({ auto_status: 'Waiting for 2 unsatisfied slots' })).toBe( + '2 unsatisfied slots', + ); + expect(waitingFor({ auto_status: 'Waiting for buffer: 1.20 GB short' })).toBe( + 'Buffer: 1.20 GB short', + ); + expect(waitingFor({ auto_status: "Couldn't queue: no client" })).toBe( + "Couldn't queue: no client", + ); + expect(waitingFor({ auto_status: null })).toBeNull(); + }); +}); diff --git a/src/frontend/src/utils/savedItems.ts b/src/frontend/src/utils/savedItems.ts index e9e75b45a..cc9036854 100644 --- a/src/frontend/src/utils/savedItems.ts +++ b/src/frontend/src/utils/savedItems.ts @@ -125,6 +125,37 @@ export const pickLabel = (pick: SavedPick, { full = false }: { full?: boolean } return formatLabel(pick.release) || 'Ebook'; }; +/** + * What a saved item will download, one line per release: "Ebook, EPUB", "Audiobook, + * Narrator Name +2"; `full` names every narrator. Empty for a book with none picked. + */ +export const savedPickLines = ( + item: Pick, + { full = false }: { full?: boolean } = {}, +): string[] => { + if (item.kind === 'book') return []; + return item.releases.map((pick) => { + if (pick.content_type === 'audiobook') { + const names = narratorsOf(pick.release); + if (names.length === 0) return 'Audiobook'; + return `Audiobook, ${full ? names.join(', ') : shortNarrators(names)}`; + } + const format = formatLabel(pick.release); + return format ? `Ebook, ${format}` : 'Ebook'; + }); +}; + +/** + * What a queued item waits for, from the last automatic check's status: "2 unsatisfied + * slots", "Freeleech or ratio 2.00: …". Null before its first check. + */ +export const waitingFor = (item: Pick): string | null => { + const status = item.auto_status?.trim(); + if (!status) return null; + const reason = status.replace(/^waiting for\s+/i, ''); + return reason.charAt(0).toUpperCase() + reason.slice(1); +}; + /** * What a saved item will download: "Ebook EPUB + audiobook, Narrator Name +2", or "Book * only"; `full` names every narrator. diff --git a/tests/core/test_saved_autoget.py b/tests/core/test_saved_autoget.py index 25845a11b..ee9e6236a 100644 --- a/tests/core/test_saved_autoget.py +++ b/tests/core/test_saved_autoget.py @@ -316,3 +316,29 @@ def get_torrent(self, _torrent_id): client.item = None with pytest.raises(MamError): mam_account.torrent_is_freeleech(1, vip=True) + + +def test_next_check_time(service, mam, monkeypatch): + import threading + import time as time_module + + getter = SavedAutoGetter(service, Recorder().hooks()) + assert getter.next_check_at() is None # Not started + + # A running schedule reports when it wakes next; a marked item brings that forward. + monkeypatch.setattr(getter, "_thread", threading.Thread(target=lambda: None)) + monkeypatch.setattr(getter._thread, "is_alive", lambda: True) + getter._next_check_at = time_module.time() + 600 + assert getter.next_check_at() == pytest.approx(time_module.time() + 600, abs=2) + monkeypatch.setattr(saved_autoget, "_SOON_DELAY_SECONDS", 3600) # Keep the timer idle + getter._next_check_at = time_module.time() + 7200 + getter.check_soon() + assert getter.next_check_at() == pytest.approx(time_module.time() + 3600, abs=2) + getter._soon.cancel() + # Once the quick check has run, the schedule's own time is next again. + monkeypatch.setattr(getter, "run_check", lambda: None) + getter._run_soon() + assert getter.next_check_at() == pytest.approx(time_module.time() + 7200, abs=2) + + monkeypatch.setattr(saved_autoget, "enabled", lambda: False) + assert getter.next_check_at() is None diff --git a/tests/core/test_saved_items.py b/tests/core/test_saved_items.py index 2fce020bf..92562930c 100644 --- a/tests/core/test_saved_items.py +++ b/tests/core/test_saved_items.py @@ -334,3 +334,18 @@ def test_route(self, service): assert self._order(service) == [b, a] assert client.post("/api/saved/reorder", json={"ids": "x"}).status_code == 400 assert client.post("/api/saved/reorder", json={"ids": [999]}).status_code == 400 + + +def test_the_list_says_when_the_next_check_is(service): + app = Flask(__name__) + app.secret_key = "test" + register_saved_routes( + app, + service, + lambda f: f, + lambda: "none", + next_check_at=lambda: 1_800_000_000.0, + ) + body = app.test_client().get("/api/saved").get_json() + assert body["items"] == [] + assert body["next_check_at"] == 1_800_000_000.0 diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index f70a1a108..b8e6787a7 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -1763,3 +1763,148 @@ def test_matches_a_qualified_id_built_from_a_guidless_release(self): built = build_source_id(raw) assert ProwlarrHandler._raw_release_matches_task(raw, built) + + +class TestRefreshSearches: + """An expired link is re-found the way the book's own release search finds it.""" + + TASK_ID = "12:https://www.myanonamouse.net/t/168537" + + def _task(self, **fields): + return DownloadTask( + task_id=self.TASK_ID, + source="prowlarr", + title="Good Omens: The Nice and Accurate Prophecies of Agnes Nutter, Witch", + subtitle="The Nice and Accurate Prophecies of Agnes Nutter, Witch", + author="Neil Gaiman, Terry Pratchett", + content_type="audiobook", + retry_source_context={"indexer": "MyAnonamouse"}, + **fields, + ) + + def _search_finding_on(self, found_on_call: int, searched: list): + """A Prowlarr search that finds the release only on call number `found_on_call`.""" + + def search(book, plan, *, expand_search=False, content_type="ebook"): + searched.append((book.provider, book.search_title, expand_search, plan.manual_query)) + if len(searched) != found_on_call: + return [] + raw = {"guid": "https://www.myanonamouse.net/t/168537", "indexerId": 12} + cache_release(self.TASK_ID + ":fresh", {**raw, "protocol": "torrent"}) + return [ + Release( + source="prowlarr", + source_id=self.TASK_ID + ":fresh", + title="Good Omens", + protocol=ReleaseProtocol.TORRENT, + ) + ] + + return search + + def _provider(self, book): + provider = MagicMock() + provider.get_book.return_value = book + return provider + + def test_searches_with_the_provider_book_first(self): + from shelfmark.metadata_providers import BookMetadata + + provider_book = BookMetadata( + provider="hardcover", + provider_id="42", + title="Good Omens", + authors=["Neil Gaiman", "Terry Pratchett"], + search_title="Good Omens", + ) + searched: list = [] + remove_release(self.TASK_ID) + try: + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=self._search_finding_on(3, searched), + ), + patch("shelfmark.metadata_providers.is_provider_registered", return_value=True), + patch("shelfmark.metadata_providers.get_provider_kwargs", return_value={}), + patch( + "shelfmark.metadata_providers.get_provider", + return_value=self._provider(provider_book), + ), + ): + raw = ProwlarrHandler()._refresh_release(self._task(book_key="hardcover:42")) + finally: + remove_release(self.TASK_ID) + remove_release(self.TASK_ID + ":fresh") + + assert raw is not None + # The provider's book as the release search uses it, then title and author, + # then without categories: found there. + assert searched == [ + ("hardcover", "Good Omens", False, None), + ("hardcover", "Good Omens", False, "Good Omens Neil Gaiman"), + ("hardcover", "Good Omens", True, None), + ] + + def test_without_a_provider_book_searches_the_title_without_its_subtitle(self): + searched: list = [] + try: + with patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=self._search_finding_on(1, searched), + ): + raw = ProwlarrHandler()._refresh_release(self._task()) + finally: + remove_release(self.TASK_ID) + remove_release(self.TASK_ID + ":fresh") + + assert raw is not None + assert searched == [("shelfmark", "Good Omens", False, None)] + + def test_a_common_title_is_searched_with_its_author(self): + # "Eric" alone brings back other books ("Eric Brown - Helix") and not this one. + searched: list = [] + task = DownloadTask( + task_id=self.TASK_ID, + source="prowlarr", + title="Eric", + author="Terry Pratchett", + content_type="audiobook", + ) + try: + with patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=self._search_finding_on(2, searched), + ): + raw = ProwlarrHandler()._refresh_release(task) + finally: + remove_release(self.TASK_ID) + remove_release(self.TASK_ID + ":fresh") + + assert raw is not None + assert searched == [ + ("shelfmark", "Eric", False, None), + ("shelfmark", "Eric", False, "Eric Terry Pratchett"), + ] + + def test_a_provider_that_fails_falls_back_to_the_download_title(self): + provider = MagicMock() + provider.get_book.side_effect = RuntimeError("Hardcover is down") + searched: list = [] + try: + with ( + patch( + "shelfmark.release_sources.prowlarr.handler.ProwlarrSource.search", + side_effect=self._search_finding_on(1, searched), + ), + patch("shelfmark.metadata_providers.is_provider_registered", return_value=True), + patch("shelfmark.metadata_providers.get_provider_kwargs", return_value={}), + patch("shelfmark.metadata_providers.get_provider", return_value=provider), + ): + raw = ProwlarrHandler()._refresh_release(self._task(book_key="hardcover:42")) + finally: + remove_release(self.TASK_ID) + remove_release(self.TASK_ID + ":fresh") + + assert raw is not None + assert searched == [("shelfmark", "Good Omens", False, None)]
Actions