From f7375d56e25498be18c8df06b1466759a6e24ac1 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 17 Jan 2026 18:56:10 +0000 Subject: [PATCH] Heuristic searches, full language support, manual search override (#483) - Added heuristic-based author and title query creation, stripping out unnecessary elements that could limit searches - Improved language support when using Hardcover. Searches will now be conducted on a per-language basis using localized book titles. - Added manual search override option in the release modal. --- shelfmark/config/settings.py | 8 +- shelfmark/main.py | 7 +- shelfmark/metadata_providers/__init__.py | 1 + shelfmark/metadata_providers/hardcover.py | 193 ++++++++-- shelfmark/release_sources/__init__.py | 10 +- shelfmark/release_sources/direct_download.py | 23 +- shelfmark/release_sources/irc/source.py | 20 +- .../prowlarr/clients/torrent_utils.py | 33 +- shelfmark/release_sources/prowlarr/handler.py | 4 +- shelfmark/release_sources/prowlarr/source.py | 83 ++--- shelfmark/release_sources/prowlarr/utils.py | 52 ++- shelfmark/release_sources/search_plan.py | 164 +++++++++ src/frontend/src/components/ReleaseModal.tsx | 342 +++++++++++------- src/frontend/src/services/api.ts | 7 +- src/frontend/src/types/index.ts | 4 + src/frontend/src/utils/bookTransformers.ts | 2 + tests/direct_download/test_search_queries.py | 37 ++ .../metadata/test_hardcover_search_author.py | 15 + tests/metadata/test_hardcover_search_title.py | 28 ++ tests/prowlarr/test_handler.py | 80 ++++ tests/prowlarr/test_source.py | 87 ++++- tests/release_sources/test_manual_query.py | 28 ++ tests/release_sources/test_search_plan.py | 61 ++++ 23 files changed, 1022 insertions(+), 267 deletions(-) create mode 100644 shelfmark/release_sources/search_plan.py create mode 100644 tests/direct_download/test_search_queries.py create mode 100644 tests/metadata/test_hardcover_search_author.py create mode 100644 tests/metadata/test_hardcover_search_title.py create mode 100644 tests/release_sources/test_manual_query.py create mode 100644 tests/release_sources/test_search_plan.py diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index b3202ef..3cdeba6 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -646,12 +646,12 @@ def download_settings(): }, { "value": "rename", - "label": "Rename", + "label": "Rename only", "description": "Rename files using a template" }, { "value": "organize", - "label": "Organize", + "label": "Rename and Organize", "description": "Create folders and rename files using a template. Do not use with ingest folders." }, ], @@ -771,8 +771,8 @@ def download_settings(): description="Choose how downloaded audiobook files are named and organized.", options=[ {"value": "none", "label": "None", "description": "Keep original filename from source"}, - {"value": "rename", "label": "Rename", "description": "Rename files using a template"}, - {"value": "organize", "label": "Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."}, + {"value": "rename", "label": "Rename only", "description": "Rename files using a template"}, + {"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."}, ], default="rename", universal_only=True, diff --git a/shelfmark/main.py b/shelfmark/main.py index 9c5533d..8458d7e 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1391,6 +1391,8 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: # Content type for audiobook vs ebook search content_type = request.args.get('content_type', 'ebook').strip() + manual_query = request.args.get('manual_query', '').strip() + if not provider or not book_id: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 @@ -1429,7 +1431,10 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: source = get_source(source_name) source_instances[source_name] = source logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search}, content_type={content_type})") - releases = source.search(book, expand_search=expand_search, languages=languages, content_type=content_type) + from shelfmark.release_sources.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=languages, manual_query=manual_query) + releases = source.search(book, plan, expand_search=expand_search, content_type=content_type) all_releases.extend(releases) except ValueError: errors.append(f"Unknown source: {source_name}") diff --git a/shelfmark/metadata_providers/__init__.py b/shelfmark/metadata_providers/__init__.py index 9edfe1b..91fcb55 100644 --- a/shelfmark/metadata_providers/__init__.py +++ b/shelfmark/metadata_providers/__init__.py @@ -145,6 +145,7 @@ class BookMetadata: source_url: Optional[str] = None # Link to book on provider's site subtitle: Optional[str] = None # Book subtitle, if any search_title: Optional[str] = None # Cleaner title for search queries (provider-specific) + search_author: Optional[str] = None # Cleaner author for search queries (provider-specific) # Provider-specific display fields for cards/lists display_fields: List[DisplayField] = field(default_factory=list) diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 195eb62..9d77a02 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -1,5 +1,6 @@ """Hardcover.app metadata provider. Requires API key.""" +import re import requests from datetime import datetime from typing import Any, Dict, List, Optional @@ -95,27 +96,139 @@ def _build_source_url(slug: str) -> Optional[str]: return f"https://hardcover.app/books/{slug}" if slug else None -def _compute_search_title(title: str, subtitle: Optional[str]) -> Optional[str]: - """Compute a cleaner search title from title and subtitle. +def _is_probably_series_position(subtitle: str) -> bool: + normalized = subtitle.strip().lower() - When Hardcover uses the "Series: Book Title" format, the subtitle contains - the actual book title which is better for searching. For example: - - title: "Mistborn: The Final Empire" - - subtitle: "The Final Empire" - - search_title: "The Final Empire" (better for Prowlarr/indexer searches) + # Common patterns: "Book One", "Book 1", "Part 2", "Volume III", etc. + if re.match(r"^(book|part|volume|vol\.?|episode)\s+([0-9]+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten)\b", normalized): + return True - Skips subtitles that start with series position indicators like "Book One", - "Part 1", "Volume 2" as these are descriptors, not the actual title. + # e.g. "A Novel", "An Epic Fantasy", etc. These add noise to indexer queries. + if normalized in {"a novel", "a novella", "a story", "a memoir"}: + return True + + return False + + +def _strip_parenthetical_suffix(title: str) -> str: + # Drop trailing qualifiers like "(Unabridged)", "(Illustrated Edition)", etc. + return re.sub(r"\s*\([^)]*\)\s*$", "", title).strip() + + +def _simplify_author_for_search(author: str) -> Optional[str]: + """Return a looser author string for indexer searches. + + Primary goal: reduce mismatch between metadata providers and indexers. + + Heuristics (intentionally conservative): + - Remove middle initials (e.g. "Robert R. McCammon" -> "Robert McCammon") + - Remove standalone middle names that are just an initial or initial+dot + - Preserve suffixes like "Jr."/"Sr."/"III" as they sometimes matter """ - if not subtitle or subtitle not in title: + if not author: return None - # Skip if subtitle starts with series position indicators - skip_prefixes = ('book ', 'part ', 'volume ') - if subtitle.lower().startswith(skip_prefixes): + normalized = " ".join(author.split()).strip() + if not normalized: return None - return subtitle + # Handle "Last, First ..." -> "First ... Last" + if "," in normalized: + parts = [p.strip() for p in normalized.split(",") if p.strip()] + if len(parts) >= 2: + normalized = " ".join(parts[1:] + [parts[0]]).strip() + + tokens = normalized.split(" ") + if len(tokens) < 2: + return None + + keep_suffixes = {"jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v"} + + simplified: list[str] = [] + for idx, token in enumerate(tokens): + t = token.strip() + if not t: + continue + + t_lower = t.lower() + is_suffix = (idx == len(tokens) - 1) and (t_lower in keep_suffixes) + if is_suffix: + simplified.append(t) + continue + + # Drop middle initials like "R." or "R" + is_initial = re.match(r"^[A-Za-z]\.?$", t) is not None + is_middle_token = 0 < idx < (len(tokens) - 1) + if is_middle_token and is_initial: + continue + + simplified.append(t) + + if len(simplified) < 2: + return None + + candidate = " ".join(simplified).strip() + if candidate.lower() == normalized.lower(): + return None + + return candidate + + +def _compute_search_title( + title: str, + subtitle: Optional[str], + *, + series_name: Optional[str] = None, +) -> Optional[str]: + """Compute a provider-specific, *looser* title for indexer searching. + + Goal: produce a string that maximizes recall in downstream sources (Prowlarr, + IRC bots, etc.). Being too detailed is counterproductive. + + Hardcover often stores titles in a "Series: Book Title" format and places the + standalone book title in `subtitle`. When this appears to be the case, prefer + the subtitle (unless it looks like a series position or other noise). + + Additional heuristics: + - If Hardcover prefixes the series in the title, remove it. + - Drop trailing parenthetical qualifiers. + """ + if not title: + return None + + original_title = " ".join(title.split()).strip() + + normalized_title = _strip_parenthetical_suffix(original_title) + + normalized_subtitle = " ".join(subtitle.split()).strip() if subtitle else "" + normalized_subtitle = _strip_parenthetical_suffix(normalized_subtitle) if normalized_subtitle else "" + + if normalized_subtitle and normalized_subtitle.lower() == normalized_title.lower(): + normalized_subtitle = "" + + # Prefer subtitle when it looks like the real title. + if normalized_subtitle and not _is_probably_series_position(normalized_subtitle): + # If title contains the subtitle, this is likely "Series: Subtitle". + if normalized_subtitle.lower() in normalized_title.lower(): + return normalized_subtitle + + # If we know the series name (from full book fetch), strip it. + if series_name: + series_normalized = " ".join(series_name.split()).strip() + if series_normalized: + # Common Hardcover format: "Series: Book Title". + prefix = f"{series_normalized}:" + if normalized_title.lower().startswith(prefix.lower()): + candidate = normalized_title[len(prefix):].strip() + candidate = _strip_parenthetical_suffix(candidate) + if candidate and candidate.lower() != normalized_title.lower(): + return candidate + + # Last resort: return a cleaned version of the title if we removed noise. + if normalized_title and normalized_title.lower() != original_title.lower(): + return normalized_title + + return None @register_provider_kwargs("hardcover") @@ -557,6 +670,8 @@ class HardcoverProvider(MetadataProvider): # Normalize whitespace in author names (some API data has multiple spaces) authors = [" ".join(name.split()) for name in authors] + search_author = _simplify_author_for_search(authors[0]) if authors else None + cover_url = _extract_cover_url(item, "image") publish_year = _extract_publish_year(item) source_url = _build_source_url(item.get("slug", "")) @@ -592,6 +707,7 @@ class HardcoverProvider(MetadataProvider): title=title, subtitle=subtitle, search_title=_compute_search_title(title, subtitle), + search_author=search_author, provider_display_name="Hardcover", authors=authors, cover_url=cover_url, @@ -601,12 +717,16 @@ class HardcoverProvider(MetadataProvider): display_fields=display_fields, ) + except Exception as e: logger.debug(f"Failed to parse Hardcover search result: {e}") return None def _parse_book(self, book: Dict) -> BookMetadata: """Parse a book object into BookMetadata.""" + title = str(book.get("title") or "") + subtitle = book.get("subtitle") + # Extract authors - try contributions first (filtered), fall back to cached_contributors authors = [] contributions = book.get("contributions") or [] @@ -634,6 +754,8 @@ class HardcoverProvider(MetadataProvider): # Normalize whitespace in author names (some API data has multiple spaces) authors = [" ".join(name.split()) for name in authors] + search_author = _simplify_author_for_search(authors[0]) if authors else None + cover_url = _extract_cover_url(book, "cached_image", "image") publish_year = _extract_publish_year(book) @@ -709,29 +831,28 @@ class HardcoverProvider(MetadataProvider): if code3 and code3 not in titles_by_language: titles_by_language[code3] = edition_title - title = book["title"] - subtitle = book.get("subtitle") - return BookMetadata( - provider="hardcover", - provider_id=str(book["id"]), - title=title, - subtitle=subtitle, - search_title=_compute_search_title(title, subtitle), - provider_display_name="Hardcover", - authors=authors, - isbn_10=isbn_10, - isbn_13=isbn_13, - cover_url=cover_url, - description=full_description, - publish_year=publish_year, - genres=genres, - source_url=source_url, - series_name=series_name, - series_position=series_position, - series_count=series_count, - titles_by_language=titles_by_language, - ) + provider="hardcover", + provider_id=str(book["id"]), + title=title, + subtitle=subtitle, + search_title=_compute_search_title(title, subtitle, series_name=series_name), + search_author=search_author, + provider_display_name="Hardcover", + authors=authors, + isbn_10=isbn_10, + isbn_13=isbn_13, + cover_url=cover_url, + description=full_description, + publish_year=publish_year, + genres=genres, + source_url=source_url, + series_name=series_name, + series_position=series_position, + series_count=series_count, + titles_by_language=titles_by_language, + ) + def _test_hardcover_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index 1edd1c4..843d749 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -4,7 +4,10 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field, asdict from enum import Enum from threading import Event -from typing import List, Optional, Dict, Type, Callable, Literal, Any +from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from shelfmark.release_sources.search_plan import ReleaseSearchPlan from shelfmark.core.models import DownloadTask from shelfmark.metadata_providers import BookMetadata @@ -233,8 +236,8 @@ class ReleaseSource(ABC): def search( self, book: BookMetadata, + plan: "ReleaseSearchPlan", expand_search: bool = False, - languages: Optional[List[str]] = None, content_type: str = "ebook" ) -> List[Release]: """Search for releases of a book.""" @@ -245,8 +248,7 @@ class ReleaseSource(ABC): """Check if this source is configured and reachable.""" pass - @classmethod - def get_column_config(cls) -> ReleaseColumnConfig: + def get_column_config(self) -> ReleaseColumnConfig: """Get column configuration for release list UI. Override for custom columns.""" return _default_column_config() diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 729657f..ba7cdea 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -1090,8 +1090,7 @@ class DirectDownloadSource(ReleaseSource): """Returns the search type used in the last search() call.""" return self._last_search_type - @classmethod - def get_column_config(cls) -> ReleaseColumnConfig: + def get_column_config(self) -> ReleaseColumnConfig: """Column configuration for Direct Download source. Shows language, format, and size badges for each release. @@ -1135,8 +1134,8 @@ class DirectDownloadSource(ReleaseSource): def search( self, book: BookMetadata, + plan: "ReleaseSearchPlan", # noqa: F821 expand_search: bool = False, - languages: Optional[List[str]] = None, content_type: str = "ebook" ) -> List[Release]: """ @@ -1151,15 +1150,17 @@ class DirectDownloadSource(ReleaseSource): languages: Language codes to filter by (overrides book.language/config) content_type: Ignored - Direct download uses format filtering instead """ - # Language filter: explicit param > book.language > config default - lang_filter = languages or ([book.language] if book.language else config.BOOK_LANGUAGE) + lang_filter = plan.languages # Reset search type tracking self._last_search_type = "title_author" # ISBN search first (unless expand_search requested) + if plan.manual_query: + expand_search = True + if not expand_search: - isbn = book.isbn_13 or book.isbn_10 + isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None if isbn: logger.debug(f"Searching by ISBN: {isbn}") filters = SearchFilters(isbn=[isbn]) @@ -1178,14 +1179,8 @@ class DirectDownloadSource(ReleaseSource): logger.warning(f"ISBN search failed: {e}") # Title + author fallback - author = book.authors[0] if book.authors else "" - - # Group languages by localized title to avoid duplicate searches - searches = group_languages_by_localized_title( - base_title=book.title, - languages=lang_filter, - titles_by_language=book.titles_by_language, - ) + author = plan.author + searches = [(v.title, v.languages) for v in plan.grouped_title_variants] # Execute searches with deduplication seen_ids: set = set() diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index 98f84bd..18014fe 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -6,7 +6,10 @@ Searches IRC ebook channels for book releases. import tempfile import time from pathlib import Path -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from shelfmark.release_sources.search_plan import ReleaseSearchPlan from shelfmark.api.websocket import ws_manager from shelfmark.core.config import config @@ -74,8 +77,7 @@ class IRCReleaseSource(ReleaseSource): # Track online servers from most recent search self._online_servers: Optional[set[str]] = None - @classmethod - def is_available(cls) -> bool: + def is_available(self) -> bool: """Check if IRC is configured (server, channel, and nick are set).""" server = config.get("IRC_SERVER", "") channel = config.get("IRC_CHANNEL", "") @@ -122,8 +124,8 @@ class IRCReleaseSource(ReleaseSource): def search( self, book: BookMetadata, + plan: "ReleaseSearchPlan", expand_search: bool = False, - languages: Optional[List[str]] = None, content_type: str = "ebook" ) -> List[Release]: """Search IRC for books matching metadata. @@ -146,7 +148,7 @@ class IRCReleaseSource(ReleaseSource): return cached["releases"] # Build search query - query = self._build_query(book) + query = plan.primary_query or self._build_query(book) if not query: logger.warning("No search query could be built") return [] @@ -248,10 +250,12 @@ class IRCReleaseSource(ReleaseSource): """Build search query from book metadata.""" parts = [] - if book.title: - parts.append(book.title) + if book.search_title or book.title: + parts.append(book.search_title or book.title) - if book.authors: + if book.search_author: + parts.append(book.search_author) + elif book.authors: # Use first author author = book.authors[0] if isinstance(book.authors, list) else book.authors parts.append(author) diff --git a/shelfmark/release_sources/prowlarr/clients/torrent_utils.py b/shelfmark/release_sources/prowlarr/clients/torrent_utils.py index 808e3fc..9160846 100644 --- a/shelfmark/release_sources/prowlarr/clients/torrent_utils.py +++ b/shelfmark/release_sources/prowlarr/clients/torrent_utils.py @@ -5,10 +5,11 @@ import hashlib import re from dataclasses import dataclass from typing import Optional, Tuple -from urllib.parse import parse_qs, urlparse +from urllib.parse import parse_qs, urljoin, urlparse import requests +from shelfmark.core.config import config from shelfmark.core.logger import setup_logger logger = setup_logger(__name__) @@ -32,7 +33,16 @@ class TorrentInfo: def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: - """Extract info_hash from magnet link or .torrent URL.""" + """Extract info_hash from magnet link or .torrent URL. + + Notes: + When the URL points at Prowlarr's proxied download endpoint, it typically + requires the `X-Api-Key` header. If `PROWLARR_API_KEY` is configured, + include it for the torrent fetch request. + + This mirrors how Sonarr builds an authenticated download request via the + indexer when grabbing torrent files. + """ is_magnet = url.startswith("magnet:") # Try to extract hash from magnet URL @@ -44,25 +54,36 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: if not fetch_torrent: return TorrentInfo(info_hash=None, torrent_data=None, is_magnet=False) + headers: dict[str, str] = {} + api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip() + if api_key: + headers["X-Api-Key"] = api_key + + def resolve_url(current: str, location: str) -> str: + if not location: + return current + # Support relative redirect locations + return urljoin(current, location) + try: logger.debug(f"Fetching torrent file from: {url[:80]}...") # Use allow_redirects=False to handle magnet link redirects manually # Some indexers redirect download URLs to magnet links - resp = requests.get(url, timeout=30, allow_redirects=False) + resp = requests.get(url, timeout=30, allow_redirects=False, headers=headers) # Check if this is a redirect to a magnet link if resp.status_code in (301, 302, 303, 307, 308): - redirect_url = resp.headers.get("Location", "") + redirect_url = resolve_url(url, resp.headers.get("Location", "")) if redirect_url.startswith("magnet:"): - logger.debug(f"Download URL redirected to magnet link") + logger.debug("Download URL redirected to magnet link") info_hash = extract_hash_from_magnet(redirect_url) return TorrentInfo( info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url ) # Not a magnet redirect, follow it manually logger.debug(f"Following redirect to: {redirect_url[:80]}...") - resp = requests.get(redirect_url, timeout=30) + resp = requests.get(redirect_url, timeout=30, headers=headers) resp.raise_for_status() torrent_data = resp.content diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 487db3f..d4aa942 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -16,7 +16,7 @@ from shelfmark.release_sources.prowlarr.clients import ( get_client, list_configured_clients, ) -from shelfmark.release_sources.prowlarr.utils import get_protocol +from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol logger = setup_logger(__name__) @@ -165,7 +165,7 @@ class ProwlarrHandler(DownloadHandler): return None # Extract download URL - download_url = prowlarr_result.get("downloadUrl") or prowlarr_result.get("magnetUrl") + download_url = get_preferred_download_url(prowlarr_result) if not download_url: status_callback("error", "No download URL available") return None diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index fdb02ec..605c514 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -1,13 +1,17 @@ """Prowlarr release source - searches indexers for book releases (torrents/usenet).""" import re -from typing import List, Optional +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from shelfmark.release_sources.search_plan import ReleaseSearchPlan from shelfmark.core.config import config from shelfmark.core.logger import setup_logger -from shelfmark.metadata_providers import BookMetadata, build_localized_search_titles +from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import ( Release, + ReleaseProtocol, ReleaseSource, register_source, ReleaseColumnConfig, @@ -20,7 +24,7 @@ from shelfmark.release_sources import ( ) from shelfmark.release_sources.prowlarr.api import ProwlarrClient from shelfmark.release_sources.prowlarr.cache import cache_release -from shelfmark.release_sources.prowlarr.utils import get_protocol_display +from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol logger = setup_logger(__name__) @@ -136,11 +140,11 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook" title = result.get("title", "Unknown") size_bytes = result.get("size") indexer = result.get("indexer", "Unknown") - protocol = get_protocol_display(result) + protocol = get_protocol(result) seeders = result.get("seeders") leechers = result.get("leechers") categories = result.get("categories", []) - is_torrent = protocol == "torrent" + is_torrent = protocol == ReleaseProtocol.TORRENT # Format peers display string: "seeders / leechers" peers_display = ( @@ -167,9 +171,15 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook" language=_extract_language(title), size=_parse_size(size_bytes), size_bytes=size_bytes, - download_url=result.get("downloadUrl") or result.get("magnetUrl"), + download_url=get_preferred_download_url(result), info_url=result.get("infoUrl") or result.get("guid"), - protocol=protocol, + protocol=( + ReleaseProtocol.TORRENT + if protocol == "torrent" + else ReleaseProtocol.NZB + if protocol == "usenet" + else None + ), indexer=indexer, seeders=seeders if is_torrent else None, peers=peers_display, @@ -195,8 +205,7 @@ class ProwlarrSource(ReleaseSource): def __init__(self): self.last_search_type: Optional[str] = None - @classmethod - def get_column_config(cls) -> ReleaseColumnConfig: + def get_column_config(self) -> ReleaseColumnConfig: """Column configuration for Prowlarr releases.""" return ReleaseColumnConfig( columns=[ @@ -254,7 +263,7 @@ class ProwlarrSource(ReleaseSource): ], grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 90px 80px", leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr - supported_filters=[], # Prowlarr has unreliable format/language metadata; content_type is auto-detected + supported_filters=["language"], # Enables multi-language query expansion; Prowlarr language metadata is unreliable ) def _get_client(self) -> Optional[ProwlarrClient]: @@ -294,8 +303,8 @@ class ProwlarrSource(ReleaseSource): def search( self, book: BookMetadata, + plan: "ReleaseSearchPlan", # noqa: F821 expand_search: bool = False, - languages: Optional[List[str]] = None, content_type: str = "ebook" ) -> List[Release]: """Search Prowlarr indexers for releases matching the book.""" @@ -304,41 +313,11 @@ class ProwlarrSource(ReleaseSource): logger.warning("Prowlarr not configured - skipping search") return [] - # Build search queries (optionally include localized titles) - query_author = "" - if book.authors: - # Use first author only - authors may be a list or a single string - # that contains multiple comma-separated names (from frontend) - first_author = book.authors[0] - # If first author contains comma, split and use only the primary author - if "," in first_author: - first_author = first_author.split(",")[0].strip() - query_author = first_author - - # Prefer search_title if available (cleaner title for searches) - search_title = book.search_title or book.title - - language_preferences = languages or ([book.language] if book.language else None) - search_titles = build_localized_search_titles( - base_title=search_title, - languages=language_preferences, - titles_by_language=book.titles_by_language, - # Keep the existing search_title behavior for English while still - # allowing additional localized searches for other languages. - excluded_languages={"en", "eng", "english"}, - ) - - queries = [ - " ".join(part for part in [title, query_author] if part).strip() - for title in search_titles - ] + queries = [v.query for v in plan.title_variants if v.query] queries = [q for q in queries if q] - if not queries: - # Try ISBN as fallback - isbn_query = book.isbn_13 or book.isbn_10 or "" - if isbn_query: - queries = [isbn_query] + if not queries and plan.isbn_candidates: + queries = list(plan.isbn_candidates) if not queries: logger.warning("No search query available for book") @@ -350,8 +329,16 @@ class ProwlarrSource(ReleaseSource): # Get search categories based on content type # Audiobooks use 3030 (Audio/Audiobook), ebooks use 7000 (Books) search_categories = [3030] if content_type == "audiobook" else [7000] - categories = None if expand_search else search_categories - self.last_search_type = "expanded" if expand_search else "categories" + + # Manual query override should behave like normal Prowlarr searches: + # - default: search within the content-type categories + # - expand: rerun without categories + if plan.manual_query: + categories = None if expand_search else search_categories + self.last_search_type = "manual_expanded" if expand_search else "manual_query" + else: + categories = None if expand_search else search_categories + self.last_search_type = "expanded" if expand_search else "categories" indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers" if len(queries) == 1: @@ -415,8 +402,8 @@ class ProwlarrSource(ReleaseSource): results = [_prowlarr_result_to_release(r, content_type) for r in all_results] if results: - torrent_count = sum(1 for r in results if r.protocol == "torrent") - nzb_count = sum(1 for r in results if r.protocol == "nzb") + torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT) + nzb_count = sum(1 for r in results if r.protocol == ReleaseProtocol.NZB) indexers = sorted(set(r.indexer for r in results if r.indexer)) indexer_str = ", ".join(indexers) if indexers else "unknown" logger.info(f"Prowlarr: {len(results)} results ({torrent_count} torrent, {nzb_count} nzb) from {indexer_str}") diff --git a/shelfmark/release_sources/prowlarr/utils.py b/shelfmark/release_sources/prowlarr/utils.py index bbe27cf..7098ef2 100644 --- a/shelfmark/release_sources/prowlarr/utils.py +++ b/shelfmark/release_sources/prowlarr/utils.py @@ -9,33 +9,51 @@ from typing import Optional def get_protocol(result: dict) -> str: + """Get the download protocol from a Prowlarr result. + + Uses the protocol field directly if available, otherwise infers from URLs. """ - Get the download protocol from a Prowlarr result. - - Uses the protocol field directly if available, otherwise infers from URL. - - Args: - result: Prowlarr search result dictionary - - Returns: - Protocol string: "torrent", "usenet", or "unknown" - """ - # Prowlarr provides protocol directly - use it - protocol = result.get("protocol", "").lower() + protocol = str(result.get("protocol", "")).lower() if protocol in ("torrent", "usenet"): return protocol - # Fallback: infer from download URL - download_url = result.get("downloadUrl") or result.get("magnetUrl") or "" - url_lower = download_url.lower() - if url_lower.startswith("magnet:") or ".torrent" in url_lower: + magnet_url = str(result.get("magnetUrl") or "").lower() + download_url = str(result.get("downloadUrl") or "").lower() + + # Prefer magnetUrl for inference if present. + if magnet_url.startswith("magnet:"): return "torrent" - if ".nzb" in url_lower: + + if download_url.startswith("magnet:") or ".torrent" in download_url: + return "torrent" + if ".nzb" in download_url: return "usenet" return "unknown" +def get_preferred_download_url(result: dict) -> str: + """Pick the best URL to hand to a download client. + + For torrent results, prefer magnetUrl when available (downloadUrl may be a + Prowlarr proxy URL that needs auth/headers). + """ + protocol = str(result.get("protocol", "")).lower() + magnet_url = str(result.get("magnetUrl") or "").strip() + download_url = str(result.get("downloadUrl") or "").strip() + + if protocol == "torrent": + return magnet_url or download_url + if protocol == "usenet": + return download_url or magnet_url + + # Unknown protocol: if it looks like a magnet, still prefer it. + if magnet_url.lower().startswith("magnet:"): + return magnet_url + + return download_url or magnet_url + + def get_protocol_display(result: dict) -> str: """ Get a user-friendly display label for the protocol. diff --git a/shelfmark/release_sources/search_plan.py b/shelfmark/release_sources/search_plan.py new file mode 100644 index 0000000..22f1f62 --- /dev/null +++ b/shelfmark/release_sources/search_plan.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + +MANUAL_QUERY_MAX_LEN = 256 + +from shelfmark.core.config import config +from shelfmark.metadata_providers import ( + BookMetadata, + group_languages_by_localized_title, + build_localized_search_titles, +) + + +@dataclass(frozen=True) +class ReleaseSearchVariant: + """A single search variant (title + author) associated with languages.""" + + title: str + author: str + languages: Optional[List[str]] = None + + @property + def query(self) -> str: + return " ".join(part for part in [self.title, self.author] if part).strip() + + +@dataclass(frozen=True) +class ReleaseSearchPlan: + """Pre-computed search inputs shared across release sources.""" + + languages: Optional[List[str]] + isbn_candidates: List[str] + author: str + title_variants: List[ReleaseSearchVariant] + grouped_title_variants: List[ReleaseSearchVariant] + manual_query: Optional[str] = None + + @property + def primary_query(self) -> str: + return self.title_variants[0].query if self.title_variants else "" + + +def _normalize_languages(languages: Optional[List[str]]) -> Optional[List[str]]: + if not languages: + default = config.BOOK_LANGUAGE + if not default: + return None + return [str(lang).strip() for lang in default if str(lang).strip()] + + normalized: List[str] = [] + for lang in languages: + if not lang: + continue + s = str(lang).strip() + if not s: + continue + normalized.append(s) + + if any(lang.lower() == "all" for lang in normalized): + return None + + return normalized or None + + +def _pick_search_author(book: BookMetadata) -> str: + if book.search_author: + return book.search_author + + if not book.authors: + return "" + + first = book.authors[0] + if "," in first: + first = first.split(",")[0].strip() + + return first + + +def _pick_search_title(book: BookMetadata) -> str: + return book.search_title or book.title + + +def build_release_search_plan( + book: BookMetadata, + languages: Optional[List[str]] = None, + manual_query: Optional[str] = None, +) -> ReleaseSearchPlan: + resolved_languages = _normalize_languages(languages) + + resolved_manual_query = None + if manual_query: + resolved_manual_query = manual_query.strip()[:MANUAL_QUERY_MAX_LEN] or None + + author = _pick_search_author(book) + base_title = _pick_search_title(book) + + if resolved_manual_query: + # Manual override: use the raw query as-is (no language/title expansion). + variant = ReleaseSearchVariant(title=resolved_manual_query, author="", languages=None) + return ReleaseSearchPlan( + languages=resolved_languages, + isbn_candidates=[], + author="", + title_variants=[variant], + grouped_title_variants=[variant], + manual_query=resolved_manual_query, + ) + + isbn_candidates: List[str] = [] + if book.isbn_13: + isbn_candidates.append(book.isbn_13) + if book.isbn_10 and book.isbn_10 not in isbn_candidates: + isbn_candidates.append(book.isbn_10) + + titles_by_language = book.titles_by_language or None + if book.search_title and titles_by_language: + titles_by_language = { + k: v + for k, v in titles_by_language.items() + if str(k).strip().lower() not in {"en", "eng", "english"} + } + + grouped = group_languages_by_localized_title( + base_title=base_title, + languages=resolved_languages, + titles_by_language=titles_by_language, + ) + + grouped_variants: List[ReleaseSearchVariant] = [ + ReleaseSearchVariant(title=title, author=author, languages=langs) + for title, langs in grouped + if title + ] + + expanded_titles = build_localized_search_titles( + base_title=base_title, + languages=resolved_languages, + titles_by_language=titles_by_language, + excluded_languages={"en", "eng", "english"}, + ) + + title_variants: List[ReleaseSearchVariant] = [ + ReleaseSearchVariant(title=title, author=author, languages=None) + for title in expanded_titles + if title + ] + + # If no titles could be built, fall back to ISBN queries. + if not title_variants and isbn_candidates: + title_variants = [ + ReleaseSearchVariant(title=isbn, author="", languages=None) + for isbn in isbn_candidates + ] + + return ReleaseSearchPlan( + languages=resolved_languages, + isbn_candidates=isbn_candidates, + author=author, + title_variants=title_variants, + grouped_title_variants=grouped_variants, + manual_query=None, + ) diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 49d1ac5..eb1a589 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -622,6 +622,8 @@ export const ReleaseModal = ({ // A specific value means "show only that format" const [formatFilter, setFormatFilter] = useState(''); const [languageFilter, setLanguageFilter] = useState([LANGUAGE_OPTION_DEFAULT]); + const [manualQuery, setManualQuery] = useState(''); + const [showManualQuery, setShowManualQuery] = useState(false); // Sort state - keyed by source name, persisted to localStorage // null means "Default" (backend order), undefined means "not set yet" @@ -844,7 +846,7 @@ export const ReleaseModal = ({ setErrorBySource((prev) => ({ ...prev, [activeTab]: null })); try { - const response = await getReleases(provider, bookId, activeTab, book.title, book.author, undefined, undefined, contentType); + const response = await getReleases(provider, bookId, activeTab, book.title, book.author, undefined, undefined, contentType, manualQuery.trim() || undefined); setCachedReleases(provider, bookId, activeTab, contentType, response); setReleasesBySource((prev) => ({ ...prev, [activeTab]: response })); } catch (err) { @@ -856,7 +858,7 @@ export const ReleaseModal = ({ }; fetchReleases(); - }, [book, activeTab, releasesBySource, loadingBySource, errorBySource, contentType]); + }, [book, activeTab, releasesBySource, loadingBySource, errorBySource, contentType, manualQuery]); // Handler for expanding search (title+author instead of ISBN) // Fetches additional results and merges with existing ISBN results @@ -879,7 +881,7 @@ export const ReleaseModal = ({ // Fetch with expand_search=true (title+author search) const expandedResponse = await getReleases( - provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType + provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined ); // Merge with existing results, deduplicating by source_id @@ -906,7 +908,7 @@ export const ReleaseModal = ({ } finally { setLoadingBySource((prev) => ({ ...prev, [activeTab]: false })); } - }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType]); + }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery]); // Build list of tabs to show // Only show enabled sources that support the current content type @@ -1230,20 +1232,22 @@ export const ReleaseModal = ({ {book.author || 'Unknown author'}

- +
+ +
- {/* Scrollable content */} -
+ {/* Scrollable content */} +
{/* Book summary - scrolls with content */}
{book.preview ? ( @@ -1401,123 +1405,137 @@ export const ReleaseModal = ({
- {/* Sort dropdown - only show if source has sortable columns */} - {sortableColumns.length > 0 && ( - ( - - )} +
+ {/* Manual query button */} + - {sortableColumns.map((col) => { - const sortKey = col.sort_key || col.key; - const isSelected = currentSort?.key === sortKey; - const direction = isSelected ? currentSort?.direction : null; - return ( - - ); - })} -
- )} -
- )} + + + + - {/* Filter funnel button - stays fixed */} - {/* Only show filter button if source supports at least one filter type */} - {((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) || - (columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && ( - { - // Active filter: format is set, or language is not just default - const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT); - const hasActiveFilter = formatFilter !== '' || hasLanguageFilter; - return ( + {/* Sort dropdown - only show if source has sortable columns */} + {sortableColumns.length > 0 && ( + ( - ); - }} - > + )} + > + {({ close }) => ( +
+ {/* Default option - no client-side sorting */} + + {sortableColumns.map((col) => { + const sortKey = col.sort_key || col.key; + const isSelected = currentSort?.key === sortKey; + const direction = isSelected ? currentSort?.direction : null; + return ( + + ); + })} +
+ )} +
+ )} + + {/* Filter funnel button - stays fixed */} + {/* Only show filter button if source supports at least one filter type */} + {((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) || + (columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && ( + { + // Active filter: format is set, or language is not just default + const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT); + const hasActiveFilter = formatFilter !== '' || hasLanguageFilter; + return ( + + ); + }} + > {({ close }) => (
{columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && ( @@ -1538,8 +1556,8 @@ export const ReleaseModal = ({ defaultLanguageCodes={defaultLanguages} /> )} - {/* Apply button - for AA, re-fetches with language filter; for others, just closes */} - {activeTab === 'direct_download' && ( + {/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */} + {(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
)} + {/* Manual query panel (below source tabs) */} + {showManualQuery && ( +
+
{ + e.preventDefault(); + if (!book?.provider || !book?.provider_id) return; + + const q = manualQuery.trim(); + if (!q) return; + + const provider = book.provider; + const bookId = book.provider_id; + + // Clear cache + clear visible results so user gets feedback. + const key = getCacheKey(provider, bookId, activeTab, contentType); + releaseCache.delete(key); + cacheTimestamps.delete(key); + setExpandedBySource((prev) => { + const next = { ...prev }; + delete next[activeTab]; + return next; + }); + setErrorBySource((prev) => ({ ...prev, [activeTab]: null })); + setReleasesBySource((prev) => ({ ...prev, [activeTab]: null })); + + setLoadingBySource((prev) => ({ ...prev, [activeTab]: true })); + try { + const response = await getReleases( + provider, + bookId, + activeTab, + book.title, + book.author, + false, + undefined, + contentType, + q + ); + setCachedReleases(provider, bookId, activeTab, contentType, response); + setReleasesBySource((prev) => ({ ...prev, [activeTab]: response })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to fetch releases'; + setErrorBySource((prev) => ({ ...prev, [activeTab]: message })); + } finally { + setLoadingBySource((prev) => ({ ...prev, [activeTab]: false })); + } + }} + > + setManualQuery(e.target.value)} + placeholder="Type a custom search query (overrides all sources)" + className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--border-muted)] bg-[var(--bg)] text-[var(--text)]" + /> + +
+

+ Manual query overrides ISBN/title/author/language expansion. +

+
+ )} + {/* Release list content */}
{sourcesLoading ? ( diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index e640157..e3ec170 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -200,6 +200,7 @@ export const downloadRelease = async (release: { series_name?: string; series_position?: number; subtitle?: string; + search_author?: string; }): Promise => { await fetchJSON(`${API_BASE}/releases/download`, { method: 'POST', @@ -324,7 +325,8 @@ export const getReleases = async ( author?: string, expandSearch?: boolean, languages?: string[], - contentType?: string + contentType?: string, + manualQuery?: string ): Promise => { const params = new URLSearchParams({ provider, @@ -348,6 +350,9 @@ export const getReleases = async ( if (contentType) { params.set('content_type', contentType); } + if (manualQuery) { + params.set('manual_query', manualQuery); + } const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS; return fetchJSON(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs); }; diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 727aab1..15eae8d 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -46,6 +46,8 @@ export interface Book { series_position?: number; // This book's position (e.g., 3, 1.5 for novellas) series_count?: number; // Total books in the series subtitle?: string; + search_title?: string; + search_author?: string; } // Status response types @@ -280,6 +282,8 @@ export interface ReleasesResponse { provider_id: string; title: string; subtitle?: string; + search_author?: string; + search_title?: string; authors?: string[]; isbn_10?: string; isbn_13?: string; diff --git a/src/frontend/src/utils/bookTransformers.ts b/src/frontend/src/utils/bookTransformers.ts index a79d337..15b00f6 100644 --- a/src/frontend/src/utils/bookTransformers.ts +++ b/src/frontend/src/utils/bookTransformers.ts @@ -29,6 +29,7 @@ export interface MetadataBookData { series_position?: number; series_count?: number; subtitle?: string; + search_author?: string; } /** @@ -57,6 +58,7 @@ export function transformMetadataToBook(data: MetadataBookData): Book { series_position: data.series_position, series_count: data.series_count, subtitle: data.subtitle, + search_author: data.search_author, info: { ...(data.isbn_13 && { ISBN: data.isbn_13 }), ...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }), diff --git a/tests/direct_download/test_search_queries.py b/tests/direct_download/test_search_queries.py new file mode 100644 index 0000000..82c3104 --- /dev/null +++ b/tests/direct_download/test_search_queries.py @@ -0,0 +1,37 @@ +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources.direct_download import DirectDownloadSource +from shelfmark.release_sources.search_plan import build_release_search_plan + + +class TestDirectDownloadSearchQueries: + def test_uses_search_title_for_english_queries(self, monkeypatch): + captured: list[str] = [] + + def fake_search_books(query: str, filters): + captured.append(query) + return [] + + import shelfmark.release_sources.direct_download as dd + + monkeypatch.setattr(dd, "search_books", fake_search_books) + + source = DirectDownloadSource() + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + search_author="Brandon Sanderson", + authors=["Brandon Sanderson"], + titles_by_language={ + "en": "Mistborn: The Final Empire", + "hu": "A végső birodalom", + }, + ) + + plan = build_release_search_plan(book, languages=["en", "hu"]) + source.search(book, plan, expand_search=True) + + assert "The Final Empire Brandon Sanderson" in captured + assert "A végső birodalom Brandon Sanderson" in captured + assert "Mistborn: The Final Empire Brandon Sanderson" not in captured diff --git a/tests/metadata/test_hardcover_search_author.py b/tests/metadata/test_hardcover_search_author.py new file mode 100644 index 0000000..36c724c --- /dev/null +++ b/tests/metadata/test_hardcover_search_author.py @@ -0,0 +1,15 @@ +from shelfmark.metadata_providers.hardcover import _simplify_author_for_search + + +class TestHardcoverSimplifyAuthorForSearch: + def test_removes_middle_initial(self): + assert _simplify_author_for_search("Robert R. McCammon") == "Robert McCammon" + + def test_keeps_suffix(self): + assert _simplify_author_for_search("Martin L. King Jr.") == "Martin King Jr." + + def test_handles_comma_format(self): + assert _simplify_author_for_search("McCammon, Robert R.") == "Robert McCammon" + + def test_returns_none_when_no_change(self): + assert _simplify_author_for_search("Frank Herbert") is None diff --git a/tests/metadata/test_hardcover_search_title.py b/tests/metadata/test_hardcover_search_title.py new file mode 100644 index 0000000..23cdd7f --- /dev/null +++ b/tests/metadata/test_hardcover_search_title.py @@ -0,0 +1,28 @@ +import pytest + +from shelfmark.metadata_providers.hardcover import _compute_search_title + + +class TestHardcoverComputeSearchTitle: + def test_prefers_subtitle_when_title_contains_subtitle(self): + assert ( + _compute_search_title("Mistborn: The Final Empire", "The Final Empire") + == "The Final Empire" + ) + + def test_does_not_use_subtitle_when_it_looks_like_series_position(self): + assert _compute_search_title("The Stormlight Archive: Book 1", "Book 1") is None + assert _compute_search_title("Some Series: Volume II", "Volume II") is None + + def test_strips_series_prefix_when_series_name_available(self): + assert ( + _compute_search_title("Mistborn: The Final Empire", None, series_name="Mistborn") + == "The Final Empire" + ) + + def test_strips_parenthetical_suffix(self): + assert _compute_search_title("The Martian (Unabridged)", None) == "The Martian" + assert _compute_search_title("The Martian", None) is None + + def test_returns_none_when_no_useful_simplification(self): + assert _compute_search_title("Dune", None) is None diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py index 3a9ef25..5312e8e 100644 --- a/tests/prowlarr/test_handler.py +++ b/tests/prowlarr/test_handler.py @@ -209,6 +209,86 @@ class TestProwlarrHandlerDownloadErrors: class TestProwlarrHandlerExistingDownload: """Tests for handling existing downloads.""" + def test_prefers_magnet_url_for_torrents(self): + """If both downloadUrl and magnetUrl exist, torrents should use magnetUrl.""" + mock_client = MagicMock() + mock_client.name = "qbittorrent" + mock_client.find_existing.return_value = None + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/123", + "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", + "title": "Test Release", + }, + ), 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="torrent-prefers-magnet", 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, + ) + + assert mock_client.find_existing.call_count == 1 + called_url = mock_client.find_existing.call_args.args[0] + assert called_url == "magnet:?xt=urn:btih:abc123&dn=test" + + def test_prefers_download_url_for_usenet(self): + """If both downloadUrl and magnetUrl exist, usenet should use downloadUrl.""" + mock_client = MagicMock() + mock_client.name = "sabnzbd" + mock_client.find_existing.return_value = None + + with patch( + "shelfmark.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "usenet", + "downloadUrl": "https://prowlarr.example.com/api/v1/indexer/1/download/456", + "magnetUrl": "magnet:?xt=urn:btih:abc123&dn=test", + "title": "Test Release", + }, + ), 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="usenet-prefers-download", 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, + ) + + assert mock_client.find_existing.call_count == 1 + called_url = mock_client.find_existing.call_args.args[0] + assert called_url == "https://prowlarr.example.com/api/v1/indexer/1/download/456" + def test_uses_existing_complete_download(self): """Test that handler uses existing complete download.""" with tempfile.TemporaryDirectory() as tmp_dir: diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 7291afd..c9ac91b 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -225,6 +225,82 @@ class TestExtractLanguage: 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 [] + + import shelfmark.release_sources.prowlarr.source as prowlarr_source + + def fake_get(key: str, default=None): + values = { + "PROWLARR_INDEXERS": "", + "PROWLARR_AUTO_EXPAND": False, + } + return values.get(key, default) + + monkeypatch.setattr(prowlarr_source.config, "get", fake_get) + + fake_client = FakeClient() + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: fake_client) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Anything", + authors=["Someone"], + ) + + from shelfmark.release_sources.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en"], manual_query="my custom") + source.search(book, plan, content_type="audiobook") + + 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 [] + + import shelfmark.release_sources.prowlarr.source as prowlarr_source + + def fake_get(key: str, default=None): + values = { + "PROWLARR_INDEXERS": "", + "PROWLARR_AUTO_EXPAND": False, + } + return values.get(key, default) + + monkeypatch.setattr(prowlarr_source.config, "get", fake_get) + + fake_client = FakeClient() + source = ProwlarrSource() + monkeypatch.setattr(source, "_get_client", lambda: fake_client) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Anything", + authors=["Someone"], + ) + + from shelfmark.release_sources.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en"], manual_query="my custom") + source.search(book, plan, expand_search=True, content_type="audiobook") + + assert fake_client.calls == [("my custom", None)] + def test_search_uses_localized_titles_when_available(self, monkeypatch): class FakeClient: def __init__(self): @@ -257,7 +333,10 @@ class TestProwlarrLocalizedQueries: titles_by_language={"hu": "A villámtolvaj"}, ) - source.search(book, languages=["en", "hu"], content_type="ebook") + from shelfmark.release_sources.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en", "hu"]) + source.search(book, plan, content_type="ebook") assert "The Lightning Thief Rick Riordan" in fake_client.queries assert "A villámtolvaj Rick Riordan" in fake_client.queries @@ -292,6 +371,7 @@ class TestProwlarrLocalizedQueries: provider_id="123", title="Mistborn: The Final Empire", search_title="The Final Empire", + search_author="Brandon Sanderson", authors=["Brandon Sanderson"], titles_by_language={ "en": "Mistborn: The Final Empire", @@ -299,7 +379,10 @@ class TestProwlarrLocalizedQueries: }, ) - source.search(book, languages=["en", "hu"], content_type="ebook") + from shelfmark.release_sources.search_plan import build_release_search_plan + + plan = build_release_search_plan(book, languages=["en", "hu"]) + source.search(book, plan, content_type="ebook") assert "The Final Empire Brandon Sanderson" in fake_client.queries assert "A végső birodalom Brandon Sanderson" in fake_client.queries diff --git a/tests/release_sources/test_manual_query.py b/tests/release_sources/test_manual_query.py new file mode 100644 index 0000000..075e88a --- /dev/null +++ b/tests/release_sources/test_manual_query.py @@ -0,0 +1,28 @@ +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources.search_plan import build_release_search_plan + + +class TestReleaseSearchPlanManualQuery: + def test_manual_query_overrides_plan(self, monkeypatch): + import shelfmark.release_sources.search_plan as sp + + monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + search_author="Brandon Sanderson", + authors=["Brandon Sanderson"], + titles_by_language={"hu": "A végső birodalom"}, + isbn_13="9780765311788", + ) + + plan = build_release_search_plan(book, languages=None, manual_query="some custom query") + + assert plan.manual_query == "some custom query" + assert plan.isbn_candidates == [] + assert plan.languages == ["en", "hu"] + assert [v.query for v in plan.title_variants] == ["some custom query"] + assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [("some custom query", None)] diff --git a/tests/release_sources/test_search_plan.py b/tests/release_sources/test_search_plan.py new file mode 100644 index 0000000..33a3b18 --- /dev/null +++ b/tests/release_sources/test_search_plan.py @@ -0,0 +1,61 @@ +from shelfmark.metadata_providers import BookMetadata +from shelfmark.release_sources.search_plan import build_release_search_plan + + +class TestReleaseSearchPlan: + def test_uses_default_languages_when_none(self, monkeypatch): + # config.BOOK_LANGUAGE is a Config attribute; patch the instance. + import shelfmark.release_sources.search_plan as sp + + monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="Mistborn: The Final Empire", + search_title="The Final Empire", + search_author="Brandon Sanderson", + authors=["Brandon Sanderson"], + titles_by_language={ + "en": "Mistborn: The Final Empire", + "hu": "A végső birodalom", + }, + isbn_13="9780765311788", + ) + + plan = build_release_search_plan(book, languages=None) + + assert plan.languages == ["en", "hu"] + assert plan.isbn_candidates == ["9780765311788"] + assert [v.query for v in plan.title_variants] == [ + "The Final Empire Brandon Sanderson", + "A végső birodalom Brandon Sanderson", + ] + + assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [ + ("The Final Empire", ["en"]), + ("A végső birodalom", ["hu"]), + ] + + def test_all_language_disables_grouping(self, monkeypatch): + import shelfmark.release_sources.search_plan as sp + + monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en"], raising=False) + + book = BookMetadata( + provider="hardcover", + provider_id="123", + title="The Lightning Thief", + authors=["Rick Riordan"], + titles_by_language={"hu": "A villámtolvaj"}, + ) + + plan = build_release_search_plan(book, languages=["all"]) + + assert plan.languages is None + assert [v.query for v in plan.title_variants] == [ + "The Lightning Thief Rick Riordan", + ] + assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [ + ("The Lightning Thief", None), + ]