From e5ccabe1ef0c9f121db4590b325c8fa78cf16b0b Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 31 Jan 2026 12:53:11 +0000 Subject: [PATCH] Patch: Various additions (#564) - Added rich Prowlarr search results for whitelisted indexers - Added torznab query for whitelisted indexers - Added flags for all Prowlarr indexers - Added completed external download retry mechanism and "locating" state - Added client side preference storage of Book/Audiobook search preference - Fixed reverse proxy base URL in edge cases - Added gevent locking for I/O operations, keeps healthcheck alive on intensive processing operations - Added M4A supported audiobook option - Improved file transfer counting and logging with hardlink fallback warnings - Fixed proxy auth header for REMOTE_USER scenario - Dependency tweak for internal bypasser --- docker-compose.bypass-test.yml | 39 ++ docs/reverse-proxy.md | 122 +---- requirements-shelfmark.txt | 1 - shelfmark/config/settings.py | 1 + shelfmark/core/models.py | 1 + shelfmark/core/queue.py | 2 +- shelfmark/core/search_plan.py | 4 + shelfmark/core/utils.py | 5 + shelfmark/download/fs.py | 45 +- shelfmark/download/orchestrator.py | 1 + shelfmark/download/outputs/folder.py | 20 +- shelfmark/download/postprocess/transfer.py | 47 +- shelfmark/download/postprocess/workspace.py | 3 +- shelfmark/download/staging.py | 9 +- shelfmark/main.py | 30 +- shelfmark/release_sources/__init__.py | 15 +- shelfmark/release_sources/prowlarr/api.py | 100 ++++ shelfmark/release_sources/prowlarr/handler.py | 419 +++++++++-------- shelfmark/release_sources/prowlarr/source.py | 429 ++++++++++++++---- shelfmark/release_sources/prowlarr/torznab.py | 171 +++++++ src/frontend/src/App.tsx | 30 +- .../src/components/BookDownloadButton.tsx | 3 +- src/frontend/src/components/BookGetButton.tsx | 3 +- .../src/components/DownloadsSidebar.tsx | 10 +- src/frontend/src/components/ReleaseCell.tsx | 353 +++++++++++++- src/frontend/src/components/ReleaseModal.tsx | 335 +++++++++----- .../src/components/shared/Tooltip.tsx | 115 +++++ src/frontend/src/hooks/useDownloadTracking.ts | 7 + src/frontend/src/hooks/useSearch.ts | 4 +- src/frontend/src/pages/LoginPage.tsx | 6 +- src/frontend/src/services/api.ts | 6 +- src/frontend/src/types/index.ts | 10 +- src/frontend/src/utils/colorMaps.ts | 27 +- tests/bypass/test_internal_bypasser.py | 24 + tests/download/test_orchestrator_status.py | 33 ++ 35 files changed, 1867 insertions(+), 563 deletions(-) create mode 100644 docker-compose.bypass-test.yml create mode 100644 shelfmark/release_sources/prowlarr/torznab.py create mode 100644 src/frontend/src/components/shared/Tooltip.tsx create mode 100644 tests/bypass/test_internal_bypasser.py create mode 100644 tests/download/test_orchestrator_status.py diff --git a/docker-compose.bypass-test.yml b/docker-compose.bypass-test.yml new file mode 100644 index 0000000..b3a5449 --- /dev/null +++ b/docker-compose.bypass-test.yml @@ -0,0 +1,39 @@ +# Bypass testing - switch between dev build and v1.0.1 +# Usage: +# Test dev build: docker compose -f docker-compose.bypass-test.yml up shelfmark-dev +# Test v1.0.1: docker compose -f docker-compose.bypass-test.yml up shelfmark-stable +# Pull latest dev: docker compose -f docker-compose.bypass-test.yml build shelfmark-dev +# Pull v1.0.1: docker compose -f docker-compose.bypass-test.yml pull shelfmark-stable + +services: + # Dev image from registry + shelfmark-dev: + image: ghcr.io/calibrain/shelfmark:dev + container_name: shelfmark-bypass-dev + environment: + PUID: 1000 + PGID: 1000 + DEBUG: true + ports: + - 8084:8084 + volumes: + - ./.local/bypass-test/config-dev:/config + - ./.local/bypass-test/books:/books + - ./.local/bypass-test/log-dev:/var/log/shelfmark + - ./.local/bypass-test/tmp:/tmp/shelfmark + + # Stable v1.0.1 for comparison + shelfmark-stable: + image: ghcr.io/calibrain/shelfmark:1.0.1 + container_name: shelfmark-bypass-stable + environment: + PUID: 1000 + PGID: 1000 + DEBUG: true + ports: + - 8085:8084 + volumes: + - ./.local/bypass-test/config-stable:/config + - ./.local/bypass-test/books:/books + - ./.local/bypass-test/log-stable:/var/log/shelfmark + - ./.local/bypass-test/tmp:/tmp/shelfmark diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md index 20f1ccf..6ec9285 100644 --- a/docs/reverse-proxy.md +++ b/docs/reverse-proxy.md @@ -4,7 +4,7 @@ Shelfmark can run behind a reverse proxy at the root path (recommended) or under ## Root path setup (Recommended) -If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids the workarounds needed for subpath deployments. +If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), leave `URL_BASE` empty. This is the simplest option and avoids extra subpath configuration. ```nginx server { @@ -26,7 +26,7 @@ server { ## Subpath setup -Running Shelfmark under a subpath like `/shelfmark` requires additional configuration due to how the frontend generates certain URLs. +Running Shelfmark under a subpath like `/shelfmark` is supported without extra rewrite rules. ### 1. Set the base path in Shelfmark @@ -35,7 +35,7 @@ Running Shelfmark under a subpath like `/shelfmark` requires additional configur ### 2. Configure your reverse proxy -The frontend generates some URLs at the root level (`/socket.io/`, `/api/`, `/logo.png`) regardless of the `URL_BASE` setting. You'll need to add proxy rules to handle these paths. +All Shelfmark paths (UI, API, assets, Socket.IO) are served under the base path. A single location block is enough. --- @@ -44,56 +44,6 @@ The frontend generates some URLs at the root level (`/socket.io/`, `/api/`, `/lo **Complete Nginx configuration for subpath deployment:** ```nginx -# Redirect /logo.png to subpath (frontend bug workaround) -location = /logo.png { - return 302 /shelfmark/logo.png; -} - -# Proxy root /api/ to /shelfmark/api/ (frontend generates root paths) -location /api/ { - proxy_pass http://shelfmark:8084/shelfmark/api/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - -# Proxy root /socket.io/ to backend (frontend connects to root) -location /socket.io/ { - proxy_pass http://shelfmark:8084/socket.io/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - -# Rewrite /shelfmark/socket.io/ to /socket.io/ on backend -# (Socket.IO endpoint is always at /socket.io/ regardless of URL_BASE) -location ^~ /shelfmark/socket.io/ { - proxy_pass http://shelfmark:8084/socket.io/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - -# Main shelfmark location location /shelfmark/ { proxy_pass http://shelfmark:8084/shelfmark/; proxy_http_version 1.1; @@ -171,60 +121,6 @@ error_page 401 =302 https://auth.example.com/?rd=$target_url; # Include Authelia auth endpoint in your server block include /etc/nginx/snippets/authelia-authrequest.conf; -# Redirect /logo.png to subpath (frontend bug workaround) -location = /logo.png { - return 302 /shelfmark/logo.png; -} - -# Proxy root /api/ to /shelfmark/api/ (frontend generates root paths) -location /api/ { - include /etc/nginx/snippets/authelia-location.conf; - - proxy_pass http://shelfmark:8084/shelfmark/api/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - -# Proxy root /socket.io/ to backend (frontend connects to root) -location /socket.io/ { - include /etc/nginx/snippets/authelia-location.conf; - - proxy_pass http://shelfmark:8084/socket.io/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - -# Rewrite /shelfmark/socket.io/ to /socket.io/ on backend -location ^~ /shelfmark/socket.io/ { - include /etc/nginx/snippets/authelia-location.conf; - - proxy_pass http://shelfmark:8084/socket.io/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 86400; - proxy_send_timeout 86400; - proxy_buffering off; -} - # Main shelfmark location location /shelfmark/ { include /etc/nginx/snippets/authelia-location.conf; @@ -246,18 +142,6 @@ location /shelfmark/ { --- -## Known issues with subpath deployments - -The following issues require the workarounds above: - -1. **Socket.IO connects to root**: The frontend Socket.IO client connects to `https://yourdomain.com/socket.io/` instead of `https://yourdomain.com/shelfmark/socket.io/` - -2. **API calls use root path**: Cover image requests and some API calls go to `/api/` instead of `/shelfmark/api/` - -3. **Logo uses root path**: The logo is requested from `/logo.png` instead of `/shelfmark/logo.png` - -4. **Socket.IO backend path**: The Socket.IO endpoint on the backend is always at `/socket.io/`, not `/shelfmark/socket.io/`, regardless of the `URL_BASE` setting - ## Health checks Health checks work at `/shelfmark/api/health` when using a subpath configuration. diff --git a/requirements-shelfmark.txt b/requirements-shelfmark.txt index 0b08ab2..3ebd6fb 100644 --- a/requirements-shelfmark.txt +++ b/requirements-shelfmark.txt @@ -1,5 +1,4 @@ pyvirtualdisplay pyautogui -selenium==4.39.0 seleniumbase==4.45.10 python-xlib diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 84d3d05..f9b2ea9 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -177,6 +177,7 @@ _FORMAT_OPTIONS = [ _AUDIOBOOK_FORMAT_OPTIONS = [ {"value": "m4b", "label": "M4B"}, + {"value": "m4a", "label": "M4A"}, {"value": "mp3", "label": "MP3"}, {"value": "zip", "label": "ZIP"}, {"value": "rar", "label": "RAR"}, diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index 573c1fa..0dca604 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -35,6 +35,7 @@ class QueueStatus(str, Enum): """Enum for possible book queue statuses.""" QUEUED = "queued" RESOLVING = "resolving" + LOCATING = "locating" DOWNLOADING = "downloading" COMPLETE = "complete" AVAILABLE = "available" diff --git a/shelfmark/core/queue.py b/shelfmark/core/queue.py index 1a0c711..d378dc5 100644 --- a/shelfmark/core/queue.py +++ b/shelfmark/core/queue.py @@ -154,7 +154,7 @@ class BookQueue: current_status = self._status.get(task_id) # Allow cancellation during any active state - if current_status in [QueueStatus.RESOLVING, QueueStatus.DOWNLOADING]: + if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING]: # Signal active download to stop if task_id in self._cancel_flags: self._cancel_flags[task_id].set() diff --git a/shelfmark/core/search_plan.py b/shelfmark/core/search_plan.py index 22f1f62..98d29a7 100644 --- a/shelfmark/core/search_plan.py +++ b/shelfmark/core/search_plan.py @@ -36,6 +36,7 @@ class ReleaseSearchPlan: title_variants: List[ReleaseSearchVariant] grouped_title_variants: List[ReleaseSearchVariant] manual_query: Optional[str] = None + indexers: Optional[List[str]] = None # Indexer names for Prowlarr (overrides settings) @property def primary_query(self) -> str: @@ -86,6 +87,7 @@ def build_release_search_plan( book: BookMetadata, languages: Optional[List[str]] = None, manual_query: Optional[str] = None, + indexers: Optional[List[str]] = None, ) -> ReleaseSearchPlan: resolved_languages = _normalize_languages(languages) @@ -106,6 +108,7 @@ def build_release_search_plan( title_variants=[variant], grouped_title_variants=[variant], manual_query=resolved_manual_query, + indexers=indexers, ) isbn_candidates: List[str] = [] @@ -161,4 +164,5 @@ def build_release_search_plan( title_variants=title_variants, grouped_title_variants=grouped_variants, manual_query=None, + indexers=indexers, ) diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 0cd59df..210345f 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -191,6 +191,11 @@ def transform_cover_url(cover_url: Optional[str], cache_id: str) -> Optional[str if not is_covers_cache_enabled(): return cover_url + from shelfmark.core.config import config as app_config + # Encode the original URL and create a proxy URL encoded_url = base64.urlsafe_b64encode(cover_url.encode()).decode() + base_path = normalize_base_path(app_config.get("URL_BASE", "")) + if base_path: + return f"{base_path}/api/covers/{cache_id}?url={encoded_url}" return f"/api/covers/{cache_id}?url={encoded_url}" diff --git a/shelfmark/download/fs.py b/shelfmark/download/fs.py index a199b62..2c29f4b 100644 --- a/shelfmark/download/fs.py +++ b/shelfmark/download/fs.py @@ -10,12 +10,46 @@ import shutil import subprocess import time from pathlib import Path +from typing import Any, Callable, Optional, TypeVar from shelfmark.core.logger import setup_logger from shelfmark.download.permissions_debug import log_transfer_permission_context logger = setup_logger(__name__) +try: + from gevent import monkey as _gevent_monkey + from gevent.threadpool import ThreadPool as _GeventThreadPool +except Exception: + _gevent_monkey = None + _GeventThreadPool = None + +T = TypeVar("T") +_IO_THREADPOOL: Optional["_GeventThreadPool"] = None + + +def _use_gevent_threadpool() -> bool: + return bool( + _gevent_monkey + and _GeventThreadPool + and _gevent_monkey.is_module_patched("threading") + ) + + +def _get_io_threadpool() -> "_GeventThreadPool": + global _IO_THREADPOOL + if _IO_THREADPOOL is None: + pool_size = max(2, min(8, os.cpu_count() or 2)) + _IO_THREADPOOL = _GeventThreadPool(pool_size) + return _IO_THREADPOOL + + +def run_blocking_io(func: Callable[..., T], *args: Any, **kwargs: Any) -> T: + """Run blocking I/O in a native thread when under gevent.""" + if _use_gevent_threadpool(): + return _get_io_threadpool().apply(func, args, kwds=kwargs) + return func(*args, **kwargs) + _VERIFY_IO_WAIT_SECONDS = 3.0 @@ -96,11 +130,12 @@ def _is_permission_error(e: Exception) -> bool: def _system_op(op: str, source: Path, dest: Path) -> None: """Execute system command (mv or cp) as final fallback.""" logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest) - subprocess.run( + run_blocking_io( + subprocess.run, [op, "-f", str(source), str(dest)], check=True, capture_output=True, - text=True + text=True, ) @@ -110,7 +145,7 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None: try: # Fallback 1: copy content only - shutil.copyfile(str(source), str(dest)) + run_blocking_io(shutil.copyfile, str(source), str(dest)) _verify_transfer_size(dest, expected_size, "copy") if is_move: @@ -225,7 +260,7 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> temp_path = try_path.parent / f".{try_path.name}.tmp" try: try: - shutil.copy2(str(source_path), str(temp_path)) + run_blocking_io(shutil.copy2, str(source_path), str(temp_path)) except (PermissionError, OSError) as copy_error: if _is_permission_error(copy_error): logger.debug( @@ -365,7 +400,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) -> temp_path = try_path.parent / f".{try_path.name}.tmp" try: try: - shutil.copy2(str(source_path), str(temp_path)) + run_blocking_io(shutil.copy2, str(source_path), str(temp_path)) except (PermissionError, OSError) as e: # Handle NFS permission errors immediately here if _is_permission_error(e): diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 092f352..dc6b7d4 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -392,6 +392,7 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N status_map = { 'queued': QueueStatus.QUEUED, 'resolving': QueueStatus.RESOLVING, + 'locating': QueueStatus.LOCATING, 'downloading': QueueStatus.DOWNLOADING, 'complete': QueueStatus.COMPLETE, 'available': QueueStatus.AVAILABLE, diff --git a/shelfmark/download/outputs/folder.py b/shelfmark/download/outputs/folder.py index fc80e4b..e41402e 100644 --- a/shelfmark/download/outputs/folder.py +++ b/shelfmark/download/outputs/folder.py @@ -30,7 +30,12 @@ def _resolve_custom_script_target(target_path: Path, destination: Path, path_mod except ValueError: if target_path.is_absolute(): return Path(target_path.name) - return target_path + return target_path + + +def _format_op_counts(op_counts: dict[str, int]) -> str: + parts = [f"{op}={count}" for op, count in op_counts.items() if count] + return ", ".join(parts) if parts else "none" @dataclass(frozen=True) @@ -255,7 +260,7 @@ def process_folder_output( record_step(steps, "cleanup_staging", path=str(prepared.working_path)) log_plan_steps(task.task_id, steps) - final_paths, error = transfer_book_files( + final_paths, error, op_counts = transfer_book_files( prepared.files, destination=plan.destination, task=task, @@ -271,12 +276,19 @@ def process_folder_output( return None logger.info( - "Task %s: transferred %d file(s) to %s (%s)", + "Task %s: transferred %d file(s) to %s (ops: %s)", task.task_id, len(final_paths), plan.destination, - op_label.lower(), + _format_op_counts(op_counts), ) + if use_hardlink and op_counts.get("copy", 0): + logger.warning( + "Task %s: hardlink requested but %d of %d file(s) copied (fallback)", + task.task_id, + op_counts.get("copy", 0), + len(final_paths), + ) # Run custom script once per successful task, after transfer. if core_config.config.CUSTOM_SCRIPT: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index ada2a34..37da2a3 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -2,7 +2,7 @@ from __future__ import annotations import os from pathlib import Path -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import shelfmark.core.config as core_config from shelfmark.core.logger import setup_logger @@ -142,15 +142,16 @@ def transfer_book_files( is_torrent: bool, preserve_source: bool = False, organization_mode: Optional[str] = None, -) -> Tuple[List[Path], Optional[str]]: +) -> Tuple[List[Path], Optional[str], Dict[str, int]]: if not book_files: - return [], "No book files found" + return [], "No book files found", {"hardlink": 0, "copy": 0, "move": 0} is_audiobook = check_audiobook(task.content_type) organization_mode = organization_mode or get_file_organization(is_audiobook) max_attempts = _max_attempts_for_batch(len(book_files)) final_paths: List[Path] = [] + op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} if organization_mode == "organize": template = get_template(is_audiobook, "organize") @@ -171,6 +172,7 @@ def transfer_book_files( max_attempts=max_attempts, ) final_paths.append(final_path) + op_counts[op] = op_counts.get(op, 0) + 1 logger.debug(f"{op.capitalize()} to destination: {final_path.name}") else: zero_pad_width = max(len(str(len(book_files))), 2) @@ -191,9 +193,10 @@ def transfer_book_files( max_attempts=max_attempts, ) final_paths.append(final_path) + op_counts[op] = op_counts.get(op, 0) + 1 logger.debug(f"{op.capitalize()} to destination: {final_path.name}") - return final_paths, None + return final_paths, None, op_counts for book_file in book_files: if len(book_files) == 1 and organization_mode != "none": @@ -223,9 +226,10 @@ def transfer_book_files( max_attempts=max_attempts, ) final_paths.append(final_path) + op_counts[op] = op_counts.get(op, 0) + 1 logger.debug(f"{op.capitalize()} to destination: {final_path.name}") - return final_paths, None + return final_paths, None, op_counts def process_directory( @@ -257,7 +261,7 @@ def process_directory( if use_hardlink is None: use_hardlink = should_hardlink(task) - final_paths, error = transfer_book_files( + final_paths, error, _op_counts = transfer_book_files( book_files, destination=ingest_dir, task=task, @@ -305,6 +309,12 @@ def transfer_file_to_library( max_attempts=_max_attempts_for_batch(1), ) logger.info(f"Library {op}: {final_path}") + if use_hardlink and op != "hardlink": + logger.warning( + "Library hardlink requested but %s used instead for %s", + op, + final_path, + ) if use_hardlink and temp_file and not is_torrent_source(temp_file, task): safe_cleanup_path(temp_file, task) @@ -344,6 +354,7 @@ def transfer_directory_to_library( is_torrent = is_torrent_source(source_dir, task) transferred_paths: List[Path] = [] + op_counts: Dict[str, int] = {"hardlink": 0, "copy": 0, "move": 0} max_attempts = _max_attempts_for_batch(len(source_files)) if len(source_files) == 1: @@ -359,6 +370,7 @@ def transfer_directory_to_library( ) logger.debug(f"Library {op}: {source_file.name} -> {final_path}") transferred_paths.append(final_path) + op_counts[op] = op_counts.get(op, 0) + 1 else: zero_pad_width = max(len(str(len(source_files))), 2) files_with_parts = assign_part_numbers(source_files, zero_pad_width) @@ -378,14 +390,23 @@ def transfer_directory_to_library( ) logger.debug(f"Library {op}: {source_file.name} -> {final_path}") transferred_paths.append(final_path) + op_counts[op] = op_counts.get(op, 0) + 1 - if use_hardlink: - operation = "hardlinks" - elif is_torrent: - operation = "copies" - else: - operation = "files" - logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}") + op_summary = ", ".join( + f"{op}={count}" for op, count in op_counts.items() if count + ) or "none" + logger.info( + "Created %d library file(s) in %s (ops: %s)", + len(transferred_paths), + base_library_path.parent, + op_summary, + ) + if use_hardlink and op_counts.get("copy", 0): + logger.warning( + "Library hardlink requested but %d of %d file(s) copied (fallback)", + op_counts.get("copy", 0), + len(transferred_paths), + ) if use_hardlink and temp_file and not is_torrent_source(temp_file, task): safe_cleanup_path(temp_file, task) diff --git a/shelfmark/download/postprocess/workspace.py b/shelfmark/download/postprocess/workspace.py index 37323e5..a1da29f 100644 --- a/shelfmark/download/postprocess/workspace.py +++ b/shelfmark/download/postprocess/workspace.py @@ -7,6 +7,7 @@ from typing import List, Optional from shelfmark.config import env as env_config from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask +from shelfmark.download.fs import run_blocking_io from shelfmark.download.staging import STAGE_NONE from .types import OutputPlan @@ -59,7 +60,7 @@ def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None: try: if path.is_dir(): - shutil.rmtree(path, ignore_errors=True) + run_blocking_io(shutil.rmtree, path, ignore_errors=True) elif path.exists(): path.unlink(missing_ok=True) except (OSError, PermissionError) as exc: diff --git a/shelfmark/download/staging.py b/shelfmark/download/staging.py index 118caf2..da6075e 100644 --- a/shelfmark/download/staging.py +++ b/shelfmark/download/staging.py @@ -7,6 +7,7 @@ from typing import Literal from shelfmark.config import env as env_config from shelfmark.core.logger import setup_logger +from shelfmark.download.fs import run_blocking_io logger = setup_logger(__name__) @@ -67,17 +68,17 @@ def stage_path(source: Path, staging_dir: Path, action: StageAction) -> Path: staged_path = staging_dir / f"{source.name}_{counter}" counter += 1 if action == STAGE_COPY: - shutil.copytree(str(source), str(staged_path)) + run_blocking_io(shutil.copytree, str(source), str(staged_path)) else: - shutil.move(str(source), str(staged_path)) + run_blocking_io(shutil.move, str(source), str(staged_path)) else: while staged_path.exists(): staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}" counter += 1 if action == STAGE_COPY: - shutil.copy2(str(source), str(staged_path)) + run_blocking_io(shutil.copy2, str(source), str(staged_path)) else: - shutil.move(str(source), str(staged_path)) + run_blocking_io(shutil.move, str(source), str(staged_path)) staged_kind = "directory" if source.is_dir() else "file" logger.debug("Staged %s via %s: %s -> %s", staged_kind, action, source, staged_path) diff --git a/shelfmark/main.py b/shelfmark/main.py index d5ce709..6ff0f61 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -51,6 +51,7 @@ if BASE_PATH: async_mode = 'gevent' # Initialize Flask-SocketIO with reverse proxy support +socketio_path = f"{BASE_PATH}/socket.io" if BASE_PATH else "/socket.io" socketio = SocketIO( app, cors_allowed_origins="*", @@ -58,7 +59,7 @@ socketio = SocketIO( logger=False, engineio_logger=False, # Reverse proxy / Traefik compatibility settings - path='/socket.io', + path=socketio_path, ping_timeout=60, # Time to wait for pong response ping_interval=25, # Send ping every 25 seconds # Allow both websocket and polling for better compatibility @@ -268,12 +269,29 @@ def proxy_auth_middleware(): from shelfmark.core.settings_registry import load_config_file + def get_proxy_header(header_name: str) -> str | None: + """Resolve proxy auth values from headers with WSGI env fallbacks.""" + value = request.headers.get(header_name) + if value: + return value + + env_key = f"HTTP_{header_name.upper().replace('-', '_')}" + value = request.environ.get(env_key) + if value: + return value + + # Some proxies set authenticated username in REMOTE_USER (not as a header). + if header_name.lower().replace("_", "-") == "remote-user": + return request.environ.get("REMOTE_USER") + + return None + try: security_config = load_config_file("security") user_header = security_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User") # Extract username from proxy header - username = request.headers.get(user_header) + username = get_proxy_header(user_header) if not username: if request.path.startswith('/api/auth/'): @@ -291,7 +309,7 @@ def proxy_auth_middleware(): admin_group_name = security_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "admins") # Extract groups from proxy header (can be comma or pipe separated) - groups_header = request.headers.get(admin_group_header, "") + groups_header = get_proxy_header(admin_group_header) or "" user_groups_delimiter = "," if "," in groups_header else "|" user_groups = [g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()] @@ -1423,6 +1441,10 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: manual_query = request.args.get('manual_query', '').strip() + # 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 + if not provider or not book_id: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 @@ -1463,7 +1485,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: from shelfmark.core.search_plan import build_release_search_plan - plan = build_release_search_plan(book, languages=languages, manual_query=manual_query) + plan = build_release_search_plan(book, languages=languages, manual_query=manual_query, indexers=indexers) if plan.manual_query: planned_query = plan.manual_query diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index e5e39de..7189782 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -62,6 +62,9 @@ class ColumnRenderType(str, Enum): SIZE = "size" # File size formatting NUMBER = "number" # Numeric value PEERS = "peers" # Peers display: "S/L" with color based on seeder count + INDEXER_PROTOCOL = "indexer_protocol" # Text + colored dot for torrent/usenet + FLAG_ICON = "flag_icon" # Icon with tooltip (VIP, freeleech, etc.) + FORMAT_CONTENT_TYPE = "format_content_type" # Content type icon + format badge class ColumnAlign(str, Enum): @@ -124,8 +127,10 @@ class ReleaseColumnConfig: grid_template: str = "minmax(0,2fr) 60px 80px 80px" # CSS grid-template-columns leading_cell: Optional[LeadingCellConfig] = None # Defaults to thumbnail mode if None online_servers: Optional[List[str]] = None # For IRC: list of currently online server nicks + available_indexers: Optional[List[str]] = None # For Prowlarr: list of all enabled indexer names + default_indexers: Optional[List[str]] = None # For Prowlarr: indexers selected in settings (pre-selected in filter) cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min) - supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language"] + supported_filters: Optional[List[str]] = None # Which filters this source supports: ["format", "language", "indexer"] action_button: Optional[SourceActionButton] = None # Custom action button (replaces default expand search) @@ -170,6 +175,14 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: if config.online_servers is not None: result["online_servers"] = config.online_servers + # Include available_indexers if provided (e.g., for Prowlarr source) + if config.available_indexers is not None: + result["available_indexers"] = config.available_indexers + + # Include default_indexers if provided (indexers selected in settings, for pre-selection) + if config.default_indexers is not None: + result["default_indexers"] = config.default_indexers + # Include cache TTL if specified (sources can request longer caching) if config.cache_ttl_seconds is not None: result["cache_ttl_seconds"] = config.cache_ttl_seconds diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index 0cbb285..a0ca8d8 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -6,6 +6,7 @@ import requests from shelfmark.core.logger import setup_logger from shelfmark.core.utils import normalize_http_url +from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml logger = setup_logger(__name__) @@ -90,6 +91,44 @@ class ProwlarrClient: logger.error(f"Failed to get indexers: {e}") return [] + def get_enabled_indexers_detailed(self) -> List[Dict[str, Any]]: + """ + Get enabled indexers, including implementation metadata. + + Note: Prowlarr indexer "name" is user-configurable; prefer + "implementation"/"implementationName" for stable identification. + """ + indexers = self.get_indexers() + return [idx for idx in indexers if idx.get("enable", False)] + + def get_enriched_indexer_ids(self, *, restrict_to: Optional[List[int]] = None) -> List[int]: + """ + Return enabled indexer IDs that should use Torznab for richer metadata. + + Args: + restrict_to: Optional list of candidate indexer IDs to consider. + """ + enriched_ids: List[int] = [] + + for idx in self.get_enabled_indexers_detailed(): + idx_id = idx.get("id") + if idx_id is None: + continue + try: + idx_id_int = int(idx_id) + except (TypeError, ValueError): + continue + + if restrict_to is not None and idx_id_int not in restrict_to: + continue + + impl = str(idx.get("implementation") or idx.get("implementationName") or idx.get("definitionName") or "") + # Currently only MyAnonamouse provides consistently rich Torznab metadata. + if impl.strip().lower() == "myanonamouse": + enriched_ids.append(idx_id_int) + + return enriched_ids + def get_enabled_indexers(self) -> List[Dict[str, Any]]: """Get enabled indexers with book capability info.""" indexers = self.get_indexers() @@ -112,6 +151,67 @@ class ProwlarrClient: return result + def torznab_search( + self, + *, + indexer_id: int, + query: str, + categories: Optional[List[int]] = None, + search_type: str = "book", + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Search a specific indexer via Prowlarr's Torznab/Newznab endpoint. + + This returns richer fields (e.g., author/booktitle, torznab tags like + FreeLeech) than the JSON /api/v1/search endpoint. + """ + if not query: + return [] + + endpoint = f"/api/v1/indexer/{int(indexer_id)}/newznab" + url = self.base_url + endpoint + + params: Dict[str, Any] = { + "t": search_type, + "q": query, + "limit": limit, + "offset": offset, + } + if categories: + params["cat"] = ",".join(str(c) for c in categories) + + logger.debug(f"Prowlarr API: GET {url} (torznab)") + + try: + response = self._session.get( + url=url, + params=params, + timeout=self.timeout, + headers={ + # Override the session default JSON accept header. + "Accept": "application/rss+xml, application/xml;q=0.9, */*;q=0.8" + }, + ) + if not response.ok: + try: + error_body = response.text[:500] + logger.error(f"Prowlarr Torznab error response: {error_body}") + except Exception: + pass + response.raise_for_status() + + results = parse_torznab_xml(response.text) + # Ensure indexerId is always set (Prowlarr includes it, but be defensive). + for r in results: + if r.get("indexerId") is None: + r["indexerId"] = int(indexer_id) + return results + except Exception as e: + logger.error(f"Prowlarr Torznab search failed for indexer {indexer_id}: {e}") + return [] + def _has_book_categories(self, categories: List[Dict[str, Any]]) -> bool: """Check if any category or subcategory is in the book range (7000-7999).""" for cat in categories: diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 66e7760..cb67f51 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,6 +1,7 @@ """Prowlarr download handler - executes downloads via torrent/usenet clients.""" import shutil +import time from pathlib import Path from threading import Event from typing import Callable, Optional @@ -23,6 +24,9 @@ logger = setup_logger(__name__) # How often to poll the download client for status (seconds) POLL_INTERVAL = 2 +# How long to wait for completed files to appear (seconds) +COMPLETED_PATH_RETRY_INTERVAL = 5 +COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period def _diagnose_path_issue(path: str) -> str: @@ -195,6 +199,212 @@ class ProwlarrHandler(DownloadHandler): f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" ) + def _handle_cancelled_download( + self, + client: DownloadClient, + download_id: str, + protocol: str, + status_callback: Callable[[str, Optional[str]], None], + ) -> None: + if protocol == "usenet": + logger.info(f"Download cancelled, removing from {client.name}: {download_id}") + try: + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) + except Exception as e: + logger.warning( + f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" + ) + else: + logger.info( + f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" + ) + status_callback("cancelled", "Cancelled") + + def _resolve_download_path_once( + self, + client: DownloadClient, + download_id: str, + *, + log_details: bool, + ) -> tuple[Optional[Path], Optional[str]]: + """Resolve and validate the completed download path once.""" + try: + raw_path = client.get_download_path(download_id) + except Exception as e: + message = ( + f"Could not locate completed download in {client.name} (path not returned). " + f"Check volume mappings and category settings." + ) + if log_details: + logger.error( + f"Failed to resolve download path for {client.name} {download_id}: {e}" + ) + else: + logger.debug( + f"Failed to resolve download path for {client.name} {download_id}: {e}" + ) + return None, message + + if not raw_path: + message = ( + f"Could not locate completed download in {client.name} (path not returned). " + f"Check volume mappings and category settings." + ) + if log_details: + logger.error(f"Download client returned empty path for {client.name} {download_id}") + else: + logger.debug(f"Download client returned empty path for {client.name} {download_id}") + return None, message + + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local_with_match, + ) + + source_path_obj = Path(raw_path) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + + if log_details: + logger.debug( + "Attempting path remap: client=%s, host=%s, path=%s, mappings=%s", + client.name, + host, + source_path_obj, + [(m.host, m.remote_path, m.local_path) for m in mappings], + ) + + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + if log_details: + logger.debug( + "Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)", + source_path_obj, + remapped, + remapped.exists(), + remapped != source_path_obj, + matched_mapping, + ) + + if matched_mapping: + if remapped.exists(): + logger.info( + "Remapped download path for %s (%s): %s -> %s", + client.name, + download_id, + source_path_obj, + remapped, + ) + return remapped, None + + message = ( + f"Remapped path '{remapped}' does not exist. " + f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings." + ) + if log_details: + logger.error( + f"Download path does not exist after remapping: {raw_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}." + ) + else: + logger.debug( + f"Download path does not exist after remapping: {raw_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}." + ) + return None, message + + if mappings: + if source_path_obj.exists(): + logger.info( + "No remote path mapping matched for %s (%s); using client path: %s", + client.name, + download_id, + source_path_obj, + ) + return source_path_obj, None + + hint = _diagnose_path_issue(raw_path) + message = f"{hint} No remote path mapping matched for client '{client.name}'." + if log_details: + logger.error( + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {raw_path}. {hint}" + ) + else: + logger.debug( + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {raw_path}. {hint}" + ) + return None, message + + if not source_path_obj.exists(): + hint = _diagnose_path_issue(raw_path) + message = hint + if log_details: + logger.error( + f"Download path does not exist: {raw_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + else: + logger.debug( + f"Download path does not exist: {raw_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + return None, message + + return source_path_obj, None + + def _wait_for_completed_path( + self, + client: DownloadClient, + download_id: str, + *, + cancel_flag: Optional[Event], + status_callback: Callable[[str, Optional[str]], None], + ) -> tuple[Optional[Path], Optional[str]]: + """Wait briefly for completed files to appear on disk.""" + last_error: Optional[str] = None + + for attempt in range(1, COMPLETED_PATH_MAX_ATTEMPTS + 1): + if cancel_flag and cancel_flag.is_set(): + return None, last_error + + log_details = attempt == COMPLETED_PATH_MAX_ATTEMPTS + resolved_path, error = self._resolve_download_path_once( + client, + download_id, + log_details=log_details, + ) + if resolved_path: + return resolved_path, None + + last_error = error + + if attempt < COMPLETED_PATH_MAX_ATTEMPTS: + status_callback("locating", "Waiting for completed files...") + logger.debug( + "Completed files not available yet for %s (%s) (attempt %d/%d)", + client.name, + download_id, + attempt, + COMPLETED_PATH_MAX_ATTEMPTS, + ) + + if cancel_flag: + if cancel_flag.wait(timeout=COMPLETED_PATH_RETRY_INTERVAL): + return None, last_error + else: + time.sleep(COMPLETED_PATH_RETRY_INTERVAL) + + return None, last_error + def _build_progress_message(self, status) -> str: """Build a progress message from download status.""" msg = f"{status.progress:.0f}%" @@ -265,86 +475,22 @@ class ProwlarrHandler(DownloadHandler): logger.info("Existing download is complete, copying file directly") status_callback("resolving", "Found existing download, copying to library") - source_path = client.get_download_path(download_id) - if not source_path: - logger.error( - f"Could not get path for existing download. " - f"Client: {client.name}, ID: {download_id}. " - f"The download may have been moved or deleted." - ) + source_path_obj, path_error = self._wait_for_completed_path( + client=client, + download_id=download_id, + cancel_flag=cancel_flag, + status_callback=status_callback, + ) + if not source_path_obj: + if cancel_flag.is_set(): + return None status_callback( "error", - f"Could not locate existing download in {client.name}. " - f"Check that the file still exists." + path_error + or f"Could not locate existing download in {client.name}. Check that the file still exists.", ) return None - from shelfmark.core.path_mappings import ( - get_client_host_identifier, - parse_remote_path_mappings, - remap_remote_to_local_with_match, - ) - - source_path_obj = Path(source_path) - host = get_client_host_identifier(client) or "" - mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) - mappings = parse_remote_path_mappings(mapping_value) - remapped, matched_mapping = remap_remote_to_local_with_match( - mappings=mappings, - host=host, - remote_path=source_path_obj, - ) - - if matched_mapping: - if remapped.exists(): - logger.info( - "Remapped existing download path for %s (%s): %s -> %s", - client.name, - download_id, - source_path_obj, - remapped, - ) - source_path_obj = remapped - else: - logger.error( - f"Download path does not exist after remapping: {source_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}. " - f"Check that the local path in your mapping is mounted correctly." - ) - status_callback( - "error", - f"Remapped path '{remapped}' does not exist. " - f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.", - ) - return None - elif mappings: - if source_path_obj.exists(): - logger.info( - "No remote path mapping matched for %s (%s); using client path: %s", - client.name, - download_id, - source_path_obj, - ) - else: - hint = _diagnose_path_issue(source_path) - logger.error( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {source_path}. {hint}" - ) - status_callback( - "error", - f"{hint} No remote path mapping matched for client '{client.name}'.", - ) - return None - elif not source_path_obj.exists(): - hint = _diagnose_path_issue(source_path) - logger.error( - f"Download path does not exist: {source_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" - ) - status_callback("error", hint) - return None - result = self._handle_completed_file( source_path=source_path_obj, protocol=protocol, @@ -495,122 +641,27 @@ class ProwlarrHandler(DownloadHandler): # Handle cancellation if cancel_flag.is_set(): - if protocol == "usenet": - logger.info(f"Download cancelled, removing from {client.name}: {download_id}") - try: - self._delete_local_download_data(client, download_id) - self._remove_usenet_download(client, download_id, delete_files=True, archive=True) - except Exception as e: - logger.warning( - f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" - ) - else: - logger.info( - f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}" - ) - status_callback("cancelled", "Cancelled") + self._handle_cancelled_download(client, download_id, protocol, status_callback) return None - # Handle completed file - source_path = client.get_download_path(download_id) - if not source_path: - logger.error( - f"Download client returned empty path for completed download. " - f"Client: {client.name}, ID: {download_id}. " - f"Check that the download client's completion folder is accessible to Shelfmark." - ) + # Handle completed file (wait briefly for files to appear) + source_path_obj, path_error = self._wait_for_completed_path( + client=client, + download_id=download_id, + cancel_flag=cancel_flag, + status_callback=status_callback, + ) + if not source_path_obj: + if cancel_flag.is_set(): + self._handle_cancelled_download(client, download_id, protocol, status_callback) + return None status_callback( "error", - f"Could not locate completed download in {client.name} (path not returned). " - f"Check volume mappings and category settings." + path_error + or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.", ) return None - # Apply remote path mappings (client path -> shelfmark container path) - from shelfmark.core.path_mappings import ( - get_client_host_identifier, - parse_remote_path_mappings, - remap_remote_to_local_with_match, - ) - - source_path_obj = Path(source_path) - host = get_client_host_identifier(client) or "" - mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) - mappings = parse_remote_path_mappings(mapping_value) - - logger.debug( - "Attempting path remap: client=%s, host=%s, path=%s, mappings=%s", - client.name, - host, - source_path_obj, - [(m.host, m.remote_path, m.local_path) for m in mappings], - ) - - remapped, matched_mapping = remap_remote_to_local_with_match( - mappings=mappings, - host=host, - remote_path=source_path_obj, - ) - - logger.debug( - "Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)", - source_path_obj, - remapped, - remapped.exists(), - remapped != source_path_obj, - matched_mapping, - ) - - if matched_mapping: - if remapped.exists(): - logger.info( - "Remapped download path for %s (%s): %s -> %s", - client.name, - download_id, - source_path_obj, - remapped, - ) - source_path_obj = remapped - else: - logger.error( - f"Download path does not exist after remapping: {source_path} -> {remapped}. " - f"Client: {client.name}, ID: {download_id}. " - f"Check that the local path in your mapping is mounted correctly." - ) - status_callback( - "error", - f"Remapped path '{remapped}' does not exist. " - f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.", - ) - return None - elif mappings: - if source_path_obj.exists(): - logger.info( - "No remote path mapping matched for %s (%s); using client path: %s", - client.name, - download_id, - source_path_obj, - ) - else: - hint = _diagnose_path_issue(source_path) - logger.error( - f"Download path does not exist and no remote path mapping matched for {client.name} " - f"({download_id}): {source_path}. {hint}" - ) - status_callback( - "error", - f"{hint} No remote path mapping matched for client '{client.name}'.", - ) - return None - elif not source_path_obj.exists(): - hint = _diagnose_path_issue(source_path) - logger.error( - f"Download path does not exist: {source_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" - ) - status_callback("error", hint) - return None - result = self._handle_completed_file( source_path=source_path_obj, protocol=protocol, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index f597716..475bdba 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -59,6 +59,51 @@ AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "o # Combined list for format detection (audiobook formats first for priority) ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS +# Map 3-char MAM language codes to 2-char ISO codes used by frontend color maps +MAM_LANGUAGE_MAP = { + "eng": "en", + "ita": "it", + "spa": "es", + "fra": "fr", + "fre": "fr", + "ger": "de", + "deu": "de", + "por": "pt", + "rus": "ru", + "jpn": "ja", + "jap": "ja", + "chi": "zh", + "zho": "zh", + "dut": "nl", + "nld": "nl", + "swe": "sv", + "nor": "no", + "dan": "da", + "fin": "fi", + "pol": "pl", + "cze": "cs", + "ces": "cs", + "hun": "hu", + "kor": "ko", + "ara": "ar", + "heb": "he", + "tur": "tr", + "gre": "el", + "ell": "el", + "hin": "hi", + "tha": "th", + "vie": "vi", + "ind": "id", + "ukr": "uk", + "rom": "ro", + "ron": "ro", + "bul": "bg", + "cat": "ca", + "hrv": "hr", + "slv": "sl", + "srp": "sr", +} + # Backend safeguard: cap total Prowlarr search time per request. PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0 @@ -83,33 +128,79 @@ def _extract_format(title: str) -> Optional[str]: return None -def _extract_language(title: str) -> Optional[str]: - """Extract language code from release title (e.g., [German] -> 'de').""" - title_lower = title.lower() +def _extract_mam_language(raw_title: str) -> Optional[str]: + """ + Extract the language code from MyAnonamouse titles. - # Common language names and their codes - languages = { - "english": "en", "eng": "en", "[en]": "en", "(en)": "en", - "german": "de", "deutsch": "de", "[de]": "de", "(de)": "de", "ger": "de", - "french": "fr", "français": "fr", "[fr]": "fr", "(fr)": "fr", "fra": "fr", - "spanish": "es", "español": "es", "[es]": "es", "(es)": "es", "spa": "es", - "italian": "it", "italiano": "it", "[it]": "it", "(it)": "it", "ita": "it", - "portuguese": "pt", "[pt]": "pt", "(pt)": "pt", "por": "pt", - "dutch": "nl", "nederlands": "nl", "[nl]": "nl", "(nl)": "nl", "nld": "nl", - "russian": "ru", "[ru]": "ru", "(ru)": "ru", "rus": "ru", - "polish": "pl", "polski": "pl", "[pl]": "pl", "(pl)": "pl", "pol": "pl", - "chinese": "zh", "[zh]": "zh", "(zh)": "zh", "chi": "zh", - "japanese": "ja", "[ja]": "ja", "(ja)": "ja", "jpn": "ja", - "korean": "ko", "[ko]": "ko", "(ko)": "ko", "kor": "ko", - } + Prowlarr's MAM parser appends a structured bracket segment like: + [ENG / EPUB MOBI PDF] - for lang_pattern, lang_code in languages.items(): - if lang_pattern in title_lower: - return lang_code + The language code appears before the "/" - we extract it and map to + the 2-char ISO code used by the frontend color maps. + """ + if not raw_title: + return None + + for bracket in re.findall(r"\[([^\]]+)\]", raw_title): + if "/" not in bracket: + continue + + before_slash, _ = bracket.split("/", 1) + # Extract the language token (should be a 3-char code like ENG, ITA, etc.) + tokens = re.findall(r"[A-Za-z]+", before_slash.strip()) + + for token in tokens: + lang_code = token.lower() + if lang_code in MAM_LANGUAGE_MAP: + return MAM_LANGUAGE_MAP[lang_code] return None +def _extract_mam_formats(raw_title: str) -> List[str]: + """ + Extract a list of formats from MyAnonamouse titles. + + Prowlarr's MAM parser appends a structured bracket segment like: + [ENG / EPUB MOBI PDF] + + We only trust this structured segment (and do not attempt generic title + heuristics for other indexers). + """ + if not raw_title: + return [] + + format_set = set(ALL_BOOK_FORMATS) + for bracket in re.findall(r"\[([^\]]+)\]", raw_title): + if "/" not in bracket: + continue + + _, after_slash = bracket.split("/", 1) + tokens = re.findall(r"[A-Za-z0-9]+", after_slash) + + formats: List[str] = [] + for token in tokens: + fmt = token.lower() + if fmt in format_set and fmt not in formats: + formats.append(fmt) + + if formats: + return formats + + return [] + + +def _formats_display(formats: List[str]) -> Optional[str]: + if not formats: + return None + if len(formats) == 1: + return formats[0] + if len(formats) == 2: + return f"{formats[0]}, {formats[1]}" + # Show first two formats + count of others to prevent overflow + return f"{formats[0]}, {formats[1]} +{len(formats) - 2}" + + # Prowlarr category IDs for content type detection # See: https://wiki.servarr.com/prowlarr/cardigann-yml-definition#categories AUDIOBOOK_CATEGORY_IDS = {3000, 3030} # 3000 = Audio, 3030 = Audio/Audiobook @@ -144,9 +235,15 @@ def _detect_content_type_from_categories(categories: list, fallback: str = "book return "other" -def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook") -> Release: +def _prowlarr_result_to_release( + result: dict, + search_content_type: str = "ebook", + *, + enable_format_detection: bool = False, +) -> Release: """Convert a Prowlarr API result to a Release object.""" - title = result.get("title", "Unknown") + raw_title = result.get("title", "Unknown") + title = raw_title size_bytes = result.get("size") indexer = result.get("indexer", "Unknown") protocol = get_protocol(result) @@ -154,6 +251,27 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook" leechers = result.get("leechers") categories = result.get("categories", []) is_torrent = protocol == ReleaseProtocol.TORRENT + raw_indexer_flags = result.get("indexerFlags") or [] + indexer_flags: List[str] = [] + seen_flags: set[str] = set() + + def add_indexer_flag(flag: object) -> None: + if flag is None: + return + flag_str = str(flag).strip() + if not flag_str: + return + lowered = flag_str.lower() + if lowered in seen_flags: + return + seen_flags.add(lowered) + indexer_flags.append(flag_str) + + if isinstance(raw_indexer_flags, list): + for flag in raw_indexer_flags: + add_indexer_flag(flag) + elif isinstance(raw_indexer_flags, str): + add_indexer_flag(raw_indexer_flags) # Format peers display string: "seeders / leechers" peers_display = ( @@ -162,22 +280,50 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook" else None ) - # For format detection, prefer fileName over title (often cleaner) - file_name = result.get("fileName", "") - format_detected = _extract_format(file_name) if file_name else _extract_format(title) + format_detected: Optional[str] = None + formats: List[str] = [] + formats_display: Optional[str] = None + language_detected: Optional[str] = None + if enable_format_detection: + book_title = str(result.get("bookTitle") or "").strip() + if book_title: + title = book_title + + formats = _extract_mam_formats(str(raw_title or "")) + format_detected = formats[0] if formats else None + formats_display = _formats_display(formats) + language_detected = _extract_mam_language(str(raw_title or "")) # Build the source_id from GUID or generate from indexer + title - source_id = result.get("guid") or f"{indexer}:{hash(title)}" + source_id = result.get("guid") or f"{indexer}:{hash(raw_title)}" # Cache the raw Prowlarr result so handler can look it up by source_id cache_release(source_id, result) + # Derive common indicators from torznab/newznab attrs when present. + download_volume_factor = result.get("downloadVolumeFactor") + is_freeleech = False + try: + if download_volume_factor is not None and float(download_volume_factor) == 0.0: + is_freeleech = True + except (TypeError, ValueError): + pass + + if any(flag.lower() in {"freeleech", "fl"} for flag in indexer_flags): + is_freeleech = True + + is_vip = "[vip]" in str(raw_title).lower() + if is_vip: + add_indexer_flag("VIP") + if is_freeleech: + add_indexer_flag("FreeLeech") + return Release( source="prowlarr", source_id=source_id, title=title, format=format_detected, - language=_extract_language(title), + language=language_detected, size=_parse_size(size_bytes), size_bytes=size_bytes, download_url=get_preferred_download_url(result), @@ -199,7 +345,20 @@ def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook" "indexer_id": result.get("indexerId"), "files": result.get("files"), "grabs": result.get("grabs"), - "indexer_flags": result.get("indexerFlags", []), + "author": result.get("author"), + "book_title": result.get("bookTitle"), + "indexer_flags": indexer_flags, + "vip": is_vip, + "freeleech": is_freeleech, + "download_volume_factor": result.get("downloadVolumeFactor"), + "upload_volume_factor": result.get("uploadVolumeFactor"), + "minimum_ratio": result.get("minimumRatio"), + "minimum_seed_time": result.get("minimumSeedTime"), + "info_hash": result.get("infoHash"), + "formats": formats if formats else None, + "formats_display": formats_display, + # Raw torznab attributes for rich tooltips (enriched indexers) + "torznab_attrs": result.get("torznabAttrs"), }, ) @@ -217,59 +376,85 @@ class ProwlarrSource(ReleaseSource): def get_column_config(self) -> ReleaseColumnConfig: """Column configuration for Prowlarr releases.""" + # Fetch available indexers from Prowlarr + available_indexers: Optional[List[str]] = None + default_indexers: Optional[List[str]] = None + client = self._get_client() + if client: + try: + enabled_indexers = client.get_enabled_indexers_detailed() + # Get user-selected indexer IDs if configured + selected_ids = self._get_selected_indexer_ids() + + all_indexer_names = [] + selected_indexer_names = [] + + for idx in enabled_indexers: + idx_id = idx.get("id") + idx_name = idx.get("name") + if not idx_name: + continue + + # Add to all indexers list + all_indexer_names.append(idx_name) + + # If user has selected specific indexers, track those separately + if selected_ids is not None: + try: + if int(idx_id) in selected_ids: + selected_indexer_names.append(idx_name) + except (TypeError, ValueError): + pass + + available_indexers = sorted(all_indexer_names) if all_indexer_names else None + # Only set default_indexers if user has selected specific ones + default_indexers = sorted(selected_indexer_names) if selected_indexer_names else None + except Exception as e: + logger.warning(f"Failed to fetch indexer list for column config: {e}") + return ReleaseColumnConfig( columns=[ ColumnSchema( key="indexer", label="Indexer", - render_type=ColumnRenderType.TEXT, + render_type=ColumnRenderType.INDEXER_PROTOCOL, align=ColumnAlign.LEFT, - width="minmax(80px, 1fr)", - hide_mobile=True, - sortable=True, - ), - ColumnSchema( - key="protocol", - label="Type", - render_type=ColumnRenderType.BADGE, - align=ColumnAlign.CENTER, - width="60px", + width="minmax(140px, 1fr)", hide_mobile=False, - color_hint=ColumnColorHint(type="map", value="download_type"), - uppercase=True, - ), - ColumnSchema( - key="peers", - label="Peers", - render_type=ColumnRenderType.PEERS, - align=ColumnAlign.CENTER, - width="70px", - hide_mobile=True, - fallback="-", sortable=True, - sort_key="seeders", ), ColumnSchema( key="extra.indexer_flags", label="Flags", render_type=ColumnRenderType.TAGS, align=ColumnAlign.CENTER, - width="minmax(80px, 1.5fr)", + width="50px", hide_mobile=False, color_hint=ColumnColorHint(type="map", value="flags"), - fallback="-", + fallback="", uppercase=True, ), ColumnSchema( - key="content_type", - label="Type", + key="language", + label="Lang", render_type=ColumnRenderType.BADGE, align=ColumnAlign.CENTER, + width="50px", + hide_mobile=True, + color_hint=ColumnColorHint(type="map", value="language"), + uppercase=True, + fallback="", + ), + ColumnSchema( + key="extra.formats_display", + label="Format", + render_type=ColumnRenderType.FORMAT_CONTENT_TYPE, + align=ColumnAlign.CENTER, width="90px", hide_mobile=False, - color_hint=ColumnColorHint(type="map", value="content_type"), + color_hint=ColumnColorHint(type="map", value="format"), uppercase=True, - fallback="-", + fallback="", ), ColumnSchema( key="size", @@ -282,9 +467,11 @@ class ProwlarrSource(ReleaseSource): sort_key="size_bytes", ), ], - grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 80px 90px 80px", + grid_template="minmax(0,2fr) minmax(140px,1fr) 50px 50px 90px 80px", leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr - supported_filters=["language"], # Enables multi-language query expansion; Prowlarr language metadata is unreliable + available_indexers=available_indexers, + default_indexers=default_indexers, + supported_filters=["language", "indexer"], # Enables multi-language query expansion and indexer filtering ) def _get_client(self) -> Optional[ProwlarrClient]: @@ -325,6 +512,39 @@ class ProwlarrSource(ReleaseSource): logger.warning(f"Invalid PROWLARR_INDEXERS format: {selected} ({e})") return None + def _resolve_indexer_ids_from_names( + self, client: ProwlarrClient, names: List[str] + ) -> Optional[List[int]]: + """ + Convert indexer names to IDs by looking up enabled indexers. + + Returns None if no names could be resolved. + """ + if not names: + return None + + try: + enabled_indexers = client.get_enabled_indexers_detailed() + name_to_id = { + idx.get("name"): idx.get("id") + for idx in enabled_indexers + if idx.get("name") and idx.get("id") is not None + } + + ids = [] + for name in names: + idx_id = name_to_id.get(name) + if idx_id is not None: + try: + ids.append(int(idx_id)) + except (TypeError, ValueError): + pass + + return ids if ids else None + except Exception as e: + logger.warning(f"Failed to resolve indexer names to IDs: {e}") + return None + def search( self, book: BookMetadata, @@ -348,8 +568,12 @@ class ProwlarrSource(ReleaseSource): logger.warning("No search query available for book") return [] - # Get selected indexer IDs from config (None means search all) - indexer_ids = self._get_selected_indexer_ids() + # Get indexer IDs: prefer plan.indexers (from filter), else use settings + if plan.indexers: + indexer_ids = self._resolve_indexer_ids_from_names(client, plan.indexers) + logger.debug(f"Using filter-specified indexers: {plan.indexers} -> IDs {indexer_ids}") + else: + indexer_ids = self._get_selected_indexer_ids() # Get search categories based on content type # Audiobooks use 3030 (Audio/Audiobook), ebooks use 7000 (Books) @@ -382,37 +606,61 @@ class ProwlarrSource(ReleaseSource): f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}" ) + # Identify indexers that should be enriched via Torznab/Newznab. + enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids) + non_enriched_indexer_ids: Optional[List[int]] = None + if indexer_ids: + non_enriched_indexer_ids = [i for i in indexer_ids if i not in enriched_indexer_ids] + def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]: """Search indexers with given categories, collecting results.""" results = [] - if indexer_ids: - # Prefer a single request for all selected indexers to reduce latency. - try: - raw = client.search(query=query, indexer_ids=indexer_ids, categories=cats) - if raw: - results.extend(raw) - return results - except Exception as e: - logger.warning( - f"Search failed for selected indexers {indexer_ids}: {e}. Falling back to per-indexer search." - ) - # Fallback: search specific indexers one at a time - for indexer_id in indexer_ids: + # Search standard indexers via JSON endpoint. + if indexer_ids: + if non_enriched_indexer_ids: + # Prefer a single request for selected indexers to reduce latency. try: - raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats) + raw = client.search(query=query, indexer_ids=non_enriched_indexer_ids, categories=cats) if raw: results.extend(raw) except Exception as e: - logger.warning(f"Search failed for indexer {indexer_id}: {e}") + logger.warning( + f"Search failed for selected indexers {non_enriched_indexer_ids}: {e}. Falling back to per-indexer search." + ) + + for indexer_id in non_enriched_indexer_ids: + try: + raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats) + if raw: + results.extend(raw) + except Exception as e: + logger.warning(f"Search failed for indexer {indexer_id}: {e}") else: - # Search all enabled indexers at once + # Search all enabled indexers at once, then remove enriched results (re-fetched via Torznab). try: raw = client.search(query=query, indexer_ids=None, categories=cats) if raw: + if enriched_indexer_ids: + raw = [r for r in raw if r.get("indexerId") not in enriched_indexer_ids] results.extend(raw) except Exception as e: logger.warning(f"Search failed for all indexers: {e}") + + # Search enriched indexers via Torznab/Newznab for richer metadata. + for indexer_id in enriched_indexer_ids: + raw = client.torznab_search(indexer_id=indexer_id, query=query, categories=cats, search_type="book") + if raw: + results.extend(raw) + else: + # Fallback to JSON search for enriched indexers if Torznab fails. + try: + raw_fallback = client.search(query=query, indexer_ids=[indexer_id], categories=cats) + if raw_fallback: + results.extend(raw_fallback) + except Exception as e: + logger.warning(f"Fallback search failed for enriched indexer {indexer_id}: {e}") + return results try: @@ -454,7 +702,30 @@ class ProwlarrSource(ReleaseSource): seen_keys.add(key) all_results.append(r) - results = [_prowlarr_result_to_release(r, content_type) for r in all_results] + enriched_indexer_ids_set = set(enriched_indexer_ids) + results: List[Release] = [] + enriched_source_ids: set[str] = set() + + for r in all_results: + idx_id = r.get("indexerId") + try: + idx_id_int = int(idx_id) if idx_id is not None else None + except (TypeError, ValueError): + idx_id_int = None + + is_enriched = bool(idx_id_int is not None and idx_id_int in enriched_indexer_ids_set) + release = _prowlarr_result_to_release( + r, + content_type, + enable_format_detection=is_enriched, + ) + results.append(release) + + if is_enriched: + enriched_source_ids.add(release.source_id) + + # Sort results: enriched indexers first, then others + results.sort(key=lambda r: (0 if r.source_id in enriched_source_ids else 1)) if results: torrent_count = sum(1 for r in results if r.protocol == ReleaseProtocol.TORRENT) diff --git a/shelfmark/release_sources/prowlarr/torznab.py b/shelfmark/release_sources/prowlarr/torznab.py new file mode 100644 index 0000000..fe2df68 --- /dev/null +++ b/shelfmark/release_sources/prowlarr/torznab.py @@ -0,0 +1,171 @@ +""" +Torznab/Newznab (RSS/XML) helpers for Prowlarr. + +Used to fetch richer metadata from specific indexers (e.g., MyAnonamouse) that +isn't available via Prowlarr's JSON search endpoint. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +from xml.etree import ElementTree as ET + + +def _local_name(tag: str) -> str: + """Return tag name without namespace.""" + if tag.startswith("{"): + return tag.split("}", 1)[1] + return tag + + +def _coerce_int(value: Optional[str]) -> Optional[int]: + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return int(value) + except ValueError: + return None + + +def _coerce_float(value: Optional[str]) -> Optional[float]: + if value is None: + return None + value = value.strip() + if not value: + return None + try: + return float(value) + except ValueError: + return None + + +def _strip_author_from_title(title: str, author: Optional[str]) -> str: + """ + Prowlarr's MyAnonamouse parser appends " by {author}" into the title while + also emitting author/booktitle fields. Shelfmark's UI shows author + separately, so strip the duplicated " by author" segment when present. + """ + if not title or not author: + return title + + needle = f" by {author}" + if needle in title: + return title.replace(needle, "", 1).strip() + + return title + + +def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]: + """ + Parse a Torznab/Newznab XML response into a list of dicts that roughly match + Prowlarr's JSON search results shape. + """ + if not xml_text or not xml_text.strip(): + return [] + + try: + root = ET.fromstring(xml_text) + except ET.ParseError: + return [] + + items = root.findall(".//item") + results: List[Dict[str, Any]] = [] + + for item in items: + title = (item.findtext("title") or "").strip() + guid = (item.findtext("guid") or "").strip() or None + download_url = (item.findtext("link") or "").strip() or None + info_url = (item.findtext("comments") or "").strip() or None + pub_date = (item.findtext("pubDate") or "").strip() or None + + size = _coerce_int(item.findtext("size")) + + enclosure = item.find("enclosure") + enclosure_type = enclosure.get("type") if enclosure is not None else None + enclosure_url = enclosure.get("url") if enclosure is not None else None + + protocol: Optional[str] = None + if enclosure_type == "application/x-bittorrent": + protocol = "torrent" + elif enclosure_type == "application/x-nzb": + protocol = "usenet" + + if not download_url and enclosure_url: + download_url = enclosure_url.strip() or None + + prowlarr_indexer_el = item.find("prowlarrindexer") + indexer_id = _coerce_int(prowlarr_indexer_el.get("id")) if prowlarr_indexer_el is not None else None + indexer_name = (prowlarr_indexer_el.text or "").strip() if prowlarr_indexer_el is not None else "" + + categories: List[int] = [] + for cat_el in item.findall("category"): + cat_id = _coerce_int(cat_el.text) + if cat_id is not None: + categories.append(cat_id) + + # Collect torznab/newznab attr elements (namespaced). + attrs: Dict[str, str] = {} + tags: List[str] = [] + for el in item.iter(): + if _local_name(el.tag) != "attr": + continue + name = (el.get("name") or "").strip() + value = (el.get("value") or "").strip() + if not name: + continue + if name == "tag" and value: + tags.append(value) + continue + if value: + attrs[name] = value + + seeders = _coerce_int(attrs.get("seeders")) + peers = _coerce_int(attrs.get("peers")) + leechers: Optional[int] = None + if peers is not None and seeders is not None and peers >= seeders: + leechers = peers - seeders + + author = attrs.get("author") or None + book_title = attrs.get("booktitle") or None + info_hash = attrs.get("infohash") or None + + download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor")) + upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor")) + minimum_ratio = _coerce_float(attrs.get("minimumratio")) + minimum_seed_time = _coerce_int(attrs.get("minimumseedtime")) + + cleaned_title = _strip_author_from_title(title, author) + + results.append({ + "title": cleaned_title or title, + "guid": guid or info_url or download_url or f"{indexer_id}:{title}", + "size": size, + "protocol": protocol or "unknown", + "downloadUrl": download_url, + "infoUrl": info_url, + "publishDate": pub_date, + "indexer": indexer_name or None, + "indexerId": indexer_id, + "categories": categories, + "seeders": seeders, + "leechers": leechers, + "files": _coerce_int(attrs.get("files")), + "grabs": _coerce_int(attrs.get("grabs")), + "infoHash": info_hash, + "indexerFlags": tags, + # Optional richer fields (not available via JSON search) + "author": author, + "bookTitle": book_title, + "downloadVolumeFactor": download_volume_factor, + "uploadVolumeFactor": upload_volume_factor, + "minimumRatio": minimum_ratio, + "minimumSeedTime": minimum_seed_time, + # Pass through all torznab attributes for tooltip display + "torznabAttrs": attrs, + }) + + return results + diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 5a81b0b..aba3f0e 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -33,6 +33,20 @@ import { withBasePath } from './utils/basePath'; import { SearchModeProvider } from './contexts/SearchModeContext'; import './styles.css'; +const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type'; + +const getInitialContentType = (): ContentType => { + try { + const saved = localStorage.getItem(CONTENT_TYPE_STORAGE_KEY); + if (saved === 'ebook' || saved === 'audiobook') { + return saved; + } + } catch { + // localStorage may be unavailable in private browsing + } + return 'ebook'; +}; + function App() { const { toasts, showToast, removeToast } = useToast(); @@ -73,7 +87,15 @@ function App() { }); // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it - const [contentType, setContentType] = useState('ebook'); + const [contentType, setContentType] = useState(() => getInitialContentType()); + + useEffect(() => { + try { + localStorage.setItem(CONTENT_TYPE_STORAGE_KEY, contentType); + } catch { + // localStorage may be unavailable in private browsing + } + }, [contentType]); // Search state and handlers const { @@ -143,6 +165,7 @@ function App() { const ongoing = [ currentStatus.queued, currentStatus.resolving, + currentStatus.locating, currentStatus.downloading, ].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0); @@ -506,6 +529,7 @@ function App() { : [bookLanguages[0]?.code || 'en']; const searchMode = config?.search_mode || 'direct'; + const logoUrl = withBasePath('/logo.png'); // Handle "View Series" - trigger search with series field and series order sort const handleSearchSeries = useCallback((seriesName: string) => { @@ -537,7 +561,7 @@ function App() { calibreWebUrl={config?.calibre_web_url || ''} audiobookLibraryUrl={config?.audiobook_library_url || ''} debug={config?.debug || false} - logoUrl="/logo.png" + logoUrl={logoUrl} showSearch={!isInitialState} searchInput={searchInput} onSearchChange={setSearchInput} @@ -604,7 +628,7 @@ function App() { bookLanguages={bookLanguages} defaultLanguage={defaultLanguageCodes} supportedFormats={config?.supported_formats || DEFAULT_SUPPORTED_FORMATS} - logoUrl="/logo.png" + logoUrl={logoUrl} searchInput={searchInput} onSearchInputChange={setSearchInput} showAdvanced={showAdvanced} diff --git a/src/frontend/src/components/BookDownloadButton.tsx b/src/frontend/src/components/BookDownloadButton.tsx index de88c08..4668068 100644 --- a/src/frontend/src/components/BookDownloadButton.tsx +++ b/src/frontend/src/components/BookDownloadButton.tsx @@ -63,7 +63,7 @@ export const BookDownloadButton = ({ const isCompleted = buttonState.state === 'complete'; const hasError = buttonState.state === 'error'; - const isInProgress = ['queued', 'resolving', 'downloading'].includes(buttonState.state); + const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state); const isDisabled = buttonState.state !== 'download' || isQueuing || isCompleted; const displayText = isQueuing ? 'Queuing...' : buttonState.text; const showCircularProgress = buttonState.state === 'downloading' && buttonState.progress !== undefined; @@ -205,4 +205,3 @@ export const BookDownloadButton = ({ ); }; - diff --git a/src/frontend/src/components/BookGetButton.tsx b/src/frontend/src/components/BookGetButton.tsx index c40f424..d62f3df 100644 --- a/src/frontend/src/components/BookGetButton.tsx +++ b/src/frontend/src/components/BookGetButton.tsx @@ -56,7 +56,7 @@ export const BookGetButton = ({ // Determine states based on buttonState const isCompleted = buttonState?.state === 'complete'; const hasError = buttonState?.state === 'error'; - const isInProgress = buttonState && ['queued', 'resolving', 'downloading'].includes(buttonState.state); + const isInProgress = buttonState && ['queued', 'resolving', 'locating', 'downloading'].includes(buttonState.state); const showCircularProgress = buttonState?.state === 'downloading' && buttonState.progress !== undefined; const showSpinner = (isInProgress && !showCircularProgress) || isLoading; @@ -104,6 +104,7 @@ export const BookGetButton = ({ if (hasError) return 'Failed'; if (isLoading) return 'Loading'; if (buttonState?.state === 'downloading') return 'Downloading'; + if (buttonState?.state === 'locating') return 'Locating files'; if (buttonState?.state === 'resolving') return 'Resolving'; if (buttonState?.state === 'queued') return 'Queued'; return 'Get'; diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx index a71fbfb..f87abab 100644 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ b/src/frontend/src/components/DownloadsSidebar.tsx @@ -14,6 +14,7 @@ const STATUS_STYLES: Record return 20 + (bookProgress * 0.8); } return 20; + case 'locating': + return 90; case 'complete': case 'error': return 100; @@ -92,6 +95,7 @@ const getProgressBarColor = (statusName: string): string => { if (statusName === 'queued') return 'bg-amber-600'; if (statusName === 'resolving') return 'bg-indigo-600'; if (statusName === 'downloading') return 'bg-sky-600'; + if (statusName === 'locating') return 'bg-teal-600'; return 'bg-sky-600'; }; @@ -120,7 +124,7 @@ export const DownloadsSidebar = ({ // Collect all download items from different status sections const allDownloadItems: Array<{ book: Book; status: string }> = []; - const statusTypes = ['downloading', 'resolving', 'queued', 'error', 'complete', 'cancelled']; + const statusTypes = ['downloading', 'locating', 'resolving', 'queued', 'error', 'complete', 'cancelled']; statusTypes.forEach((statusName) => { const items = (status as any)[statusName]; @@ -142,9 +146,9 @@ export const DownloadsSidebar = ({ label: statusName.charAt(0).toUpperCase() + statusName.slice(1), }; - const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName); + const isInProgress = ['queued', 'resolving', 'locating', 'downloading'].includes(statusName); const isQueued = statusName === 'queued'; - const isActive = statusName === 'resolving' || statusName === 'downloading'; + const isActive = statusName === 'resolving' || statusName === 'locating' || statusName === 'downloading'; const isCompleted = statusName === 'complete'; const hasError = statusName === 'error'; diff --git a/src/frontend/src/components/ReleaseCell.tsx b/src/frontend/src/components/ReleaseCell.tsx index f56001c..1d2a1ae 100644 --- a/src/frontend/src/components/ReleaseCell.tsx +++ b/src/frontend/src/components/ReleaseCell.tsx @@ -1,6 +1,7 @@ import { ColumnSchema, Release } from '../types'; -import { getColorStyleFromHint } from '../utils/colorMaps'; +import { getColorStyleFromHint, getProtocolDotColor, getFormatColor } from '../utils/colorMaps'; import { getNestedValue } from '../utils/objectHelpers'; +import { Tooltip } from './shared/Tooltip'; interface ReleaseCellProps { column: ColumnSchema; @@ -37,12 +38,48 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: return {displayValue}; } const colorStyle = getColorStyleFromHint(value, column.color_hint); + + // Build rich tooltip content for formats + let tooltipContent: React.ReactNode = null; + if (column.key === 'extra.formats_display') { + const formats = (release.extra as Record | undefined)?.formats; + if (Array.isArray(formats) && formats.length > 1) { + tooltipContent = ( +
+ {formats.map((fmt) => { + const fmtColor = getFormatColor(String(fmt)); + return ( + + {String(fmt).toUpperCase()} + + ); + })} +
+ ); + } + } + + const badge = ( + + {displayValue} + + ); + return (
{value !== column.fallback ? ( - - {displayValue} - + tooltipContent ? ( + + {badge} + + ) : ( + badge + ) ) : ( {column.fallback} )} @@ -51,18 +88,50 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: } case 'tags': { - // Tags display: render list of strings as distinct badges - // Treat non-array values as single-item array - const tags = (Array.isArray(rawValue) ? rawValue : (value ? [value] : [])) as any[]; + const tags = Array.isArray(rawValue) + ? rawValue.map((tag) => String(tag)).filter((tag) => tag.trim()) + : rawValue !== undefined && rawValue !== null && String(rawValue).trim() + ? [String(rawValue)] + : []; + const isFlags = column.color_hint?.type === 'map' && column.color_hint.value === 'flags'; + + const normalizeFlagLabel = (tag: string): string => { + const normalized = tag.trim().toLowerCase(); + if (!normalized) return tag; + switch (normalized) { + case 'freeleech': + case 'free leech': + case 'fl': + return 'FL'; + case 'double upload': + case 'doubleupload': + case 'du': + return 'DU'; + case 'vip': + return 'VIP'; + case 'internal': + case 'int': + return 'INT'; + default: + return tag; + } + }; - // Compact mode: render as comma-separated text if (compact) { - if (tags.length === 0) return {column.fallback}; - const displayTags = column.uppercase ? tags.map(t => String(t).toUpperCase()) : tags; + if (tags.length === 0) { + return column.fallback ? {column.fallback} : null; + } + const displayTags = tags.map((tag) => { + const normalized = isFlags ? normalizeFlagLabel(tag) : tag; + return column.uppercase ? normalized.toUpperCase() : normalized; + }); return {displayTags.join(', ')}; } if (!tags.length) { + if (!column.fallback) { + return
; + } return (
{column.fallback} @@ -73,13 +142,13 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: return (
{tags.map((tag, idx) => { - const tagStr = String(tag); - const displayTag = column.uppercase ? tagStr.toUpperCase() : tagStr; - const colorStyle = getColorStyleFromHint(tagStr, column.color_hint); + const normalized = isFlags ? normalizeFlagLabel(tag) : tag; + const displayTag = column.uppercase ? normalized.toUpperCase() : normalized; + const colorStyle = getColorStyleFromHint(tag, column.color_hint); return ( {displayTag} @@ -90,15 +159,109 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: ); } - case 'size': + case 'size': { + // Build tooltip from extra metadata (torznab attrs, publish date, etc.) + const extra = release.extra as Record | undefined; + const torznabAttrs = extra?.torznab_attrs as Record | undefined; + const publishDate = extra?.publish_date as string | undefined; + + // Helper to format relative time + const formatRelativeTime = (dateStr: string): string | null => { + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return null; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return '1 day ago'; + if (diffDays < 30) return `${diffDays} days ago`; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths === 1) return '1 month ago'; + if (diffMonths < 12) return `${diffMonths} months ago`; + const diffYears = Math.floor(diffDays / 365); + if (diffYears === 1) return '1 year ago'; + return `${diffYears} years ago`; + } catch { + return null; + } + }; + + const rows: Array<{ label: string; value: string }> = []; + + // Add publish date first if available + if (publishDate) { + const relativeTime = formatRelativeTime(publishDate); + if (relativeTime) { + rows.push({ label: 'Added', value: relativeTime }); + } + } + + // Add torznab attributes if available (MAM, etc.) + if (torznabAttrs && Object.keys(torznabAttrs).length > 0) { + const displayAttrs: Array<{ key: string; label: string }> = [ + { key: 'description', label: 'Description' }, + { key: 'year', label: 'Year' }, + { key: 'genre', label: 'Genre' }, + { key: 'narrator', label: 'Narrator' }, + { key: 'bitrate', label: 'Bitrate' }, + { key: 'samplerate', label: 'Sample Rate' }, + { key: 'runtime', label: 'Runtime' }, + { key: 'pages', label: 'Pages' }, + { key: 'publisher', label: 'Publisher' }, + { key: 'language', label: 'Language' }, + ]; + + for (const attr of displayAttrs) { + const val = torznabAttrs[attr.key]; + if (val && val.trim()) { + rows.push({ label: attr.label, value: val.trim() }); + } + } + } + + // Add files and grabs from extra + const files = extra?.files as number | undefined; + const grabs = extra?.grabs as number | undefined; + if (files !== undefined && files !== null) { + rows.push({ label: 'Files', value: String(files) }); + } + if (grabs !== undefined && grabs !== null) { + rows.push({ label: 'Grabs', value: String(grabs) }); + } + + let sizeTooltipContent: React.ReactNode = null; + if (rows.length > 0) { + sizeTooltipContent = ( +
+ {rows.map((row) => ( +
+ {row.label}: + {row.value} +
+ ))} +
+ ); + } + if (compact) { return {displayValue}; } + + const sizeText = {displayValue}; + return (
- {displayValue} + {sizeTooltipContent ? ( + + {sizeText} + + ) : ( + sizeText + )}
); + } case 'peers': { // Peers display: "S/L" string with badge colored by seeder count @@ -141,6 +304,164 @@ export const ReleaseCell = ({ column, release, compact = false, onlineServers }: ); } + case 'indexer_protocol': { + // Indexer name with colored dot indicating protocol (torrent/usenet) and peers count + const protocol = release.protocol as string | undefined; + const dotColor = getProtocolDotColor(protocol); + const protocolLabel = protocol === 'torrent' ? 'Torrent' : protocol === 'nzb' ? 'Usenet' : protocol || 'Unknown'; + const peers = release.peers; + + if (compact) { + return ( + + + {displayValue} + {peers && ({peers})} + + ); + } + return ( +
+ + {displayValue} + {peers && {peers}} +
+ ); + } + + case 'flag_icon': { + // Colored badge showing FL, VIP, or both + if (!value || value === column.fallback) { + if (compact) return null; + return
; + } + + const flagColor = getColorStyleFromHint(value, { type: 'map', value: 'flags' }); + + if (compact) { + return {value}; + } + return ( +
+ + {value} + +
+ ); + } + + case 'format_content_type': { + // Content type icon + format badge combined + // Shows primary format as badge with colored dots for additional formats + const contentType = release.content_type as string | undefined; + const isAudiobook = contentType === 'audiobook'; + const formats = (release.extra as Record | undefined)?.formats as string[] | undefined; + const primaryFormat = formats?.[0] || null; + const additionalFormats = formats?.slice(1) || []; + + // Use blue for book, violet for audiobook when no format specified + const noFormatStyle = isAudiobook + ? { bg: 'bg-violet-500/20', text: 'text-violet-600 dark:text-violet-400' } + : { bg: 'bg-blue-500/20', text: 'text-blue-600 dark:text-blue-400' }; + const colorStyle = primaryFormat ? getFormatColor(primaryFormat) : noFormatStyle; + + // Build rich tooltip content for formats (if multiple) + let tooltipContent: React.ReactNode = null; + if (formats && formats.length > 1) { + tooltipContent = ( +
+ {formats.map((fmt) => { + const fmtColor = getFormatColor(String(fmt)); + return ( + + {String(fmt).toUpperCase()} + + ); + })} +
+ ); + } + + // Icon sized to match visual height of format text badges + const icon = isAudiobook ? ( + // Headphones icon for audiobook + + + + ) : ( + // Book icon for ebook + + + + ); + + if (compact) { + if (!primaryFormat) { + return {icon}; + } + // Simple text tooltip for compact mode + const compactTooltip = formats && formats.length > 1 + ? formats.map((fmt) => String(fmt).toUpperCase()).join(', ') + : undefined; + return ( + + {primaryFormat.toUpperCase()} + {additionalFormats.length > 0 && ` +${additionalFormats.length}`} + + ); + } + + // No format - just show icon with same width as format badges + if (!primaryFormat) { + return ( +
+ + {icon} + +
+ ); + } + + // Format badge - left-aligned so primary format stays in place, +N appears to right + const formatBadge = ( + + + {column.uppercase ? primaryFormat.toUpperCase() : primaryFormat} + + {additionalFormats.length > 0 && ( + + +{additionalFormats.length} + + )} + + ); + + return ( +
+ {tooltipContent ? ( + + {formatBadge} + + ) : ( + formatBadge + )} +
+ ); + } + case 'number': if (compact) { return {displayValue}; diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index cc826ab..adce990 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -426,12 +426,38 @@ const ReleaseRow = ({ {/* Plugin-provided info line (format, size, indexer, seeders, etc.) */} {mobileColumns.length > 0 && (
- {mobileColumns.map((col, idx) => ( - - {idx > 0 && ·} - - - ))} + {(() => { + // Pre-filter columns that will render content to avoid orphan dots + const columnsWithContent = mobileColumns.filter((col) => { + const rawValue = getNestedValue(release as unknown as Record, col.key); + const value = rawValue !== undefined && rawValue !== null ? String(rawValue) : col.fallback; + + if (col.render_type === 'flag_icon') { + // flag_icon returns null in compact mode when empty + if (!value || value === col.fallback) { + return false; + } + } + + if (col.render_type === 'tags') { + // tags render null in compact mode when empty and no fallback + if (Array.isArray(rawValue)) { + return rawValue.some((tag) => String(tag).trim()) || Boolean(col.fallback); + } + if (!value || value === col.fallback) { + return Boolean(col.fallback); + } + } + + return true; + }); + return columnsWithContent.map((col, idx) => ( + + {idx > 0 && ·} + + + )); + })()}
)}
@@ -622,6 +648,10 @@ export const ReleaseModal = ({ // A specific value means "show only that format" const [formatFilter, setFormatFilter] = useState(''); const [languageFilter, setLanguageFilter] = useState([LANGUAGE_OPTION_DEFAULT]); + // Indexer filter - empty array means "show all", otherwise show only selected indexers + const [indexerFilter, setIndexerFilter] = useState([]); + // Track which tabs have had indexer filter initialized (to avoid overriding user changes) + const indexerFilterInitializedRef = useRef>(new Set()); const [manualQuery, setManualQuery] = useState(''); const [showManualQuery, setShowManualQuery] = useState(false); @@ -673,6 +703,8 @@ export const ReleaseModal = ({ setExpandedBySource({}); setFormatFilter(''); setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); + setIndexerFilter([]); + indexerFilterInitializedRef.current = new Set(); setManualQuery(''); setShowManualQuery(false); setSearchStatus(null); @@ -739,6 +771,22 @@ export const ReleaseModal = ({ } }, [loadingBySource, activeTab]); + // Initialize indexer filter from default_indexers when results first load for a tab + useEffect(() => { + const response = releasesBySource[activeTab]; + if (!response?.column_config) return; + + // Only initialize once per tab per book + if (indexerFilterInitializedRef.current.has(activeTab)) return; + + const defaultIndexers = response.column_config.default_indexers; + if (defaultIndexers && defaultIndexers.length > 0) { + setIndexerFilter(defaultIndexers); + } + // Mark as initialized even if no default_indexers (to avoid re-checking) + indexerFilterInitializedRef.current.add(activeTab); + }, [releasesBySource, activeTab]); + // Check if description text overflows (needs "more" button) useEffect(() => { const el = descriptionRef.current; @@ -881,9 +929,13 @@ export const ReleaseModal = ({ ? undefined : langCodes; + // Pass indexer filter only if the source supports it (empty array = search all) + const supportsIndexerFilter = releasesBySource[activeTab]?.column_config?.supported_filters?.includes('indexer'); + const indexersParam = supportsIndexerFilter && indexerFilter.length > 0 ? indexerFilter : undefined; + // Fetch with expand_search=true (title+author search) const expandedResponse = await getReleases( - provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined + provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType, manualQuery.trim() || undefined, indexersParam ); // Merge with existing results, deduplicating by source_id @@ -910,7 +962,7 @@ export const ReleaseModal = ({ } finally { setLoadingBySource((prev) => ({ ...prev, [activeTab]: false })); } - }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery]); + }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType, manualQuery, indexerFilter, releasesBySource]); // Build list of tabs to show // Only show enabled sources that support the current content type @@ -992,6 +1044,26 @@ export const ReleaseModal = ({ return options; }, [availableFormats]); + // Get available indexers for filter dropdown + // Prefer available_indexers from column config (all enabled Prowlarr indexers), + // fall back to unique indexers from results if not provided + const availableIndexers = useMemo(() => { + // Use column config's available_indexers if provided (e.g., from Prowlarr) + const configIndexers = releasesBySource[activeTab]?.column_config?.available_indexers; + if (configIndexers && configIndexers.length > 0) { + return configIndexers; + } + // Fall back to indexers found in results + const releases = releasesBySource[activeTab]?.releases || []; + const indexers = new Set(); + releases.forEach((r) => { + if (r.indexer) { + indexers.add(r.indexer); + } + }); + return Array.from(indexers).sort(); + }, [releasesBySource, activeTab]); + // Resolve language filter to actual language codes for filtering const resolvedLanguageCodes = useMemo(() => { return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages); @@ -1073,6 +1145,7 @@ export const ReleaseModal = ({ const filteredReleases = useMemo(() => { const releases = releasesBySource[activeTab]?.releases || []; const effectiveLower = effectiveFormats.map((f) => f.toLowerCase()); + const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer'); // First, filter let filtered = releases.filter((r) => { @@ -1088,12 +1161,20 @@ export const ReleaseModal = ({ } // Releases with no format pass through when no filter is set (show all) - // Language filtering - const releaseLang = r.extra?.language as string | undefined; + // Language filtering - use r.language when provided by enriched indexers + // Releases with no language (null/undefined) always pass + const releaseLang = r.language as string | undefined; if (!releaseLanguageMatchesFilter(releaseLang, resolvedLanguageCodes ?? defaultLanguages)) { return false; } + // Indexer filtering - empty array means show all + if (supportsIndexerFilter && indexerFilter.length > 0 && r.indexer) { + if (!indexerFilter.includes(r.indexer)) { + return false; + } + } + return true; }); @@ -1103,7 +1184,7 @@ export const ReleaseModal = ({ } return filtered; - }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, currentSort, sortableColumns]); + }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, indexerFilter, currentSort, sortableColumns, columnConfig]); // Pre-compute display field lookups to avoid repeated .find() calls in JSX const displayFields = useMemo(() => { @@ -1137,6 +1218,9 @@ export const ReleaseModal = ({ progress: book.progress, }; } + if (currentStatus.locating && currentStatus.locating[releaseId]) { + return { text: 'Locating files', state: 'locating' }; + } if (currentStatus.resolving && currentStatus.resolving[releaseId]) { return { text: 'Resolving', state: 'resolving' }; } @@ -1515,111 +1599,138 @@ export const ReleaseModal = ({ {/* Filter funnel button - stays fixed */} {/* Only show filter button if source supports at least one filter type */} {((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) || - (columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0)) && ( - { - // Active filter: format is set, or language is not just default - const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT); - const hasActiveFilter = formatFilter !== '' || hasLanguageFilter; - return ( + (columnConfig.supported_filters?.includes('language') && bookLanguages.length > 0) || + (columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1)) && ( + { + // Active filter: format is set, language is not default, or indexers differ from defaults + const hasLanguageFilter = !(languageFilter.length === 1 && languageFilter[0] === LANGUAGE_OPTION_DEFAULT); + const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer'); + // Check if indexer filter differs from defaults (only after initialization) + // Don't show dot while loading or before filter is initialized from defaults + const hasResults = releasesBySource[activeTab]?.releases !== undefined; + const isInitialized = indexerFilterInitializedRef.current.has(activeTab); + const defaultIndexers = columnConfig.default_indexers ?? []; + const indexersMatchDefault = ( + indexerFilter.length === defaultIndexers.length && + indexerFilter.every((idx) => defaultIndexers.includes(idx)) + ); + const hasIndexerFilter = supportsIndexerFilter && hasResults && isInitialized && !indexersMatchDefault; + const hasActiveFilter = formatFilter !== '' || hasLanguageFilter || hasIndexerFilter; + return ( + + ); + }} + > + {({ close }) => ( +
+ {columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && ( + setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')} + placeholder="All Formats" + /> + )} + {columnConfig.supported_filters?.includes('language') && ( + + )} + {columnConfig.supported_filters?.includes('indexer') && availableIndexers.length > 1 && ( + ({ value: idx, label: idx }))} + multiple + value={indexerFilter} + onChange={(val) => setIndexerFilter(Array.isArray(val) ? val : val ? [val] : [])} + placeholder="All Indexers" + /> + )} + {/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */} + {(activeTab === 'direct_download' || activeTab === 'prowlarr') && ( - ); - }} - > - {({ close }) => ( -
- {columnConfig.supported_filters?.includes('format') && availableFormats.length > 0 && ( - setFormatFilter(typeof val === 'string' ? val : val[0] ?? '')} - placeholder="All Formats" - /> - )} - {columnConfig.supported_filters?.includes('language') && ( - - )} - {/* Apply button - re-fetch with server-side filters/expansion (e.g. language-aware searches) */} - {(activeTab === 'direct_download' || activeTab === 'prowlarr') && ( - - )} -
- )} - - )} + )} +
+ )} +
+ )}
)} diff --git a/src/frontend/src/components/shared/Tooltip.tsx b/src/frontend/src/components/shared/Tooltip.tsx new file mode 100644 index 0000000..3b43374 --- /dev/null +++ b/src/frontend/src/components/shared/Tooltip.tsx @@ -0,0 +1,115 @@ +import { useState, useRef, useEffect, ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +interface TooltipProps { + content: ReactNode; + children: ReactNode; + position?: 'top' | 'bottom' | 'left' | 'right'; + delay?: number; + className?: string; +} + +export function Tooltip({ + content, + children, + position = 'top', + delay = 200, + className = '', +}: TooltipProps) { + const [isVisible, setIsVisible] = useState(false); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); + const triggerRef = useRef(null); + const timeoutRef = useRef(null); + + const showTooltip = () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + let top = 0; + let left = 0; + + switch (position) { + case 'top': + top = rect.top - 8; + left = rect.left + rect.width / 2; + break; + case 'bottom': + top = rect.bottom + 8; + left = rect.left + rect.width / 2; + break; + case 'left': + top = rect.top + rect.height / 2; + left = rect.left - 8; + break; + case 'right': + top = rect.top + rect.height / 2; + left = rect.right + 8; + break; + } + + setCoords({ top, left }); + setIsVisible(true); + } + }, delay); + }; + + const hideTooltip = () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setIsVisible(false); + setCoords(null); + }; + + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + if (!content) { + return <>{children}; + } + + // Transform classes to center tooltip relative to the anchor point + const transformClass = { + top: '-translate-x-1/2 -translate-y-full', + bottom: '-translate-x-1/2', + left: '-translate-x-full -translate-y-1/2', + right: '-translate-y-1/2', + }[position]; + + return ( + <> +
+ {children} +
+ {isVisible && coords && createPortal( +
+ {content} +
, + document.body + )} + + ); +} + +export default Tooltip; diff --git a/src/frontend/src/hooks/useDownloadTracking.ts b/src/frontend/src/hooks/useDownloadTracking.ts index 4ff8f6d..f1fd2c5 100644 --- a/src/frontend/src/hooks/useDownloadTracking.ts +++ b/src/frontend/src/hooks/useDownloadTracking.ts @@ -49,6 +49,9 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack progress: book.progress, }; } + if (currentStatus.locating && currentStatus.locating[bookId]) { + return { text: 'Locating files', state: 'locating' }; + } if (currentStatus.resolving && currentStatus.resolving[bookId]) { return { text: 'Resolving', state: 'resolving' }; } @@ -84,6 +87,10 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack }; } + if (currentStatus.locating && currentStatus.locating[releaseId]) { + return { text: 'Locating files', state: 'locating' }; + } + if (currentStatus.resolving && currentStatus.resolving[releaseId]) { return { text: 'Resolving', state: 'resolving' }; } diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index da13b09..558b422 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -201,7 +201,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { } finally { setIsSearching(false); } - }, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError]); + }, [showToast, setIsAuthenticated, authRequired, navigate, searchFieldValues, handleSearchError, contentType]); const handleResetSearch = useCallback((config: AppConfig | null) => { setBooks([]); @@ -257,7 +257,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { } finally { setIsLoadingMore(false); } - }, [currentPage, hasMore, isLoadingMore, handleSearchError]); + }, [currentPage, hasMore, isLoadingMore, handleSearchError, contentType]); const handleSortChange = useCallback((value: string, config: AppConfig | null) => { updateAdvancedFilters({ sort: value }); diff --git a/src/frontend/src/pages/LoginPage.tsx b/src/frontend/src/pages/LoginPage.tsx index 5e42479..d6e9cf2 100644 --- a/src/frontend/src/pages/LoginPage.tsx +++ b/src/frontend/src/pages/LoginPage.tsx @@ -1,5 +1,6 @@ import { LoginForm } from '../components/LoginForm'; import { LoginCredentials } from '../types'; +import { withBasePath } from '../utils/basePath'; interface LoginPageProps { onLogin: (credentials: LoginCredentials) => void; @@ -8,6 +9,8 @@ interface LoginPageProps { } export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => { + const logoUrl = withBasePath('/logo.png'); + return (
{ >
- Logo + Logo

Sign in to continue

{ ); }; - diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 00aa190..ea5e28e 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -339,7 +339,8 @@ export const getReleases = async ( expandSearch?: boolean, languages?: string[], contentType?: string, - manualQuery?: string + manualQuery?: string, + indexers?: string[] ): Promise => { const params = new URLSearchParams({ provider, @@ -366,6 +367,9 @@ export const getReleases = async ( if (manualQuery) { params.set('manual_query', manualQuery); } + if (indexers && indexers.length > 0) { + params.set('indexers', indexers.join(',')); + } // Let the backend control timeouts for release searches (can be long-running). return fetchJSON(`${API_BASE}/releases?${params.toString()}`, {}, null); }; diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 770ad5b..aed9467 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -54,6 +54,7 @@ export interface Book { export interface StatusData { queued?: Record; resolving?: Record; + locating?: Record; downloading?: Record; complete?: Record; error?: Record; @@ -65,7 +66,7 @@ export interface ActiveDownloadsResponse { } // Button states -export type ButtonState = 'download' | 'queued' | 'resolving' | 'downloading' | 'complete' | 'error'; +export type ButtonState = 'download' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error'; export interface ButtonStateInfo { text: string; @@ -204,7 +205,7 @@ export interface ReleaseSource { } // Column schema types for plugin-driven release list UI -export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers'; +export type ColumnRenderType = 'text' | 'badge' | 'tags' | 'size' | 'number' | 'peers' | 'indexer_protocol' | 'flag_icon' | 'format_content_type'; export type ColumnAlign = 'left' | 'center' | 'right'; export interface ColumnColorHint { @@ -246,8 +247,10 @@ export interface ReleaseColumnConfig { grid_template: string; // CSS grid-template-columns for dynamic section leading_cell?: LeadingCellConfig; // Defaults to thumbnail from extra.preview online_servers?: string[]; // For IRC: list of currently online server nicks + available_indexers?: string[]; // For Prowlarr: list of all enabled indexer names + default_indexers?: string[]; // For Prowlarr: indexers selected in settings (pre-selected in filter) cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min) - supported_filters?: string[]; // Which filters this source supports: ["format", "language"] + supported_filters?: string[]; // Which filters this source supports: ["format", "language", "indexer"] action_button?: SourceActionButton; // Custom action button (replaces default expand search) } @@ -266,6 +269,7 @@ export interface Release { indexer?: string; // Display name for the source/indexer seeders?: number; // For torrents peers?: string; // For torrents: "seeders/leechers" display string + content_type?: string; // "ebook", "audiobook", or "book" extra?: Record; // Source-specific metadata } diff --git a/src/frontend/src/utils/colorMaps.ts b/src/frontend/src/utils/colorMaps.ts index 7f7fdc9..e551f38 100644 --- a/src/frontend/src/utils/colorMaps.ts +++ b/src/frontend/src/utils/colorMaps.ts @@ -5,6 +5,7 @@ interface ColorStyle { } const FORMAT_COLORS: Record = { + // Ebook formats pdf: { bg: 'bg-red-500/20', text: 'text-red-700 dark:text-red-300' }, epub: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' }, mobi: { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' }, @@ -14,6 +15,10 @@ const FORMAT_COLORS: Record = { fb2: { bg: 'bg-teal-500/20', text: 'text-teal-700 dark:text-teal-300' }, cbr: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' }, cbz: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' }, + // Audiobook formats + m4b: { bg: 'bg-violet-500/20', text: 'text-violet-700 dark:text-violet-300' }, + mp3: { bg: 'bg-rose-500/20', text: 'text-rose-700 dark:text-rose-300' }, + flac: { bg: 'bg-indigo-500/20', text: 'text-indigo-700 dark:text-indigo-300' }, }; const LANGUAGE_COLORS: Record = { @@ -52,9 +57,12 @@ const CONTENT_TYPE_COLORS: Record = { }; const FLAG_COLORS: Record = { + fl: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' }, freeleech: { bg: 'bg-green-500/20', text: 'text-green-700 dark:text-green-300' }, 'double upload': { bg: 'bg-blue-500/20', text: 'text-blue-700 dark:text-blue-300' }, vip: { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' }, + 'vip fl': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' }, + 'fl vip': { bg: 'bg-amber-500/20', text: 'text-amber-700 dark:text-amber-300' }, sticky: { bg: 'bg-yellow-500/20', text: 'text-yellow-700 dark:text-yellow-300' }, }; @@ -67,7 +75,11 @@ const FALLBACK_COLOR: ColorStyle = { bg: 'bg-gray-500/20', text: 'text-gray-700 export function getFormatColor(format?: string): ColorStyle { if (!format || format === '-') return FALLBACK_COLOR; - return FORMAT_COLORS[format.toLowerCase()] || DEFAULT_FORMAT_COLOR; + const normalized = format.toLowerCase(); + // Support display strings like "EPUB, MOBI +1" by using the first token for color mapping. + const match = normalized.match(/[a-z0-9]+/); + const key = match ? match[0] : normalized; + return FORMAT_COLORS[key] || DEFAULT_FORMAT_COLOR; } export function getLanguageColor(language?: string): ColorStyle { @@ -80,6 +92,15 @@ export function getDownloadTypeColor(downloadType?: string): ColorStyle { return DOWNLOAD_TYPE_COLORS[downloadType.toLowerCase()] || DEFAULT_DOWNLOAD_TYPE_COLOR; } +// Returns just the dot color class for protocol indicators +export function getProtocolDotColor(protocol?: string): string { + if (!protocol) return 'bg-gray-400'; + const p = protocol.toLowerCase(); + if (p === 'torrent') return 'bg-orange-500'; + if (p === 'nzb' || p === 'usenet') return 'bg-sky-500'; + return 'bg-gray-400'; +} + export function getContentTypeColor(contentType?: string): ColorStyle { if (!contentType || contentType === '-') return FALLBACK_COLOR; return CONTENT_TYPE_COLORS[contentType.toLowerCase()] || DEFAULT_CONTENT_TYPE_COLOR; @@ -87,7 +108,9 @@ export function getContentTypeColor(contentType?: string): ColorStyle { export function getFlagColor(flag?: string): ColorStyle { if (!flag || flag === '-') return FALLBACK_COLOR; - return FLAG_COLORS[flag.toLowerCase()] || DEFAULT_FLAG_COLOR; + const normalized = flag.trim().toLowerCase(); + if (!normalized) return FALLBACK_COLOR; + return FLAG_COLORS[normalized] || DEFAULT_FLAG_COLOR; } /** diff --git a/tests/bypass/test_internal_bypasser.py b/tests/bypass/test_internal_bypasser.py new file mode 100644 index 0000000..7f79d81 --- /dev/null +++ b/tests/bypass/test_internal_bypasser.py @@ -0,0 +1,24 @@ +def test_bypass_tries_all_methods_before_abort(monkeypatch): + """Regression test for issue #524: don't abort before cycling through bypass methods.""" + import shelfmark.bypass.internal_bypasser as internal_bypasser + + calls: list[str] = [] + + def _make_method(name: str): + def _method(_sb) -> bool: + calls.append(name) + return False + + _method.__name__ = name + return _method + + methods = [_make_method(f"m{i}") for i in range(6)] + + monkeypatch.setattr(internal_bypasser, "BYPASS_METHODS", methods) + monkeypatch.setattr(internal_bypasser, "_is_bypassed", lambda _sb, escape_emojis=True: False) + monkeypatch.setattr(internal_bypasser, "_detect_challenge_type", lambda _sb: "ddos_guard") + monkeypatch.setattr(internal_bypasser.time, "sleep", lambda _seconds: None) + monkeypatch.setattr(internal_bypasser.random, "uniform", lambda _a, _b: 0) + + assert internal_bypasser._bypass(object(), max_retries=10) is False + assert calls == [f"m{i}" for i in range(6)] diff --git a/tests/download/test_orchestrator_status.py b/tests/download/test_orchestrator_status.py new file mode 100644 index 0000000..2856d9c --- /dev/null +++ b/tests/download/test_orchestrator_status.py @@ -0,0 +1,33 @@ +from unittest.mock import MagicMock + + +def test_update_download_status_dedupes_identical_events(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + book_id = "test-book-id" + + # Ensure clean module-level state + orchestrator._last_activity.clear() + orchestrator._last_status_event.clear() + + mock_queue = MagicMock() + monkeypatch.setattr(orchestrator, "book_queue", mock_queue) + monkeypatch.setattr(orchestrator, "queue_status", lambda: {}) + + mock_ws = MagicMock() + monkeypatch.setattr(orchestrator, "ws_manager", mock_ws) + + times = iter([1.0, 2.0]) + monkeypatch.setattr(orchestrator.time, "time", lambda: next(times)) + + orchestrator.update_download_status(book_id, "resolving", "Bypassing protection...") + orchestrator.update_download_status(book_id, "resolving", "Bypassing protection...") + + # Status + message should only be applied/broadcast once. + assert mock_queue.update_status.call_count == 1 + assert mock_queue.update_status_message.call_count == 1 + assert mock_ws.broadcast_status_update.call_count == 1 + + # Activity timestamp should still be updated on the duplicate keep-alive call. + assert orchestrator._last_activity[book_id] == 2.0 +