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 */}
+
- {/* 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 && (
+ (
- {hasActiveFilter && (
+ {currentSort && (
)}
- );
- }}
- >
+ )}
+ >
+ {({ close }) => (
+
+ {/* Default option - no client-side sorting */}
+
{
+ handleSortChange(null, null);
+ close();
+ }}
+ className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
+ !currentSort
+ ? 'text-emerald-600 dark:text-emerald-400 font-medium'
+ : 'text-gray-700 dark:text-gray-300'
+ }`}
+ >
+ Default
+ {!currentSort && (
+
+ )}
+
+ {sortableColumns.map((col) => {
+ const sortKey = col.sort_key || col.key;
+ const isSelected = currentSort?.key === sortKey;
+ const direction = isSelected ? currentSort?.direction : null;
+ return (
+
{
+ handleSortChange(sortKey, col);
+ // Don't close - allow toggling direction
+ if (!isSelected) close();
+ }}
+ className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
+ isSelected
+ ? 'text-emerald-600 dark:text-emerald-400 font-medium'
+ : 'text-gray-700 dark:text-gray-300'
+ }`}
+ >
+ {col.label}
+ {isSelected && direction && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* 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 (
+
+
+ {hasActiveFilter && (
+
+ )}
+
+ );
+ }}
+ >
{({ 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') && (
{
@@ -1575,7 +1593,7 @@ export const ReleaseModal = ({
: langCodes;
const response = await getReleases(
- provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType
+ provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType, manualQuery.trim() || undefined
);
setCachedReleases(provider, bookId, activeTab, contentType, response);
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
@@ -1595,10 +1613,86 @@ export const ReleaseModal = ({
)}
)}
+
)}
+ {/* Manual query panel (below source tabs) */}
+ {showManualQuery && (
+
+
+
+ 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),
+ ]