mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 16:31:14 +01:00
Refactor direct source to use universal API (#711)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
@@ -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]]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user