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.
This commit is contained in:
Alex
2026-01-17 18:56:10 +00:00
committed by GitHub
parent 5a6db5f8a8
commit f7375d56e2
23 changed files with 1022 additions and 267 deletions
+4 -4
View File
@@ -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,
+6 -1
View File
@@ -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}")
+1
View File
@@ -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)
+157 -36
View File
@@ -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]:
+6 -4
View File
@@ -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()
+9 -14
View File
@@ -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()
+12 -8
View File
@@ -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)
@@ -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
@@ -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
+35 -48
View File
@@ -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}")
+35 -17
View File
@@ -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.
+164
View File
@@ -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,
)
+218 -124
View File
@@ -622,6 +622,8 @@ export const ReleaseModal = ({
// A specific value means "show only that format"
const [formatFilter, setFormatFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
const [manualQuery, setManualQuery] = useState<string>('');
const [showManualQuery, setShowManualQuery] = useState<boolean>(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'}
</p>
</div>
<button
type="button"
onClick={handleClose}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100 flex-shrink-0"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<button
type="button"
onClick={handleClose}
className="rounded-full p-2 text-gray-500 transition-colors hover-action hover:text-gray-900 dark:hover:text-gray-100"
aria-label="Close"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</header>
{/* Scrollable content */}
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Scrollable content */}
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto">
{/* Book summary - scrolls with content */}
<div ref={bookSummaryRef} className="flex gap-4 px-5 py-4 border-b border-[var(--border-muted)]">
{book.preview ? (
@@ -1401,123 +1405,137 @@ export const ReleaseModal = ({
</div>
</div>
{/* Sort dropdown - only show if source has sortable columns */}
{sortableColumns.length > 0 && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Sort releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
{currentSort && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
)}
<div className="flex items-center gap-3 pl-2 pr-1">
{/* Manual query button */}
<button
type="button"
onClick={() => setShowManualQuery((prev) => !prev)}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
aria-label="Manual search query"
title="Manual query"
>
{({ close }) => (
<div className="py-1">
{/* Default option - no client-side sorting */}
<button
type="button"
onClick={() => {
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'
}`}
>
<span>Default</span>
{!currentSort && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
{sortableColumns.map((col) => {
const sortKey = col.sort_key || col.key;
const isSelected = currentSort?.key === sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (
<button
key={sortKey}
type="button"
onClick={() => {
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'
}`}
>
<span>{col.label}</span>
{isSelected && direction && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
)}
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 0 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10" />
</svg>
</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)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
// 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 && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2 rounded-full transition-colors ${
isOpen
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800'
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Filter releases"
aria-label="Sort releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
{hasActiveFilter && (
{currentSort && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
);
}}
>
)}
>
{({ close }) => (
<div className="py-1">
{/* Default option - no client-side sorting */}
<button
type="button"
onClick={() => {
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'
}`}
>
<span>Default</span>
{!currentSort && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
{sortableColumns.map((col) => {
const sortKey = col.sort_key || col.key;
const isSelected = currentSort?.key === sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (
<button
key={sortKey}
type="button"
onClick={() => {
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'
}`}
>
<span>{col.label}</span>
{isSelected && direction && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
)}
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
{/* 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)) && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-56"
noScrollLimit
renderTrigger={({ isOpen, toggle }) => {
// 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 (
<button
type="button"
onClick={toggle}
className={`relative p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Filter releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
{hasActiveFilter && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
);
}}
>
{({ close }) => (
<div className="p-4 space-y-4">
{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') && (
<button
type="button"
onClick={async () => {
@@ -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 = ({
)}
</Dropdown>
)}
</div>
</div>
)}
</div>
{/* Manual query panel (below source tabs) */}
{showManualQuery && (
<div className="px-5 py-3 border-b border-[var(--border-muted)] bg-[var(--bg)] sm:bg-[var(--bg-soft)]">
<form
className="flex items-center gap-2"
onSubmit={async (e) => {
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 }));
}
}}
>
<input
type="text"
value={manualQuery}
onChange={(e) => 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)]"
/>
<button
type="submit"
disabled={currentTabLoading || !manualQuery.trim()}
className={`px-3 py-2 text-sm font-medium text-white rounded-lg transition-colors ${
currentTabLoading || !manualQuery.trim()
? 'bg-emerald-600/60 cursor-not-allowed'
: 'bg-emerald-600 hover:bg-emerald-700'
}`}
>
{currentTabLoading ? 'Searching…' : 'Search'}
</button>
</form>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Manual query overrides ISBN/title/author/language expansion.
</p>
</div>
)}
{/* Release list content */}
<div className="min-h-[200px]">
{sourcesLoading ? (
+6 -1
View File
@@ -200,6 +200,7 @@ export const downloadRelease = async (release: {
series_name?: string;
series_position?: number;
subtitle?: string;
search_author?: string;
}): Promise<void> => {
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<ReleasesResponse> => {
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<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
};
+4
View File
@@ -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;
@@ -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 }),
@@ -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
@@ -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
@@ -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
+80
View File
@@ -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:
+85 -2
View File
@@ -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
@@ -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)]
+61
View File
@@ -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),
]