From 678c54cba27d76fe7c898334892dea42240b6b26 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:34:42 +0000 Subject: [PATCH] Fixes: Entrypoint, seedtime, request policy flow (#805) - Added a path for rootless permissions in the entrypoint script - Routed prowlarr searches through torznab for seedtime info - Added additional request flow for download permissions --- entrypoint.sh | 33 +++- shelfmark/core/request_routes.py | 136 ++++++++++++++- shelfmark/release_sources/prowlarr/api.py | 26 +-- shelfmark/release_sources/prowlarr/handler.py | 22 ++- shelfmark/release_sources/prowlarr/source.py | 164 +++++++++++------- src/frontend/src/App.tsx | 42 ++++- src/frontend/src/services/api.ts | 9 +- src/frontend/src/types/index.ts | 12 ++ tests/core/test_request_routes_api.py | 160 +++++++++++++++++ tests/prowlarr/test_handler.py | 95 ++++++++++ tests/prowlarr/test_source.py | 82 ++++----- 11 files changed, 625 insertions(+), 156 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 8f37a3b..ed3e51d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -126,6 +126,35 @@ fi USERNAME=$(getent passwd "$RUN_UID" | cut -d: -f1) echo "Username for UID $RUN_UID is $USERNAME" +# Avoid unnecessary gosu hops when we're already running as the target user. +# Some nested LXC setups spin on root-to-root gosu invocations. +needs_user_switch() { + local current_uid + local current_gid + + current_uid=$(id -u) + current_gid=$(id -g) + + [ "$current_uid" != "$RUN_UID" ] || [ "$current_gid" != "$RUN_GID" ] +} + +run_as_target_user() { + if needs_user_switch; then + gosu "$USERNAME" "$@" + return $? + fi + + "$@" +} + +exec_as_target_user() { + if needs_user_switch; then + exec gosu "$USERNAME" "$@" + fi + + exec "$@" +} + test_write() { local folder=$1 local test_file="$folder/shelfmark_TEST_WRITE" @@ -138,7 +167,7 @@ test_write() { return 1 fi - if ! gosu "$USERNAME" sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then + if ! run_as_target_user sh -c 'echo 0123456789_TEST > "$1"' _ "$test_file"; then echo "Failed to write test file in $folder as $USERNAME" return 1 fi @@ -400,4 +429,4 @@ echo "Setting umask to $UMASK_VALUE" umask $UMASK_VALUE stop_file_logging -exec gosu "$USERNAME" env HOME=/app $command +exec_as_target_user env HOME=/app $command diff --git a/shelfmark/core/request_routes.py b/shelfmark/core/request_routes.py index 133df30..78f58c2 100644 --- a/shelfmark/core/request_routes.py +++ b/shelfmark/core/request_routes.py @@ -381,6 +381,7 @@ def _prepare_request_create_arguments( }, "actor_label": actor_label, "request_title": request_title, + "resolved_mode": resolved_mode, } @@ -397,6 +398,69 @@ def _resolve_request_source_and_format(request_row: dict[str, Any]) -> tuple[str return normalize_source(request_row.get("source_hint")), None +def _build_queued_download_result( + *, + create_args: dict[str, Any], + request_title: str, +) -> dict[str, Any]: + release_data = create_args.get("release_data") + source = create_args.get("source_hint") + source_id: str | None = None + + if isinstance(release_data, dict): + source = release_data.get("source") or source + source_id = _normalize_optional_source_id(release_data.get("source_id")) + + return { + "kind": "download", + "status": "queued", + "priority": 0, + "title": request_title, + "source": normalize_source(source), + "source_id": source_id, + "content_type": create_args.get("content_type"), + } + + +def _queue_prepared_download_submission( + user_db: UserDB, + *, + queue_release: Callable[..., tuple[bool, str | None]], + create_args: dict[str, Any], + request_title: str, +) -> dict[str, Any]: + release_data = create_args.get("release_data") + if not isinstance(release_data, dict): + raise RequestServiceError( + "Download policy requires a concrete release", + status_code=400, + code="policy_requires_download", + required_mode=PolicyMode.DOWNLOAD.value, + ) + + requester = user_db.get_user(user_id=create_args["user_id"]) + if requester is None: + raise RequestServiceError("Requesting user not found", status_code=404) + + success, error = queue_release( + dict(release_data), + 0, + user_id=create_args["user_id"], + username=requester.get("username"), + ) + if not success: + raise RequestServiceError( + error or "Failed to queue release", + status_code=409, + code="queue_failed", + ) + + return _build_queued_download_result( + create_args=create_args, + request_title=request_title, + ) + + def _notify_admin_for_request_event( @@ -544,6 +608,19 @@ def register_request_routes( try: prepared = _prepare_request_create_arguments(user_db, data) + if prepared["resolved_mode"] == PolicyMode.DOWNLOAD: + queued = _queue_prepared_download_submission( + user_db, + queue_release=queue_release, + create_args=prepared["create_args"], + request_title=prepared["request_title"], + ) + logger.info( + "Policy download queued for '%s' by %s", + prepared["request_title"], + prepared["actor_label"], + ) + return jsonify(queued), 200 created = create_request(user_db, **prepared["create_args"]) except RequestServiceError as exc: return _error_response( @@ -604,10 +681,6 @@ def register_request_routes( _prepare_request_create_arguments(user_db, raw_request) for raw_request in raw_requests ] - created_rows = create_requests( - user_db, - requests=[prepared["create_args"] for prepared in prepared_requests], - ) except RequestServiceError as exc: return _error_response( str(exc), @@ -616,7 +689,34 @@ def register_request_routes( required_mode=exc.required_mode, ) - for created, prepared in zip(created_rows, prepared_requests): + request_prepared_items: list[tuple[int, dict[str, Any]]] = [] + download_prepared_items: list[tuple[int, dict[str, Any]]] = [] + + for index, prepared in enumerate(prepared_requests): + if prepared["resolved_mode"] == PolicyMode.DOWNLOAD: + download_prepared_items.append((index, prepared)) + continue + + request_prepared_items.append((index, prepared)) + + created_rows: list[dict[str, Any]] = [] + if request_prepared_items: + try: + created_rows = create_requests( + user_db, + requests=[prepared["create_args"] for _, prepared in request_prepared_items], + ) + except RequestServiceError as exc: + return _error_response( + str(exc), + exc.status_code, + code=exc.code, + required_mode=exc.required_mode, + ) + + results_by_index: dict[int, dict[str, Any]] = {} + + for (index, prepared), created in zip(request_prepared_items, created_rows): event_payload = { "request_id": created["id"], "status": created["status"], @@ -645,8 +745,32 @@ def register_request_routes( event=NotificationEvent.REQUEST_CREATED, request_row=created, ) + results_by_index[index] = created - return jsonify(created_rows), 201 + for index, prepared in download_prepared_items: + try: + results_by_index[index] = _queue_prepared_download_submission( + user_db, + queue_release=queue_release, + create_args=prepared["create_args"], + request_title=prepared["request_title"], + ) + except RequestServiceError as exc: + return _error_response( + str(exc), + exc.status_code, + code=exc.code, + required_mode=exc.required_mode, + ) + logger.info( + "Policy download queued for '%s' by %s", + prepared["request_title"], + prepared["actor_label"], + ) + + ordered_results = [results_by_index[index] for index in range(len(prepared_requests))] + status_code = 201 if request_prepared_items else 200 + return jsonify(ordered_results), status_code @app.route("/api/requests", methods=["GET"]) def api_list_requests(): diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index c43d1a8..d1bbb2e 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -105,7 +105,7 @@ class ProwlarrClient: def get_enriched_indexer_ids(self, *, restrict_to: Optional[List[int]] = None) -> List[int]: """ - Return enabled indexer IDs that should use Torznab for richer metadata. + Return enabled indexer IDs that benefit from extra Torznab handling. Args: restrict_to: Optional list of candidate indexer IDs to consider. @@ -225,27 +225,3 @@ class ProwlarrClient: if 7000 <= subcat.get("id", 0) <= 7999: return True return False - - def search( - self, - query: str, - indexer_ids: Optional[List[int]] = None, - categories: Optional[List[int]] = None, - limit: int = 100, - ) -> List[Dict[str, Any]]: - """Search for releases via Prowlarr.""" - if not query: - return [] - - params: Dict[str, Any] = {"query": query, "limit": limit} - if indexer_ids: - params["indexerIds"] = indexer_ids - if categories: - params["categories"] = categories - - try: - results = self._request("GET", "/api/v1/search", params=params) - return results if isinstance(results, list) else [] - except Exception as e: - logger.error(f"Prowlarr search failed: {e}") - return [] diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 6a50fa0..f40d779 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -25,6 +25,25 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS +def _coerce_seed_time_minutes(raw_seed_time: object) -> Optional[int]: + """Convert Prowlarr's minimum seed time from seconds to whole minutes.""" + if raw_seed_time is None: + return None + + try: + seed_time_seconds = int(raw_seed_time) + except (TypeError, ValueError): + logger.warning(f"Invalid Prowlarr minimumSeedTime value: {raw_seed_time!r}") + return None + + if seed_time_seconds < 0: + logger.warning(f"Ignoring negative Prowlarr minimumSeedTime value: {seed_time_seconds}") + return None + + # Round up so we never under-seed when a tracker uses a non-minute boundary. + return (seed_time_seconds + 59) // 60 + + @register_handler("prowlarr") class ProwlarrHandler(ExternalClientHandler): """Handler for Prowlarr downloads via configured torrent or usenet client.""" @@ -76,8 +95,9 @@ class ProwlarrHandler(ExternalClientHandler): # Seed criteria from the indexer (Torznab attributes) raw_seed_time = prowlarr_result.get("minimumSeedTime") - seeding_time_limit = int(raw_seed_time) if raw_seed_time is not None else None raw_ratio = prowlarr_result.get("minimumRatio") + + seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time) ratio_limit = float(raw_ratio) if raw_ratio is not None else None return DownloadRequest( diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 937f5b3..dd5801a 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -238,6 +238,50 @@ def _detect_content_type_from_categories(categories: list, fallback: str = "book return "other" +def _extract_capability_category_ids(categories: list[dict]) -> set[int]: + """Flatten capability categories and subcategories into a single ID set.""" + category_ids: set[int] = set() + + for category in categories: + if not isinstance(category, dict): + continue + + category_id = category.get("id") + if isinstance(category_id, int): + category_ids.add(category_id) + + for subcategory in category.get("subCategories", []): + if not isinstance(subcategory, dict): + continue + subcategory_id = subcategory.get("id") + if isinstance(subcategory_id, int): + category_ids.add(subcategory_id) + + return category_ids + + +def _indexer_supports_search_categories(indexer: dict, categories: Optional[List[int]]) -> bool: + """Return whether an indexer should be queried for the requested categories.""" + if not categories: + return True + + capability_categories = indexer.get("capabilities", {}).get("categories", []) + category_ids = _extract_capability_category_ids(capability_categories) + if not category_ids: + return True + + for requested_category in categories: + if requested_category == 7000: + if any(cat_id in BOOK_CATEGORY_RANGE for cat_id in category_ids): + return True + continue + + if requested_category in category_ids: + return True + + return False + + def _prowlarr_result_to_release( result: dict, search_content_type: str = "ebook", @@ -551,6 +595,35 @@ class ProwlarrSource(ReleaseSource): logger.warning(f"Failed to resolve indexer names to IDs: {e}") return None + def _get_search_indexer_ids( + self, + client: ProwlarrClient, + selected_indexer_ids: Optional[List[int]], + categories: Optional[List[int]], + ) -> List[int]: + """Resolve the concrete indexer IDs to query via Torznab.""" + if selected_indexer_ids is not None: + return selected_indexer_ids + + try: + enabled_indexers = client.get_enabled_indexers_detailed() + except Exception as e: + logger.warning(f"Failed to load enabled Prowlarr indexers: {e}") + return [] + + indexer_ids: List[int] = [] + for indexer in enabled_indexers: + if not _indexer_supports_search_categories(indexer, categories): + continue + + indexer_id = indexer.get("id") + try: + indexer_ids.append(int(indexer_id)) + except (TypeError, ValueError): + continue + + return indexer_ids + def search( self, book: BookMetadata, @@ -614,81 +687,39 @@ class ProwlarrSource(ReleaseSource): f"Searching Prowlarr: {query_type} ({len(variants)} variants), {indexer_desc}, categories={categories}" ) - # Identify indexers that should be enriched via Torznab/Newznab. - enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids) - non_enriched_indexer_ids: Optional[List[int]] = None - if indexer_ids: - non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids] - - def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]: - """Search indexers with given categories, collecting results. - - Args: - query: Query string for standard indexers (title only). - cats: Category filter list. - enriched_query: Optional query for enriched indexers (title + author). - Falls back to ``query`` when not provided. - """ - results = [] - eq = enriched_query or query - - # Search standard indexers via JSON endpoint. - if indexer_ids: - if non_enriched_indexer_ids: - # Prefer a single request for selected indexers to reduce latency. - try: - raw = client.search(query=query, indexer_ids=non_enriched_indexer_ids, categories=cats) - if raw: - results.extend(raw) - except Exception as e: - logger.warning( - f"Search failed for selected indexers {non_enriched_indexer_ids}: {e}. Falling back to per-indexer search." - ) - - for indexer_id in non_enriched_indexer_ids: - try: - raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats) - if raw: - results.extend(raw) - except Exception as e: - logger.warning(f"Search failed for indexer {indexer_id}: {e}") - else: - # Search all enabled indexers at once, then remove enriched results (re-fetched via Torznab). - try: - raw = client.search(query=query, indexer_ids=None, categories=cats) - if raw: - if enriched_indexer_ids: - raw = [r for r in raw if r.get("indexerId") not in enriched_indexer_ids] - results.extend(raw) - except Exception as e: - logger.warning(f"Search failed for all indexers: {e}") - - # Search enriched indexers via Torznab/Newznab for richer metadata. - # Use enriched_query (title + author) for better results on these indexers. - for indexer_id in enriched_indexer_ids: - raw = client.torznab_search(indexer_id=indexer_id, query=eq, categories=cats, search_type="book") - if raw: - results.extend(raw) - else: - # Fallback to JSON search for enriched indexers if Torznab fails. - try: - raw_fallback = client.search(query=eq, indexer_ids=[indexer_id], categories=cats) - if raw_fallback: - results.extend(raw_fallback) - except Exception as e: - logger.warning(f"Fallback search failed for enriched indexer {indexer_id}: {e}") - - return results - try: auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False) deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS + # Some indexers benefit from title+author queries and extra format detection. + enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids) + enriched_indexer_ids_set = set(enriched_indexer_ids) def _check_timeout() -> None: if time.monotonic() > deadline: raise TimeoutError( f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s" ) + + def search_indexers(query: str, cats: Optional[List[int]], *, enriched_query: Optional[str] = None) -> List[dict]: + """Search indexers with given categories via Torznab/Newznab.""" + results: List[dict] = [] + target_indexer_ids = self._get_search_indexer_ids(client, indexer_ids, cats) + if not target_indexer_ids: + return results + + for indexer_id in target_indexer_ids: + _check_timeout() + indexer_query = enriched_query if indexer_id in enriched_indexer_ids_set and enriched_query else query + raw = client.torznab_search( + indexer_id=indexer_id, + query=indexer_query, + categories=cats, + search_type="book", + ) + if raw: + results.extend(raw) + + return results seen_keys: set[str] = set() all_results: List[dict] = [] @@ -722,7 +753,6 @@ class ProwlarrSource(ReleaseSource): seen_keys.add(key) all_results.append(r) - enriched_indexer_ids_set = set(enriched_indexer_ids) results: List[Release] = [] enriched_source_ids: set[str] = set() diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 27af9ff..c86e026 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { Book, Release, RequestRecord, + RequestSubmissionResult, StatusData, AppConfig, ContentType, @@ -13,6 +14,7 @@ import { ActingAsUserSelection, MetadataProviderSummary, MetadataSearchConfig, + QueuedDownloadResult, QueryTargetOption, SearchMode, isMetadataBook, @@ -143,6 +145,37 @@ const getErrorMessage = (error: unknown, fallback: string): string => { return fallback; }; +const isQueuedDownloadResult = (value: unknown): value is QueuedDownloadResult => { + if (!value || typeof value !== 'object') { + return false; + } + + const row = value as Record; + return row.kind === 'download' && row.status === 'queued'; +}; + +const getSubmissionSuccessMessage = ( + results: RequestSubmissionResult[], + fallback: string, +): string => { + const queuedDownloads = results.filter(isQueuedDownloadResult); + if (queuedDownloads.length === 0) { + return fallback; + } + + if (queuedDownloads.length === results.length) { + if (queuedDownloads.length === 1) { + const title = typeof queuedDownloads[0].title === 'string' && queuedDownloads[0].title.trim() + ? queuedDownloads[0].title.trim() + : 'Untitled'; + return `Download queued: ${title}`; + } + return 'Downloads queued'; + } + + return 'Download queued and request submitted'; +}; + const CONFIRMED_DOWNLOAD_INTERRUPTED_MESSAGE = 'Download queued, but the proxy interrupted the response. Status will refresh shortly.'; @@ -1097,9 +1130,12 @@ function App() { const submitRequests = useCallback( async (payloads: CreateRequestPayload[], successMessage: string): Promise => { try { - await createRequests(payloads); + const results = await createRequests(payloads); await refreshActivitySnapshot(); - showToast(successMessage, 'success'); + if (results.some(isQueuedDownloadResult)) { + await fetchStatus(); + } + showToast(getSubmissionSuccessMessage(results, successMessage), 'success'); await refreshRequestPolicy({ force: true }); return true; } catch (error) { @@ -1111,7 +1147,7 @@ function App() { return false; } }, - [showToast, refreshRequestPolicy, refreshActivitySnapshot] + [fetchStatus, showToast, refreshRequestPolicy, refreshActivitySnapshot] ); const openRequestConfirmation = useCallback(( diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index b2b11be..5c2a850 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -9,6 +9,7 @@ import { RequestPolicyResponse, CreateRequestPayload, RequestRecord, + RequestSubmissionResult, MetadataProvidersResponse, MetadataSearchConfig, } from '../types'; @@ -585,15 +586,15 @@ export const fetchRequestPolicy = async (): Promise => { return fetchJSON(API.requestPolicy); }; -export const createRequest = async (payload: CreateRequestPayload): Promise => { - return fetchJSON(API.requests, { +export const createRequest = async (payload: CreateRequestPayload): Promise => { + return fetchJSON(API.requests, { method: 'POST', body: JSON.stringify(payload), }); }; -export const createRequests = async (payloads: CreateRequestPayload[]): Promise => { - return fetchJSON(API.requestsBatch, { +export const createRequests = async (payloads: CreateRequestPayload[]): Promise => { + return fetchJSON(API.requestsBatch, { method: 'POST', body: JSON.stringify({ requests: payloads }), }); diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 58ad429..277a287 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -247,6 +247,18 @@ export interface RequestRecord { username?: string; } +export interface QueuedDownloadResult { + kind: 'download'; + status: 'queued'; + priority: number; + title: string; + source: string; + source_id: string | null; + content_type?: ContentType; +} + +export type RequestSubmissionResult = RequestRecord | QueuedDownloadResult; + export type BooksOutputMode = 'folder' | 'booklore' | 'email'; export interface AppConfig { diff --git a/tests/core/test_request_routes_api.py b/tests/core/test_request_routes_api.py index fb6ea50..b6fdabf 100644 --- a/tests/core/test_request_routes_api.py +++ b/tests/core/test_request_routes_api.py @@ -227,6 +227,134 @@ class TestRequestRoutes: assert updated["user_id"] == user["id"] assert updated["status"] == "cancelled" + def test_download_policy_queues_release_without_creating_request(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + policy = _policy(default_ebook="download") + + payload = { + "book_data": { + "title": "Policy Download", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "policy-download-1", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "policy-download-release-1", + "title": "Policy Download.epub", + }, + } + + captured: dict[str, object] = {} + + def fake_queue_release(release_data, priority, user_id=None, username=None): + captured["release_data"] = release_data + captured["priority"] = priority + captured["user_id"] = user_id + captured["username"] = username + return True, None + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): + with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): + with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin: + with patch("shelfmark.core.request_routes.notify_user") as mock_notify_user: + resp = client.post("/api/requests", json=payload) + + assert resp.status_code == 200 + assert resp.json["kind"] == "download" + assert resp.json["status"] == "queued" + assert resp.json["title"] == "Policy Download" + assert resp.json["source"] == "prowlarr" + assert resp.json["source_id"] == "policy-download-release-1" + assert captured["priority"] == 0 + assert captured["user_id"] == user["id"] + assert captured["username"] == user["username"] + assert captured["release_data"]["source_id"] == "policy-download-release-1" + assert main_module.user_db.list_requests(user_id=user["id"]) == [] + mock_notify_admin.assert_not_called() + mock_notify_user.assert_not_called() + + def test_batch_download_policy_queues_releases_without_creating_requests(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + policy = _policy(default_ebook="download") + + payloads = [ + { + "book_data": { + "title": "Batch Download One", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "batch-download-1", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "batch-download-release-1", + "title": "Batch Download One.epub", + }, + }, + { + "book_data": { + "title": "Batch Download Two", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "batch-download-2", + }, + "context": { + "source": "prowlarr", + "content_type": "ebook", + "request_level": "release", + }, + "release_data": { + "source": "prowlarr", + "source_id": "batch-download-release-2", + "title": "Batch Download Two.epub", + }, + }, + ] + + queued: list[tuple[str, int, int | None, str | None]] = [] + + def fake_queue_release(release_data, priority, user_id=None, username=None): + queued.append((release_data["source_id"], priority, user_id, username)) + return True, None + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): + with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object(main_module.backend, "queue_release", side_effect=fake_queue_release): + with patch("shelfmark.core.request_routes.notify_admin") as mock_notify_admin: + resp = client.post("/api/requests/batch", json={"requests": payloads}) + + assert resp.status_code == 200 + assert [row["kind"] for row in resp.json] == ["download", "download"] + assert [row["source_id"] for row in resp.json] == [ + "batch-download-release-1", + "batch-download-release-2", + ] + assert queued == [ + ("batch-download-release-1", 0, user["id"], user["username"]), + ("batch-download-release-2", 0, user["id"], user["username"]), + ] + assert main_module.user_db.list_requests(user_id=user["id"]) == [] + mock_notify_admin.assert_not_called() + def test_admin_can_create_request_on_behalf_of_another_user(self, main_module, client): admin = _create_user(main_module, prefix="admin", role="admin") target_user = _create_user(main_module, prefix="reader") @@ -1249,6 +1377,38 @@ class TestRequestCreationEdgeCases: assert resp.status_code == 403 assert resp.json["code"] == "requests_unavailable" + def test_download_policy_without_concrete_release_returns_400(self, main_module, client): + user = _create_user(main_module, prefix="reader") + _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) + policy = _policy(default_ebook="download") + + payload = { + "book_data": { + "title": "Needs Release Selection", + "author": "Shelfmark", + "content_type": "ebook", + "provider": "openlibrary", + "provider_id": "needs-release-selection", + }, + "context": { + "source": "*", + "content_type": "ebook", + "request_level": "book", + }, + } + + with patch.object(main_module, "get_auth_mode", return_value="builtin"): + with patch.object(main_module, "load_users_request_policy_settings", return_value=policy): + with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy): + with patch.object(main_module.backend, "queue_release") as mock_queue_release: + resp = client.post("/api/requests", json=payload) + + assert resp.status_code == 400 + assert resp.json["code"] == "policy_requires_download" + assert resp.json["required_mode"] == "download" + mock_queue_release.assert_not_called() + assert main_module.user_db.list_requests(user_id=user["id"]) == [] + def test_blocked_policy_returns_403(self, main_module, client): user = _create_user(main_module, prefix="reader") _set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False) diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index 728fa07..abfeefe 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -206,6 +206,101 @@ class TestProwlarrHandlerDownloadErrors: assert "client" in recorder.last_message.lower() +class TestProwlarrHandlerSeedCriteria: + """Tests for seed criteria passed through from Prowlarr.""" + + def test_resolve_download_converts_seed_time_seconds_to_minutes(self): + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "minimumSeedTime": 259200, + "minimumRatio": 1, + }, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-time-conversion", + source="prowlarr", + title="Test Book", + ) + + request = handler._resolve_download(task, lambda *_: None) + + assert request is not None + assert request.seeding_time_limit == 4320 + assert request.ratio_limit == 1.0 + + def test_resolve_download_rounds_seed_time_up_to_next_minute(self): + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "minimumSeedTime": 61, + }, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-time-round-up", + source="prowlarr", + title="Test Book", + ) + + request = handler._resolve_download(task, lambda *_: None) + + assert request is not None + assert request.seeding_time_limit == 2 + + def test_download_passes_seed_limits_to_client(self): + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + "minimumSeedTime": 259200, + "minimumRatio": 1.25, + }, + ), patch( + "shelfmark.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "shelfmark.release_sources.prowlarr.handler.remove_release", + ), patch.object( + ProwlarrHandler, + "_poll_and_complete", + return_value=None, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="seed-limit-pass-through", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + call_kwargs = mock_client.add_download.call_args.kwargs + assert call_kwargs["seeding_time_limit"] == 4320 + assert call_kwargs["ratio_limit"] == 1.25 + + class TestProwlarrHandlerExistingDownload: """Tests for handling existing downloads.""" diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 79ae3b0..62ce3f5 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -208,19 +208,38 @@ class TestDetectContentType: assert _detect_content_type_from_categories([{"id": 2000}], "ebook") == "other" +class FakeTorznabClient: + def __init__(self): + self.calls: list[tuple[str, object]] = [] + self.queries: list[str] = [] + + def get_enabled_indexers_detailed(self): + return [ + { + "id": 1, + "enable": True, + "capabilities": { + "categories": [ + {"id": 7000, "subCategories": []}, + {"id": 3030, "subCategories": []}, + ] + }, + } + ] + + def torznab_search(self, *, indexer_id: int, query: str, categories=None, search_type="book", limit=100, offset=0): + del indexer_id, search_type, limit, offset + self.calls.append((query, categories)) + self.queries.append(query) + return [] + + def get_enriched_indexer_ids(self, restrict_to=None): + del restrict_to + return [] + + class TestProwlarrLocalizedQueries: def test_manual_query_still_applies_content_type_categories(self, monkeypatch): - class FakeClient: - def __init__(self): - self.calls: list[tuple[str, object]] = [] - - def search(self, query: str, indexer_ids=None, categories=None): - self.calls.append((query, categories)) - return [] - - def get_enriched_indexer_ids(self, restrict_to=None): - return [] - import shelfmark.release_sources.prowlarr.source as prowlarr_source def fake_get(key: str, default=None): @@ -232,7 +251,7 @@ class TestProwlarrLocalizedQueries: monkeypatch.setattr(prowlarr_source.config, "get", fake_get) - fake_client = FakeClient() + fake_client = FakeTorznabClient() source = ProwlarrSource() monkeypatch.setattr(source, "_get_client", lambda: fake_client) @@ -251,17 +270,6 @@ class TestProwlarrLocalizedQueries: assert fake_client.calls == [("my custom", [3030])] def test_manual_query_expand_removes_categories(self, monkeypatch): - class FakeClient: - def __init__(self): - self.calls: list[tuple[str, object]] = [] - - def search(self, query: str, indexer_ids=None, categories=None): - self.calls.append((query, categories)) - return [] - - def get_enriched_indexer_ids(self, restrict_to=None): - return [] - import shelfmark.release_sources.prowlarr.source as prowlarr_source def fake_get(key: str, default=None): @@ -273,7 +281,7 @@ class TestProwlarrLocalizedQueries: monkeypatch.setattr(prowlarr_source.config, "get", fake_get) - fake_client = FakeClient() + fake_client = FakeTorznabClient() source = ProwlarrSource() monkeypatch.setattr(source, "_get_client", lambda: fake_client) @@ -292,17 +300,6 @@ class TestProwlarrLocalizedQueries: assert fake_client.calls == [("my custom", None)] def test_search_uses_localized_titles_when_available(self, monkeypatch): - class FakeClient: - def __init__(self): - self.queries: list[str] = [] - - def search(self, query: str, indexer_ids=None, categories=None): - self.queries.append(query) - return [] - - def get_enriched_indexer_ids(self, restrict_to=None): - return [] - import shelfmark.release_sources.prowlarr.source as prowlarr_source def fake_get(key: str, default=None): @@ -314,7 +311,7 @@ class TestProwlarrLocalizedQueries: monkeypatch.setattr(prowlarr_source.config, "get", fake_get) - fake_client = FakeClient() + fake_client = FakeTorznabClient() source = ProwlarrSource() monkeypatch.setattr(source, "_get_client", lambda: fake_client) @@ -336,17 +333,6 @@ class TestProwlarrLocalizedQueries: assert len(fake_client.queries) == 2 def test_search_does_not_override_search_title_for_english(self, monkeypatch): - class FakeClient: - def __init__(self): - self.queries: list[str] = [] - - def search(self, query: str, indexer_ids=None, categories=None): - self.queries.append(query) - return [] - - def get_enriched_indexer_ids(self, restrict_to=None): - return [] - import shelfmark.release_sources.prowlarr.source as prowlarr_source def fake_get(key: str, default=None): @@ -358,7 +344,7 @@ class TestProwlarrLocalizedQueries: monkeypatch.setattr(prowlarr_source.config, "get", fake_get) - fake_client = FakeClient() + fake_client = FakeTorznabClient() source = ProwlarrSource() monkeypatch.setattr(source, "_get_client", lambda: fake_client)