Refactor direct source to use universal API (#711)

This commit is contained in:
Alex
2026-03-06 12:59:37 +00:00
committed by GitHub
parent d6d10a450e
commit 8bb188c903
33 changed files with 1121 additions and 850 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
"""Core module - shared models, queue, and utilities."""
from shelfmark.core.models import BookInfo, QueueItem, SearchFilters, QueueStatus
from shelfmark.core.models import QueueItem, SearchFilters, QueueStatus
from shelfmark.core.queue import BookQueue, book_queue
from shelfmark.core.logger import setup_logger
-49
View File
@@ -131,55 +131,6 @@ class DownloadTask:
return build_filename(self.title, self.author, self.year, self.format)
@dataclass
class BookInfo:
"""Data class representing book information."""
id: str
title: str
preview: Optional[str] = None
author: Optional[str] = None
publisher: Optional[str] = None
year: Optional[str] = None
language: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
info: Optional[Dict[str, List[str]]] = None
description: Optional[str] = None
download_urls: List[str] = field(default_factory=list)
download_path: Optional[str] = None
priority: int = 0
progress: Optional[float] = None
status_message: Optional[str] = None # Detailed status message for UI display
added_time: Optional[float] = None # Timestamp when added to queue
source: str = "direct_download" # Release source handler to use for downloads
source_url: Optional[str] = None # Link to source page (e.g., Anna's Archive)
def get_filename(self, fallback_url: Optional[str] = None) -> str:
"""Build sanitized filename: 'Author - Title (Year).format'
Resolves format from self.format, download_urls, or fallback_url.
Args:
fallback_url: URL to extract format from if not already known
Returns:
Sanitized filename safe for filesystem use
"""
# Resolve format if needed
if not self.format:
urls = [self.download_urls[0]] if self.download_urls else []
if fallback_url:
urls.append(fallback_url)
for url in urls:
ext = url.split(".")[-1].lower()
if ext and len(ext) <= 5 and ext.isalnum():
self.format = ext
break
return build_filename(self.title, self.author, self.year, self.format)
@dataclass
class SearchFilters:
"""Filters for book search queries."""
+16 -8
View File
@@ -43,9 +43,17 @@ def cap_mode(mode: PolicyMode, ceiling: PolicyMode) -> PolicyMode:
return mode
def _normalize_direct_source_mode(source: Any, mode: PolicyMode) -> PolicyMode:
"""Direct search results are concrete releases; normalize request_book to request_release."""
if normalize_source(source) == "direct_download" and mode == PolicyMode.REQUEST_BOOK:
def _source_results_are_releases(source: Any) -> bool:
normalized_source = normalize_source(source)
if normalized_source in {"", "*"}:
return False
from shelfmark.release_sources import source_results_are_releases
return source_results_are_releases(normalized_source)
def _normalize_release_result_mode(source: Any, mode: PolicyMode) -> PolicyMode:
"""Concrete release browse results cannot fall back to request_book semantics."""
if mode == PolicyMode.REQUEST_BOOK and _source_results_are_releases(source):
return PolicyMode.REQUEST_RELEASE
return mode
@@ -328,9 +336,9 @@ def resolve_policy_mode(
The content-type default acts as a ceiling — matrix rules can only
match or restrict further, never upgrade beyond the default.
Direct-download exception:
- direct_download results are concrete releases, so request_book is
normalized to request_release for that source.
Concrete-release browse exception:
- sources whose browse results are already concrete releases normalize
request_book to request_release.
"""
effective = merge_request_policy_settings(global_settings, user_settings)
@@ -357,9 +365,9 @@ def resolve_policy_mode(
for candidate_source, candidate_content_type in candidates:
for rule_source, rule_content_type, rule_mode in rules:
if rule_source == candidate_source and rule_content_type == candidate_content_type:
return _normalize_direct_source_mode(
return _normalize_release_result_mode(
normalized_source,
cap_mode(rule_mode, ceiling),
)
return _normalize_direct_source_mode(normalized_source, ceiling)
return _normalize_release_result_mode(normalized_source, ceiling)
+23 -9
View File
@@ -132,17 +132,18 @@ def _normalize_optional_source_id(value: Any) -> str | None:
return normalize_optional_text(value)
def _build_direct_release_data_from_book_data(
def _build_release_result_data_from_book_data(
*,
source: str,
book_data: dict[str, Any],
content_type: str,
) -> dict[str, Any]:
"""Build release-level payload fields for direct-download requests."""
"""Build release-level payload fields for sources whose browse results are releases."""
source_id = _normalize_optional_source_id(book_data.get("provider_id")) or _normalize_optional_source_id(
book_data.get("id")
)
payload: dict[str, Any] = {
"source": "direct_download",
"source": source,
"source_id": source_id,
"title": book_data.get("title"),
"author": book_data.get("author"),
@@ -151,11 +152,21 @@ def _build_direct_release_data_from_book_data(
"size": book_data.get("size"),
"preview": book_data.get("preview"),
"content_type": content_type,
"source_url": book_data.get("source_url"),
"search_mode": "direct",
}
return {key: value for key, value in payload.items() if value is not None}
def _normalize_direct_request_payload(
def _source_results_are_releases(source: str) -> bool:
normalized_source = normalize_source(source)
if normalized_source in {"", "*"}:
return False
from shelfmark.release_sources import source_results_are_releases
return source_results_are_releases(normalized_source)
def _normalize_release_result_request_payload(
*,
source: str,
request_level: Any,
@@ -163,13 +174,14 @@ def _normalize_direct_request_payload(
release_data: Any,
content_type: str,
) -> tuple[Any, Any]:
"""Direct-search requests are always release-level with direct source metadata."""
if source != "direct_download":
"""Concrete-release browse results are always handled as release-level requests."""
if not _source_results_are_releases(source):
return request_level, release_data
normalized_release_data = release_data
if normalized_release_data is None and isinstance(book_data, dict):
normalized_release_data = _build_direct_release_data_from_book_data(
normalized_release_data = _build_release_result_data_from_book_data(
source=source,
book_data=book_data,
content_type=content_type,
)
@@ -177,7 +189,7 @@ def _normalize_direct_request_payload(
normalized_release_data = dict(normalized_release_data)
if isinstance(normalized_release_data, dict):
normalized_release_data["source"] = "direct_download"
normalized_release_data["source"] = source
if normalized_release_data.get("content_type") is None:
normalized_release_data["content_type"] = content_type
@@ -320,6 +332,7 @@ def register_request_routes(
default_audio_mode = parse_policy_mode(effective.get("REQUEST_POLICY_DEFAULT_AUDIOBOOK"))
source_capabilities = get_source_content_type_capabilities()
from shelfmark.release_sources import source_results_are_releases
source_modes = []
for source_name in sorted(source_capabilities):
supported_types = sorted(
@@ -339,6 +352,7 @@ def register_request_routes(
{
"source": source_name,
"supported_content_types": supported_types,
"browse_results_are_releases": source_results_are_releases(source_name),
"modes": modes,
}
)
@@ -401,7 +415,7 @@ def register_request_routes(
or data.get("content_type")
or book_data.get("content_type")
)
request_level, release_data = _normalize_direct_request_payload(
request_level, release_data = _normalize_release_result_request_payload(
source=source,
request_level=request_level,
book_data=book_data,
+5
View File
@@ -6,6 +6,7 @@ from typing import List, Optional
MANUAL_QUERY_MAX_LEN = 256
from shelfmark.core.config import config
from shelfmark.core.models import SearchFilters
from shelfmark.metadata_providers import (
BookMetadata,
group_languages_by_localized_title,
@@ -37,6 +38,7 @@ class ReleaseSearchPlan:
grouped_title_variants: List[ReleaseSearchVariant]
manual_query: Optional[str] = None
indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings)
source_filters: Optional[SearchFilters] = None
@property
def primary_query(self) -> str:
@@ -88,6 +90,7 @@ def build_release_search_plan(
languages: Optional[List[str]] = None,
manual_query: Optional[str] = None,
indexers: Optional[List[str]] = None,
source_filters: Optional[SearchFilters] = None,
) -> ReleaseSearchPlan:
resolved_languages = _normalize_languages(languages)
@@ -109,6 +112,7 @@ def build_release_search_plan(
grouped_title_variants=[variant],
manual_query=resolved_manual_query,
indexers=indexers,
source_filters=source_filters,
)
isbn_candidates: List[str] = []
@@ -165,4 +169,5 @@ def build_release_search_plan(
grouped_title_variants=grouped_variants,
manual_query=None,
indexers=indexers,
source_filters=source_filters,
)
+20 -111
View File
@@ -16,15 +16,17 @@ from typing import Any, Dict, List, Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode
from shelfmark.core.models import DownloadTask, QueueStatus, SearchMode
from shelfmark.core.queue import book_queue
from shelfmark.core.utils import transform_cover_url, is_audiobook as check_audiobook
from shelfmark.config import env as env_config
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import direct_download, get_handler, get_source_display_name
from shelfmark.release_sources.direct_download import SearchUnavailable
from shelfmark.release_sources import (
get_handler,
get_source_display_name,
)
logger = setup_logger(__name__)
@@ -57,26 +59,6 @@ _last_activity: Dict[str, float] = {}
_last_status_event: Dict[str, Tuple[str, Optional[str]]] = {}
STALL_TIMEOUT = 300 # 5 minutes without progress/status update = stalled
def search_books(query: str, filters: SearchFilters) -> List[Dict[str, Any]]:
"""Search for books matching the query."""
try:
books = direct_download.search_books(query, filters)
return [_book_info_to_dict(book) for book in books]
except SearchUnavailable:
raise
except Exception as e:
logger.error_trace(f"Error searching books: {e}")
raise
def get_book_info(book_id: str) -> Optional[Dict[str, Any]]:
"""Get detailed information for a specific book."""
try:
book = direct_download.get_book_info(book_id)
return _book_info_to_dict(book)
except Exception as e:
logger.error_trace(f"Error getting book info: {e}")
raise
def _is_plain_email_address(value: str) -> bool:
parsed = parseaddr(value or "")[1]
return bool(parsed) and "@" in parsed and parsed == value
@@ -97,76 +79,17 @@ def _resolve_email_destination(
return None, "Configured email recipient is invalid"
return None, None
def queue_book(
book_id: str,
priority: int = 0,
source: str = "direct_download",
user_id: Optional[int] = None,
username: Optional[str] = None,
) -> Tuple[bool, Optional[str]]:
"""Add a book to the download queue. Returns (success, error_message)."""
try:
book_info = direct_download.get_book_info(book_id, fetch_download_count=False)
if not book_info:
error_msg = f"Could not fetch book info for {book_id}"
logger.warning(error_msg)
return False, error_msg
books_output_mode = str(
config.get("BOOKS_OUTPUT_MODE", "folder", user_id=user_id) or "folder"
).strip().lower()
is_audiobook = check_audiobook(book_info.content)
# Capture output mode at queue time so tasks aren't affected if settings change later.
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
if output_mode == "email" and not is_audiobook:
email_to, email_error = _resolve_email_destination(user_id=user_id)
if email_error:
return False, email_error
if email_to:
output_args = {"to": email_to}
# Create a source-agnostic download task
task = DownloadTask(
task_id=book_id,
source=source,
title=book_info.title,
author=book_info.author,
format=book_info.format,
size=book_info.size,
preview=book_info.preview,
content_type=book_info.content,
search_mode=SearchMode.DIRECT,
output_mode=output_mode,
output_args=output_args,
priority=priority,
user_id=user_id,
username=username,
)
if not book_queue.add(task):
logger.info(f"Book already in queue: {book_info.title}")
return False, "Book is already in the download queue"
logger.info(f"Book queued with priority {priority}: {book_info.title}")
# Broadcast status update via WebSocket
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
return True, None
except SearchUnavailable as e:
error_msg = f"Search service unavailable: {e}"
logger.warning(error_msg)
return False, error_msg
except Exception as e:
error_msg = f"Error queueing book: {e}"
logger.error_trace(error_msg)
return False, error_msg
def _parse_release_search_mode(value: Any) -> SearchMode:
if isinstance(value, SearchMode):
return value
if value is None:
return SearchMode.UNIVERSAL
if isinstance(value, str):
try:
return SearchMode(value.strip().lower())
except ValueError as exc:
raise ValueError(f"Invalid search_mode: {value}") from exc
raise ValueError(f"Invalid search_mode: {value}")
def queue_release(
@@ -177,12 +100,13 @@ def queue_release(
) -> Tuple[bool, Optional[str]]:
"""Add a release to the download queue. Returns (success, error_message)."""
try:
source = release_data.get('source', 'direct_download')
source = release_data['source']
extra = release_data.get('extra', {})
raw_request_id = release_data.get('_request_id')
request_id: Optional[int] = None
if isinstance(raw_request_id, int) and raw_request_id > 0:
request_id = raw_request_id
search_mode = _parse_release_search_mode(release_data.get("search_mode"))
# Get author, year, preview, and content_type from top-level (preferred) or extra (fallback)
author = release_data.get('author') or extra.get('author')
@@ -235,7 +159,7 @@ def queue_release(
series_name=series_name,
series_position=series_position,
subtitle=subtitle,
search_mode=SearchMode.UNIVERSAL,
search_mode=search_mode,
output_mode=output_mode,
output_args=output_args,
priority=priority,
@@ -257,8 +181,7 @@ def queue_release(
return True, None
except ValueError as e:
# Handler not found for this source
error_msg = f"Unknown release source: {e}"
error_msg = str(e)
logger.warning(error_msg)
return False, error_msg
except KeyError as e:
@@ -307,20 +230,6 @@ def get_book_data(task_id: str) -> Tuple[Optional[bytes], Optional[DownloadTask]
task.download_path = None
return None, task
def _book_info_to_dict(book: BookInfo) -> Dict[str, Any]:
"""Convert BookInfo to dict, transforming cover URLs for caching."""
result = {
key: value for key, value in book.__dict__.items()
if value is not None
}
# Transform external preview URLs to local proxy URLs
if result.get('preview'):
result['preview'] = transform_cover_url(result['preview'], book.id)
return result
def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
"""Convert DownloadTask to dict for frontend, transforming cover URLs."""
# Transform external preview URLs to local proxy URLs
@@ -6,12 +6,12 @@ from pathlib import Path
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import (
get_aa_content_type_dir,
get_destination,
is_audiobook as check_audiobook,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import get_source
logger = setup_logger("shelfmark.download.postprocess.pipeline")
@@ -63,9 +63,12 @@ def get_final_destination(task: DownloadTask) -> Path:
is_audiobook = check_audiobook(task.content_type)
if task.source == "direct_download" and not is_audiobook:
override = get_aa_content_type_dir(task.content_type)
if override:
return override
try:
override = get_source(task.source).get_destination_override(task)
except ValueError:
override = None
if override:
return override
return get_destination(is_audiobook, user_id=task.user_id, username=task.username)
+142 -157
View File
@@ -18,8 +18,7 @@ from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
from shelfmark.download import orchestrator as backend
from shelfmark.release_sources import get_source_display_name
from shelfmark.release_sources.direct_download import SearchUnavailable
from shelfmark.release_sources import SourceUnavailableError, get_source_display_name
from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
from shelfmark.config.env import (
BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, HIDE_LOCAL_AUTH,
@@ -810,120 +809,77 @@ if DEBUG:
"""
os._exit(0)
@app.route('/api/search', methods=['GET'])
@login_required
def api_search() -> Union[Response, Tuple[Response, int]]:
"""
Search for books matching the provided query.
Query Parameters:
query (str): Search term (ISBN, title, author, etc.)
isbn (str): Book ISBN
author (str): Book Author
title (str): Book Title
lang (str): Book Language
sort (str): Order to sort results
content (str): Content type of book
format (str): File format filter (pdf, epub, mobi, azw3, fb2, djvu, cbz, cbr)
Returns:
flask.Response: JSON array of matching books or error response.
"""
query = request.args.get('query', '')
filters = SearchFilters(
isbn = request.args.getlist('isbn'),
author = request.args.getlist('author'),
title = request.args.getlist('title'),
lang = request.args.getlist('lang'),
sort = request.args.get('sort'),
content = request.args.getlist('content'),
format = request.args.getlist('format'),
def _parse_search_filters_from_request() -> SearchFilters:
"""Parse direct/source browse filters from query parameters."""
return SearchFilters(
isbn=request.args.getlist('isbn'),
author=request.args.getlist('author'),
title=request.args.getlist('title'),
lang=request.args.getlist('lang'),
sort=request.args.get('sort'),
content=request.args.getlist('content'),
format=request.args.getlist('format'),
)
if not query and not any(vars(filters).values()):
return jsonify([])
try:
books = backend.search_books(query, filters)
return jsonify(books)
except SearchUnavailable as e:
logger.warning(f"Search unavailable: {e}")
return jsonify({"error": str(e)}), 503
except Exception as e:
logger.error_trace(f"Search error: {e}")
return jsonify({"error": str(e)}), 500
def _build_source_query_book(query_text: str, filters: SearchFilters):
"""Build a synthetic book context for source-native browse searches."""
from shelfmark.metadata_providers import BookMetadata
@app.route('/api/info', methods=['GET'])
@login_required
def api_info() -> Union[Response, Tuple[Response, int]]:
"""
Get detailed book information.
author_values = [value.strip() for value in (filters.author or []) if str(value).strip()]
title_values = [value.strip() for value in (filters.title or []) if str(value).strip()]
isbn_values = [value.strip() for value in (filters.isbn or []) if str(value).strip()]
title = (
title_values[0]
if title_values
else query_text
or (isbn_values[0] if isbn_values else "")
or (author_values[0] if author_values else "Direct Search")
)
author = author_values[0] if author_values else ""
Query Parameters:
id (str): Book identifier (MD5 hash)
return BookMetadata(
provider="manual",
provider_id=query_text or title,
provider_display_name="Manual Search",
title=title,
search_title=title,
search_author=author or None,
authors=author_values,
)
Returns:
flask.Response: JSON object with book details, or an error message.
"""
book_id = request.args.get('id', '')
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
try:
book = backend.get_book_info(book_id)
if book:
return jsonify(book)
return jsonify({"error": "Book not found"}), 404
except Exception as e:
logger.error_trace(f"Info error: {e}")
return jsonify({"error": str(e)}), 500
def _serialize_browse_record(record) -> dict:
"""Serialize a source-native browse record for the frontend."""
result = {
key: value for key, value in record.__dict__.items()
if value is not None
}
@app.route('/api/download', methods=['GET'])
@login_required
def api_download() -> Union[Response, Tuple[Response, int]]:
"""
Queue a book for download.
preview = result.get("preview")
if isinstance(preview, str) and preview:
from shelfmark.core.utils import transform_cover_url
Query Parameters:
id (str): Book identifier (MD5 hash)
result["preview"] = transform_cover_url(preview, record.id)
Returns:
flask.Response: JSON status object indicating success or failure.
"""
book_id = request.args.get('id', '')
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
return result
try:
policy_mode = _resolve_policy_mode_for_current_user(
source="direct_download",
content_type="ebook",
)
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
return _policy_block_response(policy_mode)
priority = int(request.args.get('priority', 0))
# Per-user download overrides
db_user_id = session.get('db_user_id')
_username = session.get('user_id')
db_user_id, _username, on_behalf_error = _resolve_download_user_context(
db_user_id,
_username,
request.args.get("on_behalf_of_user_id"),
)
if on_behalf_error:
return on_behalf_error
success, error_msg = backend.queue_book(
book_id, priority,
user_id=db_user_id, username=_username,
)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": error_msg or "Failed to queue book"}), 500
except Exception as e:
logger.error_trace(f"Download error: {e}")
return jsonify({"error": str(e)}), 500
def _serialize_release(release) -> dict:
"""Serialize a release for the frontend, normalizing preview URLs."""
from dataclasses import asdict
from shelfmark.core.utils import transform_cover_url
result = asdict(release)
extra = result.get("extra")
if isinstance(extra, dict):
preview = extra.get("preview")
if isinstance(preview, str) and preview:
extra = dict(extra)
extra["preview"] = transform_cover_url(preview, release.source_id)
result["extra"] = extra
return result
@app.route('/api/releases/download', methods=['POST'])
@@ -953,8 +909,10 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]:
if 'source_id' not in data:
return jsonify({"error": "source_id is required"}), 400
if 'source' not in data:
return jsonify({"error": "source is required"}), 400
source = data.get('source', 'direct_download')
source = data['source']
resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source)
policy_mode = _resolve_policy_mode_for_current_user(
source=source,
@@ -2258,18 +2216,25 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with list of available releases.
"""
try:
from dataclasses import asdict
from shelfmark.metadata_providers import (
BookMetadata,
get_provider,
is_provider_registered,
get_provider_kwargs,
)
from shelfmark.release_sources import get_source, list_available_sources, serialize_column_config
from dataclasses import asdict
from shelfmark.release_sources import (
browse_record_to_book_metadata,
get_source,
list_available_sources,
serialize_column_config,
source_results_are_releases,
)
from shelfmark.core.search_plan import build_release_search_plan
provider = request.args.get('provider', '').strip()
book_id = request.args.get('book_id', '').strip()
source_filter = request.args.get('source', '').strip()
query_text = request.args.get('query', '').strip()
# Accept title/author from frontend to avoid re-fetching metadata
title_param = request.args.get('title', '').strip()
author_param = request.args.get('author', '').strip()
@@ -2285,47 +2250,33 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
# Accept indexer names for Prowlarr filtering (comma-separated)
indexers_param = request.args.get('indexers', '').strip()
indexers = [idx.strip() for idx in indexers_param.split(',') if idx.strip()] if indexers_param else None
browse_filters = _parse_search_filters_from_request()
has_browse_filters = bool(query_text or any(vars(browse_filters).values()))
source_query_filters = None
is_source_provider = bool(provider) and source_results_are_releases(provider)
if not provider or not book_id:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
if not source_filter or not has_browse_filters:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
if not source_results_are_releases(source_filter):
return jsonify({"error": f"Source does not support browse release search: {source_filter}"}), 400
# Direct mode request approvals can open ReleaseModal with provider=direct_download.
# In that flow, treat the direct result as release-search context instead of requiring
# a metadata provider registration.
if provider == "direct_download":
direct_book = backend.get_book_info(book_id)
if not isinstance(direct_book, dict):
return jsonify({"error": "Book not found in direct source"}), 404
book = _build_source_query_book(query_text, browse_filters)
source_query_filters = browse_filters
elif is_source_provider:
# Source-backed browse flows can reopen the release modal with provider=<source name>.
# In that flow, treat the source-native record as release-search context instead of
# requiring a metadata provider registration.
source = get_source(provider)
direct_record = source.get_record(book_id)
if direct_record is None:
return jsonify({"error": "Book not found in release source"}), 404
resolved_title = title_param or str(direct_book.get("title") or "").strip() or "Unknown title"
resolved_author = author_param or str(direct_book.get("author") or "").strip()
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
if not authors and resolved_author:
authors = [resolved_author]
raw_publish_year = direct_book.get("year")
publish_year = None
if isinstance(raw_publish_year, int):
publish_year = raw_publish_year
elif isinstance(raw_publish_year, str):
normalized_year = raw_publish_year.strip()
if normalized_year.isdigit():
publish_year = int(normalized_year)
book = BookMetadata(
provider="direct_download",
provider_id=book_id,
provider_display_name="Direct Download",
title=resolved_title,
search_title=resolved_title,
search_author=resolved_author or None,
authors=authors,
cover_url=direct_book.get("preview"),
description=direct_book.get("description"),
publisher=direct_book.get("publisher"),
publish_year=publish_year,
language=direct_book.get("language"),
source_url=direct_book.get("source_url"),
book = browse_record_to_book_metadata(
direct_record,
title_override=title_param or None,
author_override=author_param or None,
)
elif provider == "manual":
resolved_title = title_param or manual_query or "Manual Search"
@@ -2361,12 +2312,13 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
book.title = title_param
# Determine which release sources to search
if source_filter:
if source_query_filters is not None:
sources_to_search = [source_filter]
elif provider == "direct_download":
# Direct mode has no metadata-provider fanout; keep release browsing focused
# on Direct Download results (same dataset as legacy direct search).
sources_to_search = ["direct_download"]
elif source_filter:
sources_to_search = [source_filter]
elif is_source_provider:
# Source-backed browse flows stay within the source that produced the record.
sources_to_search = [provider]
else:
# Search only enabled sources
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
@@ -2381,11 +2333,18 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
source = get_source(source_name)
source_instances[source_name] = source
from shelfmark.core.search_plan import build_release_search_plan
plan = build_release_search_plan(
book,
languages=browse_filters.lang if source_query_filters is not None else languages,
manual_query=query_text if source_query_filters is not None else manual_query,
indexers=indexers,
source_filters=source_query_filters,
)
plan = build_release_search_plan(book, languages=languages, manual_query=manual_query, indexers=indexers)
if plan.manual_query:
if plan.source_filters is not None:
planned_query = plan.manual_query or plan.primary_query
planned_query_type = "query"
elif plan.manual_query:
planned_query = plan.manual_query
planned_query_type = "manual"
elif not expand_search and plan.isbn_candidates:
@@ -2409,7 +2368,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
errors.append(f"{source_name}: {str(e)}")
# Convert Release objects to dicts
releases_data = [asdict(release) for release in all_releases]
releases_data = [_serialize_release(release) for release in all_releases]
# Get column config from the first source searched
# Reuse the same instance to get any dynamic data (e.g., online_servers for IRC)
@@ -2446,8 +2405,9 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
if errors:
response["errors"] = errors
# If no releases found and there were errors, return 503 with error message
# This matches the behavior of /api/search when Anna's Archive is unreachable
# If no releases found and there were errors, return 503 with the first
# source failure message so direct-mode source query searches surface the
# same unavailable-state messaging as release modal searches.
if not releases_data and errors:
# Use the first error message (typically the most relevant)
error_message = errors[0]
@@ -2457,6 +2417,9 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": error_message}), 503
return jsonify(response)
except SourceUnavailableError as e:
logger.warning(f"Release search unavailable: {e}")
return jsonify({"error": str(e)}), 503
except Exception as e:
logger.error_trace(f"Releases search error: {e}")
return jsonify({"error": str(e)}), 500
@@ -2480,6 +2443,28 @@ def api_release_sources() -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": str(e)}), 500
@app.route('/api/release-sources/<source_name>/records/<path:record_id>', methods=['GET'])
@login_required
def api_release_source_record(source_name: str, record_id: str) -> Union[Response, Tuple[Response, int]]:
"""Resolve a source-native browse record for a release source."""
try:
from shelfmark.release_sources import get_source
source = get_source(source_name)
record = source.get_record(record_id)
if record is None:
return jsonify({"error": "Record not found"}), 404
return jsonify(_serialize_browse_record(record))
except ValueError:
return jsonify({"error": f"Unknown release source: {source_name}"}), 400
except SourceUnavailableError as e:
logger.warning(f"Release source record unavailable: {e}")
return jsonify({"error": str(e)}), 503
except Exception as e:
logger.error_trace(f"Release source record error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/settings', methods=['GET'])
@login_required
def api_settings_get_all() -> Union[Response, Tuple[Response, int]]:
+91
View File
@@ -3,6 +3,7 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass, field, asdict
from enum import Enum
from pathlib import Path
from threading import Event
from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING
@@ -21,6 +22,35 @@ class ReleaseProtocol(str, Enum):
DCC = "dcc" # IRC DCC
class SourceUnavailableError(Exception):
"""Raised when a source is configured but currently unreachable."""
@dataclass
class BrowseRecord:
"""Source-native browse/search record used before normalization to Release."""
id: str
title: str
source: str
preview: Optional[str] = None
author: Optional[str] = None
publisher: Optional[str] = None
year: Optional[str] = None
language: Optional[str] = None
content: Optional[str] = None
format: Optional[str] = None
size: Optional[str] = None
info: Optional[Dict[str, List[str]]] = None
description: Optional[str] = None
download_urls: List[str] = field(default_factory=list)
download_path: Optional[str] = None
priority: int = 0
progress: Optional[float] = None
status_message: Optional[str] = None
added_time: Optional[float] = None
source_url: Optional[str] = None
@dataclass
class Release:
"""A downloadable release - all sources return this same structure."""
@@ -281,6 +311,23 @@ class ReleaseSource(ABC):
"""Get column configuration for release list UI. Override for custom columns."""
return _default_column_config()
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> Optional[BrowseRecord]:
"""Resolve a source-native record for browse flows."""
raise NotImplementedError(f"{self.display_name} does not support record lookup")
def search_results_are_releases(self) -> bool:
"""Whether source-native browse results already represent concrete releases."""
return False
def get_destination_override(self, task: DownloadTask) -> Optional[Path]:
"""Return a source-specific destination override for a queued download."""
return None
class DownloadHandler(ABC):
"""Interface for executing downloads.
@@ -364,6 +411,7 @@ def list_available_sources() -> List[dict]:
"display_name": instance.display_name,
"enabled": instance.is_available(),
"supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]),
"browse_results_are_releases": instance.search_results_are_releases(),
"can_be_default": getattr(instance, 'can_be_default', True),
})
return result
@@ -376,6 +424,49 @@ def get_source_display_name(name: str) -> str:
return name.replace('_', ' ').title()
def browse_record_to_book_metadata(
record: BrowseRecord,
*,
title_override: Optional[str] = None,
author_override: Optional[str] = None,
) -> BookMetadata:
"""Convert a source-native browse record into generic book metadata."""
resolved_title = title_override or str(record.title or "").strip() or "Unknown title"
resolved_author = author_override or str(record.author or "").strip()
authors = [part.strip() for part in resolved_author.split(",") if part.strip()]
publish_year = None
if isinstance(record.year, int):
publish_year = record.year
elif isinstance(record.year, str):
normalized_year = record.year.strip()
if normalized_year.isdigit():
publish_year = int(normalized_year)
return BookMetadata(
provider=record.source,
provider_id=record.id,
provider_display_name=get_source_display_name(record.source),
title=resolved_title,
search_title=resolved_title,
search_author=resolved_author or None,
authors=authors,
cover_url=record.preview,
description=record.description,
publisher=record.publisher,
publish_year=publish_year,
language=record.language,
source_url=record.source_url,
)
def source_results_are_releases(name: str) -> bool:
"""Whether a source's browse/search results already map to concrete releases."""
if name not in _SOURCES:
return False
return _SOURCES[name]().search_results_are_releases()
# Import source implementations to trigger registration
# These must be imported AFTER the base classes and registry are defined
from shelfmark.release_sources import direct_download # noqa: F401, E402
+87 -49
View File
@@ -17,15 +17,17 @@ from shelfmark.download import http as downloader
from shelfmark.download import network
from shelfmark.config.env import DEBUG_SKIP_SOURCES, TMP_DIR
from shelfmark.core.config import config
from shelfmark.core.utils import CONTENT_TYPES
from shelfmark.core.utils import CONTENT_TYPES, get_aa_content_type_dir, is_audiobook as check_audiobook
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask
from shelfmark.core.models import SearchFilters, DownloadTask, build_filename
from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title
from shelfmark.release_sources import (
BrowseRecord,
Release,
ReleaseProtocol,
ReleaseSource,
DownloadHandler,
SourceUnavailableError,
register_source,
register_handler,
ReleaseColumnConfig,
@@ -136,11 +138,11 @@ def _normalize_size(size_str: str) -> str:
return _SIZE_UNIT_PATTERN.sub(lambda m: m.group(1).upper(), size_str.strip())
class SearchUnavailable(Exception):
class SearchUnavailable(SourceUnavailableError):
"""Raised when Anna's Archive cannot be reached via any mirror/DNS."""
def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
def search_books(query: str, filters: SearchFilters) -> List[BrowseRecord]:
"""Search for books matching the query.
Args:
@@ -148,7 +150,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
filters: Search filters (language, format, content type, etc.)
Returns:
List[BookInfo]: List of matching books
List[BrowseRecord]: List of matching books
Raises:
SearchUnavailable: If Anna's Archive cannot be reached
@@ -232,7 +234,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]:
return books
def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo:
def get_book_info(book_id: str, fetch_download_count: bool = True) -> BrowseRecord:
"""Get detailed information for a specific book.
Args:
@@ -241,22 +243,22 @@ def get_book_info(book_id: str, fetch_download_count: bool = True) -> BookInfo:
Only needed for display in DetailsModal, not for downloads.
Returns:
BookInfo: Detailed book information including download URLs
BrowseRecord: Detailed book information including download URLs
"""
url = f"{network.get_aa_base_url()}/md5/{book_id}"
selector = network.AAMirrorSelector()
html = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=False)
if not html:
raise Exception(f"Failed to fetch book info for ID: {book_id}")
raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.")
soup = BeautifulSoup(html, "html.parser")
return _parse_book_info_page(soup, book_id, fetch_download_count)
def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
"""Parse a single search result row into a BookInfo object."""
def _parse_search_result_row(row: Tag) -> Optional[BrowseRecord]:
"""Parse a single search result row into a browse record."""
try:
if row.text.strip().lower().startswith("your ad here"):
return None
@@ -264,10 +266,11 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
preview_img = cells[0].find("img")
preview = preview_img["src"] if preview_img else None
return BookInfo(
return BrowseRecord(
id=row.find_all("a")[0]["href"].split("/")[-1],
preview=preview,
title=cells[1].find("span").next,
source="direct_download",
preview=preview,
author=cells[2].find("span").next,
publisher=cells[3].find("span").next,
year=cells[4].find("span").next,
@@ -281,8 +284,8 @@ def _parse_search_result_row(row: Tag) -> Optional[BookInfo]:
return None
def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BookInfo:
"""Parse the book info page HTML into a BookInfo object."""
def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_count: bool = True) -> BrowseRecord:
"""Parse the book info page HTML into a browse record."""
data = soup.select_one("body > main > div:nth-of-type(1)")
if not data:
@@ -379,10 +382,11 @@ def _parse_book_info_page(soup: BeautifulSoup, book_id: str, fetch_download_coun
# Extract basic information
description = _extract_book_description(soup)
book_info = BookInfo(
book_info = BrowseRecord(
id=book_id,
preview=preview,
title=book_title,
source="direct_download",
preview=preview,
content=content,
publisher=(_find_in_divs(divs, "icon-[mdi--company]", is_class=True) or [""])[0],
author=(_find_in_divs(divs, "icon-[mdi--user-edit]", is_class=True) or [""])[0],
@@ -538,7 +542,7 @@ def _group_urls_by_source(urls: List[str], urls_by_source: Dict[str, List[str]])
urls_by_source.setdefault(source_type, []).append(url)
def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]]) -> None:
def _fetch_aa_page_urls(book_info: BrowseRecord, urls_by_source: Dict[str, List[str]]) -> None:
"""Fetch and parse AA page, populating urls_by_source dict.
Groups existing book_info.download_urls by source type. If book_info
@@ -557,7 +561,7 @@ def _fetch_aa_page_urls(book_info: BookInfo, urls_by_source: Dict[str, List[str]
def _get_urls_for_source(
source_id: str,
book_info: BookInfo,
book_info: BrowseRecord,
selector: network.AAMirrorSelector,
cancel_flag: Optional[Event],
status_callback: Optional[Callable[[str, Optional[str]], None]],
@@ -608,7 +612,7 @@ def _get_urls_for_source(
def _try_download_url(
url: str,
source_id: str,
book_info: BookInfo,
book_info: BrowseRecord,
book_path: Path,
progress_callback: Optional[Callable[[float], None]],
cancel_flag: Optional[Event],
@@ -747,7 +751,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None)
def _download_book(
book_info: BookInfo,
book_info: BrowseRecord,
book_path: Path,
progress_callback: Optional[Callable[[float], None]] = None,
cancel_flag: Optional[Event] = None,
@@ -1046,33 +1050,32 @@ def _extract_countdown_seconds(soup: BeautifulSoup, html_str: str) -> int:
return 0
def _book_info_to_release(book_info: BookInfo) -> Release:
"""Convert a BookInfo object to a Release object.
def _browse_record_to_release(record: BrowseRecord) -> Release:
"""Convert a browse record to a Release object.
This bridges the existing BookInfo model (which combines metadata + release info)
to the new Release model (release info only).
This bridges the direct source's browse data to the generic release model.
"""
return Release(
source="direct_download",
source_id=book_info.id,
title=book_info.title,
format=book_info.format,
language=book_info.language, # Top-level language for filtering
size=book_info.size,
download_url=book_info.download_urls[0] if book_info.download_urls else None,
info_url=f"{network.get_aa_base_url()}/md5/{book_info.id}",
source=record.source,
source_id=record.id,
title=record.title,
format=record.format,
language=record.language, # Top-level language for filtering
size=record.size,
download_url=record.download_urls[0] if record.download_urls else None,
info_url=f"{network.get_aa_base_url()}/md5/{record.id}",
protocol=ReleaseProtocol.HTTP,
indexer="Direct Download",
content_type=book_info.content, # Preserve content type from source
content_type=record.content, # Preserve content type from source
extra={
"author": book_info.author,
"publisher": book_info.publisher,
"year": book_info.year,
"language": book_info.language,
"preview": book_info.preview,
"description": book_info.description,
"download_urls": book_info.download_urls,
"info": book_info.info,
"author": record.author,
"publisher": record.publisher,
"year": record.year,
"language": record.language,
"preview": record.preview,
"description": record.description,
"download_urls": record.download_urls,
"info": record.info,
}
)
@@ -1140,6 +1143,25 @@ class DirectDownloadSource(ReleaseSource):
supported_filters=["format", "language"], # AA has reliable language metadata
)
def get_record(
self,
record_id: str,
*,
fetch_download_count: bool = True,
) -> Optional[BrowseRecord]:
"""Resolve a direct-download record for direct-mode info/download flows."""
return get_book_info(record_id, fetch_download_count=fetch_download_count)
def search_results_are_releases(self) -> bool:
"""Direct search results already represent concrete downloadable releases."""
return True
def get_destination_override(self, task: DownloadTask) -> Optional[Path]:
"""Apply Anna's Archive content-type routing when configured."""
if check_audiobook(task.content_type):
return None
return get_aa_content_type_dir(task.content_type)
def search(
self,
book: BookMetadata,
@@ -1164,6 +1186,15 @@ class DirectDownloadSource(ReleaseSource):
# Reset search type tracking
self._last_search_type = "title_author"
if plan.source_filters is not None:
query = plan.manual_query or ""
logger.debug(f"Searching direct_download: source_query='{query}', langs={lang_filter}")
filters = plan.source_filters or SearchFilters()
filters.lang = lang_filter if lang_filter is not None else (filters.lang or [])
results = search_books(query, filters)
self._last_search_type = "manual" if query else "title_author"
return [_browse_record_to_release(record) for record in results]
# ISBN search first (unless expand_search requested)
if plan.manual_query:
expand_search = True
@@ -1179,7 +1210,7 @@ class DirectDownloadSource(ReleaseSource):
if results:
logger.info(f"Found {len(results)} releases via ISBN")
self._last_search_type = "isbn"
return [_book_info_to_release(bi) for bi in results]
return [_browse_record_to_release(record) for record in results]
logger.debug("No ISBN results, falling back to title+author")
except SearchUnavailable:
raise
@@ -1192,7 +1223,7 @@ class DirectDownloadSource(ReleaseSource):
# Execute searches with deduplication
seen_ids: set = set()
all_results: List[BookInfo] = []
all_results: List[BrowseRecord] = []
for title, langs in searches:
query = f"{title} {author}".strip()
@@ -1212,7 +1243,7 @@ class DirectDownloadSource(ReleaseSource):
logger.error(f"Search error: {e}")
logger.info(f"Found {len(all_results)} releases via title+author")
return [_book_info_to_release(bi) for bi in all_results]
return [_browse_record_to_release(record) for record in all_results]
def is_available(self) -> bool:
"""Direct download is always available."""
@@ -1258,13 +1289,15 @@ class DirectDownloadHandler(DownloadHandler):
status_callback("cancelled", "Cancelled")
return None
# Create BookInfo from task data - NO AA page fetch here
# Create browse record from task data - NO AA page fetch here
# AA page is fetched lazily by _fetch_aa_page_urls only when
# we actually reach an AA slow source in the priority order
book_info = BookInfo(
book_info = BrowseRecord(
id=task.task_id,
title=task.title,
source="direct_download",
author=task.author,
year=task.year,
format=task.format,
size=task.size,
preview=task.preview,
@@ -1288,13 +1321,13 @@ class DirectDownloadHandler(DownloadHandler):
def _execute_download(
self,
book_info: BookInfo,
book_info: BrowseRecord,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None]
) -> Optional[str]:
"""
Internal method to execute the download with fetched BookInfo.
Internal method to execute the download with fetched browse record.
This contains the core download logic: cascade through sources,
handle bypass, move to final location.
@@ -1308,7 +1341,12 @@ class DirectDownloadHandler(DownloadHandler):
if file_org == "none":
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
else:
book_name = book_info.get_filename()
book_name = build_filename(
book_info.title,
book_info.author,
book_info.year,
book_info.format,
)
book_path = TMP_DIR / book_name
# Check cancellation before download
+43 -19
View File
@@ -14,9 +14,8 @@ import {
isMetadataBook,
} from './types';
import {
getBookInfo,
getSourceRecordInfo,
getMetadataBookInfo,
downloadBook,
downloadRelease,
cancelDownload,
retryDownload,
@@ -60,8 +59,10 @@ import {
} from './utils/requestPolicyUi';
import {
buildDirectRequestPayload,
buildReleaseDataFromDirectBook,
buildMetadataBookRequestData,
buildReleaseDataFromMetadataRelease,
getBrowseSource,
getRequestSuccessMessage,
toContentType,
} from './utils/requestPayload';
@@ -751,7 +752,8 @@ function App() {
// Show book details
const handleShowDetails = async (id: string): Promise<void> => {
const metadataBook = books.find(b => b.id === id && b.provider && b.provider_id);
const book = books.find((entry) => entry.id === id);
const metadataBook = book && isMetadataBook(book) ? book : null;
if (metadataBook) {
try {
@@ -769,11 +771,18 @@ function App() {
}
} else {
try {
const book = await getBookInfo(id);
setSelectedBook(book);
if (!book?.source) {
throw new Error('Book is missing source context');
}
const fullBook = await getSourceRecordInfo(book.source, id);
setSelectedBook(fullBook);
} catch (error) {
console.error('Failed to load book details:', error);
showToast('Failed to load book details', 'error');
console.error('Failed to load book details, using search data:', error);
if (book) {
setSelectedBook(book);
} else {
showToast('Failed to load book details', 'error');
}
}
}
};
@@ -813,8 +822,8 @@ function App() {
[submitRequest]
);
const getDirectPolicyMode = useCallback((): RequestPolicyMode => {
return getSourceMode('direct_download', 'ebook');
const getDirectPolicyMode = useCallback((book: Book): RequestPolicyMode => {
return getSourceMode(getBrowseSource(book), 'ebook');
}, [getSourceMode]);
const getUniversalDefaultPolicyMode = useCallback((): RequestPolicyMode => {
@@ -853,8 +862,10 @@ function App() {
const executeBookDownload = useCallback(
async (book: Book, onBehalfOfUserId?: number): Promise<void> => {
const source = getBrowseSource(book);
const directContentType: ContentType = 'ebook';
try {
await downloadBook(book.id, onBehalfOfUserId);
await downloadRelease(buildReleaseDataFromDirectBook(book), onBehalfOfUserId);
await fetchStatus();
} catch (error) {
console.error('Download failed:', error);
@@ -862,6 +873,8 @@ function App() {
const requiredMode = getPolicyGuardRequiredMode(error);
policyTrace('direct.action:policy_guard', {
bookId: book.id,
source,
contentType: directContentType,
requiredMode,
code: isApiResponseError(error) ? error.code : null,
});
@@ -913,7 +926,7 @@ function App() {
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
context: {
source: release.source || 'direct_download',
source: release.source,
content_type: normalizedContentType,
request_level: 'release',
},
@@ -927,7 +940,7 @@ function App() {
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: null,
context: {
source: release.source || 'direct_download',
source: release.source,
content_type: normalizedContentType,
request_level: 'book',
},
@@ -972,19 +985,24 @@ function App() {
// Direct-mode action (download or release-level request based on policy).
const handleDownload = async (book: Book): Promise<void> => {
let mode = getDirectPolicyMode();
const source = getBrowseSource(book);
const directContentType: ContentType = 'ebook';
let mode = getDirectPolicyMode(book);
policyTrace('direct.action:start', {
bookId: book.id,
contentType: 'ebook',
source,
contentType: directContentType,
cachedMode: mode,
isAdmin: requestRoleIsAdmin,
});
try {
const latestPolicy = await refreshRequestPolicy({ force: true });
const effectiveIsAdmin = latestPolicy ? Boolean(latestPolicy.is_admin) : requestRoleIsAdmin;
mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, 'direct_download', 'ebook');
mode = resolveSourceModeFromPolicy(latestPolicy, effectiveIsAdmin, source, directContentType);
policyTrace('direct.action:resolved', {
bookId: book.id,
source,
contentType: directContentType,
resolvedMode: mode,
effectiveIsAdmin,
defaults: latestPolicy?.defaults ?? null,
@@ -994,6 +1012,8 @@ function App() {
console.warn('Failed to refresh request policy before direct action:', error);
policyTrace('direct.action:refresh_failed', {
bookId: book.id,
source,
contentType: directContentType,
mode,
message: error instanceof Error ? error.message : String(error),
});
@@ -1164,7 +1184,7 @@ function App() {
book_data: buildMetadataBookRequestData(book, normalizedContentType),
release_data: buildReleaseDataFromMetadataRelease(book, release, normalizedContentType),
context: {
source: release.source || 'direct_download',
source: release.source,
content_type: normalizedContentType,
request_level: 'release',
},
@@ -1300,16 +1320,20 @@ function App() {
const getDirectActionButtonState = useCallback(
(bookId: string): ButtonStateInfo => {
const baseState = getButtonState(bookId);
const book = books.find((entry) => entry.id === bookId);
if (!book) {
return baseState;
}
if (baseState.state === 'complete' && isDownloadTaskDismissed(bookId)) {
return applyDirectPolicyModeToButtonState(
{ text: 'Download', state: 'download' },
getDirectPolicyMode()
getDirectPolicyMode(book)
);
}
const mode = getDirectPolicyMode();
const mode = getDirectPolicyMode(book);
return applyDirectPolicyModeToButtonState(baseState, mode);
},
[getButtonState, getDirectPolicyMode, isDownloadTaskDismissed]
[books, getButtonState, getDirectPolicyMode, isDownloadTaskDismissed]
);
const getUniversalActionButtonState = useCallback(
+44 -30
View File
@@ -534,13 +534,13 @@ export const ReleaseModal = ({
const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0
? supportedAudiobookFormats
: supportedFormats;
const isDirectProviderContext = (book?.provider || '').toLowerCase() === 'direct_download';
const [isClosing, setIsClosing] = useState(false);
const [isRequestingBook, setIsRequestingBook] = useState(false);
// Available sources from plugin registry
const [availableSources, setAvailableSources] = useState<ReleaseSource[]>([]);
const [sourcesLoading, setSourcesLoading] = useState(true);
const [sourcesError, setSourcesError] = useState<string | null>(null);
// Active tab (source name)
const [activeTab, setActiveTab] = useState<string>('');
@@ -767,24 +767,22 @@ export const ReleaseModal = ({
const fetchSources = async () => {
try {
setSourcesLoading(true);
setSourcesError(null);
const sources = await getReleaseSources();
const modalSources = isDirectProviderContext
? sources.filter((source) => source.name === 'direct_download')
: sources;
setAvailableSources(modalSources);
setAvailableSources(sources);
const providerContextSource = sources.find((source) => (
source.name === book.provider && source.browse_results_are_releases
));
// Filter sources by content type support
const supportedSources = modalSources.filter(s => {
const supportedSources = sources.filter(s => {
const types = s.supported_content_types || ['ebook', 'audiobook'];
return types.includes(contentType);
});
if (isDirectProviderContext) {
if (supportedSources.some((source) => source.name === 'direct_download')) {
setActiveTab('direct_download');
} else {
setActiveTab('');
}
if (providerContextSource) {
setActiveTab(providerContextSource.name);
return;
}
@@ -806,26 +804,21 @@ export const ReleaseModal = ({
} else if (sources.length > 0) {
// No sources support this content type - fall back to first source
setActiveTab(sources[0].name);
} else {
setActiveTab('');
}
} catch (err) {
console.error('Failed to fetch release sources:', err);
// Fallback: assume direct_download is available (for ebooks)
setAvailableSources([{
name: 'direct_download',
display_name: "Direct Download",
enabled: true,
supported_content_types: ['ebook']
}]);
if (contentType === 'ebook') {
setActiveTab('direct_download');
}
setAvailableSources([]);
setActiveTab('');
setSourcesError(err instanceof Error ? err.message : 'Failed to load release sources');
} finally {
setSourcesLoading(false);
}
};
fetchSources();
}, [book, defaultReleaseSource, contentType, isDirectProviderContext]);
}, [book, defaultReleaseSource, contentType]);
// Fetch releases when active tab changes (with caching)
// Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch
@@ -923,12 +916,21 @@ export const ReleaseModal = ({
type TabInfo = { name: string; displayName: string; enabled: boolean };
const enabledTabs: TabInfo[] = [];
const providerContextSourceName = (
availableSources.find((source) => (
source.name === book?.provider && source.browse_results_are_releases
))?.name || null
);
// Filter to only enabled sources that support this content type
availableSources.forEach((src) => {
const allowDisabledDirectTab = isDirectProviderContext && src.name === 'direct_download';
// Skip disabled sources entirely, except direct tab in direct-provider context.
if (!src.enabled && !allowDisabledDirectTab) {
if (providerContextSourceName && src.name !== providerContextSourceName) {
return;
}
const allowDisabledProviderContextTab = providerContextSourceName === src.name;
// Skip disabled sources entirely, except the source that owns the current browse record.
if (!src.enabled && !allowDisabledProviderContextTab) {
return;
}
@@ -951,7 +953,7 @@ export const ReleaseModal = ({
}
return enabledTabs;
}, [availableSources, defaultReleaseSource, contentType, isDirectProviderContext]);
}, [availableSources, book?.provider, defaultReleaseSource, contentType]);
// Update tab indicator position when active tab changes
useEffect(() => {
@@ -1273,7 +1275,10 @@ export const ReleaseModal = ({
const currentTabLoading = loadingBySource[activeTab] ?? false;
const currentTabError = errorBySource[activeTab] ?? null;
const isInitialLoading = currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError);
const hasActiveTab = activeTab.length > 0;
const isInitialLoading = hasActiveTab && (
currentTabLoading || (releasesBySource[activeTab] === undefined && !currentTabError)
);
const modal = (
<div
@@ -1497,6 +1502,10 @@ export const ReleaseModal = ({
<div className="flex gap-1 px-5 py-2">
<div className="h-10 w-32 animate-pulse bg-gray-200 dark:bg-gray-700 rounded" />
</div>
) : allTabs.length === 0 ? (
<div className="px-5 py-3 text-sm text-gray-500 dark:text-gray-400">
{sourcesError || 'No release sources are available for this book.'}
</div>
) : (
<div className="flex items-center justify-between px-5">
{/* Tabs - scrollable on narrow screens */}
@@ -1768,8 +1777,9 @@ export const ReleaseModal = ({
placeholder="All Indexers"
/>
)}
{/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */}
{(activeTab === 'direct_download' || activeTab === 'prowlarr') && (
{/* Apply button - re-fetch when the source supports server-side filters */}
{(columnConfig.supported_filters?.includes('language') ||
columnConfig.supported_filters?.includes('indexer')) && (
<button
type="button"
onClick={async () => {
@@ -1908,6 +1918,10 @@ export const ReleaseModal = ({
<div className="min-h-[200px]">
{sourcesLoading ? (
<ReleaseSkeleton />
) : sourcesError ? (
<ErrorState message={sourcesError} />
) : !hasActiveTab ? (
<EmptyState message="No release sources are available for this book." />
) : isInitialLoading && filteredReleases.length === 0 ? (
<ReleaseSkeleton />
) : currentTabError ? (
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CreateRequestPayload } from '../types';
import { getMetadataBookInfo } from '../services/api';
import { isSourceBackedRequestPayload } from '../utils/requestPayload';
import {
applyRequestNoteToPayload,
buildRequestConfirmationPreview,
@@ -85,7 +86,7 @@ export const RequestConfirmationModal = ({
if (
typeof provider !== 'string' || !provider ||
typeof providerId !== 'string' || !providerId ||
provider === 'direct_download' ||
isSourceBackedRequestPayload(payload) ||
bookData.series_name
) {
return;
+17 -7
View File
@@ -30,13 +30,21 @@ export const normalizeSource = (value: string): string => {
return source || '*';
};
const normalizeDirectSourceMode = (
const normalizeReleaseResultMode = (
policy: RequestPolicyResponse | null,
source: string,
mode: RequestPolicyMode
): RequestPolicyMode => {
return source === 'direct_download' && mode === 'request_book'
? 'request_release'
: mode;
if (mode !== 'request_book') {
return mode;
}
const normalizedSource = normalizeSource(source);
const sourceMode = policy?.source_modes?.find(
(entry) => normalizeSource(entry.source) === normalizedSource
);
return sourceMode?.browse_results_are_releases ? 'request_release' : mode;
};
const normalizeRuleSource = (value: unknown): string | null => {
@@ -108,7 +116,8 @@ export const resolveSourceModeFromPolicy = (
);
const fromSource = sourceModes?.modes?.[normalizedContentType];
if (fromSource) {
return normalizeDirectSourceMode(
return normalizeReleaseResultMode(
policy,
normalizedSource,
capModeToCeiling(fromSource, defaultMode)
);
@@ -141,13 +150,14 @@ export const resolveSourceModeFromPolicy = (
continue;
}
return normalizeDirectSourceMode(
return normalizeReleaseResultMode(
policy,
normalizedSource,
capModeToCeiling(parsedMode, defaultMode)
);
}
return normalizeDirectSourceMode(normalizedSource, defaultMode);
return normalizeReleaseResultMode(policy, normalizedSource, defaultMode);
};
export class RequestPolicyCache {
+15 -16
View File
@@ -11,7 +11,13 @@ import {
RequestRecord,
} from '../types';
import { SettingsResponse, ActionResult, UpdateResult, SettingsTab } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import {
MetadataBookData,
SourceRecordData,
transformMetadataToBook,
transformReleaseToDirectBook,
transformSourceRecordToBook,
} from '../utils/bookTransformers';
import { getApiBase, withBasePath } from '../utils/basePath';
import {
buildAdminRequestActionUrl,
@@ -27,10 +33,7 @@ const API_BASE = getApiBase();
// API endpoints
const API = {
search: `${API_BASE}/search`,
metadataSearch: `${API_BASE}/metadata/search`,
info: `${API_BASE}/info`,
download: `${API_BASE}/download`,
status: `${API_BASE}/status`,
cancelDownload: `${API_BASE}/download`,
retryDownload: `${API_BASE}/download`,
@@ -205,7 +208,8 @@ async function fetchJSON<T>(
// API functions
export const searchBooks = async (query: string): Promise<Book[]> => {
if (!query) return [];
return fetchJSON<Book[]>(`${API.search}?${query}`);
const response = await fetchJSON<ReleasesResponse>(`${API_BASE}/releases?source=direct_download&${query}`);
return response.releases.map(transformReleaseToDirectBook);
};
// Metadata search response type (internal)
@@ -297,8 +301,11 @@ export const fetchFieldOptions = async (endpoint: string): Promise<DynamicFieldO
.filter((option) => option.value !== '');
};
export const getBookInfo = async (id: string): Promise<Book> => {
return fetchJSON<Book>(`${API.info}?id=${encodeURIComponent(id)}`);
export const getSourceRecordInfo = async (source: string, id: string): Promise<Book> => {
const response = await fetchJSON<SourceRecordData>(
`${API_BASE}/release-sources/${encodeURIComponent(source)}/records/${encodeURIComponent(id)}`
);
return transformSourceRecordToBook(response);
};
// Get full book details from a metadata provider
@@ -310,15 +317,6 @@ export const getMetadataBookInfo = async (provider: string, bookId: string): Pro
return transformMetadataToBook(response);
};
export const downloadBook = async (id: string, onBehalfOfUserId?: number): Promise<void> => {
const params = new URLSearchParams();
params.set('id', id);
if (typeof onBehalfOfUserId === 'number') {
params.set('on_behalf_of_user_id', String(onBehalfOfUserId));
}
await fetchJSON(`${API.download}?${params.toString()}`);
};
// Download a specific release (from ReleaseModal)
export type DownloadReleasePayload = {
source: string;
@@ -340,6 +338,7 @@ export type DownloadReleasePayload = {
series_position?: number;
subtitle?: string;
search_author?: string;
search_mode?: 'direct' | 'universal';
};
export const downloadRelease = async (
@@ -0,0 +1,83 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { isMetadataBook, type Release } from '../types/index.js';
import { transformReleaseToDirectBook, transformSourceRecordToBook } from '../utils/bookTransformers.js';
describe('bookTransformers.transformReleaseToDirectBook', () => {
it('maps direct release data into the direct-mode book card shape', () => {
const release: Release = {
source: 'direct_download',
source_id: 'md5-123',
title: 'Example Title',
format: 'epub',
language: 'en',
size: '2 MB',
info_url: 'https://example.com/md5/md5-123',
extra: {
author: 'Example Author',
year: '2001',
preview: 'https://example.com/cover.jpg',
publisher: 'Example Publisher',
description: 'Example description',
info: {
Downloads: ['42'],
},
},
};
const book = transformReleaseToDirectBook(release);
assert.equal(book.id, 'md5-123');
assert.equal(book.title, 'Example Title');
assert.equal(book.author, 'Example Author');
assert.equal(book.year, '2001');
assert.equal(book.language, 'en');
assert.equal(book.format, 'epub');
assert.equal(book.size, '2 MB');
assert.equal(book.preview, 'https://example.com/cover.jpg');
assert.equal(book.publisher, 'Example Publisher');
assert.equal(book.description, 'Example description');
assert.equal(book.source, 'direct_download');
assert.equal(book.provider, 'direct_download');
assert.equal(book.provider_id, 'md5-123');
assert.equal(book.provider_display_name, 'Direct Download');
assert.equal(book.source_url, 'https://example.com/md5/md5-123');
assert.deepEqual(book.info, { Downloads: ['42'] });
assert.equal(isMetadataBook(book), false);
});
});
describe('bookTransformers.transformSourceRecordToBook', () => {
it('maps source-native records into source-backed book context', () => {
const book = transformSourceRecordToBook({
id: 'md5-456',
title: 'Record Title',
source: 'direct_download',
author: 'Record Author',
preview: '/api/covers/md5-456',
year: 1999,
language: 'en',
format: 'epub',
size: '3 MB',
publisher: 'Record Publisher',
description: 'Record description',
info: {
Downloads: ['64'],
},
source_url: 'https://example.com/record/md5-456',
});
assert.equal(book.id, 'md5-456');
assert.equal(book.title, 'Record Title');
assert.equal(book.author, 'Record Author');
assert.equal(book.source, 'direct_download');
assert.equal(book.provider, 'direct_download');
assert.equal(book.provider_id, 'md5-456');
assert.equal(book.provider_display_name, 'Direct Download');
assert.equal(book.year, '1999');
assert.equal(book.preview, '/api/covers/md5-456');
assert.equal(book.source_url, 'https://example.com/record/md5-456');
assert.deepEqual(book.info, { Downloads: ['64'] });
assert.equal(isMetadataBook(book), false);
});
});
@@ -7,6 +7,7 @@ describe('requestFulfil.bookFromRequestData', () => {
const book = bookFromRequestData({
title: 'The Pragmatic Programmer',
author: 'Andrew Hunt',
source: 'direct_download',
provider: 'openlibrary',
provider_id: 'ol-123',
preview: 'https://example.com/cover.jpg',
@@ -20,6 +21,7 @@ describe('requestFulfil.bookFromRequestData', () => {
assert.equal(book.id, 'ol-123');
assert.equal(book.title, 'The Pragmatic Programmer');
assert.equal(book.author, 'Andrew Hunt');
assert.equal(book.source, 'direct_download');
assert.equal(book.provider, 'openlibrary');
assert.equal(book.provider_id, 'ol-123');
assert.equal(book.preview, 'https://example.com/cover.jpg');
@@ -4,7 +4,10 @@ import type { Book, CreateRequestPayload, Release } from '../types/index.js';
import {
buildDirectRequestPayload,
buildMetadataBookRequestData,
buildReleaseDataFromDirectBook,
buildReleaseDataFromMetadataRelease,
getBrowseSource,
isSourceBackedRequestPayload,
getRequestSuccessMessage,
toContentType,
} from '../utils/requestPayload.js';
@@ -43,11 +46,27 @@ describe('requestPayload utilities', () => {
assert.equal(payload.context.content_type, 'ebook');
assert.ok(payload.release_data);
assert.equal(payload.release_data?.source, 'direct_download');
assert.equal(payload.release_data?.search_mode, 'direct');
assert.equal(isSourceBackedRequestPayload(payload), true);
});
it('builds metadata book + release payload fragments', () => {
const bookData = buildMetadataBookRequestData(baseBook, 'ebook');
const releaseData = buildReleaseDataFromMetadataRelease(baseBook, baseRelease, 'ebook');
const directReleaseData = buildReleaseDataFromDirectBook(baseBook);
const sourceBackedReleaseData = buildReleaseDataFromMetadataRelease(
{
...baseBook,
provider: 'direct_download',
provider_id: 'dd-1',
source: 'direct_download',
},
{
...baseRelease,
source: 'direct_download',
},
'ebook'
);
assert.equal(bookData.provider, 'openlibrary');
assert.equal(bookData.provider_id, 'ol-1');
@@ -55,6 +74,31 @@ describe('requestPayload utilities', () => {
assert.equal(releaseData.source, 'prowlarr');
assert.equal(releaseData.format, 'epub');
assert.equal(releaseData.content_type, 'ebook');
assert.equal(directReleaseData.search_mode, 'direct');
assert.equal(sourceBackedReleaseData.search_mode, 'direct');
});
it('resolves browse source from source-backed or provider-backed books', () => {
assert.equal(getBrowseSource(baseBook), 'direct_download');
assert.equal(
getBrowseSource({
...baseBook,
source: undefined,
provider: 'direct_download',
}),
'direct_download'
);
});
it('throws when browse-source context is missing', () => {
assert.throws(
() => getBrowseSource({
id: 'missing-source',
title: 'Example',
author: 'Author',
}),
/missing source context/
);
});
it('builds success toast message from payload title with fallback', () => {
@@ -20,6 +20,7 @@ const makePolicy = (overrides: Partial<RequestPolicyResponse> = {}): RequestPoli
{
source: 'direct_download',
supported_content_types: ['ebook'],
browse_results_are_releases: true,
modes: {
ebook: 'request_release',
},
@@ -57,7 +58,14 @@ describe('requestPolicyCore mode resolution', () => {
ebook: 'request_book',
audiobook: 'request_release',
},
source_modes: [],
source_modes: [
{
source: 'direct_download',
supported_content_types: ['ebook'],
browse_results_are_releases: true,
modes: {},
},
],
rules: [],
});
+4 -4
View File
@@ -178,6 +178,7 @@ export interface RequestPolicyDefaults {
export interface RequestPolicySourceMode {
source: string;
supported_content_types: string[];
browse_results_are_releases?: boolean;
modes: Record<string, RequestPolicyMode>;
}
@@ -278,13 +279,11 @@ export interface ActingAsUserSelection {
displayName: string | null;
}
// Type guard to check if a book is from a metadata provider
// Returns true and narrows type to include required provider fields
export const isMetadataBook = (book: Book): book is Book & {
provider: string;
provider_id: string;
} => {
return !!book.provider && !!book.provider_id;
return Boolean(book.provider && book.provider_id) && book.provider !== book.source;
};
// Release source types (from plugin system)
@@ -293,6 +292,7 @@ export interface ReleaseSource {
display_name: string; // e.g., 'Direct Download', 'Prowlarr'
enabled: boolean; // Whether the source is available for use
supported_content_types?: string[]; // Content types this source supports (e.g., ['ebook', 'audiobook'])
browse_results_are_releases?: boolean;
}
// Column schema types for plugin-driven release list UI
@@ -372,7 +372,7 @@ export interface Release {
// Search info returned by release sources
export interface SourceSearchInfo {
search_type: 'isbn' | 'title_author' | 'categories' | 'expanded';
search_type: 'isbn' | 'title_author' | 'categories' | 'expanded' | 'manual' | 'query';
}
// Response from /api/releases endpoint
+101 -1
View File
@@ -1,4 +1,4 @@
import { Book } from '../types';
import { Book, Release } from '../types';
/**
* Raw metadata book data from the API (provider responses).
@@ -34,6 +34,38 @@ export interface MetadataBookData {
titles_by_language?: Record<string, string>;
}
export interface SourceRecordData {
id: string;
title: string;
source: string;
preview?: string;
author?: string;
publisher?: string;
year?: string | number;
language?: string;
format?: string;
size?: string;
info?: Record<string, string | string[]>;
description?: string;
source_url?: string;
}
interface SourceBackedBookData {
id: string;
title: string;
source: string;
author?: unknown;
year?: unknown;
language?: unknown;
format?: unknown;
size?: unknown;
preview?: unknown;
publisher?: unknown;
info?: Record<string, string | string[]>;
description?: unknown;
source_url?: unknown;
}
/**
* Transform raw metadata book data to the frontend Book format.
* Handles ID generation, author joining, and info object construction.
@@ -71,3 +103,71 @@ export function transformMetadataToBook(data: MetadataBookData): Book {
},
};
}
const toOptionalText = (value: unknown): string | undefined => {
if (typeof value === 'string' && value.trim()) {
return value.trim();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
return undefined;
};
const humanizeSourceName = (value: string): string => {
return value
.split('_')
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
const transformSourceBackedDataToBook = (data: SourceBackedBookData): Book => {
const displayName = humanizeSourceName(data.source);
return {
id: data.id,
title: data.title,
author: toOptionalText(data.author) || 'Unknown author',
year: toOptionalText(data.year),
language: toOptionalText(data.language),
format: toOptionalText(data.format),
size: toOptionalText(data.size),
preview: toOptionalText(data.preview),
publisher: toOptionalText(data.publisher),
info: data.info,
description: toOptionalText(data.description),
source: data.source,
source_display_name: displayName,
provider: data.source,
provider_display_name: displayName,
provider_id: data.id,
source_url: toOptionalText(data.source_url),
};
};
export function transformReleaseToDirectBook(release: Release): Book {
const extra = release.extra || {};
return transformSourceBackedDataToBook({
id: release.source_id,
title: release.title,
source: release.source,
author: extra.author,
year: extra.year,
language: release.language || extra.language,
format: release.format,
size: release.size,
preview: extra.preview,
publisher: extra.publisher,
info:
typeof extra.info === 'object' && extra.info !== null
? extra.info as Record<string, string | string[]>
: undefined,
description: extra.description,
source_url: release.info_url || release.download_url,
});
}
export function transformSourceRecordToBook(record: SourceRecordData): Book {
return transformSourceBackedDataToBook(record);
}
+1
View File
@@ -39,6 +39,7 @@ export const bookFromRequestData = (bookData: Record<string, unknown> | null | u
id: providerId || title || 'request',
title,
author: toOptionalText(row.author) || 'Unknown author',
source: toOptionalText(row.source),
provider: toOptionalText(row.provider),
provider_id: providerId,
preview: toOptionalText(row.preview),
+40 -5
View File
@@ -9,6 +9,31 @@ export const toContentType = (value: ContentType | string): ContentType => {
return String(value).trim().toLowerCase() === 'audiobook' ? 'audiobook' : 'ebook';
};
export const getBrowseSource = (book: Book): string => {
const source = book.source || book.provider;
if (source) {
return source;
}
throw new Error(`Book ${book.id} is missing source context`);
};
export const isSourceBackedRequestPayload = (payload: CreateRequestPayload | null | undefined): boolean => {
if (!payload) {
return false;
}
const provider =
typeof payload.book_data?.provider === 'string'
? payload.book_data.provider.trim()
: '';
const source =
typeof payload.context?.source === 'string'
? payload.context.source.trim()
: '';
return Boolean(provider) && Boolean(source) && source !== '*' && provider === source;
};
export const buildMetadataBookRequestData = (book: Book, contentType: ContentType) => {
return {
title: book.title || 'Unknown title',
@@ -27,17 +52,18 @@ export const buildMetadataBookRequestData = (book: Book, contentType: ContentTyp
};
export const buildDirectBookRequestData = (book: Book) => {
const source = getBrowseSource(book);
return {
title: book.title || 'Unknown title',
author: book.author || 'Unknown author',
content_type: 'ebook' as const,
provider: 'direct_download',
provider_id: book.id,
provider: source,
provider_id: book.provider_id || book.id,
year: book.year,
format: book.format,
size: book.size,
preview: book.preview,
source: book.source || 'direct_download',
source,
source_url: book.source_url,
};
};
@@ -47,6 +73,11 @@ export const buildReleaseDataFromMetadataRelease = (
release: Release,
contentType: ContentType
) => {
const isSourceBackedReleaseContext =
Boolean(book.provider) &&
book.provider === release.source &&
(!book.source || book.source === release.source);
return {
source: release.source,
source_id: release.source_id,
@@ -67,12 +98,14 @@ export const buildReleaseDataFromMetadataRelease = (
series_position: book.series_position,
series_count: book.series_count,
subtitle: book.subtitle,
...(isSourceBackedReleaseContext ? { search_mode: 'direct' as const } : {}),
};
};
export const buildReleaseDataFromDirectBook = (book: Book) => {
const source = getBrowseSource(book);
return {
source: 'direct_download',
source,
source_id: book.id,
title: book.title || 'Unknown title',
author: book.author,
@@ -81,6 +114,7 @@ export const buildReleaseDataFromDirectBook = (book: Book) => {
size: book.size,
preview: book.preview,
content_type: 'ebook' as const,
search_mode: 'direct' as const,
};
};
@@ -88,6 +122,7 @@ export const buildDirectRequestPayload = (
book: Book
): CreateRequestPayload => {
const bookData = buildDirectBookRequestData(book);
const source = getBrowseSource(book);
// In direct mode, every result already represents a concrete downloadable release.
// Keep request payloads release-level so admins can approve immediately while still
@@ -96,7 +131,7 @@ export const buildDirectRequestPayload = (
book_data: bookData,
release_data: buildReleaseDataFromDirectBook(book),
context: {
source: 'direct_download',
source,
content_type: 'ebook',
request_level: 'release',
},
+15 -183
View File
@@ -1,9 +1,4 @@
"""Baseline guardrail tests for download API endpoints.
These tests lock current behavior for `/api/download`, `/api/releases/download`,
and `/api/status` so policy work in later phases cannot accidentally change
existing contracts.
"""
"""Baseline guardrail tests for shared release download and queue endpoints."""
from __future__ import annotations
@@ -50,183 +45,6 @@ def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
return main_module.user_db.create_user(username=username, role=role)
class TestDownloadEndpointGuardrails:
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download")
assert resp.status_code == 400
assert resp.get_json() == {"error": "No book ID provided"}
mock_queue_book.assert_not_called()
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
captured: dict[str, object] = {}
def fake_queue_book(book_id, priority, user_id=None, username=None):
captured.update(
{
"book_id": book_id,
"priority": priority,
"user_id": user_id,
"username": username,
}
)
return True, None
_set_authenticated_session(
client,
user_id="alice",
db_user_id=42,
is_admin=False,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book", side_effect=fake_queue_book):
resp = client.get("/api/download?id=book-123&priority=5")
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "priority": 5}
assert captured == {
"book_id": "book-123",
"priority": 5,
"user_id": 42,
"username": "alice",
}
def test_malformed_priority_returns_500_current_behavior(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download?id=book-123&priority=high")
body = resp.get_json()
assert resp.status_code == 500
assert "invalid literal for int()" in body["error"]
mock_queue_book.assert_not_called()
def test_auth_enabled_without_session_returns_401(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
resp = client.get("/api/download?id=book-123")
assert resp.status_code == 401
assert resp.get_json() == {"error": "Unauthorized"}
def test_admin_can_queue_book_on_behalf_of_another_user(self, main_module, client):
target_user = _create_user(main_module, prefix="target")
admin_user = _create_user(main_module, prefix="admin", role="admin")
captured: dict[str, object] = {}
def fake_queue_book(book_id, priority, user_id=None, username=None):
captured.update(
{
"book_id": book_id,
"priority": priority,
"user_id": user_id,
"username": username,
}
)
return True, None
_set_authenticated_session(
client,
user_id=admin_user["username"],
db_user_id=admin_user["id"],
is_admin=True,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book", side_effect=fake_queue_book):
resp = client.get(
f"/api/download?id=book-123&priority=4&on_behalf_of_user_id={target_user['id']}"
)
assert resp.status_code == 200
assert resp.get_json() == {"status": "queued", "priority": 4}
assert captured == {
"book_id": "book-123",
"priority": 4,
"user_id": target_user["id"],
"username": target_user["username"],
}
def test_non_admin_cannot_queue_book_on_behalf_of_user(self, main_module, client):
target_user = _create_user(main_module, prefix="target")
actor_user = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor_user["username"],
db_user_id=actor_user["id"],
is_admin=False,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get(
f"/api/download?id=book-123&on_behalf_of_user_id={target_user['id']}"
)
assert resp.status_code == 403
assert resp.get_json() == {"error": "Admin required"}
mock_queue_book.assert_not_called()
@pytest.mark.parametrize("raw_user_id", ["abc", "-1", "0"])
def test_invalid_on_behalf_user_id_returns_400_for_book_download(
self, main_module, client, raw_user_id
):
admin_user = _create_user(main_module, prefix="admin", role="admin")
_set_authenticated_session(
client,
user_id=admin_user["username"],
db_user_id=admin_user["id"],
is_admin=True,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get(
f"/api/download?id=book-123&on_behalf_of_user_id={raw_user_id}"
)
assert resp.status_code == 400
assert resp.get_json() == {"error": "Invalid on_behalf_of_user_id"}
mock_queue_book.assert_not_called()
def test_unknown_on_behalf_user_returns_404_for_book_download(self, main_module, client):
admin_user = _create_user(main_module, prefix="admin", role="admin")
_set_authenticated_session(
client,
user_id=admin_user["username"],
db_user_id=admin_user["id"],
is_admin=True,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download?id=book-123&on_behalf_of_user_id=99999999")
assert resp.status_code == 404
assert resp.get_json() == {"error": "User not found"}
mock_queue_book.assert_not_called()
def test_on_behalf_book_download_returns_503_when_user_db_unavailable(self, main_module, client):
admin_user = _create_user(main_module, prefix="admin", role="admin")
_set_authenticated_session(
client,
user_id=admin_user["username"],
db_user_id=admin_user["id"],
is_admin=True,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "user_db", None):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download?id=book-123&on_behalf_of_user_id=7")
assert resp.status_code == 503
assert resp.get_json() == {"error": "User database unavailable"}
mock_queue_book.assert_not_called()
class TestReleaseDownloadEndpointGuardrails:
def test_empty_json_payload_returns_400(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
@@ -250,6 +68,19 @@ class TestReleaseDownloadEndpointGuardrails:
assert resp.get_json() == {"error": "source_id is required"}
mock_queue_release.assert_not_called()
def test_missing_source_returns_400(self, main_module, client):
payload = {
"source_id": "release-xyz",
"title": "Example",
}
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
resp = client.post("/api/releases/download", json=payload)
assert resp.status_code == 400
assert resp.get_json() == {"error": "source is required"}
mock_queue_release.assert_not_called()
def test_success_returns_queued_payload_and_forwards_user_context(self, main_module, client):
captured: dict[str, object] = {}
@@ -275,6 +106,7 @@ class TestReleaseDownloadEndpointGuardrails:
"source_id": "release-xyz",
"title": "Release Title",
"priority": 3,
"search_mode": "direct",
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
+1 -1
View File
@@ -65,7 +65,7 @@ class TestSettingsRestrictionPolicy:
def test_settings_path_detection(self):
assert is_settings_or_onboarding_path("/api/settings/downloads")
assert is_settings_or_onboarding_path("/api/onboarding")
assert not is_settings_or_onboarding_path("/api/search")
assert not is_settings_or_onboarding_path("/api/releases")
def test_default_is_admin_restricted(self):
assert should_restrict_settings_to_admin({}) is True
+10 -6
View File
@@ -163,8 +163,8 @@ class TestPerUserDestination:
fake_get_destination,
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
"shelfmark.download.postprocess.destination.get_source",
lambda _: type("Source", (), {"get_destination_override": staticmethod(lambda task: None)})(),
)
from shelfmark.download.postprocess.destination import get_final_destination
@@ -196,8 +196,8 @@ class TestPerUserDestination:
fake_get_destination,
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
"shelfmark.download.postprocess.destination.get_source",
lambda _: type("Source", (), {"get_destination_override": staticmethod(lambda task: None)})(),
)
from shelfmark.download.postprocess.destination import get_final_destination
@@ -224,8 +224,12 @@ class TestPerUserDestination:
lambda is_audiobook=False, user_id=None, username=None: Path("/global/books"),
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: Path("/routed/books"),
"shelfmark.download.postprocess.destination.get_source",
lambda _: type(
"Source",
(),
{"get_destination_override": staticmethod(lambda task: Path("/routed/books"))},
)(),
)
from shelfmark.download.postprocess.destination import get_final_destination
+161 -35
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import importlib
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -13,6 +14,7 @@ from shelfmark.release_sources import (
ColumnSchema,
Release,
ReleaseColumnConfig,
SourceUnavailableError,
)
@@ -34,6 +36,22 @@ def client(main_module):
class _FakeDirectSource:
last_search_type = "title_author"
def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001
assert record_id == "md5-abc"
assert fetch_download_count is True
return SimpleNamespace(
id="md5-abc",
title="The Gun Seller",
author="Iain Banks",
preview="https://example.com/cover.jpg",
description=None,
publisher=None,
year=None,
language=None,
source="direct_download",
source_url=None,
)
def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001
assert book.provider == "direct_download"
assert book.provider_id == "md5-abc"
@@ -68,28 +86,18 @@ def test_releases_accepts_direct_download_provider(main_module, client):
fake_direct_source = _FakeDirectSource()
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(
main_module.backend,
"get_book_info",
return_value={
"id": "md5-abc",
"title": "The Gun Seller",
"author": "Iain Banks",
"preview": "https://example.com/cover.jpg",
},
) as mock_get_book_info:
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
with patch(
"shelfmark.release_sources.list_available_sources",
side_effect=AssertionError("list_available_sources should not be called"),
):
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "md5-abc",
},
)
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
with patch(
"shelfmark.release_sources.list_available_sources",
side_effect=AssertionError("list_available_sources should not be called"),
):
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "md5-abc",
},
)
assert resp.status_code == 200
body = resp.get_json()
@@ -100,22 +108,140 @@ def test_releases_accepts_direct_download_provider(main_module, client):
assert body["releases"][0]["source"] == "direct_download"
assert body["releases"][0]["source_id"] == "md5-rel-1"
assert body["search_info"]["direct_download"]["search_type"] == "title_author"
mock_get_book_info.assert_called_once_with("md5-abc")
mock_get_source.assert_called_once_with("direct_download")
assert mock_get_source.call_count == 2
assert all(call.args == ("direct_download",) for call in mock_get_source.call_args_list)
def test_releases_direct_provider_returns_404_when_book_missing(main_module, client):
class _MissingDirectSource:
def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001
assert record_id == "missing-md5"
assert fetch_download_count is True
return None
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "get_book_info", return_value=None):
with patch("shelfmark.release_sources.get_source") as mock_get_source:
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "missing-md5",
},
)
with patch("shelfmark.release_sources.get_source", return_value=_MissingDirectSource()) as mock_get_source:
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "missing-md5",
},
)
assert resp.status_code == 404
assert resp.get_json() == {"error": "Book not found in direct source"}
mock_get_source.assert_not_called()
assert resp.get_json() == {"error": "Book not found in release source"}
mock_get_source.assert_called_once_with("direct_download")
def test_releases_accepts_direct_source_query_mode(main_module, client):
class _QueryDirectSource:
last_search_type = "manual"
def search(self, book, plan, expand_search=False, content_type="ebook"): # noqa: ANN001
assert book.provider == "manual"
assert book.title == "Pride and Prejudice"
assert plan.source_filters is not None
assert plan.manual_query == "Pride and Prejudice"
assert plan.source_filters.author == ["Jane Austen"]
assert plan.source_filters.format == ["epub"]
assert content_type == "ebook"
return [
Release(
source="direct_download",
source_id="md5-rel-query",
title="Pride and Prejudice",
format="epub",
size="1 MB",
extra={
"author": "Jane Austen",
"preview": "https://example.com/cover.jpg",
},
)
]
def get_column_config(self):
return ReleaseColumnConfig(
columns=[
ColumnSchema(
key="format",
label="Format",
render_type=ColumnRenderType.BADGE,
align=ColumnAlign.CENTER,
width="80px",
),
],
grid_template="minmax(0,2fr) 80px",
)
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch("shelfmark.release_sources.get_source", return_value=_QueryDirectSource()) as mock_get_source:
resp = client.get(
"/api/releases",
query_string={
"source": "direct_download",
"query": "Pride and Prejudice",
"author": "Jane Austen",
"format": "epub",
},
)
assert resp.status_code == 200
body = resp.get_json()
assert body["sources_searched"] == ["direct_download"]
assert body["book"]["provider"] == "manual"
assert body["book"]["title"] == "Pride and Prejudice"
assert body["releases"][0]["source"] == "direct_download"
assert body["releases"][0]["source_id"] == "md5-rel-query"
mock_get_source.assert_called_once_with("direct_download")
def test_release_source_record_endpoint_returns_generic_browse_record(main_module, client):
fake_direct_source = _FakeDirectSource()
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch("shelfmark.release_sources.get_source", return_value=fake_direct_source) as mock_get_source:
resp = client.get("/api/release-sources/direct_download/records/md5-abc")
assert resp.status_code == 200
body = resp.get_json()
assert body["id"] == "md5-abc"
assert body["title"] == "The Gun Seller"
assert body["source"] == "direct_download"
mock_get_source.assert_called_once_with("direct_download")
def test_releases_direct_provider_returns_503_when_source_unavailable(main_module, client):
class _UnavailableDirectSource:
def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001
raise SourceUnavailableError("Unable to reach download source. Network restricted or mirrors are blocked.")
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch("shelfmark.release_sources.get_source", return_value=_UnavailableDirectSource()):
resp = client.get(
"/api/releases",
query_string={
"provider": "direct_download",
"book_id": "md5-abc",
},
)
assert resp.status_code == 503
assert resp.get_json() == {
"error": "Unable to reach download source. Network restricted or mirrors are blocked."
}
def test_release_source_record_endpoint_returns_503_when_source_unavailable(main_module, client):
class _UnavailableDirectSource:
def get_record(self, record_id, *, fetch_download_count=True): # noqa: ANN001
raise SourceUnavailableError("Unable to reach download source. Network restricted or mirrors are blocked.")
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch("shelfmark.release_sources.get_source", return_value=_UnavailableDirectSource()):
resp = client.get("/api/release-sources/direct_download/records/md5-abc")
assert resp.status_code == 503
assert resp.get_json() == {
"error": "Unable to reach download source. Network restricted or mirrors are blocked."
}
+30 -15
View File
@@ -60,7 +60,7 @@ def _policy(
class TestDownloadPolicyGuards:
def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
def test_release_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -71,13 +71,16 @@ class TestDownloadPolicyGuards:
return_value=_policy(default_ebook="request_release"),
):
with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="request_release")):
with patch.object(main_module.backend, "queue_book") as mock_queue_book:
resp = client.get("/api/download?id=book-123")
with patch.object(main_module.backend, "queue_release") as mock_queue_release:
resp = client.post(
"/api/releases/download",
json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"},
)
assert resp.status_code == 403
assert resp.json["code"] == "policy_requires_request"
assert resp.json["required_mode"] == "request_release"
mock_queue_book.assert_not_called()
mock_queue_release.assert_not_called()
def test_release_download_endpoint_blocks_before_queue_when_policy_blocked(self, main_module, client):
user = _create_user(main_module, prefix="reader")
@@ -112,12 +115,15 @@ class TestDownloadPolicyGuards:
return_value=_policy(default_ebook="blocked"),
):
with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="blocked")):
with patch.object(main_module.backend, "queue_book", return_value=(True, None)) as mock_queue_book:
resp = client.get("/api/download?id=book-123")
with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue_release:
resp = client.post(
"/api/releases/download",
json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"},
)
assert resp.status_code == 200
assert resp.json["status"] == "queued"
mock_queue_book.assert_called_once()
mock_queue_release.assert_called_once()
def test_no_auth_mode_bypasses_policy_guards(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
@@ -127,12 +133,15 @@ class TestDownloadPolicyGuards:
return_value=_policy(default_ebook="blocked"),
):
with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=_policy(default_ebook="blocked")):
with patch.object(main_module.backend, "queue_book", return_value=(True, None)) as mock_queue_book:
resp = client.get("/api/download?id=book-123")
with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue_release:
resp = client.post(
"/api/releases/download",
json={"source": "direct_download", "source_id": "book-123", "search_mode": "direct"},
)
assert resp.status_code == 200
assert resp.json["status"] == "queued"
mock_queue_book.assert_called_once()
mock_queue_release.assert_called_once()
class TestRequestRoutes:
@@ -1218,6 +1227,7 @@ class TestRequestCreationEdgeCases:
assert resp.json["policy_mode"] == "request_release"
assert resp.json["release_data"]["source"] == "direct_download"
assert resp.json["release_data"]["source_id"] == "ol-auto-1"
assert resp.json["release_data"]["search_mode"] == "direct"
def test_auto_infers_release_level_when_release_data_present(self, main_module, client):
user = _create_user(main_module, prefix="reader")
@@ -1820,8 +1830,11 @@ class TestDownloadPolicyGuardsExtended:
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy):
with patch.object(main_module.backend, "queue_book", return_value=(True, None)):
resp = client.get("/api/download?id=book-pass")
with patch.object(main_module.backend, "queue_release", return_value=(True, None)):
resp = client.post(
"/api/releases/download",
json={"source": "direct_download", "source_id": "book-pass", "search_mode": "direct"},
)
assert resp.status_code == 200
assert resp.json["status"] == "queued"
@@ -1834,8 +1847,11 @@ class TestDownloadPolicyGuardsExtended:
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy):
with patch.object(main_module.backend, "queue_book", return_value=(True, None)):
resp = client.get("/api/download?id=book-free")
with patch.object(main_module.backend, "queue_release", return_value=(True, None)):
resp = client.post(
"/api/releases/download",
json={"source": "direct_download", "source_id": "book-free", "search_mode": "direct"},
)
assert resp.status_code == 200
assert resp.json["status"] == "queued"
@@ -1963,4 +1979,3 @@ class TestDownloadPolicyGuardsExtended:
})
assert resp.status_code == 200
+36
View File
@@ -0,0 +1,36 @@
from threading import Event
from shelfmark.core.models import DownloadTask
from shelfmark.release_sources.direct_download import DirectDownloadHandler
def test_direct_download_handler_builds_staging_filename_from_browse_record(monkeypatch):
captured = {}
def fake_download_book(book_info, book_path, progress_callback, cancel_flag, status_callback): # noqa: ANN001
captured["title"] = book_info.title
captured["year"] = book_info.year
captured["path"] = book_path
return "https://example.com/file.epub"
import shelfmark.release_sources.direct_download as dd
monkeypatch.setattr(dd, "_download_book", fake_download_book)
monkeypatch.setattr(dd.config, "get", lambda key, default=None: "rename" if key == "FILE_ORGANIZATION" else default)
task = DownloadTask(
task_id="92c7879138d18678b763118250228955",
source="direct_download",
title="Project Hail Mary: A Novel",
author="Andy Weir",
year="2021",
format="epub",
)
handler = DirectDownloadHandler()
result = handler.download(task, Event(), lambda _progress: None, lambda _status, _message: None)
assert result is not None
assert captured["title"] == "Project Hail Mary: A Novel"
assert captured["year"] == "2021"
assert captured["path"].name == "Andy Weir - Project Hail Mary_ A Novel (2021).epub"
@@ -1,48 +1,4 @@
from types import SimpleNamespace
def test_queue_book_uses_user_specific_books_output_mode(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
config_calls: list[tuple[str, object]] = []
def fake_get_book_info(_book_id, fetch_download_count=False):
assert fetch_download_count is False
return SimpleNamespace(
title="Test Book",
author="Tester",
format="epub",
size="1 MB",
preview=None,
content="book (fiction)",
)
def fake_config_get(key, default=None, user_id=None):
config_calls.append((key, user_id))
if key == "BOOKS_OUTPUT_MODE":
return "email" if user_id == 42 else "folder"
if key == "EMAIL_RECIPIENT":
return "alice@example.com" if user_id == 42 else ""
return default
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.direct_download, "get_book_info", fake_get_book_info)
monkeypatch.setattr(orchestrator.config, "get", fake_config_get)
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_book("book-1", user_id=42, username="alice")
assert success is True
assert error is None
task = captured["task"]
assert task.output_mode == "email"
assert task.output_args == {"to": "alice@example.com"}
assert ("BOOKS_OUTPUT_MODE", 42) in config_calls
from shelfmark.core.models import SearchMode
def test_queue_release_uses_user_specific_books_output_mode(monkeypatch):
@@ -85,48 +41,37 @@ def test_queue_release_uses_user_specific_books_output_mode(monkeypatch):
assert task.output_mode == "email"
assert task.output_args == {"to": "alice@example.com"}
assert task.source_url == "https://audiobookbay.lu/abss/release-title/"
assert task.search_mode == SearchMode.UNIVERSAL
assert ("BOOKS_OUTPUT_MODE", 42) in config_calls
def test_queue_book_email_mode_without_recipient_is_queued(monkeypatch):
def test_queue_release_preserves_direct_search_mode_from_payload(monkeypatch):
import shelfmark.download.orchestrator as orchestrator
captured: dict[str, object] = {}
def fake_get_book_info(_book_id, fetch_download_count=False):
assert fetch_download_count is False
return SimpleNamespace(
title="Test Book",
author="Tester",
format="epub",
size="1 MB",
preview=None,
content="book (fiction)",
)
def fake_config_get(key, default=None, user_id=None):
if key == "BOOKS_OUTPUT_MODE":
return "email" if user_id == 42 else "folder"
if key == "EMAIL_RECIPIENT":
return ""
return default
def fake_add(task):
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.direct_download, "get_book_info", fake_get_book_info)
monkeypatch.setattr(orchestrator.config, "get", fake_config_get)
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
success, error = orchestrator.queue_book("book-1", user_id=42, username="alice")
success, error = orchestrator.queue_release(
{
"source": "direct_download",
"source_id": "release-direct",
"title": "Direct Title",
"content_type": "ebook",
"search_mode": "direct",
},
user_id=42,
username="alice",
)
assert success is True
assert error is None
task = captured["task"]
assert task.output_mode == "email"
assert task.output_args == {}
assert captured["task"].search_mode == SearchMode.DIRECT
def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
+20 -36
View File
@@ -251,19 +251,6 @@ class TestSettingsEndpoint:
class TestDownloadFlow:
"""Tests for the complete download flow."""
def test_download_requires_id(self, api_client: APIClient):
"""Test that download endpoint requires an ID."""
resp = api_client.get("/api/download")
assert resp.status_code in [400, 422]
def test_download_invalid_id_returns_error(self, api_client: APIClient):
"""Test that invalid ID returns appropriate error."""
resp = api_client.get("/api/download", params={"id": "nonexistent-id-12345"})
# Should return 404 or error status
assert resp.status_code in [400, 404, 500]
def test_cancel_nonexistent_download(self, api_client: APIClient):
"""Test cancelling a download that doesn't exist."""
resp = api_client.delete("/api/download/nonexistent-id-xyz/cancel")
@@ -365,39 +352,36 @@ class TestCoverProxy:
@pytest.mark.e2e
class TestLegacySearchEndpoint:
"""Tests for the legacy search endpoint (backwards compatibility)."""
class TestDirectSourceQueryEndpoint:
"""Tests for direct-mode source query search on the shared releases API."""
def test_legacy_search_without_query(self, api_client: APIClient):
"""Test legacy search behavior without query parameter."""
resp = api_client.get("/api/search")
def test_direct_source_query_requires_browse_context(self, api_client: APIClient):
"""Source query mode requires a query or browse filters."""
resp = api_client.get("/api/releases", params={"source": "direct_download"})
# May return 400 (error) or 200 with empty results depending on implementation
assert resp.status_code in [200, 400, 422]
assert resp.status_code in [400, 422]
def test_legacy_search_returns_results(self, api_client: APIClient):
"""Test legacy search with a query."""
resp = api_client.get("/api/search", params={"query": "Pride Prejudice"})
def test_direct_source_query_returns_results(self, api_client: APIClient):
"""Direct mode uses /api/releases source query mode."""
resp = api_client.get(
"/api/releases",
params={"source": "direct_download", "query": "Pride Prejudice"},
)
# May return results or 503 if source unavailable
if resp.status_code == 200:
data = resp.json()
assert isinstance(data, list)
assert data.get("sources_searched") == ["direct_download"]
assert isinstance(data.get("releases"), list)
@pytest.mark.e2e
class TestLegacyInfoEndpoint:
"""Tests for the legacy info endpoint."""
class TestSourceRecordEndpoint:
"""Tests for source-native record lookup on the shared source-record API."""
def test_legacy_info_requires_id(self, api_client: APIClient):
"""Test that legacy info requires ID parameter."""
resp = api_client.get("/api/info")
assert resp.status_code in [400, 422]
def test_legacy_info_invalid_id(self, api_client: APIClient):
"""Test legacy info with invalid ID."""
resp = api_client.get("/api/info", params={"id": "invalid-id-xyz"})
def test_source_record_invalid_id(self, api_client: APIClient):
"""Unknown source records should return a not-found style response."""
resp = api_client.get("/api/release-sources/direct_download/records/invalid-id-xyz")
# Should return 404 or error
assert resp.status_code in [400, 404, 500]
assert resp.status_code in [404, 500, 503]
+24 -20
View File
@@ -242,46 +242,50 @@ class TestFullDownloadJourney:
@pytest.mark.e2e
@pytest.mark.slow
class TestLegacyDownloadFlow:
"""Test the legacy download API (for backwards compatibility)."""
class TestDirectSourceReleaseFlow:
"""Test direct-mode search, record lookup, and download via shared release APIs."""
def test_legacy_search_and_download(
def test_direct_source_search_and_download(
self, api_client: APIClient, download_tracker: DownloadTracker
):
"""Test the legacy search -> info -> download flow."""
# Use the legacy search endpoint
"""Test the shared direct-mode source query -> record -> release download flow."""
search_resp = api_client.get(
"/api/search",
params={"query": "Frankenstein Mary Shelley"},
"/api/releases",
params={"source": "direct_download", "query": "Frankenstein Mary Shelley"},
timeout=30,
)
if search_resp.status_code == 503:
pytest.skip("Legacy search source unavailable")
pytest.skip("Direct source query unavailable")
if search_resp.status_code != 200:
pytest.skip(f"Legacy search failed: {search_resp.status_code}")
pytest.skip(f"Direct source query failed: {search_resp.status_code}")
results = search_resp.json()
payload = search_resp.json()
results = payload.get("releases") or []
if not results:
pytest.skip("No legacy search results")
pytest.skip("No direct source query results")
first_result = results[0]
book_id = first_result.get("id")
assert book_id, "Result missing ID"
source = first_result.get("source")
source_id = first_result.get("source_id")
assert source == "direct_download", "Result missing direct source context"
assert source_id, "Result missing source_id"
# Get book info
info_resp = api_client.get("/api/info", params={"id": book_id})
info_resp = api_client.get(f"/api/release-sources/{source}/records/{source_id}")
if info_resp.status_code != 200:
pytest.skip(f"Info endpoint failed: {info_resp.status_code}")
pytest.skip(f"Source record endpoint failed: {info_resp.status_code}")
# Queue download (legacy endpoint)
download_tracker.track(book_id)
download_resp = api_client.get("/api/download", params={"id": book_id})
# Queue download from the shared release payload
download_tracker.track(source_id)
download_resp = api_client.post(
"/api/releases/download",
json={**first_result, "content_type": "ebook", "search_mode": "direct"},
)
if download_resp.status_code != 200:
pytest.skip(f"Download queue failed: {download_resp.status_code}")
pytest.skip(f"Release download queue failed: {download_resp.status_code}")
download_data = download_resp.json()
assert download_data.get("status") == "queued"
+11 -11
View File
@@ -30,7 +30,7 @@ def main_module():
class TestProxyAuthMiddleware:
def test_skips_for_non_proxy_mode(self, main_module):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with main_module.app.test_request_context("/api/search"):
with main_module.app.test_request_context("/api/releases"):
result = main_module.proxy_auth_middleware()
assert result is None
assert "user_id" not in main_module.session
@@ -61,7 +61,7 @@ class TestProxyAuthMiddleware:
},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={"X-Auth-User": "proxyuser"},
):
result = main_module.proxy_auth_middleware()
@@ -89,7 +89,7 @@ class TestProxyAuthMiddleware:
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={"X-Auth-User": "proxy_takeover_local"},
):
result = main_module.proxy_auth_middleware()
@@ -111,7 +111,7 @@ class TestProxyAuthMiddleware:
},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={"X-Auth-User": "proxyuser2"},
):
main_module.session["user_id"] = "old-user"
@@ -137,7 +137,7 @@ class TestProxyAuthMiddleware:
},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={"X-Auth-User": username},
):
main_module.session["user_id"] = username
@@ -171,7 +171,7 @@ class TestProxyAuthMiddleware:
},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={"X-Auth-User": username},
):
main_module.session["user_id"] = username
@@ -195,7 +195,7 @@ class TestProxyAuthMiddleware:
"shelfmark.core.settings_registry.load_config_file",
return_value={"PROXY_AUTH_USER_HEADER": "X-Auth-User"},
):
with main_module.app.test_request_context("/api/search"):
with main_module.app.test_request_context("/api/releases"):
resp = _as_response(main_module.proxy_auth_middleware())
data = resp.get_json()
@@ -213,7 +213,7 @@ class TestProxyAuthMiddleware:
},
):
with main_module.app.test_request_context(
"/api/search",
"/api/releases",
headers={
"X-Auth-User": "adminuser",
"X-Auth-Groups": "users,admins,devs",
@@ -234,7 +234,7 @@ class TestLoginRequiredDecorator:
def test_allows_no_auth(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with main_module.app.test_request_context("/api/search"):
with main_module.app.test_request_context("/api/releases"):
decorated = main_module.login_required(view)
resp = decorated()
@@ -242,7 +242,7 @@ class TestLoginRequiredDecorator:
def test_blocks_when_not_authenticated(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with main_module.app.test_request_context("/api/search"):
with main_module.app.test_request_context("/api/releases"):
decorated = main_module.login_required(view)
resp = _as_response(decorated())
@@ -250,7 +250,7 @@ class TestLoginRequiredDecorator:
def test_allows_authenticated(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with main_module.app.test_request_context("/api/search"):
with main_module.app.test_request_context("/api/releases"):
main_module.session["user_id"] = "user"
decorated = main_module.login_required(view)
resp = decorated()