mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 18:31:43 +01:00
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:
@@ -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
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)]
|
||||
@@ -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),
|
||||
]
|
||||
Reference in New Issue
Block a user