diff --git a/.gitignore b/.gitignore index 9d548301..b17fa9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -228,3 +228,4 @@ pyrightconfig.json # End of https://www.toptal.com/developers/gitignore/api/macos,visualstudiocode,python /downloaded_files /.local/ +*.local.md diff --git a/Dockerfile b/Dockerfile index 892a152e..fb24d8e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,9 @@ WORKDIR /frontend # Copy frontend package files COPY src/frontend/package*.json ./ -# Install dependencies -RUN npm ci +# Install dependencies (cache mount for faster rebuilds) +RUN --mount=type=cache,target=/root/.npm \ + npm ci # Copy frontend source COPY src/frontend/ ./ @@ -84,12 +85,11 @@ RUN apt-get update && \ WORKDIR /app # Install Python dependencies using pip -# Upgrade pip first, then copy requirements and install -# Copying requirements-base.txt separately leverages build cache -COPY requirements-base.txt . -RUN pip install --no-cache-dir -r requirements-base.txt && \ - # Clean root's pip cache - rm -rf /root/.cache +# Copying requirements files separately leverages build cache +# Cache mount persists pip cache between builds for faster installs +COPY requirements-base.txt requirements-cwa-bd.txt ./ +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements-base.txt # Copy application code *after* dependencies are installed COPY . . @@ -132,15 +132,17 @@ RUN apt-get update && \ # For RAR extraction unrar-free && \ # Create symlink so rarfile library can find unrar - ln -sf /usr/bin/unrar-free /usr/bin/unrar + ln -sf /usr/bin/unrar-free /usr/bin/unrar && \ + # Cleanup APT cache + apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* -# install additional dependencies -COPY requirements-cwa-bd.txt ./ -RUN pip install --no-cache-dir -r requirements-cwa-bd.txt && \ - # Clean root's pip cache - rm -rf /root/.cache +# Install additional dependencies (requirements file already copied in base stage) +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install -r requirements-cwa-bd.txt -# Add this line to grant read/execute permissions to others +# Grant read/execute permissions to others RUN chmod -R o+rx /usr/bin/chromium && \ chmod -R o+rx /usr/bin/chromedriver && \ chmod -R o+w /usr/local/lib/python3.10/site-packages/seleniumbase/drivers/ diff --git a/Makefile b/Makefile index 94152f5d..73d594e6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install dev build preview typecheck clean up down docker-build refresh +.PHONY: help install dev build preview typecheck clean up down docker-build refresh restart # Frontend directory FRONTEND_DIR := src/frontend @@ -21,6 +21,7 @@ help: @echo "Backend (Docker):" @echo " up - Start backend services" @echo " down - Stop backend services" + @echo " restart - Restart backend services (no rebuild)" @echo " docker-build - Build Docker image" @echo " refresh - Rebuild and restart backend services" @@ -70,6 +71,11 @@ docker-build: @echo "Building Docker image..." docker compose -f $(COMPOSE_FILE) build +# Restart backend services (no rebuild) +restart: + @echo "Restarting backend services..." + docker compose -f $(COMPOSE_FILE) restart + # Rebuild and restart backend services refresh: @echo "Rebuilding and restarting backend services..." diff --git a/cwa_book_downloader/api/websocket.py b/cwa_book_downloader/api/websocket.py index 89d74876..c0e656f5 100644 --- a/cwa_book_downloader/api/websocket.py +++ b/cwa_book_downloader/api/websocket.py @@ -157,6 +157,38 @@ class WebSocketManager: except Exception as e: logger.error(f"Error broadcasting notification: {e}") + def broadcast_search_status( + self, + source: str, + provider: str, + book_id: str, + message: str, + phase: str = 'searching' + ): + """Broadcast search status update for a release source search. + + Args: + source: Release source name (e.g., 'irc', 'direct_download') + provider: Metadata provider name (e.g., 'hardcover') + book_id: Book ID from the metadata provider + message: Human-readable status message + phase: Search phase ('connecting', 'searching', 'downloading', 'parsing', 'complete', 'error') + """ + if not self.is_enabled(): + return + + try: + data = { + 'source': source, + 'provider': provider, + 'book_id': book_id, + 'message': message, + 'phase': phase, + } + self.socketio.emit('search_status', data) + except Exception as e: + logger.error(f"Error broadcasting search status: {e}") + # Global WebSocket manager instance ws_manager = WebSocketManager() diff --git a/cwa_book_downloader/config/env.py b/cwa_book_downloader/config/env.py index b76ddb22..c2f36d21 100644 --- a/cwa_book_downloader/config/env.py +++ b/cwa_book_downloader/config/env.py @@ -1,5 +1,6 @@ """Environment variable parsing. No local dependencies - import first.""" +import json import os import shutil from pathlib import Path @@ -9,6 +10,34 @@ def string_to_bool(s: str) -> bool: return s.lower() in ["true", "yes", "1", "y"] +def _read_debug_from_config() -> bool: + """ + Read DEBUG setting directly from config JSON file. + + This is called at import time before the config singleton is available. + Priority: ENV var > config file > default (False) + """ + # Check env var first (takes priority) + env_debug = os.environ.get("DEBUG") + if env_debug is not None: + return string_to_bool(env_debug) + + # Try to read from config file + config_dir = Path(os.getenv("CONFIG_DIR", "/config")) + config_file = config_dir / "plugins" / "advanced.json" + + if config_file.exists(): + try: + with open(config_file, "r") as f: + config = json.load(f) + if "DEBUG" in config: + return bool(config["DEBUG"]) + except (json.JSONDecodeError, OSError): + pass + + return False + + # Authentication and session settings SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false") @@ -55,7 +84,7 @@ _BOOK_LANGUAGE = os.getenv("BOOK_LANGUAGE", "en").lower() _CUSTOM_SCRIPT = os.getenv("CUSTOM_SCRIPT", "").strip() FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0") FLASK_PORT = int(os.getenv("FLASK_PORT", "8084")) -DEBUG = string_to_bool(os.getenv("DEBUG", "false")) +DEBUG = _read_debug_from_config() # Debug: skip specific download sources for testing fallback chains # Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib _DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower() @@ -70,11 +99,8 @@ _LEGACY_ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true")) BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A") RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A") -# If debug is true, we want to log everything -if DEBUG: - LOG_LEVEL = "DEBUG" -else: - LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() +# Log level is derived from DEBUG - no separate LOG_LEVEL setting +LOG_LEVEL = "DEBUG" if DEBUG else "INFO" ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true")) MAIN_LOOP_SLEEP_TIME = int(os.getenv("MAIN_LOOP_SLEEP_TIME", "5")) MAX_CONCURRENT_DOWNLOADS = int(os.getenv("MAX_CONCURRENT_DOWNLOADS", "3")) diff --git a/cwa_book_downloader/core/models.py b/cwa_book_downloader/core/models.py index 0bce4f6c..b0aabdb3 100644 --- a/cwa_book_downloader/core/models.py +++ b/cwa_book_downloader/core/models.py @@ -82,6 +82,7 @@ class DownloadTask: # Display info for queue sidebar author: Optional[str] = None + year: Optional[str] = None format: Optional[str] = None size: Optional[str] = None preview: Optional[str] = None @@ -105,7 +106,7 @@ class DownloadTask: """Build sanitized filename from task metadata.""" if self.download_path: return Path(self.download_path).name - return build_filename(self.title, self.author, fmt=self.format) + return build_filename(self.title, self.author, self.year, self.format) @dataclass diff --git a/cwa_book_downloader/core/settings_registry.py b/cwa_book_downloader/core/settings_registry.py index 4a6534ba..24aa91d8 100644 --- a/cwa_book_downloader/core/settings_registry.py +++ b/cwa_book_downloader/core/settings_registry.py @@ -24,7 +24,7 @@ class FieldBase: env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only) disabled: bool = False # Whether field is disabled/greyed out disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} + show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} requires_restart: bool = False # Whether changing this setting requires a container restart @@ -117,7 +117,7 @@ class ActionButton: callback: Optional[Callable[[], Dict[str, Any]]] = None # Returns {"success": bool, "message": str} disabled: bool = False # Whether button is disabled/greyed out disabled_reason: str = "" # Explanation shown when disabled - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} + show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."} def get_field_type(self) -> str: @@ -137,7 +137,7 @@ class HeadingField: description: str = "" # Description text (supports markdown-style links) link_url: str = "" # Optional URL for a link link_text: str = "" # Text for the link (defaults to URL if not provided) - show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} + show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True} def get_field_type(self) -> str: return "HeadingField" diff --git a/cwa_book_downloader/download/archive.py b/cwa_book_downloader/download/archive.py index ccb5e776..1e748840 100644 --- a/cwa_book_downloader/download/archive.py +++ b/cwa_book_downloader/download/archive.py @@ -8,9 +8,20 @@ from pathlib import Path from typing import List, Optional, Tuple from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.models import build_filename logger = setup_logger(__name__) + +def _get_supported_formats() -> List[str]: + """Get current supported formats from config singleton.""" + formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]) + # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) + if isinstance(formats, str): + return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] + return [fmt.lower() for fmt in formats] + # Check for rarfile availability at module load try: import rarfile @@ -20,12 +31,6 @@ except ImportError: RAR_AVAILABLE = False logger.warning("rarfile not installed - RAR extraction disabled") -# Book file extensions that should be kept after extraction -BOOK_EXTENSIONS = frozenset({ - "epub", "mobi", "azw", "azw3", "pdf", "fb2", "djvu", - "cbz", "cbr", "txt", "rtf", "doc", "docx", "lit", "pdb", -}) - class ArchiveExtractionError(Exception): """Raised when archive extraction fails.""" @@ -52,34 +57,44 @@ def is_archive(file_path: Path) -> bool: def _is_book_file(file_path: Path) -> bool: - """Check if file is a recognized book format.""" + """Check if file matches user's SUPPORTED_FORMATS setting.""" ext = file_path.suffix.lower().lstrip(".") - return ext in BOOK_EXTENSIONS + supported_formats = _get_supported_formats() + return ext in supported_formats -def _filter_book_files(extracted_files: List[Path]) -> Tuple[List[Path], List[Path]]: +def _filter_book_files(extracted_files: List[Path]) -> Tuple[List[Path], List[Path], List[Path]]: """ Filter extracted files to only book formats. Returns: - Tuple of (book_files, non_book_files) + Tuple of (book_files, rejected_ebook_files, non_book_files) + - book_files: Match SUPPORTED_FORMATS + - rejected_ebook_files: Ebook formats not in SUPPORTED_FORMATS + - non_book_files: Non-ebook files (images, html, etc) """ + # All known ebook extensions (superset of what user might enable) + ALL_EBOOK_EXTENSIONS = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'} + book_files = [] + rejected_ebook_files = [] non_book_files = [] for file_path in extracted_files: if _is_book_file(file_path): book_files.append(file_path) + elif file_path.suffix.lower() in ALL_EBOOK_EXTENSIONS: + rejected_ebook_files.append(file_path) else: non_book_files.append(file_path) - return book_files, non_book_files + return book_files, rejected_ebook_files, non_book_files def extract_archive( archive_path: Path, output_dir: Path, -) -> Tuple[List[Path], List[str]]: +) -> Tuple[List[Path], List[str], List[Path]]: """ Extract book files from an archive. @@ -91,7 +106,10 @@ def extract_archive( output_dir: Directory to extract files to Returns: - Tuple of (extracted_book_file_paths, warnings) + Tuple of (book_files, warnings, rejected_ebook_files) + - book_files: Paths to extracted files matching SUPPORTED_FORMATS + - warnings: List of warning messages + - rejected_ebook_files: Ebook files that were rejected (format not enabled) Raises: ArchiveExtractionError: If extraction fails @@ -108,8 +126,21 @@ def extract_archive( raise ArchiveExtractionError(f"Unsupported archive format: {suffix}") # Filter to only book files, delete non-book files - book_files, non_book_files = _filter_book_files(extracted_files) + book_files, rejected_ebook_files, non_book_files = _filter_book_files(extracted_files) + # Delete rejected ebook files (valid formats but not enabled by user) + for rejected_file in rejected_ebook_files: + try: + rejected_file.unlink() + logger.debug(f"Deleted rejected ebook file: {rejected_file.name}") + except OSError as e: + logger.warning(f"Failed to delete rejected ebook file {rejected_file}: {e}") + + if rejected_ebook_files: + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_ebook_files)) + warnings.append(f"Skipped {len(rejected_ebook_files)} ebook(s) with unsupported format: {', '.join(rejected_exts)}") + + # Delete non-book files (images, html, etc) for non_book_file in non_book_files: try: non_book_file.unlink() @@ -120,7 +151,7 @@ def extract_archive( if non_book_files: warnings.append(f"Skipped {len(non_book_files)} non-book file(s)") - return book_files, warnings + return book_files, warnings, rejected_ebook_files def _extract_zip( @@ -253,6 +284,7 @@ def process_archive( temp_dir: Path, ingest_dir: Path, archive_id: str, + task: Optional["DownloadTask"] = None, ) -> ArchiveResult: """ Process an archive file: extract, filter to book files, move to ingest. @@ -264,6 +296,7 @@ def process_archive( temp_dir: Base temp directory for extraction (e.g., TMP_DIR) ingest_dir: Final destination directory for book files archive_id: Unique identifier for temp directory naming + task: Optional download task for filename generation Returns: ArchiveResult with success status, final paths, and status message @@ -276,12 +309,29 @@ def process_archive( os.makedirs(ingest_dir, exist_ok=True) # Extract to temp directory (filters to book files only) - extracted_files, warnings = extract_archive(archive_path, extract_dir) + extracted_files, warnings, rejected_ebook_files = extract_archive(archive_path, extract_dir) if not extracted_files: # Clean up and return error shutil.rmtree(extract_dir, ignore_errors=True) archive_path.unlink(missing_ok=True) + + if rejected_ebook_files: + # Found ebooks but they weren't in supported formats + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_ebook_files)) + rejected_list = ", ".join(rejected_exts) + supported_formats = _get_supported_formats() + logger.warning( + f"Found {len(rejected_ebook_files)} ebook(s) in archive but format not supported. " + f"Rejected: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}" + ) + return ArchiveResult( + success=False, + final_paths=[], + message="", + error=f"Found {len(rejected_ebook_files)} ebook(s) but format not supported ({rejected_list}). Enable in Settings > Formats.", + ) + return ArchiveResult( success=False, final_paths=[], @@ -297,7 +347,18 @@ def process_archive( # Move book files to ingest folder final_paths = [] for extracted_file in extracted_files: - final_path = ingest_dir / extracted_file.name + # For multi-file archives (book packs, series), always preserve original filenames + # since metadata title only applies to the searched book, not the whole pack. + # For single files, respect USE_BOOK_TITLE setting. + if len(extracted_files) == 1 and config.USE_BOOK_TITLE and task: + # Use the extracted file's actual extension, not the archive's extension + # (task.download_path points to the archive, so we must use build_filename directly) + extracted_format = extracted_file.suffix.lower().lstrip('.') + filename = build_filename(task.title, task.author, task.year, extracted_format) + else: + filename = extracted_file.name + + final_path = ingest_dir / filename final_path = _handle_duplicate_filename(final_path) shutil.move(str(extracted_file), str(final_path)) final_paths.append(final_path) diff --git a/cwa_book_downloader/download/orchestrator.py b/cwa_book_downloader/download/orchestrator.py index b7112a1c..5c32bccb 100644 --- a/cwa_book_downloader/download/orchestrator.py +++ b/cwa_book_downloader/download/orchestrator.py @@ -20,6 +20,7 @@ This ensures: - Single point of control for what enters the ingest folder """ +import hashlib import os import random import shutil @@ -71,7 +72,9 @@ def get_staging_path(task_id: str, extension: str) -> Path: Path in staging directory for this download """ staging_dir = get_staging_dir() - return staging_dir / f"{task_id}.{extension.lstrip('.')}" + # Hash task_id in case it contains invalid filename chars (e.g., Prowlarr URLs) + safe_id = hashlib.md5(task_id.encode()).hexdigest()[:16] + return staging_dir / f"{safe_id}.{extension.lstrip('.')}" def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: @@ -89,7 +92,13 @@ def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: Path to the staged file """ staging_dir = get_staging_dir() - staged_path = staging_dir / f"{task_id}{source_path.suffix}" + # Stage with original filename, add counter suffix if collision + staged_path = staging_dir / source_path.name + if staged_path.exists(): + counter = 1 + while staged_path.exists(): + staged_path = staging_dir / f"{source_path.stem}_{counter}{source_path.suffix}" + counter += 1 if copy: shutil.copy2(str(source_path), str(staged_path)) @@ -100,6 +109,162 @@ def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path: return staged_path + +def _get_supported_formats() -> List[str]: + """Get current supported formats from config singleton.""" + formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]) + # Handle both list (from MultiSelectField) and comma-separated string (legacy/env) + if isinstance(formats, str): + return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] + return [fmt.lower() for fmt in formats] + + +def _find_book_files_in_directory(directory: Path) -> Tuple[List[Path], List[Path]]: + """Find all book files in a directory matching SUPPORTED_FORMATS. + + Args: + directory: Directory to search recursively + + Returns: + Tuple of (matching book files, rejected files with unsupported extensions) + """ + book_files = [] + rejected_files = [] + supported_formats = _get_supported_formats() + supported_exts = {f".{fmt}" for fmt in supported_formats} + + for file_path in directory.rglob("*"): + if file_path.is_file(): + if file_path.suffix.lower() in supported_exts: + book_files.append(file_path) + elif file_path.suffix.lower() in {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'}: + # Track ebook-like files that were rejected due to format settings + rejected_files.append(file_path) + + return book_files, rejected_files + + +def process_directory( + directory: Path, + ingest_dir: Path, + task: DownloadTask, +) -> Tuple[List[Path], Optional[str]]: + """Process a staged directory: find book files, handle archives, move to ingest. + + For multi-file torrent/usenet downloads. If book files exist, moves them directly. + If only archives exist, extracts them to find book files inside. + + Args: + directory: Staged directory containing downloaded files + ingest_dir: Final destination directory for book files + task: Download task for filename generation + + Returns: + Tuple of (list of final paths, error message if failed) + """ + try: + book_files, rejected_files = _find_book_files_in_directory(directory) + + # Find archives in directory (ZIP/RAR) + archive_files = [f for f in directory.rglob("*") if f.is_file() and is_archive(f)] + + if not book_files: + # No direct book files - check for archives to extract + if archive_files: + logger.info(f"No book files found, extracting {len(archive_files)} archive(s)") + all_final_paths = [] + all_errors = [] + + for archive in archive_files: + result = process_archive( + archive_path=archive, + temp_dir=directory, + ingest_dir=ingest_dir, + archive_id=f"{task.task_id}_{archive.stem}", + task=task, + ) + if result.success: + all_final_paths.extend(result.final_paths) + elif result.error: + all_errors.append(f"{archive.name}: {result.error}") + + # Clean up directory after processing archives + shutil.rmtree(directory, ignore_errors=True) + + if all_final_paths: + return all_final_paths, None + elif all_errors: + return [], "; ".join(all_errors) + else: + return [], "No book files found in archives" + + # No book files and no archives + shutil.rmtree(directory, ignore_errors=True) + + if rejected_files: + # Files were found but didn't match supported formats + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + rejected_list = ", ".join(rejected_exts) + supported_formats = _get_supported_formats() + logger.warning( + f"Found {len(rejected_files)} file(s) but none match supported formats. " + f"Rejected formats: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}" + ) + return [], f"Found {len(rejected_files)} file(s) but format not supported ({rejected_list}). Enable in Settings > Formats." + + return [], "No book files found in download" + + # We have book files - use them directly, skip any archives + if archive_files: + logger.debug(f"Ignoring {len(archive_files)} archive(s) - already have {len(book_files)} book file(s)") + + logger.info(f"Found {len(book_files)} book file(s) in directory") + + if rejected_files: + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + logger.debug(f"Also found {len(rejected_files)} file(s) with unsupported formats: {', '.join(rejected_exts)}") + + # Move each book file to ingest + final_paths = [] + for book_file in book_files: + # For multi-file downloads (book packs, series), always preserve original filenames + # since metadata title only applies to the searched book, not the whole pack. + # For single files, respect USE_BOOK_TITLE setting. + if len(book_files) == 1 and config.USE_BOOK_TITLE: + # Update task format from actual file if not already set + # (Prowlarr releases may not know the format until download completes) + if not task.format: + task.format = book_file.suffix.lower().lstrip('.') + filename = task.get_filename() or book_file.name + else: + filename = book_file.name + + final_path = ingest_dir / filename + + # Handle duplicates + if final_path.exists(): + base = final_path.stem + ext = final_path.suffix + counter = 1 + while final_path.exists(): + final_path = ingest_dir / f"{base}_{counter}{ext}" + counter += 1 + + shutil.move(str(book_file), str(final_path)) + final_paths.append(final_path) + logger.debug(f"Moved to ingest: {final_path.name}") + + # Clean up the now-empty directory + shutil.rmtree(directory, ignore_errors=True) + + return final_paths, None + + except Exception as e: + logger.error(f"Error processing directory: {e}") + shutil.rmtree(directory, ignore_errors=True) + return [], str(e) + + # WebSocket manager (initialized by app.py) try: from cwa_book_downloader.api.websocket import ws_manager @@ -222,8 +387,9 @@ def queue_release(release_data: dict, priority: int = 0) -> bool: source = release_data.get('source', 'direct_download') extra = release_data.get('extra', {}) - # Get author, preview, and content_type from top-level (preferred) or extra (fallback) + # Get author, year, preview, and content_type from top-level (preferred) or extra (fallback) author = release_data.get('author') or extra.get('author') + year = release_data.get('year') or extra.get('year') preview = release_data.get('preview') or extra.get('preview') content_type = release_data.get('content_type') or extra.get('content_type') @@ -233,6 +399,7 @@ def queue_release(release_data: dict, priority: int = 0) -> bool: source=source, title=release_data.get('title', 'Unknown'), author=author, + year=year, format=release_data.get('format'), size=release_data.get('size'), preview=preview, @@ -419,7 +586,10 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]: # Check cancellation before post-processing if cancel_flag.is_set(): logger.info(f"Download cancelled before post-processing: {task_id}") - temp_file.unlink(missing_ok=True) + if temp_file.is_dir(): + shutil.rmtree(temp_file, ignore_errors=True) + else: + temp_file.unlink(missing_ok=True) return None # Post-processing: archive extraction or direct move to ingest @@ -471,6 +641,7 @@ def _post_process_download( temp_dir=TMP_DIR, ingest_dir=ingest_dir, archive_id=task.task_id, + task=task, ) if result.success: @@ -480,6 +651,33 @@ def _post_process_download( status_callback("error", result.error) return None + # Handle directory (multi-file torrent/usenet downloads) + if temp_file.is_dir(): + logger.info(f"Directory detected, processing: {temp_file.name}") + status_callback("resolving", "Processing download folder...") + + final_paths, error = process_directory( + directory=temp_file, + ingest_dir=ingest_dir, + task=task, + ) + + if error: + status_callback("error", error) + return None + + if final_paths: + formats = [p.suffix.lstrip(".").upper() for p in final_paths] + if len(formats) == 1: + message = f"Downloaded: {formats[0]}" + else: + message = f"Downloaded: {len(formats)} files ({', '.join(formats)})" + status_callback("complete", message) + return str(final_paths[0]) + else: + status_callback("error", "No book files found") + return None + # Non-archive: run custom script if configured, then move to ingest if config.CUSTOM_SCRIPT: logger.info(f"Running custom script: {config.CUSTOM_SCRIPT}") @@ -491,10 +689,13 @@ def _post_process_download( temp_file.unlink(missing_ok=True) return None - # Generate filename and move to ingest - filename = task.get_filename() - if not filename: - filename = f"{task.task_id}.{task.format or 'bin'}" + # Generate filename: use formatted name if USE_BOOK_TITLE, else preserve original + if config.USE_BOOK_TITLE: + filename = task.get_filename() + if not filename: + filename = temp_file.name + else: + filename = temp_file.name final_path = ingest_dir / filename @@ -509,7 +710,7 @@ def _post_process_download( logger.info(f"File already exists, saving as: {final_path.name}") # Use intermediate .crdownload file for atomic move - intermediate_path = ingest_dir / f"{task.task_id}.crdownload" + intermediate_path = ingest_dir / f"{temp_file.stem}.crdownload" try: shutil.move(str(temp_file), str(intermediate_path)) diff --git a/cwa_book_downloader/main.py b/cwa_book_downloader/main.py index 310d7655..0087a6e6 100644 --- a/cwa_book_downloader/main.py +++ b/cwa_book_downloader/main.py @@ -523,7 +523,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), - "debug": DEBUG, + "debug": app_config.get("DEBUG", False), "build_version": BUILD_VERSION, "release_version": RELEASE_VERSION, "book_languages": _SUPPORTED_BOOK_LANGUAGE, @@ -1272,6 +1272,10 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: # Accept title/author from frontend to avoid re-fetching metadata title_param = request.args.get('title', '').strip() author_param = request.args.get('author', '').strip() + expand_search = request.args.get('expand_search', '').lower() == 'true' + # Accept language codes for filtering (comma-separated) + languages_param = request.args.get('languages', '').strip() + languages = [lang.strip() for lang in languages_param.split(',') if lang.strip()] if languages_param else None if not provider or not book_id: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 @@ -1287,12 +1291,12 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: if not book: return jsonify({"error": "Book not found in metadata provider"}), 404 - # Override with frontend-provided title/author if available (these come from search results - # which may have more complete data than get_book returns) + # Override title from frontend if available (search results may have better data) + # Note: We intentionally DON'T override authors here - get_book() now returns + # filtered authors (primary authors only, excluding translators/narrators), + # which gives better release search results than the unfiltered search data if title_param: book.title = title_param - if author_param: - book.authors = [author_param] if author_param else [] # Determine which release sources to search if source_filter: @@ -1304,11 +1308,14 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: # Search each source for releases all_releases = [] errors = [] + source_instances = {} # Keep source instances for column config for source_name in sources_to_search: try: source = get_source(source_name) - releases = source.search(book) + source_instances[source_name] = source + logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search})") + releases = source.search(book, expand_search=expand_search, languages=languages) all_releases.extend(releases) except ValueError: errors.append(f"Unknown source: {source_name}") @@ -1320,11 +1327,11 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: releases_data = [asdict(release) for release in all_releases] # Get column config from the first source searched - # (In the UI, releases are shown per-source tab anyway) + # Reuse the same instance to get any dynamic data (e.g., online_servers for IRC) column_config = None - if sources_to_search: + if sources_to_search and sources_to_search[0] in source_instances: try: - first_source = get_source(sources_to_search[0]) + first_source = source_instances[sources_to_search[0]] column_config = serialize_column_config(first_source.get_column_config()) except Exception as e: logger.warning(f"Failed to get column config: {e}") diff --git a/cwa_book_downloader/metadata_providers/__init__.py b/cwa_book_downloader/metadata_providers/__init__.py index 38e0643f..97362e21 100644 --- a/cwa_book_downloader/metadata_providers/__init__.py +++ b/cwa_book_downloader/metadata_providers/__init__.py @@ -21,6 +21,7 @@ class SortOrder(str, Enum): RATING = "rating" # Highest rated first NEWEST = "newest" # Most recently published first OLDEST = "oldest" # Oldest published first + SERIES_ORDER = "series_order" # By series position (requires series field) # Display labels for sort options @@ -30,6 +31,7 @@ SORT_LABELS: Dict[SortOrder, str] = { SortOrder.RATING: "Highest rated", SortOrder.NEWEST: "Newest", SortOrder.OLDEST: "Oldest", + SortOrder.SERIES_ORDER: "Series order", } @@ -165,6 +167,11 @@ class BookMetadata: # Provider-specific display fields for cards/lists display_fields: List[DisplayField] = field(default_factory=list) + # Series info (if book is part of a series) + series_name: Optional[str] = None # Name of the series + series_position: Optional[float] = None # This book's position (e.g., 3, 1.5 for novellas) + series_count: Optional[int] = None # Total books in the series + class MetadataProvider(ABC): """Interface for metadata providers. @@ -479,3 +486,8 @@ try: from cwa_book_downloader.metadata_providers import openlibrary # noqa: F401, E402 except ImportError: pass # Open Library provider is optional + +try: + from cwa_book_downloader.metadata_providers import googlebooks # noqa: F401, E402 +except ImportError: + pass # Google Books provider is optional diff --git a/cwa_book_downloader/metadata_providers/googlebooks.py b/cwa_book_downloader/metadata_providers/googlebooks.py new file mode 100644 index 00000000..ee620628 --- /dev/null +++ b/cwa_book_downloader/metadata_providers/googlebooks.py @@ -0,0 +1,518 @@ +"""Google Books metadata provider. + +Uses the Google Books API v1 to search and retrieve book metadata. +Requires a free API key from Google Cloud Console (~1000 requests/day quota). + +API Documentation: https://developers.google.com/books/docs/v1/using +""" + +import requests +from typing import Any, Dict, List, Optional + +from cwa_book_downloader.core.cache import cacheable +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.core.settings_registry import ( + register_settings, + CheckboxField, + PasswordField, + SelectField, + ActionButton, + HeadingField, +) +from cwa_book_downloader.core.config import config as app_config +from cwa_book_downloader.metadata_providers import ( + BookMetadata, + DisplayField, + MetadataProvider, + MetadataSearchOptions, + SearchType, + SortOrder, + register_provider, + register_provider_kwargs, + TextSearchField, +) + + +logger = setup_logger(__name__) + +GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1" + +# Sort mapping - Google only supports "relevance" and "newest" +SORT_MAPPING: Dict[SortOrder, Optional[str]] = { + SortOrder.RELEVANCE: None, # Default, no param needed + SortOrder.NEWEST: "newest", + # POPULARITY, RATING, OLDEST not supported - fall back to relevance +} + + +@register_provider_kwargs("googlebooks") +def _googlebooks_kwargs() -> Dict[str, Any]: + """Provide Google Books-specific constructor kwargs.""" + return {"api_key": app_config.get("GOOGLEBOOKS_API_KEY", "")} + + +@register_provider("googlebooks") +class GoogleBooksProvider(MetadataProvider): + """Google Books metadata provider using REST API. + + Attributes: + name: Internal provider identifier. + display_name: Human-readable name for UI. + requires_auth: True - requires API key. + supported_sorts: Only RELEVANCE and NEWEST supported. + search_fields: Author and title search fields. + """ + + name = "googlebooks" + display_name = "Google Books" + requires_auth = True + supported_sorts = [SortOrder.RELEVANCE, SortOrder.NEWEST] + search_fields = [ + TextSearchField( + key="author", + label="Author", + description="Search by author name", + ), + TextSearchField( + key="title", + label="Title", + description="Search by book title", + ), + ] + + def __init__(self, api_key: Optional[str] = None): + """Initialize provider with API key. + + Args: + api_key: Google Books API key. If not provided, reads from config. + """ + self.api_key = api_key or app_config.get("GOOGLEBOOKS_API_KEY", "") + self.session = requests.Session() + + def is_available(self) -> bool: + """Check if provider is configured with an API key.""" + return bool(self.api_key) + + def search(self, options: MetadataSearchOptions) -> List[BookMetadata]: + """Search for books using Google Books API. + + Args: + options: Search options (query, type, sort, pagination, fields). + + Returns: + List of BookMetadata objects matching the search. + """ + if not self.api_key: + logger.warning("Google Books API key not configured") + return [] + + # Handle ISBN search separately + if options.search_type == SearchType.ISBN: + result = self.search_by_isbn(options.query) + return [result] if result else [] + + # Build cache key from all options + fields_key = ":".join(f"{k}={v}" for k, v in sorted(options.fields.items())) + cache_key = ( + f"{options.query}:{options.search_type.value}:{options.sort.value}:" + f"{options.language}:{options.limit}:{options.page}:{fields_key}" + ) + return self._search_cached(cache_key, options) + + @cacheable( + ttl_key="METADATA_CACHE_SEARCH_TTL", + ttl_default=300, + key_prefix="googlebooks:search", + ) + def _search_cached( + self, cache_key: str, options: MetadataSearchOptions + ) -> List[BookMetadata]: + """Cached search implementation. + + Args: + cache_key: Cache key for this search (includes all options). + options: Search options. + + Returns: + List of BookMetadata objects. + """ + # Build query string with Google Books operators + author_value = options.fields.get("author", "").strip() + title_value = options.fields.get("title", "").strip() + + query_parts = [] + + if author_value and not title_value: + # Author-only search + query_parts.append(f"inauthor:{author_value}") + elif title_value and not author_value: + # Title-only search + query_parts.append(f"intitle:{title_value}") + elif author_value and title_value: + # Both provided - combine + query_parts.append(f"intitle:{title_value}") + query_parts.append(f"inauthor:{author_value}") + elif options.search_type == SearchType.TITLE: + query_parts.append(f"intitle:{options.query}") + elif options.search_type == SearchType.AUTHOR: + query_parts.append(f"inauthor:{options.query}") + else: + # General search + query_parts.append(options.query) + + query = "+".join(query_parts) if query_parts else options.query + + # Build request params + params: Dict[str, Any] = { + "q": query, + "maxResults": min(options.limit, 40), # Google max is 40 + "startIndex": (options.page - 1) * options.limit, + "printType": "books", # Exclude magazines + } + + # Map sort order (Google only supports relevance and newest) + sort = SORT_MAPPING.get(options.sort) + if sort: # Only add if not default (relevance) + params["orderBy"] = sort + + # Add language filter if specified + if options.language: + params["langRestrict"] = options.language + + try: + result = self._make_request("/volumes", params) + if not result: + return [] + + items = result.get("items", []) + books = [] + + for item in items: + book = self._parse_volume(item) + if book: + books.append(book) + + logger.info(f"Google Books search '{query}' returned {len(books)} results") + return books + + except Exception as e: + logger.error(f"Google Books search error: {e}") + return [] + + @cacheable( + ttl_key="METADATA_CACHE_BOOK_TTL", + ttl_default=600, + key_prefix="googlebooks:book", + ) + def get_book(self, book_id: str) -> Optional[BookMetadata]: + """Get book details by Google Books volume ID. + + Args: + book_id: Google Books volume ID. + + Returns: + BookMetadata or None if not found. + """ + try: + result = self._make_request(f"/volumes/{book_id}", {}) + if not result: + return None + + return self._parse_volume(result) + + except Exception as e: + logger.error(f"Google Books get_book error: {e}") + return None + + @cacheable( + ttl_key="METADATA_CACHE_BOOK_TTL", + ttl_default=600, + key_prefix="googlebooks:isbn", + ) + def search_by_isbn(self, isbn: str) -> Optional[BookMetadata]: + """Search for a book by ISBN. + + Args: + isbn: ISBN-10 or ISBN-13. + + Returns: + BookMetadata or None if not found. + """ + # Clean ISBN (remove hyphens and spaces) + clean_isbn = isbn.replace("-", "").replace(" ", "").strip() + + # Use ISBN operator for precise lookup + params: Dict[str, Any] = { + "q": f"isbn:{clean_isbn}", + "maxResults": 1, + } + + try: + result = self._make_request("/volumes", params) + if not result: + return None + + items = result.get("items", []) + if not items: + logger.debug(f"No Google Books result for ISBN: {isbn}") + return None + + return self._parse_volume(items[0]) + + except Exception as e: + logger.error(f"Google Books ISBN search error: {e}") + return None + + def _make_request( + self, endpoint: str, params: Dict[str, Any] + ) -> Optional[Dict[str, Any]]: + """Make authenticated API request. + + Args: + endpoint: API endpoint path (e.g., "/volumes"). + params: Query parameters. + + Returns: + Response JSON or None on error. + """ + if not self.api_key: + logger.warning("Google Books API key not configured") + return None + + # Add API key to params + params["key"] = self.api_key + + url = f"{GOOGLE_BOOKS_BASE_URL}{endpoint}" + + try: + response = self.session.get(url, params=params, timeout=15) + response.raise_for_status() + return response.json() + + except requests.Timeout: + logger.warning("Google Books API request timed out") + return None + except requests.HTTPError as e: + if e.response is not None: + if e.response.status_code == 403: + # Quota exceeded or invalid API key + logger.error( + "Google Books API: quota exceeded or invalid API key (HTTP 403)" + ) + elif e.response.status_code == 400: + logger.warning(f"Google Books API: bad request - {e}") + elif e.response.status_code == 404: + logger.debug("Google Books: volume not found") + else: + logger.error(f"Google Books API HTTP error: {e}") + else: + logger.error(f"Google Books API HTTP error: {e}") + return None + except Exception as e: + logger.error(f"Google Books API request failed: {e}") + return None + + def _parse_volume(self, volume: Dict[str, Any]) -> Optional[BookMetadata]: + """Parse a volume object into BookMetadata. + + Args: + volume: Volume data from Google Books API. + + Returns: + BookMetadata or None if parsing fails. + """ + try: + volume_id = volume.get("id") + volume_info = volume.get("volumeInfo", {}) + + title = volume_info.get("title") + if not volume_id or not title: + return None + + # Authors (list) + authors = volume_info.get("authors", []) + + # ISBNs - extract from industryIdentifiers + isbn_10 = None + isbn_13 = None + for identifier in volume_info.get("industryIdentifiers", []): + id_type = identifier.get("type", "") + id_value = identifier.get("identifier", "") + if id_type == "ISBN_10" and not isbn_10: + isbn_10 = id_value + elif id_type == "ISBN_13" and not isbn_13: + isbn_13 = id_value + + # Cover URL - prefer larger images + image_links = volume_info.get("imageLinks", {}) + cover_url = ( + image_links.get("large") + or image_links.get("medium") + or image_links.get("small") + or image_links.get("thumbnail") + or image_links.get("smallThumbnail") + ) + # Remove edge=curl parameter and upgrade to https + if cover_url: + cover_url = cover_url.replace("&edge=curl", "").replace( + "http://", "https://" + ) + + # Publisher + publisher = volume_info.get("publisher") + + # Publish year - extract from publishedDate (YYYY-MM-DD or YYYY) + publish_year = None + published_date = volume_info.get("publishedDate", "") + if published_date: + try: + publish_year = int(published_date[:4]) + except (ValueError, TypeError): + pass + + # Language + language = volume_info.get("language") + + # Genres/categories (limit to 5) + genres = volume_info.get("categories", [])[:5] + + # Description (may contain HTML - leave as-is for UI to sanitize) + description = volume_info.get("description") + + # Source URL + source_url = volume_info.get("infoLink") + + # Build display fields - rating only + display_fields: List[DisplayField] = [] + + average_rating = volume_info.get("averageRating") + ratings_count = volume_info.get("ratingsCount") + if average_rating is not None: + rating_str = f"{average_rating:.1f}" + if ratings_count: + rating_str += f" ({ratings_count:,})" + display_fields.append( + DisplayField(label="Rating", value=rating_str, icon="star") + ) + + return BookMetadata( + provider="googlebooks", + provider_id=volume_id, + title=title, + provider_display_name="Google Books", + authors=authors, + isbn_10=isbn_10, + isbn_13=isbn_13, + cover_url=cover_url, + description=description, + publisher=publisher, + publish_year=publish_year, + language=language, + genres=genres, + source_url=source_url, + display_fields=display_fields, + ) + + except Exception as e: + logger.debug(f"Failed to parse Google Books volume: {e}") + return None + + +def _test_googlebooks_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the Google Books API connection using current form values. + + Args: + current_values: Current unsaved form values from the UI. + + Returns: + Dict with 'success' bool and 'message' string. + """ + current_values = current_values or {} + + # Use current form values first, fall back to saved config + api_key = current_values.get("GOOGLEBOOKS_API_KEY") or app_config.get("GOOGLEBOOKS_API_KEY", "") + + if not api_key: + return { + "success": False, + "message": "API key is required", + } + + try: + provider = GoogleBooksProvider(api_key=api_key) + # Simple test search + result = provider._make_request("/volumes", {"q": "test", "maxResults": 1}) + + if result is not None and "items" in result: + return { + "success": True, + "message": "Successfully connected to Google Books API", + } + elif result is not None: + return { + "success": True, + "message": "API connected but returned no results for test query", + } + else: + return { + "success": False, + "message": "API request failed - check your API key", + } + except Exception as e: + logger.exception("Google Books connection test failed") + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +# Sort options for settings UI +_GOOGLEBOOKS_SORT_OPTIONS = [ + {"value": "relevance", "label": "Most relevant"}, + {"value": "newest", "label": "Newest"}, +] + + +@register_settings( + "googlebooks", "Google Books", icon="book", order=53, group="metadata_providers" +) +def googlebooks_settings(): + """Google Books metadata provider settings.""" + return [ + HeadingField( + key="googlebooks_heading", + title="Google Books", + description=( + "Access Google's comprehensive book database. " + "Requires a free API key with ~1000 requests/day quota." + ), + link_url="https://console.cloud.google.com/apis/library/books.googleapis.com", + link_text="Get API Key", + ), + CheckboxField( + key="GOOGLEBOOKS_ENABLED", + label="Enable Google Books", + description="Enable Google Books as a metadata provider for book searches", + default=False, + ), + PasswordField( + key="GOOGLEBOOKS_API_KEY", + label="API Key", + description=( + "Get your API key from Google Cloud Console " + "(APIs & Services > Credentials)" + ), + required=True, + ), + ActionButton( + key="test_connection", + label="Test Connection", + description="Verify your API key works", + style="primary", + callback=_test_googlebooks_connection, + ), + SelectField( + key="GOOGLEBOOKS_DEFAULT_SORT", + label="Default Sort Order", + description="Default sort order for Google Books search results.", + options=_GOOGLEBOOKS_SORT_OPTIONS, + default="relevance", + ), + ] diff --git a/cwa_book_downloader/metadata_providers/hardcover.py b/cwa_book_downloader/metadata_providers/hardcover.py index 5d46c862..5a1b0157 100644 --- a/cwa_book_downloader/metadata_providers/hardcover.py +++ b/cwa_book_downloader/metadata_providers/hardcover.py @@ -92,6 +92,7 @@ class HardcoverProvider(MetadataProvider): SortOrder.RATING, SortOrder.NEWEST, SortOrder.OLDEST, + SortOrder.SERIES_ORDER, ] search_fields = [ TextSearchField( @@ -104,6 +105,11 @@ class HardcoverProvider(MetadataProvider): label="Title", description="Search by book title", ), + TextSearchField( + key="series", + label="Series", + description="Search by series name", + ), ] def __init__(self, api_key: Optional[str] = None): @@ -162,29 +168,43 @@ class HardcoverProvider(MetadataProvider): # Field-first search: when a specific field has a value, search that field author_value = options.fields.get("author", "").strip() title_value = options.fields.get("title", "").strip() + series_value = options.fields.get("series", "").strip() - logger.debug(f"Field-first search check: author_value='{author_value}', title_value='{title_value}'") + logger.debug(f"Field-first search check: author='{author_value}', title='{title_value}', series='{series_value}'") # Determine what to search and which fields to target # Note: Hardcover API requires 'weights' when using 'fields' parameter - if author_value and not title_value: + if series_value and not author_value and not title_value: + # Series-only search: search series_names field + query = series_value + search_fields = "series_names" + search_weights = "1" + logger.debug(f"Series-only search: query='{query}', fields='{search_fields}'") + elif author_value and not title_value and not series_value: # Author-only search: search author_names field with author query query = author_value search_fields = "author_names" search_weights = "1" logger.debug(f"Author-only search: query='{query}', fields='{search_fields}'") - elif title_value and not author_value: + elif title_value and not author_value and not series_value: # Title-only search: search title fields with title query query = title_value search_fields = "title,alternative_titles" search_weights = "5,1" logger.debug(f"Title-only search: query='{query}', fields='{search_fields}'") - elif author_value and title_value: - # Both provided: combine into query, search both fields + elif author_value and title_value and not series_value: + # Author + Title: combine into query, search both fields query = f"{title_value} {author_value}" search_fields = "title,alternative_titles,author_names" search_weights = "5,1,3" - logger.debug(f"Combined search: query='{query}', fields='{search_fields}'") + logger.debug(f"Combined title+author search: query='{query}', fields='{search_fields}'") + elif series_value: + # Series with other fields: include series_names in search + parts = [p for p in [series_value, title_value, author_value] if p] + query = " ".join(parts) + search_fields = "series_names,title,alternative_titles,author_names" + search_weights = "5,3,1,2" + logger.debug(f"Combined search with series: query='{query}', fields='{search_fields}'") else: # No custom fields: use general query with all default fields query = options.query @@ -265,6 +285,11 @@ class HardcoverProvider(MetadataProvider): if book: books.append(book) + # If series order sort is selected and series field is provided, + # filter to exact matches and sort by position + if options.sort == SortOrder.SERIES_ORDER and series_value and books: + books = self._apply_series_ordering(books, series_value) + logger.info(f"Hardcover search '{query}' (fields={search_fields}) returned {len(books)} results") return books @@ -272,6 +297,44 @@ class HardcoverProvider(MetadataProvider): logger.error(f"Hardcover search error: {e}") return [] + def _apply_series_ordering(self, books: List[BookMetadata], series_name: str) -> List[BookMetadata]: + """Filter books to exact series match and sort by series position. + + Args: + books: List of books from search results. + series_name: The series name to match. + + Returns: + Filtered and sorted list of books. + """ + series_name_lower = series_name.lower() + books_with_position = [] + + for book in books: + # Fetch full book details to get series info + full_book = self.get_book(book.provider_id) + if not full_book or not full_book.series_name: + continue + + # Exact match on series name + if full_book.series_name.lower() != series_name_lower: + continue + + # Merge series info into the search result book + book.series_name = full_book.series_name + book.series_position = full_book.series_position + book.series_count = full_book.series_count + # Also grab description if search didn't have it + if not book.description and full_book.description: + book.description = full_book.description + books_with_position.append(book) + + # Sort by series position (books without position go last) + books_with_position.sort(key=lambda b: (b.series_position is None, b.series_position or 0)) + + logger.debug(f"Series ordering: filtered {len(books)} -> {len(books_with_position)} books for '{series_name}'") + return books_with_position + @cacheable(ttl_key="METADATA_CACHE_BOOK_TTL", ttl_default=600, key_prefix="hardcover:book") def get_book(self, book_id: str) -> Optional[BookMetadata]: """Get book details by Hardcover ID. @@ -287,7 +350,9 @@ class HardcoverProvider(MetadataProvider): return None # Query for specific book by ID - # Note: API has max depth of 3, so use cached_* fields instead of nested relationships + # Use contributions with filter to get only primary authors (not translators/narrators) + # Also include cached_contributors as fallback if contributions is empty + # Include featured_book_series for series info graphql_query = """ query GetBook($id: Int!) { books(where: {id: {_eq: $id}}, limit: 1) { @@ -299,12 +364,24 @@ class HardcoverProvider(MetadataProvider): description pages cached_image - cached_contributors cached_tags + cached_contributors + contributions(where: {contribution: {_eq: "Author"}}) { + author { + name + } + } default_physical_edition { isbn_10 isbn_13 } + featured_book_series { + position + series { + name + primary_books_count + } + } } } """ @@ -346,7 +423,7 @@ class HardcoverProvider(MetadataProvider): clean_isbn = isbn.replace("-", "").strip() # Search for editions with matching ISBN - # Note: API has max depth of 3, so use cached_* fields instead of nested relationships + # Use contributions with filter to get only primary authors (not translators/narrators) graphql_query = """ query SearchByISBN($isbn: String!) { editions( @@ -369,8 +446,12 @@ class HardcoverProvider(MetadataProvider): description pages cached_image - cached_contributors cached_tags + contributions(where: {contribution: {_eq: "Author"}}) { + author { + name + } + } } } } @@ -536,20 +617,33 @@ class HardcoverProvider(MetadataProvider): Returns: BookMetadata object. """ - # Extract authors from cached_contributors (json array) or contributions relationship + # Extract authors - try contributions first (filtered), fall back to cached_contributors authors = [] - if book.get("cached_contributors"): - for contrib in book["cached_contributors"]: - if isinstance(contrib, dict) and contrib.get("name"): - authors.append(contrib["name"]) + contributions = book.get("contributions") or [] + cached_contributors = book.get("cached_contributors") or [] + + logger.debug(f"_parse_book [{book.get('id')}]: contributions={contributions}, cached_contributors={cached_contributors}") + + # Try contributions first (filtered to "Author" role only - cleaner data) + for contrib in contributions: + author = contrib.get("author", {}) + if author and author.get("name"): + authors.append(author["name"]) + + # Fallback to cached_contributors if no authors found + if not authors: + for contrib in cached_contributors: + if isinstance(contrib, dict): + # Handle nested structure: {"author": {"name": "..."}, "contribution": ...} + if contrib.get("author", {}).get("name"): + authors.append(contrib["author"]["name"]) + # Handle flat structure: {"name": "..."} + elif contrib.get("name"): + authors.append(contrib["name"]) elif isinstance(contrib, str): authors.append(contrib) - elif book.get("contributions"): - # Fallback for contributions relationship (if used) - for contrib in book["contributions"]: - author = contrib.get("author", {}) - if author and author.get("name"): - authors.append(author["name"]) + + logger.debug(f"_parse_book [{book.get('id')}]: final authors={authors}") # Get cover URL from cached_image (jsonb) or image relationship cover_url = None @@ -608,6 +702,18 @@ class HardcoverProvider(MetadataProvider): description = book.get("description") full_description = _combine_headline_description(headline, description) + # Extract series info from featured_book_series + series_name = None + series_position = None + series_count = None + featured_series = book.get("featured_book_series") + if featured_series: + series_position = featured_series.get("position") + series_data = featured_series.get("series") + if series_data: + series_name = series_data.get("name") + series_count = series_data.get("primary_books_count") + return BookMetadata( provider="hardcover", provider_id=str(book["id"]), @@ -621,20 +727,20 @@ class HardcoverProvider(MetadataProvider): publish_year=publish_year, genres=genres, source_url=source_url, + series_name=series_name, + series_position=series_position, + series_count=series_count, ) -def _test_hardcover_connection() -> Dict[str, Any]: - """Test the Hardcover API connection.""" +def _test_hardcover_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the Hardcover API connection using current form values.""" from cwa_book_downloader.core.config import config as app_config - from cwa_book_downloader.core.settings_registry import save_config_file, load_config_file - from cwa_book_downloader.metadata_providers import get_provider_kwargs - # Refresh config to pick up any recently saved settings - app_config.refresh() + current_values = current_values or {} - kwargs = get_provider_kwargs("hardcover") - api_key = kwargs.get("api_key") + # Use current form values first, fall back to saved config + api_key = current_values.get("HARDCOVER_API_KEY") or app_config.get("HARDCOVER_API_KEY", "") # Debug: log key info key_len = len(api_key) if api_key else 0 @@ -644,7 +750,7 @@ def _test_hardcover_connection() -> Dict[str, Any]: if not api_key: # Clear any stored username since there's no key _save_connected_username(None) - return {"success": False, "message": "No API key configured. Save your key and try again."} + return {"success": False, "message": "API key is required"} if key_len < 100: return {"success": False, "message": f"API key seems too short ({key_len} chars). Expected 500+ chars."} diff --git a/cwa_book_downloader/release_sources/__init__.py b/cwa_book_downloader/release_sources/__init__.py index 8e455643..ea8c8aa4 100644 --- a/cwa_book_downloader/release_sources/__init__.py +++ b/cwa_book_downloader/release_sources/__init__.py @@ -115,6 +115,8 @@ class ReleaseColumnConfig: columns: List[ColumnSchema] 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 + cache_ttl_seconds: Optional[int] = None # How long to cache results (default: 5 min) def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: @@ -152,6 +154,14 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]: "uppercase": config.leading_cell.uppercase, } + # Include online_servers if provided (e.g., for IRC source) + if config.online_servers is not None: + result["online_servers"] = config.online_servers + + # 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 + return result @@ -198,8 +208,22 @@ class ReleaseSource(ABC): display_name: str # "Direct Download", "Prowlarr" @abstractmethod - def search(self, book: BookMetadata) -> List[Release]: - """Search for releases of a book.""" + def search( + self, + book: BookMetadata, + expand_search: bool = False, + languages: Optional[List[str]] = None + ) -> List[Release]: + """Search for releases of a book. + + Args: + book: Book metadata from provider + expand_search: If True, use broader search (e.g., title+author instead of ISBN). + Not all sources support this - they may ignore it. + languages: Optional list of language codes to filter by. + If provided, overrides book.language and default settings. + Not all sources support this - they may ignore it. + """ pass @abstractmethod @@ -315,11 +339,23 @@ def get_handler(name: str) -> DownloadHandler: def list_available_sources() -> List[dict]: - """For frontend - list sources that are configured.""" + """For frontend - list all registered sources with their status. + + Returns all sources (not just available ones) so the frontend can show + appropriate UI for disabled/unconfigured sources instead of hiding them. + + Each source includes: + - name: Source identifier (e.g., 'prowlarr') + - display_name: Human-readable name (e.g., 'Prowlarr') + - enabled: Whether the source is available for use + """ return [ - {"name": name, "display_name": src().display_name} + { + "name": name, + "display_name": src().display_name, + "enabled": src().is_available(), + } for name, src in _SOURCES.items() - if src().is_available() ] @@ -337,4 +373,5 @@ def get_source_display_name(name: str) -> str: # Import source implementations to trigger registration # These must be imported AFTER the base classes and registry are defined from cwa_book_downloader.release_sources import direct_download # noqa: F401, E402 -# from cwa_book_downloader.release_sources import prowlarr # noqa: F401, E402 +from cwa_book_downloader.release_sources import prowlarr # noqa: F401, E402 +from cwa_book_downloader.release_sources import irc # noqa: F401, E402 diff --git a/cwa_book_downloader/release_sources/direct_download.py b/cwa_book_downloader/release_sources/direct_download.py index 375da476..580aac04 100644 --- a/cwa_book_downloader/release_sources/direct_download.py +++ b/cwa_book_downloader/release_sources/direct_download.py @@ -966,38 +966,55 @@ class DirectDownloadSource(ReleaseSource): grid_template="minmax(0,2fr) 60px 80px 80px" ) - def search(self, book: BookMetadata) -> List[Release]: + def search( + self, + book: BookMetadata, + expand_search: bool = False, + languages: Optional[List[str]] = None + ) -> List[Release]: """ Search for releases using the book's metadata. - Uses an ISBN-first strategy: + Args: + book: Book metadata from provider + expand_search: If True, skip ISBN and use title+author search directly. + Useful when ISBN search returns few results. + languages: Optional list of language codes to filter by. + If provided, overrides book.language and default settings. + + Default behavior (expand_search=False): 1. If ISBN available, try ISBN search first (most precise) 2. If no results or no ISBN, fall back to title+author search - This approach maximizes accuracy while ensuring we find results. + Expanded search (expand_search=True): + - Skip ISBN, go straight to title+author search (finds more editions) """ - # Try ISBN search first if available - isbn = book.isbn_13 or book.isbn_10 - if isbn: - logger.debug(f"Searching direct downloads by ISBN: {isbn}") - filters = SearchFilters(isbn=[isbn]) - if book.language: - filters.lang = [book.language] + # Determine language filter: explicit languages param > book.language > default + lang_filter = languages if languages else ([book.language] if book.language else None) - try: - book_infos = search_books(isbn, filters) - if book_infos: - logger.info(f"Found {len(book_infos)} releases via ISBN search") - return [_book_info_to_release(bi) for bi in book_infos] - logger.debug(f"No results from ISBN search, falling back to title+author") - except SearchUnavailable: - logger.warning("Direct download search unavailable during ISBN search") - raise # Service unreachable - no point trying title search - except Exception as e: - logger.warning(f"ISBN search failed, falling back to title+author: {e}") - # Fall through to title search + # Expanded search skips ISBN and goes straight to title+author + if not expand_search: + # Try ISBN search first if available + isbn = book.isbn_13 or book.isbn_10 + if isbn: + logger.debug(f"Searching direct downloads by ISBN: {isbn}") + filters = SearchFilters(isbn=[isbn]) + if lang_filter: + filters.lang = lang_filter - # Fallback to title + author search + try: + book_infos = search_books(isbn, filters) + if book_infos: + logger.info(f"Found {len(book_infos)} releases via ISBN search") + return [_book_info_to_release(bi) for bi in book_infos] + logger.debug("No results from ISBN search, falling back to title+author") + except SearchUnavailable: + logger.warning("Direct download search unavailable during ISBN search") + raise + except Exception as e: + logger.warning(f"ISBN search failed, falling back to title+author: {e}") + + # Title + author search (fallback or expanded mode) query_parts = [] if book.title: query_parts.append(book.title) @@ -1011,8 +1028,8 @@ class DirectDownloadSource(ReleaseSource): logger.debug(f"Searching direct downloads by title+author: {query}") filters = SearchFilters() - if book.language: - filters.lang = [book.language] + if lang_filter: + filters.lang = lang_filter try: book_infos = search_books(query, filters) @@ -1020,10 +1037,10 @@ class DirectDownloadSource(ReleaseSource): return [_book_info_to_release(bi) for bi in book_infos] except SearchUnavailable: logger.warning("Direct download search unavailable") - raise # Re-raise so the API endpoint can report the error + raise except Exception as e: logger.error(f"Error searching direct download source: {e}") - raise # Re-raise so the API endpoint can report the error + raise def search_raw(self, query: str, filters: SearchFilters) -> List[BookInfo]: """ diff --git a/cwa_book_downloader/release_sources/irc/__init__.py b/cwa_book_downloader/release_sources/irc/__init__.py new file mode 100644 index 00000000..d46531eb --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/__init__.py @@ -0,0 +1,14 @@ +"""IRC Highway release source plugin. + +This plugin enables searching and downloading ebooks from IRC Highway's +#ebooks channel using the DCC (Direct Client-to-Client) protocol. + +Special thanks to the OpenBooks project (https://github.com/evan-buss/openbooks), +an MIT-licensed IRC ebook downloader written in Go, for serving as inspiration for the +high-level design and reference for IRC Highway best practices, DCC protocol handling, and result parsing. +""" + +# Import to trigger decorator registration +from cwa_book_downloader.release_sources.irc import source # noqa: F401 +from cwa_book_downloader.release_sources.irc import handler # noqa: F401 +from cwa_book_downloader.release_sources.irc import settings # noqa: F401 diff --git a/cwa_book_downloader/release_sources/irc/client.py b/cwa_book_downloader/release_sources/irc/client.py new file mode 100644 index 00000000..52d4de15 --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/client.py @@ -0,0 +1,490 @@ +"""IRC client implementation using raw sockets. + +Minimal IRC client for IRC Highway ebook searches. +""" + +import random +import re +import socket +import ssl +import string +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Iterator, Optional + +from cwa_book_downloader.core.logger import setup_logger + +from .dcc import DCCOffer, parse_dcc_send + +logger = setup_logger(__name__) + + +# IRC Highway server details +DEFAULT_SERVER = "irc.irchighway.net" +DEFAULT_PORT_TLS = 6697 +DEFAULT_PORT_PLAIN = 6667 +DEFAULT_CHANNEL = "ebooks" + +# Timing +POST_CONNECT_DELAY = 2.0 # Seconds to wait after connect before joining +SOCKET_TIMEOUT = 300.0 # 5 minutes - long because we wait for DCC offers +RECV_BUFFER = 4096 + +# IRC channel user prefixes that indicate elevated status (ops, voice, etc.) +# These are the download bots/servers +ELEVATED_PREFIXES = frozenset({'~', '&', '@', '%', '+'}) + + +class IRCEvent(Enum): + """Events detected from IRC messages.""" + MESSAGE = auto() # Generic message + SEARCH_RESULT = auto() # DCC SEND with "_results_for" + BOOK_RESULT = auto() # DCC SEND for actual book + NO_RESULTS = auto() # "Sorry" notice + BAD_SERVER = auto() # "try another server" notice + SEARCH_ACCEPTED = auto() # "has been accepted" notice + MATCHES_FOUND = auto() # "X matches" notice + SERVER_LIST = auto() # User list (353/366) + PING = auto() # Server PING + VERSION = auto() # CTCP VERSION request + + +@dataclass +class IRCMessage: + """Parsed IRC message.""" + raw: str + prefix: Optional[str] = None + command: str = "" + params: list[str] = field(default_factory=list) + trailing: Optional[str] = None + event: IRCEvent = IRCEvent.MESSAGE + + +class IRCError(Exception): + """Base IRC error.""" + pass + + +class IRCConnectionError(IRCError): + """Connection failed.""" + pass + + +class IRCClient: + """Minimal IRC client for IRC Highway ebook searches. + + Designed for per-request connections - connect, do operation, disconnect. + Not intended for long-lived connections. + + Usage: + client = IRCClient(nick="mybot") + client.connect() + client.join_channel("ebooks") + client.send_message("#ebooks", "@search harry potter") + + for msg in client.read_messages(): + if msg.event == IRCEvent.SEARCH_RESULT: + offer = parse_dcc_send(msg.raw) + break + + client.disconnect() + """ + + def __init__( + self, + nick: Optional[str] = None, + server: str = DEFAULT_SERVER, + port: Optional[int] = None, + use_tls: bool = True, + version: str = "CWA-BD 1.0", + ): + self.nick = nick or self._generate_nick() + self.server = server + self.port = port or (DEFAULT_PORT_TLS if use_tls else DEFAULT_PORT_PLAIN) + self.use_tls = use_tls + self.version = version + + self._socket: Optional[socket.socket] = None + self._buffer = "" + self._connected = False + + # Track online servers (elevated users in channel) + self.online_servers: set[str] = set() + + @staticmethod + def _generate_nick() -> str: + """Generate random nickname like 'cwa_abc123'.""" + suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) + return f"cwa_{suffix}" + + def connect(self) -> None: + """Connect to IRC server and authenticate. + + Connection sequence: + 1. TCP/TLS connect + 2. Send USER and NICK + 3. Wait for server welcome messages + + Raises: + IRCConnectionError: If connection fails + """ + logger.info(f"Connecting to {self.server}:{self.port} (TLS={self.use_tls})") + + try: + # Create socket + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(SOCKET_TIMEOUT) + + # Wrap with TLS if needed + if self.use_tls: + context = ssl.create_default_context() + # Skip verification for self-signed certs common on IRC servers + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + sock = context.wrap_socket(sock, server_hostname=self.server) + + sock.connect((self.server, self.port)) + self._socket = sock + + except socket.error as e: + raise IRCConnectionError(f"Failed to connect: {e}") + + # Send authentication (USER before NICK per IRC protocol) + self._send(f"USER {self.nick} 0 * :{self.nick}") + self._send(f"NICK {self.nick}") + + # Wait for server to process welcome messages + logger.debug(f"Waiting {POST_CONNECT_DELAY}s for server welcome") + time.sleep(POST_CONNECT_DELAY) + + self._connected = True + logger.info(f"Connected as {self.nick}") + + def disconnect(self) -> None: + """Gracefully disconnect from server.""" + if self._socket: + try: + self._send("QUIT :Goodbye") + except Exception: + pass # Best effort + + try: + self._socket.close() + except Exception: + pass + + self._socket = None + self._connected = False + logger.info("Disconnected from IRC") + + def join_channel(self, channel: str, wait_for_join: bool = True) -> None: + """Join an IRC channel. + + Args: + channel: Channel name without # prefix + wait_for_join: If True, wait for server confirmation (366 message) + + Also captures the channel's user list to track online servers. + """ + self._send(f"JOIN #{channel}") + logger.debug(f"Sent JOIN #{channel}") + + # Clear any existing server list before joining + self.online_servers.clear() + + if wait_for_join: + # Wait for end of NAMES list (366) which confirms we're in the channel + start = time.time() + timeout = 10.0 # 10 seconds should be plenty + + for line in self._recv_lines(): + if time.time() - start > timeout: + logger.warning(f"Timeout waiting for JOIN confirmation on #{channel}") + break + + msg = self._parse_message(line) + + # Handle PING during join wait + if msg.event == IRCEvent.PING: + self._handle_ping(msg) + continue + + # 353 = RPL_NAMREPLY - parse the names list + if msg.command == "353": + self._parse_names_list(msg.raw) + continue + + # 366 = RPL_ENDOFNAMES - channel join is complete + if msg.command == "366": + logger.info(f"Joined #{channel} - {len(self.online_servers)} servers online") + return + + # Check for errors (e.g., banned, channel doesn't exist) + if msg.command in ("473", "474", "475", "403"): + logger.error(f"Cannot join #{channel}: {msg.trailing}") + return + + logger.warning(f"Joined #{channel} (no confirmation received)") + + def send_message(self, target: str, message: str) -> None: + """Send a PRIVMSG to a channel or user. + + Args: + target: Channel (with #) or user nick + message: Message content + """ + self._send(f"PRIVMSG {target} :{message}") + logger.debug(f"Sent to {target}: {message[:50]}...") + + def send_notice(self, target: str, message: str) -> None: + """Send a NOTICE to a user. + + Args: + target: User nick + message: Notice content + """ + self._send(f"NOTICE {target} :{message}") + + def request_names(self, channel: str) -> None: + """Request user list for a channel. + + Args: + channel: Channel name without # prefix + """ + self._send(f"NAMES #{channel}") + + def _parse_names_list(self, names_data: str) -> None: + """Parse NAMES list and extract elevated users (download servers). + + IRC NAMES reply format (353): + :server 353 nick = #channel :@user1 +user2 user3 ... + + Users with prefixes (~, &, @, %, +) are elevated (ops/voice). + These are the download bots/servers. + """ + # Extract the trailing part after the last colon (the actual names) + if ' :' in names_data: + names_part = names_data.split(' :')[-1] + else: + names_part = names_data + + for name in names_part.split(): + if not name: + continue + + # Check if user has an elevated prefix + if name[0] in ELEVATED_PREFIXES: + # Strip the prefix to get the actual nick + self.online_servers.add(name[1:]) + # Note: we only care about elevated users for server status + + def _send(self, message: str) -> None: + """Send raw IRC message.""" + if not self._socket: + raise IRCError("Not connected") + + data = f"{message}\r\n".encode('utf-8') + self._socket.sendall(data) + + def _recv_lines(self) -> Iterator[str]: + """Receive and yield complete IRC lines. + + IRC messages are delimited by \\r\\n. We buffer partial + reads and yield complete lines as they arrive. + """ + while True: + # Check if we have a complete line in buffer + while '\r\n' in self._buffer: + line, self._buffer = self._buffer.split('\r\n', 1) + if line: + yield line + + # Read more data + try: + data = self._socket.recv(RECV_BUFFER) + if not data: + return # Connection closed + self._buffer += data.decode('utf-8', errors='replace') + except socket.timeout: + continue # Keep waiting + except socket.error as e: + logger.warning(f"Socket error: {e}") + return # Connection error + + def _parse_message(self, line: str) -> IRCMessage: + """Parse an IRC message line into components. + + Format: [:prefix] COMMAND [params] [:trailing] + """ + msg = IRCMessage(raw=line) + + # Extract prefix if present + if line.startswith(':'): + space_idx = line.find(' ') + if space_idx != -1: + msg.prefix = line[1:space_idx] + line = line[space_idx + 1:] + + # Extract trailing if present + if ' :' in line: + idx = line.find(' :') + msg.trailing = line[idx + 2:] + line = line[:idx] + + # Split remaining into command and params + parts = line.split() + if parts: + msg.command = parts[0] + msg.params = parts[1:] + + # Classify event type based on message content + msg.event = self._classify_event(msg) + + return msg + + def _classify_event(self, msg: IRCMessage) -> IRCEvent: + """Classify message into event type. + + Uses simple string containment checks for robustness + rather than strict IRC protocol parsing. + """ + raw = msg.raw + trailing = msg.trailing or "" + + # DCC SEND detection + if "DCC SEND" in raw: + if "_results_for" in raw: + return IRCEvent.SEARCH_RESULT + return IRCEvent.BOOK_RESULT + + # NOTICE messages + if msg.command == "NOTICE" or "NOTICE" in raw: + if "Sorry" in trailing: + return IRCEvent.NO_RESULTS + if "try another server" in trailing: + return IRCEvent.BAD_SERVER + if "has been accepted" in trailing: + return IRCEvent.SEARCH_ACCEPTED + if "matches" in trailing: + return IRCEvent.MATCHES_FOUND + + # User list (RPL_NAMREPLY and RPL_ENDOFNAMES) + if msg.command == "353": + return IRCEvent.SERVER_LIST + if msg.command == "366": + return IRCEvent.SERVER_LIST + + # Server PING + if msg.command == "PING": + return IRCEvent.PING + + # CTCP VERSION + if "\x01VERSION\x01" in raw: + return IRCEvent.VERSION + + return IRCEvent.MESSAGE + + def _handle_ping(self, msg: IRCMessage) -> None: + """Respond to server PING with PONG.""" + # PING message format: PING :server + server = msg.trailing or self.server + self._send(f"PONG :{server}") + logger.debug(f"PONG {server}") + + def _handle_version(self, msg: IRCMessage) -> None: + """Respond to CTCP VERSION request.""" + if msg.prefix: + # Extract nick from prefix (nick!user@host) + sender = msg.prefix.split('!')[0] + self.send_notice(sender, f"\x01VERSION {self.version}\x01") + logger.debug(f"Sent VERSION to {sender}") + + def read_messages(self, auto_handle: bool = True) -> Iterator[IRCMessage]: + """Read and yield IRC messages. + + Args: + auto_handle: If True, automatically handle PING and VERSION + + Yields: + IRCMessage objects for each received message + """ + for line in self._recv_lines(): + msg = self._parse_message(line) + + # Auto-handle certain events + if auto_handle: + if msg.event == IRCEvent.PING: + self._handle_ping(msg) + continue # Don't yield PING messages + + if msg.event == IRCEvent.VERSION: + self._handle_version(msg) + continue # Don't yield VERSION messages + + yield msg + + def wait_for_dcc( + self, + timeout: float = 60.0, + result_type: bool = False, + ) -> Optional[DCCOffer]: + """Wait for a DCC SEND offer. + + Args: + timeout: Maximum time to wait in seconds + result_type: If True, wait for SEARCH_RESULT; else BOOK_RESULT + + Returns: + DCCOffer if received, None if timeout + """ + target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT + start = time.time() + + for msg in self.read_messages(): + if time.time() - start > timeout: + logger.warning("Timeout waiting for DCC offer") + return None + + if msg.event == target_event: + try: + offer = parse_dcc_send(msg.raw) + logger.info(f"Received DCC offer: {offer.filename}") + return offer + except Exception as e: + logger.error(f"Failed to parse DCC: {e}") + return None + + # Log other events for debugging + if msg.event == IRCEvent.NO_RESULTS: + logger.info("Server reports no results") + return None + if msg.event == IRCEvent.BAD_SERVER: + logger.warning("Server unavailable") + return None + if msg.event == IRCEvent.SEARCH_ACCEPTED: + logger.info("Search accepted, waiting for results...") + if msg.event == IRCEvent.MATCHES_FOUND: + # Extract count from "returned X matches" + if msg.trailing and "returned" in msg.trailing: + try: + match = re.search(r'returned\s+(\d+)\s+matches', msg.trailing) + if match: + count = match.group(1) + logger.info(f"Found {count} matches") + except Exception: + pass + + return None + + @property + def is_connected(self) -> bool: + """Check if currently connected.""" + return self._connected and self._socket is not None + + def __enter__(self): + self.connect() + return self + + def __exit__(self, *args): + self.disconnect() diff --git a/cwa_book_downloader/release_sources/irc/dcc.py b/cwa_book_downloader/release_sources/irc/dcc.py new file mode 100644 index 00000000..295c9924 --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/dcc.py @@ -0,0 +1,176 @@ +"""DCC (Direct Client-to-Client) protocol implementation. + +Handles DCC SEND file transfers used by IRC bots to send files. +""" + +import re +import socket +import struct +from dataclasses import dataclass +from pathlib import Path +from threading import Event +from typing import Callable, Optional + +from cwa_book_downloader.core.logger import setup_logger + +logger = setup_logger(__name__) + +# Regex to parse DCC SEND messages - handles quoted filenames +# Format: DCC SEND "filename.epub" 2760158537 2050 2321788 +# | | | | +# filename IP(int) port size +DCC_REGEX = re.compile(r'DCC SEND "?(.+[^"])"?\s(\d+)\s+(\d+)\s+(\d+)\s*') + +# Buffer size for DCC transfers - 4096 bytes provides good performance +BUFFER_SIZE = 4096 + + +@dataclass +class DCCOffer: + """Parsed DCC SEND offer.""" + filename: str + ip: str + port: int + size: int + + @property + def address(self) -> tuple[str, int]: + """Return (ip, port) tuple for socket.connect().""" + return (self.ip, self.port) + + +class DCCError(Exception): + """Base exception for DCC operations.""" + pass + + +class DCCParseError(DCCError): + """Failed to parse DCC SEND string.""" + pass + + +class DCCSizeError(DCCError): + """Downloaded size doesn't match expected size.""" + pass + + +class DCCConnectionError(DCCError): + """Failed to connect to DCC sender.""" + pass + + +def int_to_ip(ip_int: int) -> str: + """Convert 32-bit integer to dotted IP notation. + + DCC protocol sends IP addresses as 32-bit unsigned integers + in network byte order (big-endian). + + Example: 2760158537 -> "164.132.173.73" + """ + packed = struct.pack('>I', ip_int) + return '.'.join(str(b) for b in packed) + + +def parse_dcc_send(text: str) -> DCCOffer: + """Parse a DCC SEND message into a DCCOffer. + + Args: + text: Full IRC message containing DCC SEND + + Returns: + DCCOffer with filename, ip, port, size + + Raises: + DCCParseError: If message doesn't match expected format + """ + match = DCC_REGEX.search(text) + if not match: + raise DCCParseError(f"Invalid DCC SEND format: {text[:100]}") + + filename = match.group(1).strip('"') + ip_int = int(match.group(2)) + port = int(match.group(3)) + size = int(match.group(4)) + + return DCCOffer( + filename=filename, + ip=int_to_ip(ip_int), + port=port, + size=size, + ) + + +def download_dcc( + offer: DCCOffer, + dest_path: Path, + progress_callback: Optional[Callable[[float], None]] = None, + cancel_flag: Optional[Event] = None, + timeout: float = 30.0, +) -> None: + """Download file via DCC protocol. + + Uses a custom read loop with 4096-byte buffer which is faster than + Python's shutil.copyfileobj since DCC servers don't properly signal EOF. + + Args: + offer: Parsed DCC offer with connection details + dest_path: Where to save the file + progress_callback: Called with percentage (0-100) during download + cancel_flag: If set, abort the download + timeout: Socket timeout in seconds + + Raises: + DCCConnectionError: Failed to connect to sender + DCCSizeError: Downloaded bytes != expected size + DCCError: Other socket/IO errors + """ + logger.info(f"DCC connecting to {offer.ip}:{offer.port} for {offer.filename}") + + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(offer.address) + except socket.error as e: + raise DCCConnectionError(f"Failed to connect to {offer.ip}:{offer.port}: {e}") + + try: + received = 0 + last_progress = -1 + + with open(dest_path, 'wb') as f: + while received < offer.size: + # Check for cancellation + if cancel_flag and cancel_flag.is_set(): + logger.info("DCC download cancelled") + return + + # Read chunk + try: + chunk = sock.recv(BUFFER_SIZE) + except socket.timeout: + raise DCCError(f"Timeout reading from {offer.ip}:{offer.port}") + + if not chunk: + # Connection closed prematurely + break + + f.write(chunk) + received += len(chunk) + + # Report progress (every 1%) + if progress_callback: + progress = int((received / offer.size) * 100) + if progress != last_progress: + progress_callback(progress) + last_progress = progress + + # Verify downloaded size matches expected + if received != offer.size: + raise DCCSizeError( + f"Size mismatch: expected {offer.size} bytes, got {received}" + ) + + logger.info(f"DCC download complete: {received} bytes") + + finally: + sock.close() diff --git a/cwa_book_downloader/release_sources/irc/handler.py b/cwa_book_downloader/release_sources/irc/handler.py new file mode 100644 index 00000000..a677a18d --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/handler.py @@ -0,0 +1,137 @@ +"""IRC DCC download handler. + +Handles downloading books via IRC DCC protocol. +""" + +from pathlib import Path +from threading import Event +from typing import Callable, Optional + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.core.models import DownloadTask +from cwa_book_downloader.release_sources import DownloadHandler, register_handler + +from .client import DEFAULT_CHANNEL, IRCClient +from .dcc import DCCError, download_dcc + +logger = setup_logger(__name__) + + +@register_handler("irc") +class IRCDownloadHandler(DownloadHandler): + """Handle IRC DCC downloads.""" + + def download( + self, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Download a book via IRC DCC. + + The task.task_id contains the full IRC request string, + e.g., "!ServerName Author - Title.epub ::INFO:: 2.5MB" + + Args: + task: Download task with IRC request info + cancel_flag: Set to cancel download + progress_callback: Report progress 0-100 + status_callback: Report status messages + + Returns: + Path to downloaded file, or None on failure + """ + download_request = task.task_id + logger.info(f"IRC download: {download_request[:60]}...") + + nick = config.get("IRC_NICK") or None + + client = None + + try: + # Phase 1: Connect to IRC + status_callback("resolving", "Connecting to IRC...") + + if cancel_flag.is_set(): + return None + + client = IRCClient(nick=nick) + client.connect() + client.join_channel(DEFAULT_CHANNEL) + + # Phase 2: Send download request + status_callback("resolving", "Requesting file from bot...") + + if cancel_flag.is_set(): + client.disconnect() + return None + + # Send the full request line to the channel + client.send_message(f"#{DEFAULT_CHANNEL}", download_request) + + # Phase 3: Wait for DCC offer + status_callback("resolving", "Waiting for bot response...") + + offer = client.wait_for_dcc(timeout=120.0, result_type=False) + + if not offer: + status_callback("error", "No response from bot") + client.disconnect() + return None + + if cancel_flag.is_set(): + client.disconnect() + return None + + # Phase 4: Download via DCC + status_callback("downloading", "") + + # Get file extension from offer filename + ext = Path(offer.filename).suffix.lstrip('.') or task.format or "epub" + + # Stage to temp directory (lazy import to avoid circular import) + from cwa_book_downloader.download.orchestrator import get_staging_path + staging_path = get_staging_path(task.task_id, ext) + + download_dcc( + offer=offer, + dest_path=staging_path, + progress_callback=progress_callback, + cancel_flag=cancel_flag, + timeout=60.0, + ) + + client.disconnect() + + if cancel_flag.is_set(): + # Clean up partial download + staging_path.unlink(missing_ok=True) + return None + + logger.info(f"Download complete: {staging_path}") + return str(staging_path) + + except DCCError as e: + logger.error(f"DCC error: {e}") + status_callback("error", str(e)) + if client: + client.disconnect() + return None + + except Exception as e: + logger.error(f"Download failed: {e}") + status_callback("error", f"Download failed: {e}") + if client: + client.disconnect() + return None + + def cancel(self, task_id: str) -> bool: + """Cancel an in-progress download. + + Note: Actual cancellation is handled via the cancel_flag in download(). + This method is for cleanup if the cancel_flag mechanism fails. + """ + logger.debug(f"Cancel requested for IRC task: {task_id}") + return True diff --git a/cwa_book_downloader/release_sources/irc/parser.py b/cwa_book_downloader/release_sources/irc/parser.py new file mode 100644 index 00000000..0d0e59a7 --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/parser.py @@ -0,0 +1,206 @@ +"""Search results file parser. + +Parses the text files sent via DCC that contain search results. +""" + +import re +import zipfile +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger + +logger = setup_logger(__name__) + +# All recognized ebook formats for parsing IRC result lines. +# This comprehensive list is used to identify file extensions in results. +# User's configured formats are used separately for filtering. +ALL_EBOOK_FORMATS = { + 'epub', 'mobi', 'azw3', 'azw', 'pdf', 'doc', 'docx', + 'html', 'htm', 'rtf', 'txt', 'lit', 'fb2', 'djvu', + 'cbr', 'cbz', 'cdr', 'jpg', 'rar', 'zip' +} + + +def _get_supported_formats() -> List[str]: + """Get user's configured supported formats from settings.""" + formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"]) + if isinstance(formats, str): + return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] + return [fmt.lower() for fmt in formats] + +# Regex to parse result lines +# Format: !Server Author - Title.format ::INFO:: size +RESULT_LINE_REGEX = re.compile( + r'^!(\S+)\s+' # !ServerName + r'(.+?)\s+-\s+' # Author Name - + r'(.+?)\.(\w+)' # Title.format + r'(?:\s+::INFO::\s*(.+?))?' # Optional ::INFO:: metadata + r'(?:\s+::HASH::\s*(\S+))?' # Optional ::HASH:: + r'\s*$' +) + +# Simpler fallback pattern +SIMPLE_RESULT_REGEX = re.compile( + r'^!(\S+)\s+(.+)$' # !Server everything_else +) + + +@dataclass +class SearchResult: + """Parsed search result entry.""" + server: str # Bot name (without !) + author: str # Author name + title: str # Book title + format: str # File format (epub, mobi, etc) + size: Optional[str] # Human-readable size + full_line: str # Original line for download request + + @property + def download_request(self) -> str: + """The string to send to IRC to request this book.""" + return self.full_line.strip() + + @property + def display_name(self) -> str: + """Human-readable display name.""" + return f"{self.author} - {self.title}" + + +def parse_result_line(line: str) -> Optional[SearchResult]: + """Parse a single search result line. + + Args: + line: Raw line from search results file + + Returns: + SearchResult if parseable, None otherwise + """ + line = line.strip() + + # Must start with ! + if not line.startswith('!'): + return None + + # Try detailed pattern first + match = RESULT_LINE_REGEX.match(line) + if match: + server, author, title, fmt, size, _ = match.groups() + return SearchResult( + server=server, + author=author.strip(), + title=title.strip(), + format=fmt.lower(), + size=size.strip() if size else None, + full_line=line, + ) + + # Fallback: simpler parsing + match = SIMPLE_RESULT_REGEX.match(line) + if match: + server, rest = match.groups() + + # Try to extract format from the line + fmt = None + for known_fmt in ALL_EBOOK_FORMATS: + if f'.{known_fmt}' in rest.lower(): + fmt = known_fmt + break + + # Try to split author - title + if ' - ' in rest: + parts = rest.split(' - ', 1) + author = parts[0].strip() + title_part = parts[1].strip() if len(parts) > 1 else rest + else: + author = "Unknown" + title_part = rest + + # Extract size if present + size = None + if '::INFO::' in title_part: + title_part, info = title_part.split('::INFO::', 1) + size = info.split('::')[0].strip() + + # Clean up title (remove extension) + title = title_part + for known_fmt in ALL_EBOOK_FORMATS: + title = re.sub(rf'\.{known_fmt}\b', '', title, flags=re.IGNORECASE) + + return SearchResult( + server=server, + author=author, + title=title.strip(), + format=fmt or 'unknown', + size=size, + full_line=line, + ) + + logger.debug(f"Could not parse line: {line[:80]}...") + return None + + +def parse_results_file(content: str) -> list[SearchResult]: + """Parse a search results file. + + Args: + content: Full file content + + Returns: + List of parsed SearchResult objects + """ + results = [] + supported = set(_get_supported_formats()) + + for line in content.splitlines(): + result = parse_result_line(line) + if result: + # Filter to user's configured formats + if result.format in supported or result.format == 'unknown': + results.append(result) + + logger.info(f"Parsed {len(results)} results from search file") + return results + + +def extract_results_from_zip(zip_path: Path) -> str: + """Extract and return content from a search results ZIP. + + Search results are sent as ZIP files containing a single text file. + + Args: + zip_path: Path to downloaded ZIP file + + Returns: + Text content of the results file + """ + with zipfile.ZipFile(zip_path, 'r') as zf: + # Should contain exactly one text file + names = zf.namelist() + if not names: + raise ValueError("Empty ZIP file") + + # Find the text file + txt_file = None + for name in names: + if name.endswith('.txt'): + txt_file = name + break + + if not txt_file: + # Use first file + txt_file = names[0] + + content = zf.read(txt_file) + + # Try different encodings + for encoding in ['utf-8', 'latin-1', 'cp1252']: + try: + return content.decode(encoding) + except UnicodeDecodeError: + continue + + # Last resort + return content.decode('utf-8', errors='replace') diff --git a/cwa_book_downloader/release_sources/irc/settings.py b/cwa_book_downloader/release_sources/irc/settings.py new file mode 100644 index 00000000..b1978fe2 --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/settings.py @@ -0,0 +1,59 @@ +"""IRC settings registration. + +Registers IRC Highway settings for the settings UI. +""" + +from cwa_book_downloader.core.settings_registry import ( + CheckboxField, + HeadingField, + TextField, + register_settings, +) + + +@register_settings( + name="irc", + display_name="IRC Highway", + icon="download", + order=56, +) +def irc_settings(): + """Define IRC source settings.""" + return [ + HeadingField( + key="heading", + title="IRC Highway", + description=( + "Search and download books from IRC Highway #ebooks channel. " + "This source connects via IRC and uses DCC for file transfers. " + "Note: DCC requires direct TCP connections to arbitrary ports, " + "which may not work behind strict firewalls or NAT." + ), + ), + + CheckboxField( + key="IRC_ENABLED", + label="Enable IRC source", + default=False, + description="Enable searching and downloading from IRC Highway", + ), + + TextField( + key="IRC_NICK", + label="Nickname", + placeholder="Leave empty for random", + description="Your IRC nickname. Leave empty to generate a random one.", + env_supported=True, + show_when={"field": "IRC_ENABLED", "value": True}, + ), + + TextField( + key="IRC_SEARCH_BOT", + label="Search bot", + placeholder="search", + default="search", + description="The search bot to query (usually 'search' or 'searchook')", + env_supported=True, + show_when={"field": "IRC_ENABLED", "value": True}, + ), + ] diff --git a/cwa_book_downloader/release_sources/irc/source.py b/cwa_book_downloader/release_sources/irc/source.py new file mode 100644 index 00000000..4a186f9f --- /dev/null +++ b/cwa_book_downloader/release_sources/irc/source.py @@ -0,0 +1,261 @@ +"""IRC Highway release source plugin. + +Searches IRC Highway #ebooks channel for book releases. +""" + +import tempfile +import time +from pathlib import Path +from typing import List, Optional + +from cwa_book_downloader.api.websocket import ws_manager +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.metadata_providers import BookMetadata +from cwa_book_downloader.release_sources import ( + ColumnColorHint, + ColumnRenderType, + ColumnSchema, + LeadingCellConfig, + LeadingCellType, + Release, + ReleaseColumnConfig, + ReleaseProtocol, + ReleaseSource, + register_source, +) + +from .client import DEFAULT_CHANNEL, IRCClient +from .dcc import DCCError, download_dcc +from .parser import SearchResult, extract_results_from_zip, parse_results_file + +logger = setup_logger(__name__) + + +def _emit_status(message: str, phase: str = 'searching') -> None: + """Emit search status to frontend via WebSocket.""" + ws_manager.broadcast_search_status( + source='irc', + provider='', + book_id='', + message=message, + phase=phase, + ) + +# Rate limiting to avoid server throttling +MIN_SEARCH_INTERVAL = 15.0 +_last_search_time: float = 0 + + +def _enforce_rate_limit() -> None: + """Ensure minimum time between searches.""" + global _last_search_time + + elapsed = time.time() - _last_search_time + if elapsed < MIN_SEARCH_INTERVAL: + wait_time = MIN_SEARCH_INTERVAL - elapsed + logger.info(f"Rate limiting: waiting {wait_time:.1f}s") + time.sleep(wait_time) + + _last_search_time = time.time() + + +@register_source("irc") +class IRCReleaseSource(ReleaseSource): + """Search IRC Highway #ebooks for book releases.""" + + name = "irc" + display_name = "IRC Highway" + + def __init__(self): + # Track online servers from most recent search + self._online_servers: Optional[list[str]] = None + + @classmethod + def is_available(cls) -> bool: + """Check if IRC is enabled in settings.""" + return config.get("IRC_ENABLED", False) + + def get_column_config(self) -> ReleaseColumnConfig: + """Configure UI columns for IRC results. + + Includes online_servers from the most recent search, allowing + the frontend to show status indicators for each server. + """ + return ReleaseColumnConfig( + columns=[ + ColumnSchema( + key="extra.server", + label="Server", + render_type=ColumnRenderType.TEXT, + width="100px", + ), + ColumnSchema( + key="format", + label="Format", + render_type=ColumnRenderType.BADGE, + color_hint=ColumnColorHint(type="map", value="format"), + width="70px", + uppercase=True, + ), + ColumnSchema( + key="size", + label="Size", + render_type=ColumnRenderType.TEXT, + width="70px", + ), + ], + grid_template="minmax(0,2fr) 100px 70px 70px", + leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), + online_servers=list(self._online_servers) if self._online_servers else None, + cache_ttl_seconds=1800, # 30 minutes - IRC searches are slow, cache longer + ) + + def search( + self, + book: BookMetadata, + expand_search: bool = False, + languages: Optional[List[str]] = None + ) -> List[Release]: + """Search IRC Highway for books matching metadata. + + Args: + book: Book metadata (title, authors, etc.) + expand_search: Ignored - IRC always uses title+author search + languages: Ignored - IRC doesn't support language filtering + + Returns: + List of matching releases + """ + # Build search query + query = self._build_query(book) + if not query: + logger.warning("No search query could be built") + return [] + + logger.info(f"IRC search: {query}") + + # Enforce rate limit + _enforce_rate_limit() + + search_bot = config.get("IRC_SEARCH_BOT", "search") + nick = config.get("IRC_NICK") or None + + client = None + try: + # Connect to IRC + _emit_status("Connecting to IRC Highway...", phase='connecting') + client = IRCClient(nick=nick) + client.connect() + + _emit_status("Joining #ebooks...", phase='connecting') + client.join_channel(DEFAULT_CHANNEL) + + # Capture online servers (elevated users in channel) + self._online_servers = client.online_servers + + # Send search request + client.send_message(f"#{DEFAULT_CHANNEL}", f"@{search_bot} {query}") + + # Wait for results DCC - this is the long wait + _emit_status("Connected to #ebooks - Waiting for results...", phase='searching') + offer = client.wait_for_dcc(timeout=60.0, result_type=True) + if not offer: + logger.info("No search results received") + _emit_status("No results found", phase='complete') + client.disconnect() + return [] + + # Download results file + _emit_status("Connected to #ebooks - Downloading results...", phase='downloading') + with tempfile.TemporaryDirectory() as tmpdir: + result_path = Path(tmpdir) / offer.filename + download_dcc(offer, result_path, timeout=30.0) + + # Parse results + if result_path.suffix.lower() == '.zip': + content = extract_results_from_zip(result_path) + else: + content = result_path.read_text(errors='replace') + + client.disconnect() + + # Convert to Release objects + results = parse_results_file(content) + return self._convert_to_releases(results) + + except DCCError as e: + logger.error(f"DCC error during search: {e}") + _emit_status(f"DCC error: {e}", phase='error') + if client: + client.disconnect() + return [] + except Exception as e: + logger.error(f"IRC search failed: {e}") + _emit_status(f"Search failed: {e}", phase='error') + if client: + client.disconnect() + return [] + + def _build_query(self, book: BookMetadata) -> str: + """Build search query from book metadata.""" + parts = [] + + if book.title: + parts.append(book.title) + + if book.authors: + # Use first author + author = book.authors[0] if isinstance(book.authors, list) else book.authors + parts.append(author) + + return ' '.join(parts) + + def _convert_to_releases(self, results: List[SearchResult]) -> List[Release]: + """Convert parsed results to Release objects.""" + releases = [] + + for result in results: + release = Release( + source="irc", + source_id=result.download_request, # Full line for download + title=result.title, + format=result.format, + size=result.size, + size_bytes=self._parse_size(result.size) if result.size else None, + protocol=ReleaseProtocol.DCC, + indexer=f"IRC:{result.server}", + extra={ + "server": result.server, + "author": result.author, + "full_line": result.full_line, + }, + ) + releases.append(release) + + return releases + + @staticmethod + def _parse_size(size_str: str) -> Optional[int]: + """Parse human-readable size to bytes.""" + if not size_str: + return None + + size_str = size_str.strip().upper() + + multipliers = { + 'B': 1, + 'KB': 1024, + 'MB': 1024 * 1024, + 'GB': 1024 * 1024 * 1024, + } + + for suffix, mult in multipliers.items(): + if size_str.endswith(suffix): + try: + num = float(size_str[:-len(suffix)].strip()) + return int(num * mult) + except ValueError: + return None + + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/__init__.py b/cwa_book_downloader/release_sources/prowlarr/__init__.py new file mode 100644 index 00000000..57f3c163 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/__init__.py @@ -0,0 +1,26 @@ +""" +Prowlarr release source plugin. + +This plugin integrates with Prowlarr to search for book releases +across multiple indexers (torrent and usenet). + +Includes: +- ProwlarrSource: Search integration with Prowlarr +- ProwlarrHandler: Download handling via external clients +- Download clients: qBittorrent (torrents), NZBGet (usenet) +""" + +# Import submodules to trigger decorator registration +from cwa_book_downloader.release_sources.prowlarr import source # noqa: F401 +from cwa_book_downloader.release_sources.prowlarr import handler # noqa: F401 +from cwa_book_downloader.release_sources.prowlarr import settings # noqa: F401 + +# Import clients to trigger client registration +# This is in a try/except to handle optional dependencies gracefully +try: + from cwa_book_downloader.release_sources.prowlarr import clients # noqa: F401 +except ImportError as e: + # Log but don't fail - clients require optional dependencies + import logging + + logging.getLogger(__name__).debug(f"Prowlarr clients not loaded: {e}") diff --git a/cwa_book_downloader/release_sources/prowlarr/api.py b/cwa_book_downloader/release_sources/prowlarr/api.py new file mode 100644 index 00000000..8414ae2c --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/api.py @@ -0,0 +1,213 @@ +""" +Prowlarr API client. + +Handles communication with the Prowlarr API for: +- Connection testing +- Indexer listing +- Book search +""" + +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlencode, urljoin + +import requests + +from cwa_book_downloader.core.logger import setup_logger + +logger = setup_logger(__name__) + + +class ProwlarrClient: + """Client for interacting with the Prowlarr API.""" + + def __init__(self, url: str, api_key: str, timeout: int = 30): + """ + Initialize the Prowlarr client. + + Args: + url: Base URL of the Prowlarr instance (e.g., http://prowlarr:9696) + api_key: Prowlarr API key + timeout: Request timeout in seconds + """ + self.base_url = url.rstrip("/") + self.api_key = api_key + self.timeout = timeout + self._session = requests.Session() + self._session.headers.update({ + "X-Api-Key": api_key, + "Accept": "application/json", + }) + + def _request( + self, + method: str, + endpoint: str, + params: Optional[Dict[str, Any]] = None, + json_data: Optional[Dict[str, Any]] = None, + ) -> Any: + """ + Make an API request to Prowlarr. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: API endpoint (e.g., /api/v1/search) + params: Query parameters + json_data: JSON body data + + Returns: + Parsed JSON response + + Raises: + requests.RequestException: On network errors + ValueError: On invalid JSON response + """ + url = urljoin(self.base_url, endpoint) + logger.debug(f"Prowlarr API: {method} {url}") + + try: + response = self._session.request( + method=method, + url=url, + params=params, + json=json_data, + timeout=self.timeout, + ) + + if not response.ok: + try: + error_body = response.text[:500] + logger.error(f"Prowlarr API error response: {error_body}") + except Exception: + pass + + response.raise_for_status() + return response.json() + + except requests.exceptions.JSONDecodeError as e: + logger.error(f"Invalid JSON response from Prowlarr: {e}") + raise ValueError(f"Invalid JSON response: {e}") + except requests.exceptions.HTTPError as e: + logger.error(f"Prowlarr API HTTP error: {e.response.status_code} {e.response.reason}") + raise + except requests.exceptions.RequestException as e: + logger.error(f"Prowlarr API request failed: {e}") + raise + + def test_connection(self) -> Tuple[bool, str]: + """ + Test the connection to Prowlarr. + + Returns: + Tuple of (success: bool, message: str) + """ + logger.info(f"Testing Prowlarr connection to: {self.base_url}") + try: + data = self._request("GET", "/api/v1/system/status") + version = data.get("version", "unknown") + logger.info(f"Prowlarr connection successful: version {version}") + return True, f"Connected to Prowlarr {version}" + except requests.exceptions.ConnectionError: + return False, "Could not connect to Prowlarr. Check the URL." + except requests.exceptions.HTTPError as e: + status = e.response.status_code if e.response is not None else "unknown" + if e.response is not None and e.response.status_code == 401: + return False, "Invalid API key" + return False, f"HTTP error {status}" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + def get_indexers(self) -> List[Dict[str, Any]]: + """ + Get all configured indexers. + + Returns: + List of indexer configurations with id, name, protocol, enabled status + """ + try: + indexers = self._request("GET", "/api/v1/indexer") + return indexers + except Exception as e: + logger.error(f"Failed to get indexers: {e}") + return [] + + def get_enabled_indexers(self) -> List[Dict[str, Any]]: + """ + Get enabled indexers with book-related info. + + Returns: + List of enabled indexers with simplified structure + """ + indexers = self.get_indexers() + result = [] + + for idx in indexers: + if not idx.get("enable", False): + continue + + # Check for book categories + capabilities = idx.get("capabilities", {}) + categories = capabilities.get("categories", []) + has_books = False + + for cat in categories: + cat_id = cat.get("id", 0) + if 7000 <= cat_id <= 7999: + has_books = True + break + for subcat in cat.get("subCategories", []): + if 7000 <= subcat.get("id", 0) <= 7999: + has_books = True + break + + result.append({ + "id": idx.get("id"), + "name": idx.get("name"), + "protocol": idx.get("protocol"), + "has_books": has_books, + }) + + return result + + def search( + self, + query: str, + indexer_ids: Optional[List[int]] = None, + categories: Optional[List[int]] = None, + limit: int = 100, + ) -> List[Dict[str, Any]]: + """ + Search for releases via Prowlarr. + + Args: + query: Search query + indexer_ids: Specific indexer IDs to search (required for targeted search) + categories: Category IDs to filter by (optional) + limit: Maximum number of results to return (default: 100) + + Returns: + List of search results + """ + if not query: + return [] + + # Build query string with repeated params for arrays + query_parts = [f"query={requests.utils.quote(query)}", f"limit={limit}"] + + if indexer_ids: + for idx_id in indexer_ids: + query_parts.append(f"indexerIds={idx_id}") + + if categories: + for cat_id in categories: + query_parts.append(f"categories={cat_id}") + + query_string = "&".join(query_parts) + endpoint = f"/api/v1/search?{query_string}" + + try: + results = self._request("GET", endpoint) + return results if isinstance(results, list) else [] + + except Exception as e: + logger.error(f"Prowlarr search failed: {e}") + return [] diff --git a/cwa_book_downloader/release_sources/prowlarr/cache.py b/cwa_book_downloader/release_sources/prowlarr/cache.py new file mode 100644 index 00000000..074d51ea --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/cache.py @@ -0,0 +1,113 @@ +""" +Prowlarr release cache. + +Stores search results so the handler can look up releases by source_id. +This keeps all Prowlarr-specific data within the plugin. +""" + +import time +from threading import Lock +from typing import Dict, Optional + +from cwa_book_downloader.core.logger import setup_logger + +logger = setup_logger(__name__) + +# Cache TTL in seconds (1 hour - releases should be downloaded within this time) +RELEASE_CACHE_TTL = 3600 + +# Internal cache storage: source_id -> (release_dict, timestamp) +_cache: Dict[str, tuple] = {} +_cache_lock = Lock() + + +def cache_release(source_id: str, release_data: dict) -> None: + """ + Cache a release by its source_id. + + Args: + source_id: The unique identifier for this release (GUID) + release_data: The full Prowlarr API result dict + """ + with _cache_lock: + _cache[source_id] = (release_data, time.time()) + + +def get_release(source_id: str) -> Optional[dict]: + """ + Get a cached release by source_id. + + Args: + source_id: The unique identifier for the release + + Returns: + The cached release dict, or None if not found or expired + """ + with _cache_lock: + if source_id not in _cache: + logger.debug(f"Prowlarr release not in cache: {source_id}") + return None + + release_data, cached_at = _cache[source_id] + age = time.time() - cached_at + + if age > RELEASE_CACHE_TTL: + # Expired - remove from cache + del _cache[source_id] + logger.debug(f"Prowlarr release expired: {source_id}") + return None + + return release_data + + +def remove_release(source_id: str) -> None: + """ + Remove a release from the cache (e.g., after successful download). + + Args: + source_id: The unique identifier for the release + """ + with _cache_lock: + if source_id in _cache: + del _cache[source_id] + logger.debug(f"Removed Prowlarr release from cache: {source_id}") + + +def cleanup_expired() -> int: + """ + Remove all expired entries from the cache. + + Returns: + Number of entries removed + """ + current_time = time.time() + removed = 0 + + with _cache_lock: + expired_ids = [ + source_id + for source_id, (_, cached_at) in _cache.items() + if current_time - cached_at > RELEASE_CACHE_TTL + ] + for source_id in expired_ids: + del _cache[source_id] + removed += 1 + + if removed: + logger.debug(f"Cleaned up {removed} expired Prowlarr cache entries") + + return removed + + +def get_cache_stats() -> dict: + """ + Get cache statistics for debugging. + + Returns: + Dict with cache stats + """ + with _cache_lock: + return { + "size": len(_cache), + "entries": list(_cache.keys()), + } diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/__init__.py b/cwa_book_downloader/release_sources/prowlarr/clients/__init__.py new file mode 100644 index 00000000..d253a966 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/__init__.py @@ -0,0 +1,289 @@ +""" +Download client infrastructure for Prowlarr integration. + +This module provides: +- DownloadState: Enum of valid download states +- DownloadStatus: Status dataclass for external download progress +- DownloadClient: Abstract base class for download clients +- Client registry and factory functions + +Clients register themselves via the @register_client decorator. +""" + +import logging +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Optional, Tuple, Type, Union + +_logger = logging.getLogger(__name__) + + +class DownloadState(Enum): + """Valid states for a download.""" + + DOWNLOADING = "downloading" + COMPLETE = "complete" + ERROR = "error" + SEEDING = "seeding" + PAUSED = "paused" + QUEUED = "queued" + CHECKING = "checking" + PROCESSING = "processing" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class DownloadStatus: + """Status of an external download (immutable).""" + + progress: float # 0-100 + state: Union[DownloadState, str] # Prefer DownloadState enum; strings auto-normalized + message: Optional[str] # Status message + complete: bool # True when download finished + file_path: Optional[str] # Path in client's download dir (when complete) + download_speed: Optional[int] = None # Bytes per second + eta: Optional[int] = None # Seconds remaining + + def __post_init__(self): + """Validate and normalize state.""" + # Normalize string states to enum + if isinstance(self.state, str): + try: + normalized_state = DownloadState(self.state) + object.__setattr__(self, 'state', normalized_state) + except ValueError: + # Unknown state string - keep as-is for backwards compatibility + _logger.warning(f"Unknown download state '{self.state}', keeping as string") + + # Validate progress is in range + if not 0 <= self.progress <= 100: + _logger.debug(f"Progress {self.progress} out of range, clamping to [0, 100]") + object.__setattr__(self, 'progress', max(0, min(100, self.progress))) + + @property + def state_value(self) -> str: + """Get the state as a string value (for JSON serialization).""" + if isinstance(self.state, DownloadState): + return self.state.value + return self.state + + +class DownloadClient(ABC): + """ + Base class for external download clients. + + Subclasses implement protocol-specific download management: + - Torrent clients: qBittorrent, Transmission, Deluge + - Usenet clients: NZBGet, SABnzbd + + Subclasses must define: + - protocol: "torrent" or "usenet" + - name: Unique client identifier (e.g., "qbittorrent", "nzbget") + """ + + # Class attributes that subclasses must define + protocol: str + name: str + + def __init_subclass__(cls, **kwargs): + """Validate that subclasses define required class attributes.""" + super().__init_subclass__(**kwargs) + + # Skip validation for abstract subclasses + if ABC in cls.__bases__: + return + + # Validate protocol attribute + if not hasattr(cls, 'protocol') or not cls.protocol: + raise TypeError(f"{cls.__name__} must define 'protocol' class attribute") + if cls.protocol not in ('torrent', 'usenet'): + raise TypeError( + f"{cls.__name__}.protocol must be 'torrent' or 'usenet', got '{cls.protocol}'" + ) + + # Validate name attribute + if not hasattr(cls, 'name') or not cls.name: + raise TypeError(f"{cls.__name__} must define 'name' class attribute") + + @staticmethod + @abstractmethod + def is_configured() -> bool: + """ + Check if this client is configured. + + Returns: + True if required settings (URL, etc.) are present. + """ + pass + + @abstractmethod + def test_connection(self) -> Tuple[bool, str]: + """ + Test connectivity to the client. + + Returns: + Tuple of (success, message). + """ + pass + + @abstractmethod + def add_download(self, url: str, name: str, category: str = "cwabd") -> str: + """ + Add a download to the client. + + Args: + url: Download URL (magnet link, .torrent URL, or NZB URL) + name: Display name for the download + category: Category/label for organization + + Returns: + Client-specific download ID (hash for torrents, ID for NZBGet). + + Raises: + Exception: If adding fails. + """ + pass + + @abstractmethod + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get status of a download. + + Args: + download_id: The ID returned by add_download() + + Returns: + Current download status. + """ + pass + + @abstractmethod + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a download from the client. + + Args: + download_id: The ID returned by add_download() + delete_files: Whether to also delete downloaded files + + Returns: + True if removal succeeded. + """ + pass + + @abstractmethod + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where files were downloaded. + + Args: + download_id: The ID returned by add_download() + + Returns: + File or directory path, or None if not available. + """ + pass + + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: + """ + Check if a download for this URL already exists in the client. + + This is useful for detecting already-completed downloads so we can + skip re-downloading and just copy the existing file. + + Args: + url: Download URL (magnet link, .torrent URL, or NZB URL) + + Returns: + Tuple of (download_id, status) if found, None if not found. + Default implementation returns None. + """ + return None + + +# Client registry: protocol -> list of client classes +_CLIENTS: Dict[str, List[Type[DownloadClient]]] = {} + + +def register_client(protocol: str): + """ + Decorator to register a download client for a protocol. + + Multiple clients can be registered for the same protocol. + The `is_configured()` method determines which one is active. + + Args: + protocol: The protocol this client handles ("torrent" or "usenet") + + Example: + @register_client("torrent") + class QBittorrentClient(DownloadClient): + ... + """ + + def decorator(cls: Type[DownloadClient]) -> Type[DownloadClient]: + if protocol not in _CLIENTS: + _CLIENTS[protocol] = [] + _CLIENTS[protocol].append(cls) + return cls + + return decorator + + +def get_client(protocol: str) -> Optional[DownloadClient]: + """ + Get a configured client instance for the given protocol. + + Iterates through all registered clients for the protocol and + returns the first one that is configured. + + Args: + protocol: "torrent" or "usenet" + + Returns: + Configured client instance, or None if not available/configured. + """ + if protocol not in _CLIENTS: + return None + + for client_cls in _CLIENTS[protocol]: + if client_cls.is_configured(): + return client_cls() + + return None + + +def list_configured_clients() -> List[str]: + """ + List protocols that have configured clients. + + Returns: + List of protocol names (e.g., ["torrent", "usenet"]). + """ + result = [] + for protocol, client_classes in _CLIENTS.items(): + for cls in client_classes: + if cls.is_configured(): + result.append(protocol) + break + return result + + +def get_all_clients() -> Dict[str, List[Type[DownloadClient]]]: + """ + Get all registered client classes. + + Returns: + Dict of protocol -> list of client classes. + """ + return dict(_CLIENTS) + + +# Import client implementations to trigger registration +# These imports are at the bottom to avoid circular imports +from cwa_book_downloader.release_sources.prowlarr.clients import qbittorrent # noqa: F401, E402 +from cwa_book_downloader.release_sources.prowlarr.clients import nzbget # noqa: F401, E402 +from cwa_book_downloader.release_sources.prowlarr.clients import sabnzbd # noqa: F401, E402 +from cwa_book_downloader.release_sources.prowlarr.clients import transmission # noqa: F401, E402 +from cwa_book_downloader.release_sources.prowlarr.clients import deluge # noqa: F401, E402 diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py b/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py new file mode 100644 index 00000000..3efe1c40 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py @@ -0,0 +1,383 @@ +""" +Deluge download client for Prowlarr integration. + +Uses the deluge-client library to communicate with Deluge's RPC daemon. +Note: Deluge uses a custom binary RPC protocol over TCP (default port 58846, +configurable via DELUGE_PORT), which requires the daemon to have +"Allow Remote Connections" enabled. +""" + +import base64 +from typing import Optional, Tuple + +import requests + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadClient, + DownloadStatus, + register_client, +) +from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + extract_hash_from_magnet, + extract_info_hash_from_torrent, +) + +logger = setup_logger(__name__) + + +@register_client("torrent") +class DelugeClient(DownloadClient): + """Deluge download client using deluge-client RPC library.""" + + protocol = "torrent" + name = "deluge" + + def __init__(self): + """Initialize Deluge client with settings from config.""" + from deluge_client import DelugeRPCClient + + host = config.get("DELUGE_HOST", "localhost") + password = config.get("DELUGE_PASSWORD", "") + + if not host: + raise ValueError("DELUGE_HOST is required") + if not password: + raise ValueError("DELUGE_PASSWORD is required") + + port = int(config.get("DELUGE_PORT", "58846")) + username = config.get("DELUGE_USERNAME", "") + + self._client = DelugeRPCClient( + host=host, + port=port, + username=username, + password=password, + ) + self._connected = False + self._category = config.get("DELUGE_CATEGORY", "cwabd") + + def _ensure_connected(self): + """Ensure we're connected to the Deluge daemon.""" + if not self._connected: + logger.debug("Connecting to Deluge daemon...") + try: + self._client.connect() + self._connected = True + logger.debug("Connected to Deluge daemon") + except Exception as e: + logger.error(f"Failed to connect to Deluge daemon: {type(e).__name__}: {e}") + raise + + @staticmethod + def is_configured() -> bool: + """Check if Deluge is configured and selected as the torrent client.""" + client = config.get("PROWLARR_TORRENT_CLIENT", "") + host = config.get("DELUGE_HOST", "") + password = config.get("DELUGE_PASSWORD", "") + return client == "deluge" and bool(host) and bool(password) + + def test_connection(self) -> Tuple[bool, str]: + """Test connection to Deluge.""" + try: + self._ensure_connected() + # Get daemon info + version = self._client.call('daemon.info') + return True, f"Connected to Deluge {version}" + except Exception as e: + self._connected = False + return False, f"Connection failed: {str(e)}" + + def add_download(self, url: str, name: str, category: str = None) -> str: + """ + Add torrent by URL (magnet or .torrent). + + Args: + url: Magnet link or .torrent URL + name: Display name for the torrent + category: Category for organization (uses configured default if not specified) + + Returns: + Torrent hash (info_hash). + + Raises: + Exception: If adding fails. + """ + try: + self._ensure_connected() + + # Use configured category if not explicitly provided + category = category or self._category + + # Try to extract hash from magnet URL before adding + expected_hash = extract_hash_from_magnet(url) + if expected_hash: + logger.debug(f"Extracted hash from magnet: {expected_hash}") + + is_magnet = url.startswith("magnet:") + logger.debug(f"Adding torrent - URL type: {'magnet' if is_magnet else 'torrent file'}") + + torrent_data = None + + # For non-magnet URLs, fetch the .torrent file + if not is_magnet: + logger.debug(f"Fetching torrent file from: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + torrent_data = resp.content + expected_hash = extract_info_hash_from_torrent(torrent_data) + if expected_hash: + logger.debug(f"Extracted hash from torrent file: {expected_hash}") + else: + logger.warning("Could not extract hash from torrent file") + except Exception as e: + logger.warning(f"Failed to fetch torrent file: {e}") + raise + + # Add options + options = {} + + # Add the torrent + if is_magnet: + # Add magnet link + torrent_id = self._client.call( + 'core.add_torrent_magnet', + url, + options, + ) + else: + # Add from torrent file content (base64 encoded) + filedump = base64.b64encode(torrent_data).decode('ascii') + torrent_id = self._client.call( + 'core.add_torrent_file', + f"{name}.torrent", + filedump, + options, + ) + + if torrent_id: + # Deluge returns bytes, decode to string + if isinstance(torrent_id, bytes): + torrent_id = torrent_id.decode('utf-8') + logger.info(f"Added torrent to Deluge: {torrent_id}") + return torrent_id.lower() + + raise Exception("Deluge returned no torrent ID") + + except Exception as e: + self._connected = False + logger.error(f"Deluge add failed: {e}") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get torrent status by hash. + + Args: + download_id: Torrent info_hash + + Returns: + Current download status. + """ + try: + self._ensure_connected() + + # Get torrent status + status = self._client.call( + 'core.get_torrent_status', + download_id, + ['state', 'progress', 'download_payload_rate', 'eta', 'save_path', 'name'], + ) + + if not status: + return DownloadStatus( + progress=0, + state="error", + message="Torrent not found", + complete=False, + file_path=None, + ) + + # Deluge states: Downloading, Seeding, Paused, Checking, Queued, Error, Moving + state_map = { + 'Downloading': ('downloading', None), + 'Seeding': ('seeding', 'Seeding'), + 'Paused': ('paused', 'Paused'), + 'Checking': ('checking', 'Checking files'), + 'Queued': ('queued', 'Queued'), + 'Error': ('error', 'Error'), + 'Moving': ('processing', 'Moving files'), + 'Allocating': ('downloading', 'Allocating space'), + } + + deluge_state = status.get(b'state', b'Unknown') + if isinstance(deluge_state, bytes): + deluge_state = deluge_state.decode('utf-8') + + state, message = state_map.get(deluge_state, ('unknown', deluge_state)) + progress = status.get(b'progress', 0) + complete = progress >= 100 + + if complete: + message = "Download complete" + + # Get ETA if available and reasonable + eta = status.get(b'eta') + if eta and eta > 604800: # More than 1 week + eta = None + + # Build file path for completed downloads + file_path = None + if complete: + save_path = status.get(b'save_path', b'') + name = status.get(b'name', b'') + if isinstance(save_path, bytes): + save_path = save_path.decode('utf-8') + if isinstance(name, bytes): + name = name.decode('utf-8') + if save_path and name: + file_path = f"{save_path}/{name}" + + return DownloadStatus( + progress=progress, + state="complete" if complete else state, + message=message, + complete=complete, + file_path=file_path, + download_speed=status.get(b'download_payload_rate'), + eta=eta, + ) + + except Exception as e: + self._connected = False + error_type = type(e).__name__ + logger.error(f"Deluge get_status failed ({error_type}): {e}") + return DownloadStatus( + progress=0, + state="error", + message=f"{error_type}: {e}", + complete=False, + file_path=None, + ) + + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a torrent from Deluge. + + Args: + download_id: Torrent info_hash + delete_files: Whether to also delete files + + Returns: + True if successful. + """ + try: + self._ensure_connected() + + result = self._client.call( + 'core.remove_torrent', + download_id, + delete_files, + ) + + if result: + logger.info( + f"Removed torrent from Deluge: {download_id}" + + (" (with files)" if delete_files else "") + ) + return True + return False + + except Exception as e: + self._connected = False + error_type = type(e).__name__ + logger.error(f"Deluge remove failed ({error_type}): {e}") + return False + + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where torrent files are located. + + Args: + download_id: Torrent info_hash + + Returns: + Content path (file or directory), or None. + """ + try: + self._ensure_connected() + + status = self._client.call( + 'core.get_torrent_status', + download_id, + ['save_path', 'name'], + ) + + if status: + save_path = status.get(b'save_path', b'') + name = status.get(b'name', b'') + if isinstance(save_path, bytes): + save_path = save_path.decode('utf-8') + if isinstance(name, bytes): + name = name.decode('utf-8') + if save_path and name: + return f"{save_path}/{name}" + return None + + except Exception as e: + self._connected = False + error_type = type(e).__name__ + logger.debug(f"Deluge get_download_path failed ({error_type}): {e}") + return None + + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: + """ + Check if a torrent for this URL already exists in Deluge. + + Args: + url: Magnet link or .torrent URL + + Returns: + Tuple of (info_hash, status) if found, None if not found. + """ + try: + self._ensure_connected() + + # Try to extract hash from magnet URL + expected_hash = extract_hash_from_magnet(url) + + # If not a magnet, try to fetch and parse the .torrent file + if not expected_hash and not url.startswith("magnet:"): + logger.debug(f"Fetching torrent file to check for existing: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + expected_hash = extract_info_hash_from_torrent(resp.content) + except Exception as e: + logger.debug(f"Could not fetch torrent file: {e}") + return None + + if not expected_hash: + logger.debug("Could not extract hash from URL") + return None + + # Check if this torrent exists in Deluge + status = self._client.call( + 'core.get_torrent_status', + expected_hash, + ['state'], + ) + + if status: + full_status = self.get_status(expected_hash) + logger.debug(f"Found existing torrent in Deluge: {expected_hash} (state: {full_status.state})") + return (expected_hash, full_status) + + return None + + except Exception as e: + self._connected = False + logger.debug(f"Error checking for existing torrent: {e}") + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/nzbget.py b/cwa_book_downloader/release_sources/prowlarr/clients/nzbget.py new file mode 100644 index 00000000..0e463382 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/nzbget.py @@ -0,0 +1,304 @@ +""" +NZBGet download client for Prowlarr integration. + +Uses NZBGet's JSON-RPC API directly via requests (no external dependency). +""" + +from typing import Any, Optional, Tuple + +import requests + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadClient, + DownloadStatus, + register_client, +) + +logger = setup_logger(__name__) + + +@register_client("usenet") +class NZBGetClient(DownloadClient): + """NZBGet download client using JSON-RPC API.""" + + protocol = "usenet" + name = "nzbget" + + def __init__(self): + """Initialize NZBGet client with settings from config.""" + url = config.get("NZBGET_URL", "") + if not url: + raise ValueError("NZBGET_URL is required") + + self.url = url.rstrip("/") + self.username = config.get("NZBGET_USERNAME", "nzbget") + self.password = config.get("NZBGET_PASSWORD", "") + self._category = config.get("NZBGET_CATEGORY", "Books") + + @staticmethod + def is_configured() -> bool: + """Check if NZBGet is configured and selected as the usenet client.""" + client = config.get("PROWLARR_USENET_CLIENT", "") + url = config.get("NZBGET_URL", "") + return client == "nzbget" and bool(url) + + def _rpc_call(self, method: str, params: list = None) -> Any: + """ + Make a JSON-RPC call to NZBGet. + + Args: + method: RPC method name + params: Method parameters + + Returns: + Result from NZBGet. + + Raises: + Exception: If RPC call fails. + """ + rpc_url = f"{self.url}/jsonrpc" + + # Build JSON-RPC 2.0 request + import json + payload = json.dumps({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params or [], + }, separators=(',', ':')) + + response = requests.post( + rpc_url, + data=payload, + headers={"Content-Type": "application/json"}, + auth=(self.username, self.password), + timeout=30, + ) + response.raise_for_status() + + result = response.json() + if "error" in result and result["error"]: + raise Exception(result["error"].get("message", "RPC error")) + + return result.get("result") + + def test_connection(self) -> Tuple[bool, str]: + """Test connection to NZBGet.""" + try: + status = self._rpc_call("status") + version = status.get("Version", "unknown") + return True, f"Connected to NZBGet {version}" + except requests.exceptions.ConnectionError: + return False, "Could not connect to NZBGet" + except requests.exceptions.Timeout: + return False, "Connection timed out" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + def add_download(self, url: str, name: str, category: str = None) -> str: + """ + Add NZB by URL. + + Fetches the NZB content from the URL (e.g., Prowlarr proxy) and sends + it base64-encoded to NZBGet, since NZBGet may not handle redirects well. + + Args: + url: NZB URL (can be Prowlarr proxy URL) + name: Display name for the download + category: Category for organization (uses configured default if not specified) + + Returns: + NZBGet download ID (NZBID). + + Raises: + Exception: If adding fails. + """ + import base64 + + # Use configured category if not explicitly provided + category = category or self._category + + try: + # Fetch NZB content from the URL (handles Prowlarr proxy redirects) + logger.debug(f"Fetching NZB from: {url}") + response = requests.get(url, timeout=30) + response.raise_for_status() + nzb_content = base64.b64encode(response.content).decode('ascii') + + # Ensure filename has .nzb extension + nzb_filename = name if name.endswith('.nzb') else f"{name}.nzb" + + # NZBGet append method parameters (all 10 required): + # NZBFilename, Content, Category, Priority, AddToTop, AddPaused, + # DupeKey, DupeScore, DupeMode, PPParameters + nzb_id = self._rpc_call( + "append", + [ + nzb_filename, # NZBFilename + nzb_content, # Content (base64-encoded NZB) + category, # Category + 0, # Priority (0 = normal) + False, # AddToTop + False, # AddPaused + "", # DupeKey + 0, # DupeScore + "SCORE", # DupeMode + [], # PPParameters (empty array) + ], + ) + + if nzb_id and nzb_id > 0: + logger.info(f"Added NZB to NZBGet: {nzb_id}") + return str(nzb_id) + + raise Exception("NZBGet returned invalid ID") + except requests.RequestException as e: + logger.error(f"Failed to fetch NZB from URL: {e}") + raise Exception(f"Failed to fetch NZB: {e}") + except Exception as e: + logger.error(f"NZBGet add failed: {e}") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get NZB status by ID. + + Args: + download_id: NZBGet NZBID + + Returns: + Current download status. + """ + try: + nzb_id = int(download_id) + + # Check active downloads (queue) + groups = self._rpc_call("listgroups", [0]) + + for group in groups: + if group.get("NZBID") == nzb_id: + # Calculate progress + # NZBGet uses Hi/Lo for 64-bit values on 32-bit systems + file_size = (group.get("FileSizeHi", 0) << 32) + group.get( + "FileSizeLo", 0 + ) + remaining = (group.get("RemainingSizeHi", 0) << 32) + group.get( + "RemainingSizeLo", 0 + ) + + progress = ( + ((file_size - remaining) / file_size * 100) + if file_size > 0 + else 0 + ) + status = group.get("Status", "") + + # Map NZBGet status to our states + if "DOWNLOADING" in status: + state = "downloading" + elif "PAUSED" in status: + state = "paused" + elif "QUEUED" in status: + state = "queued" + elif "POST-PROCESSING" in status or "UNPACKING" in status: + state = "processing" + else: + state = "unknown" + + return DownloadStatus( + progress=progress, + state=state, + message=status, + complete=False, + file_path=None, + download_speed=group.get("DownloadRate"), + eta=( + group.get("RemainingSec") + if group.get("RemainingSec", 0) > 0 + else None + ), + ) + + # Check history for completed downloads + history = self._rpc_call("history", [False]) + + for item in history: + if item.get("NZBID") == nzb_id: + status = item.get("Status", "") + dest_dir = item.get("DestDir", "") + + if "SUCCESS" in status: + return DownloadStatus( + progress=100, + state="complete", + message="Download complete", + complete=True, + file_path=dest_dir, + ) + else: + return DownloadStatus( + progress=100, + state="error", + message=f"Download failed: {status}", + complete=True, + file_path=None, + ) + + # Not found in queue or history + return DownloadStatus( + progress=0, + state="error", + message="Download not found", + complete=False, + file_path=None, + ) + except Exception as e: + error_type = type(e).__name__ + logger.error(f"NZBGet get_status failed ({error_type}): {e}") + return DownloadStatus( + progress=0, + state="error", + message=f"{error_type}: {e}", + complete=False, + file_path=None, + ) + + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a download from NZBGet. + + Args: + download_id: NZBGet NZBID + delete_files: Whether to permanently delete (vs move to history) + + Returns: + True if successful. + """ + try: + nzb_id = int(download_id) + # editqueue params: Command (str), Param (str), IDs (int[]) + # GroupFinalDelete = permanent removal, GroupDelete = move to history + command = "GroupFinalDelete" if delete_files else "GroupDelete" + result = self._rpc_call("editqueue", [command, "", [nzb_id]]) + if result: + logger.info(f"Removed NZB from NZBGet: {download_id}") + return bool(result) + except Exception as e: + error_type = type(e).__name__ + logger.error(f"NZBGet remove failed ({error_type}): {e}") + return False + + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where NZB files are located. + + Args: + download_id: NZBGet NZBID + + Returns: + Destination directory, or None. + """ + status = self.get_status(download_id) + return status.file_path diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py b/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py new file mode 100644 index 00000000..1a23686d --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py @@ -0,0 +1,332 @@ +""" +qBittorrent download client for Prowlarr integration. + +Uses the qbittorrent-api library to communicate with qBittorrent's Web API. +""" + +import time +from typing import Optional, Tuple + +import requests + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadClient, + DownloadStatus, + register_client, +) +from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + extract_hash_from_magnet, + extract_info_hash_from_torrent, +) + +logger = setup_logger(__name__) + + +@register_client("torrent") +class QBittorrentClient(DownloadClient): + """qBittorrent download client.""" + + protocol = "torrent" + name = "qbittorrent" + + def __init__(self): + """Initialize qBittorrent client with settings from config.""" + # Lazy import to avoid dependency issues if not using torrents + from qbittorrentapi import Client + + url = config.get("QBITTORRENT_URL", "") + if not url: + raise ValueError("QBITTORRENT_URL is required") + + self._client = Client( + host=url, + username=config.get("QBITTORRENT_USERNAME", ""), + password=config.get("QBITTORRENT_PASSWORD", ""), + ) + self._category = config.get("QBITTORRENT_CATEGORY", "cwabd") + + @staticmethod + def is_configured() -> bool: + """Check if qBittorrent is configured and selected as the torrent client.""" + client = config.get("PROWLARR_TORRENT_CLIENT", "") + url = config.get("QBITTORRENT_URL", "") + return client == "qbittorrent" and bool(url) + + def test_connection(self) -> Tuple[bool, str]: + """Test connection to qBittorrent.""" + try: + self._client.auth_log_in() + version = self._client.app.version + return True, f"Connected to qBittorrent {version}" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + def add_download(self, url: str, name: str, category: str = None) -> str: + """ + Add torrent by URL (magnet or .torrent). + + Args: + url: Magnet link or .torrent URL + name: Display name for the torrent + category: Category for organization (uses configured default if not specified) + + Returns: + Torrent hash (info_hash). + + Raises: + Exception: If adding fails. + """ + try: + # Use configured category if not explicitly provided + category = category or self._category + + # Ensure category exists (may already exist, which is fine) + try: + self._client.torrents_create_category(name=category) + except Exception as e: + # Conflict409Error means category exists - that's expected + # Log other errors but continue since download may still work + if "Conflict" not in type(e).__name__ and "409" not in str(e): + logger.debug(f"Could not create category '{category}': {type(e).__name__}: {e}") + + # Try to extract hash from magnet URL before adding + expected_hash = extract_hash_from_magnet(url) + if expected_hash: + logger.debug(f"Extracted hash from magnet: {expected_hash}") + + is_magnet = url.startswith("magnet:") + logger.debug(f"Adding torrent - URL type: {'magnet' if is_magnet else 'torrent file'}") + + torrent_data = None + + # For non-magnet URLs, fetch the .torrent file to extract the hash + if not is_magnet and not expected_hash: + logger.debug(f"Fetching torrent file from: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + torrent_data = resp.content + expected_hash = extract_info_hash_from_torrent(torrent_data) + if expected_hash: + logger.debug(f"Extracted hash from torrent file: {expected_hash}") + else: + logger.warning("Could not extract hash from torrent file") + except Exception as e: + logger.warning(f"Failed to fetch torrent file: {e}") + + logger.debug(f"Expected hash: {expected_hash}") + + # Add the torrent - use file content if we have it, otherwise URL + if torrent_data: + result = self._client.torrents_add( + torrent_files=torrent_data, + category=category, + rename=name, + ) + else: + result = self._client.torrents_add( + urls=url, + category=category, + rename=name, + ) + + logger.debug(f"qBittorrent add result: {result}") + + if result == "Ok.": + if expected_hash: + # We know the hash - verify it was added + for attempt in range(10): + torrents = self._client.torrents_info( + torrent_hashes=expected_hash + ) + if torrents: + logger.info(f"Added torrent to qBittorrent: {expected_hash}") + return expected_hash + time.sleep(0.5) + + # qBittorrent said Ok, trust it even if we can't find it yet + logger.warning( + f"qBittorrent returned Ok but torrent not yet visible. " + f"Returning expected hash: {expected_hash}" + ) + return expected_hash + + # No hash available - this shouldn't happen often + raise Exception( + "Could not determine torrent hash. " + "Try using a magnet link instead." + ) + + raise Exception(f"Failed to add torrent: {result}") + except Exception as e: + logger.error(f"qBittorrent add failed: {e}") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get torrent status by hash. + + Args: + download_id: Torrent info_hash + + Returns: + Current download status. + """ + try: + torrents = self._client.torrents_info(torrent_hashes=download_id) + if not torrents: + return DownloadStatus( + progress=0, + state="error", + message="Torrent not found", + complete=False, + file_path=None, + ) + + torrent = torrents[0] + + # Map qBittorrent states to our states and user-friendly messages + state_info = { + "downloading": ("downloading", None), # None = use default progress message + "stalledDL": ("downloading", "Stalled"), + "metaDL": ("downloading", "Fetching metadata"), + "forcedDL": ("downloading", None), + "allocating": ("downloading", "Allocating space"), + "uploading": ("seeding", "Seeding"), + "stalledUP": ("seeding", "Seeding (stalled)"), + "forcedUP": ("seeding", "Seeding"), + "pausedDL": ("paused", "Paused"), + "pausedUP": ("paused", "Paused"), + "queuedDL": ("queued", "Queued"), + "queuedUP": ("queued", "Queued"), + "checkingDL": ("checking", "Checking files"), + "checkingUP": ("checking", "Checking files"), + "checkingResumeData": ("checking", "Checking resume data"), + "moving": ("processing", "Moving files"), + "error": ("error", "Error"), + "missingFiles": ("error", "Missing files"), + "unknown": ("unknown", "Unknown state"), + } + + state, message = state_info.get(torrent.state, ("unknown", torrent.state)) + complete = torrent.progress >= 1.0 + + # For active downloads without a special message, leave message as None + # so the handler can build the progress message + if complete: + message = "Download complete" + + # Only include ETA if it's reasonable (less than 1 week) + eta = torrent.eta if 0 < torrent.eta < 604800 else None + + return DownloadStatus( + progress=torrent.progress * 100, + state="complete" if complete else state, + message=message, + complete=complete, + file_path=torrent.content_path if complete else None, + download_speed=torrent.dlspeed, + eta=eta, + ) + except Exception as e: + error_type = type(e).__name__ + logger.error(f"qBittorrent get_status failed ({error_type}): {e}") + return DownloadStatus( + progress=0, + state="error", + message=f"{error_type}: {e}", + complete=False, + file_path=None, + ) + + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a torrent from qBittorrent. + + Args: + download_id: Torrent info_hash + delete_files: Whether to also delete files + + Returns: + True if successful. + """ + try: + self._client.torrents_delete( + torrent_hashes=download_id, delete_files=delete_files + ) + logger.info( + f"Removed torrent from qBittorrent: {download_id}" + + (" (with files)" if delete_files else "") + ) + return True + except Exception as e: + error_type = type(e).__name__ + logger.error(f"qBittorrent remove failed ({error_type}): {e}") + return False + + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where torrent files are located. + + Args: + download_id: Torrent info_hash + + Returns: + Content path (file or directory), or None. + """ + try: + torrents = self._client.torrents_info(torrent_hashes=download_id) + if torrents: + return torrents[0].content_path + return None + except Exception as e: + error_type = type(e).__name__ + logger.debug(f"qBittorrent get_download_path failed ({error_type}): {e}") + return None + + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: + """ + Check if a torrent for this URL already exists in qBittorrent. + + Extracts the info_hash from the magnet link or .torrent file and + checks if qBittorrent already has this torrent. + + Args: + url: Magnet link or .torrent URL + + Returns: + Tuple of (info_hash, status) if found, None if not found. + """ + try: + # Try to extract hash from magnet URL + expected_hash = extract_hash_from_magnet(url) + + # If not a magnet, try to fetch and parse the .torrent file + if not expected_hash and not url.startswith("magnet:"): + logger.debug(f"Fetching torrent file to check for existing: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + expected_hash = extract_info_hash_from_torrent(resp.content) + except Exception as e: + logger.debug(f"Could not fetch torrent file: {e}") + return None + + if not expected_hash: + logger.debug("Could not extract hash from URL") + return None + + # Check if this torrent exists in qBittorrent + torrents = self._client.torrents_info(torrent_hashes=expected_hash) + if torrents: + status = self.get_status(expected_hash) + logger.debug(f"Found existing torrent in qBittorrent: {expected_hash} (state: {status.state})") + return (expected_hash, status) + + return None + + except Exception as e: + logger.debug(f"Error checking for existing torrent: {e}") + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/sabnzbd.py b/cwa_book_downloader/release_sources/prowlarr/clients/sabnzbd.py new file mode 100644 index 00000000..ee99f024 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/sabnzbd.py @@ -0,0 +1,408 @@ +""" +SABnzbd download client for Prowlarr integration. + +Uses SABnzbd's REST API directly via requests (no external dependency). +""" + +from typing import Any, Optional, Tuple + +import requests + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadClient, + DownloadStatus, + register_client, +) + +logger = setup_logger(__name__) + + +@register_client("usenet") +class SABnzbdClient(DownloadClient): + """SABnzbd download client using REST API.""" + + protocol = "usenet" + name = "sabnzbd" + + def __init__(self): + """Initialize SABnzbd client with settings from config.""" + url = config.get("SABNZBD_URL", "") + if not url: + raise ValueError("SABNZBD_URL is required") + + api_key = config.get("SABNZBD_API_KEY", "") + if not api_key: + raise ValueError("SABNZBD_API_KEY is required") + + self.url = url.rstrip("/") + self.api_key = api_key + self._category = config.get("SABNZBD_CATEGORY", "cwabd") + + @staticmethod + def is_configured() -> bool: + """Check if SABnzbd is configured and selected as the usenet client.""" + client = config.get("PROWLARR_USENET_CLIENT", "") + url = config.get("SABNZBD_URL", "") + api_key = config.get("SABNZBD_API_KEY", "") + return client == "sabnzbd" and bool(url) and bool(api_key) + + def _api_call(self, mode: str, params: dict = None) -> Any: + """ + Make an API call to SABnzbd. + + Args: + mode: API mode (e.g., "version", "addurl", "queue", "history") + params: Additional parameters + + Returns: + JSON response from SABnzbd. + + Raises: + Exception: If API call fails. + """ + api_url = f"{self.url}/api" + + request_params = { + "apikey": self.api_key, + "mode": mode, + "output": "json", + } + if params: + request_params.update(params) + + response = requests.get(api_url, params=request_params, timeout=30) + response.raise_for_status() + + result = response.json() + + # Check for error in response + if isinstance(result, dict) and result.get("status") is False: + error = result.get("error", "Unknown error") + raise Exception(f"SABnzbd error: {error}") + + return result + + def test_connection(self) -> Tuple[bool, str]: + """Test connection to SABnzbd.""" + try: + result = self._api_call("version") + version = result.get("version", "unknown") + return True, f"Connected to SABnzbd {version}" + except requests.exceptions.ConnectionError: + return False, "Could not connect to SABnzbd" + except requests.exceptions.Timeout: + return False, "Connection timed out" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + def add_download(self, url: str, name: str, category: str = None) -> str: + """ + Add NZB by URL. + + Args: + url: NZB URL (can be Prowlarr proxy URL) + name: Display name for the download + category: Category for organization (uses configured default if not specified) + + Returns: + SABnzbd nzo_id. + + Raises: + Exception: If adding fails. + """ + # Use configured category if not explicitly provided + category = category or self._category + + try: + logger.debug(f"Adding NZB to SABnzbd: {name}") + + result = self._api_call( + "addurl", + { + "name": url, + "nzbname": name, + "cat": category, + }, + ) + + # SABnzbd returns {"status": True, "nzo_ids": ["SABnzbd_nzo_xxx"]} + nzo_ids = result.get("nzo_ids", []) + if nzo_ids: + nzo_id = nzo_ids[0] + logger.info(f"Added NZB to SABnzbd: {nzo_id}") + return nzo_id + + raise Exception("SABnzbd returned no nzo_id") + except Exception as e: + logger.error(f"SABnzbd add failed: {e}") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get NZB status by nzo_id. + + Args: + download_id: SABnzbd nzo_id + + Returns: + Current download status. + """ + try: + # Check active queue first + queue_result = self._api_call("queue") + queue = queue_result.get("queue", {}) + slots = queue.get("slots", []) + + for slot in slots: + if slot.get("nzo_id") == download_id: + # Found in queue + status_text = slot.get("status", "").upper() + percentage = float(slot.get("percentage", 0)) + + # Map SABnzbd status to our states + status_mapping = { + "DOWNLOADING": "downloading", + "PAUSED": "paused", + "QUEUED": "queued", + "IDLE": "queued", + "PROPAGATING": "queued", + "FETCHING": "queued", + "GRABBING": "queued", + "VERIFYING": "processing", + "REPAIRING": "processing", + "EXTRACTING": "processing", + "MOVING": "processing", + "RUNNING": "processing", + "FAILED": "error", + } + state = status_mapping.get(status_text, "downloading") + + # Parse ETA (format: "0:01:23" or empty) + eta_str = slot.get("timeleft", "") + eta_seconds = None + if eta_str and eta_str != "0:00:00": + try: + parts = eta_str.split(":") + if len(parts) == 3: + eta_seconds = ( + int(parts[0]) * 3600 + + int(parts[1]) * 60 + + int(parts[2]) + ) + except (ValueError, IndexError): + pass # ETA display is optional + + # Parse speed - prefer kbpersec field (more reliable numeric value) + download_speed = None + kbpersec_str = slot.get("kbpersec", "") + if kbpersec_str: + try: + kbpersec = float(kbpersec_str) + download_speed = int(kbpersec * 1024) # Convert KB/s to bytes/s + except (ValueError, TypeError): + pass # Speed display is optional + + # Fall back to human-readable speed field if kbpersec not available + if download_speed is None: + speed_str = slot.get("speed", "") + if speed_str: + try: + speed_parts = speed_str.split() + if len(speed_parts) >= 2: + speed_val = float(speed_parts[0]) + unit = speed_parts[1].upper() + if "K" in unit: + download_speed = int(speed_val * 1024) + elif "M" in unit: + download_speed = int(speed_val * 1024 * 1024) + elif "G" in unit: + download_speed = int(speed_val * 1024 * 1024 * 1024) + else: + download_speed = int(speed_val) + except (ValueError, IndexError): + pass # Speed display is optional + + return DownloadStatus( + progress=percentage, + state=state, + message=status_text.lower().replace("_", " ").title(), + complete=False, + file_path=None, + download_speed=download_speed, + eta=eta_seconds, + ) + + # Not in queue, check history + history_result = self._api_call("history", {"limit": 100}) + history = history_result.get("history", {}) + history_slots = history.get("slots", []) + + for slot in history_slots: + if slot.get("nzo_id") == download_id: + status_text = slot.get("status", "").upper() + storage = slot.get("storage", "") + + if status_text == "COMPLETED": + return DownloadStatus( + progress=100, + state="complete", + message="Download complete", + complete=True, + file_path=storage, + ) + else: + # Failed or other status + fail_message = slot.get("fail_message", status_text) + return DownloadStatus( + progress=100, + state="error", + message=f"Download failed: {fail_message}", + complete=True, + file_path=None, + ) + + # Not found + return DownloadStatus( + progress=0, + state="error", + message="Download not found", + complete=False, + file_path=None, + ) + except Exception as e: + error_type = type(e).__name__ + logger.error(f"SABnzbd get_status failed ({error_type}): {e}") + return DownloadStatus( + progress=0, + state="error", + message=f"{error_type}: {e}", + complete=False, + file_path=None, + ) + + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a download from SABnzbd. + + Args: + download_id: SABnzbd nzo_id + delete_files: Whether to delete the files + + Returns: + True if successful. + """ + try: + # First try to remove from queue + result = self._api_call( + "queue", + { + "name": "delete", + "value": download_id, + "del_files": 1 if delete_files else 0, + }, + ) + + if result.get("status"): + logger.info(f"Removed NZB from SABnzbd queue: {download_id}") + return True + + # If not in queue, try to remove from history + result = self._api_call( + "history", + { + "name": "delete", + "value": download_id, + "del_files": 1 if delete_files else 0, + }, + ) + + if result.get("status"): + logger.info(f"Removed NZB from SABnzbd history: {download_id}") + return True + + return False + except Exception as e: + error_type = type(e).__name__ + logger.error(f"SABnzbd remove failed ({error_type}): {e}") + return False + + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where NZB files are located. + + Args: + download_id: SABnzbd nzo_id + + Returns: + Storage directory, or None. + """ + status = self.get_status(download_id) + return status.file_path + + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: + """ + Check if an NZB for this URL already exists in SABnzbd. + + Note: Unlike torrents which have a unique info_hash, usenet NZBs don't have + a universal unique identifier. SABnzbd generates an nzo_id when adding, + but there's no way to derive it from the URL. This method searches by + NZB name extracted from the URL, which may not always be accurate. + + Args: + url: NZB URL + + Returns: + Tuple of (nzo_id, status) if found, None if not found. + """ + try: + # Extract NZB name from URL (last path component without extension) + from urllib.parse import unquote, urlparse + parsed = urlparse(url) + path = unquote(parsed.path) + + # Get filename from path + if "/" in path: + filename = path.rsplit("/", 1)[-1] + else: + filename = path + + # Remove common NZB extensions + for ext in [".nzb", ".nzb.gz"]: + if filename.lower().endswith(ext): + filename = filename[:-len(ext)] + break + + if not filename: + return None + + # Search queue + queue_result = self._api_call("queue") + queue = queue_result.get("queue", {}) + for slot in queue.get("slots", []): + slot_name = slot.get("filename", "") + if filename.lower() in slot_name.lower(): + nzo_id = slot.get("nzo_id") + if nzo_id: + status = self.get_status(nzo_id) + logger.debug(f"Found existing NZB in SABnzbd queue: {nzo_id}") + return (nzo_id, status) + + # Search history + history_result = self._api_call("history", {"limit": 100}) + history = history_result.get("history", {}) + for slot in history.get("slots", []): + slot_name = slot.get("name", "") + if filename.lower() in slot_name.lower(): + nzo_id = slot.get("nzo_id") + if nzo_id: + status = self.get_status(nzo_id) + logger.debug(f"Found existing NZB in SABnzbd history: {nzo_id}") + return (nzo_id, status) + + return None + + except Exception as e: + logger.debug(f"Error checking for existing NZB: {e}") + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py b/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py new file mode 100644 index 00000000..fd1902ad --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py @@ -0,0 +1,214 @@ +""" +Shared utilities for torrent clients. + +Provides: +- Bencode encoding/decoding for .torrent files +- Info hash extraction from torrent files and magnet links +- URL parsing utilities for torrent clients + +Bencode is the encoding used by BitTorrent for .torrent files. +See BEP-3: http://bittorrent.org/beps/bep_0003.html +""" + +import base64 +import hashlib +import re +from typing import Optional, Tuple +from urllib.parse import parse_qs, urlparse + +from cwa_book_downloader.core.logger import setup_logger + +logger = setup_logger(__name__) + + +def parse_transmission_url(url: str) -> Tuple[str, int, str]: + """ + Parse a Transmission URL into host, port, and RPC path. + + Handles various URL formats and ensures the path ends with /rpc. + + Args: + url: Transmission URL (e.g., "http://transmission:9091" or + "http://localhost:9091/transmission/rpc") + + Returns: + Tuple of (host, port, path) for transmission-rpc Client. + - host: Hostname (defaults to "localhost" if not specified) + - port: Port number (defaults to 9091 if not specified) + - path: RPC path (ensures it ends with "/rpc") + """ + parsed = urlparse(url) + host = parsed.hostname or "localhost" + port = parsed.port or 9091 + path = parsed.path or "/transmission/rpc" + + # Ensure path ends with /rpc + if not path.endswith("/rpc"): + path = path.rstrip("/") + "/transmission/rpc" + + return host, port, path + + +def bencode_decode(data: bytes) -> tuple: + """ + Decode bencoded data. + + Bencode format: + - 'ie' for integers + - ':' for byte strings + - 'le' for lists + - 'de' for dicts (keys must be byte strings, sorted) + + Args: + data: Bencoded bytes + + Returns: + Tuple of (decoded_value, remaining_bytes) + + Raises: + ValueError: If data is not valid bencode + """ + if data[0:1] == b'd': + # Dictionary + result = {} + data = data[1:] + while data[0:1] != b'e': + key, data = bencode_decode(data) + value, data = bencode_decode(data) + result[key] = value + return result, data[1:] + elif data[0:1] == b'l': + # List + result = [] + data = data[1:] + while data[0:1] != b'e': + value, data = bencode_decode(data) + result.append(value) + return result, data[1:] + elif data[0:1] == b'i': + # Integer + end = data.index(b'e') + return int(data[1:end]), data[end + 1:] + elif data[0:1].isdigit(): + # Byte string + colon = data.index(b':') + length = int(data[:colon]) + start = colon + 1 + return data[start:start + length], data[start + length:] + else: + first_byte = data[0:1] + raise ValueError( + f"Invalid bencode data: expected 'd', 'l', 'i', or digit, " + f"got {first_byte!r}. First 20 bytes: {data[:20]!r}" + ) + + +def bencode_encode(data) -> bytes: + """ + Encode data to bencode format. + + Args: + data: Python object (dict, list, int, bytes, or str) + + Returns: + Bencoded bytes + + Raises: + ValueError: If data type cannot be bencoded + """ + if isinstance(data, dict): + # Keys must be sorted (bencode spec requirement) + result = b'd' + for key in sorted(data.keys()): + result += bencode_encode(key) + result += bencode_encode(data[key]) + result += b'e' + return result + elif isinstance(data, list): + result = b'l' + for item in data: + result += bencode_encode(item) + result += b'e' + return result + elif isinstance(data, int): + return f'i{data}e'.encode() + elif isinstance(data, bytes): + return f'{len(data)}:'.encode() + data + elif isinstance(data, str): + encoded = data.encode('utf-8') + return f'{len(encoded)}:'.encode() + encoded + else: + raise ValueError( + f"Cannot bencode type {type(data).__name__}: " + f"expected dict, list, int, bytes, or str. Value: {data!r}" + ) + + +def extract_info_hash_from_torrent(torrent_data: bytes) -> Optional[str]: + """ + Extract info_hash from raw .torrent file data. + + The info_hash is the SHA1 hash of the bencoded 'info' dictionary, + which uniquely identifies a torrent in the BitTorrent network. + + Args: + torrent_data: Raw bytes of a .torrent file + + Returns: + 40-character lowercase hex string of the info_hash, or None if extraction fails + """ + try: + decoded, _ = bencode_decode(torrent_data) + if b'info' not in decoded: + return None + + # Re-encode info dict to get canonical bytes for hashing + info_dict = decoded[b'info'] + info_bencoded = bencode_encode(info_dict) + + # SHA1 hash is required by BitTorrent spec (BEP-3) + return hashlib.sha1(info_bencoded).hexdigest().lower() + except Exception as e: + logger.debug(f"Failed to parse torrent file: {e}") + return None + + +def extract_hash_from_magnet(magnet_url: str) -> Optional[str]: + """ + Extract info_hash from a magnet URL. + + Magnet URIs contain the info_hash in the 'xt' (exact topic) parameter + as either a 40-character hex string or 32-character base32 string. + + Args: + magnet_url: Magnet URI string + + Returns: + 40-character lowercase hex string of the info_hash, or None if extraction fails + """ + if not magnet_url.startswith("magnet:"): + return None + + parsed = urlparse(magnet_url) + params = parse_qs(parsed.query) + + # Get the xt (exact topic) parameter + xt_list = params.get("xt", []) + for xt in xt_list: + # Format: urn:btih: + # Hash can be 40 hex chars or 32 base32 chars + match = re.match(r"urn:btih:([a-fA-F0-9]{40}|[a-zA-Z2-7]{32})", xt) + if match: + hash_value = match.group(1) + # Convert base32 to hex if needed (32 chars = base32, 40 chars = hex) + if len(hash_value) == 32: + try: + decoded = base64.b32decode(hash_value.upper()) + return decoded.hex().lower() + except Exception as e: + logger.debug( + f"Base32 decode failed for hash '{hash_value}': {e}, " + f"treating as hex value" + ) + return hash_value.lower() + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py b/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py new file mode 100644 index 00000000..1467f5c2 --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py @@ -0,0 +1,324 @@ +""" +Transmission download client for Prowlarr integration. + +Uses the transmission-rpc library to communicate with Transmission's RPC API. +""" + +from typing import Optional, Tuple + +import requests + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadClient, + DownloadStatus, + register_client, +) +from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + extract_hash_from_magnet, + extract_info_hash_from_torrent, + parse_transmission_url, +) + +logger = setup_logger(__name__) + + +@register_client("torrent") +class TransmissionClient(DownloadClient): + """Transmission download client using transmission-rpc library.""" + + protocol = "torrent" + name = "transmission" + + def __init__(self): + """Initialize Transmission client with settings from config.""" + from transmission_rpc import Client + + url = config.get("TRANSMISSION_URL", "") + if not url: + raise ValueError("TRANSMISSION_URL is required") + + username = config.get("TRANSMISSION_USERNAME", "") + password = config.get("TRANSMISSION_PASSWORD", "") + + # Parse URL to extract host, port, and path + host, port, path = parse_transmission_url(url) + + self._client = Client( + host=host, + port=port, + path=path, + username=username if username else None, + password=password if password else None, + ) + self._category = config.get("TRANSMISSION_CATEGORY", "cwabd") + + @staticmethod + def is_configured() -> bool: + """Check if Transmission is configured and selected as the torrent client.""" + client = config.get("PROWLARR_TORRENT_CLIENT", "") + url = config.get("TRANSMISSION_URL", "") + return client == "transmission" and bool(url) + + def test_connection(self) -> Tuple[bool, str]: + """Test connection to Transmission.""" + try: + session = self._client.get_session() + version = session.version + return True, f"Connected to Transmission {version}" + except Exception as e: + return False, f"Connection failed: {str(e)}" + + def add_download(self, url: str, name: str, category: str = None) -> str: + """ + Add torrent by URL (magnet or .torrent). + + Args: + url: Magnet link or .torrent URL + name: Display name for the torrent + category: Category for organization (uses configured default if not specified) + + Returns: + Torrent hash (info_hash). + + Raises: + Exception: If adding fails. + """ + try: + # Use configured category if not explicitly provided + category = category or self._category + + # Try to extract hash from magnet URL before adding + expected_hash = extract_hash_from_magnet(url) + if expected_hash: + logger.debug(f"Extracted hash from magnet: {expected_hash}") + + is_magnet = url.startswith("magnet:") + logger.debug(f"Adding torrent - URL type: {'magnet' if is_magnet else 'torrent file'}") + + torrent_data = None + + # For non-magnet URLs, fetch the .torrent file to extract the hash + if not is_magnet and not expected_hash: + logger.debug(f"Fetching torrent file from: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + torrent_data = resp.content + expected_hash = extract_info_hash_from_torrent(torrent_data) + if expected_hash: + logger.debug(f"Extracted hash from torrent file: {expected_hash}") + else: + logger.warning("Could not extract hash from torrent file") + except Exception as e: + logger.warning(f"Failed to fetch torrent file: {e}") + + logger.debug(f"Expected hash: {expected_hash}") + + # Add the torrent + if torrent_data: + # Add from torrent file content (pass raw bytes, library handles encoding) + torrent = self._client.add_torrent( + torrent=torrent_data, + labels=[category], + ) + else: + # Add from URL or magnet + torrent = self._client.add_torrent( + torrent=url, + labels=[category], + ) + + # Get the hash from the returned torrent + torrent_hash = torrent.hashString.lower() + logger.info(f"Added torrent to Transmission: {torrent_hash}") + + # Verify hash matches if we extracted one + if expected_hash and torrent_hash != expected_hash: + logger.warning( + f"Hash mismatch: expected {expected_hash}, got {torrent_hash}" + ) + + return torrent_hash + + except Exception as e: + logger.error(f"Transmission add failed: {e}") + raise + + def get_status(self, download_id: str) -> DownloadStatus: + """ + Get torrent status by hash. + + Args: + download_id: Torrent info_hash + + Returns: + Current download status. + """ + try: + torrent = self._client.get_torrent(download_id) + + # Transmission status values: + # 0: stopped + # 1: check pending + # 2: checking + # 3: download pending + # 4: downloading + # 5: seed pending + # 6: seeding + # torrent.status is an enum with .value as string + status_value = torrent.status.value if hasattr(torrent.status, 'value') else str(torrent.status) + status_map = { + "stopped": ("paused", "Paused"), + "check pending": ("checking", "Waiting to check"), + "checking": ("checking", "Checking files"), + "download pending": ("queued", "Waiting to download"), + "downloading": ("downloading", "Downloading"), + "seed pending": ("processing", "Moving files"), + "seeding": ("seeding", "Seeding"), + } + + state, message = status_map.get(status_value, ("downloading", "Downloading")) + progress = torrent.percent_done * 100 + # Only mark complete when seeding - seed pending means files still being moved + complete = progress >= 100 and status_value == "seeding" + + if complete: + message = "Download complete" + + # Get ETA if available and reasonable (less than 1 week) + eta = None + if hasattr(torrent, 'eta') and torrent.eta: + eta_seconds = torrent.eta.total_seconds() + if 0 < eta_seconds < 604800: + eta = int(eta_seconds) + + # Get download speed + download_speed = torrent.rate_download if hasattr(torrent, 'rate_download') else None + + # Get file path for completed downloads + file_path = None + if complete: + download_dir = torrent.download_dir + name = torrent.name + file_path = f"{download_dir}/{name}" + + return DownloadStatus( + progress=progress, + state="complete" if complete else state, + message=message, + complete=complete, + file_path=file_path, + download_speed=download_speed, + eta=eta, + ) + + except KeyError: + # Torrent not found + return DownloadStatus( + progress=0, + state="error", + message="Torrent not found", + complete=False, + file_path=None, + ) + except Exception as e: + error_type = type(e).__name__ + logger.error(f"Transmission get_status failed ({error_type}): {e}") + return DownloadStatus( + progress=0, + state="error", + message=f"{error_type}: {e}", + complete=False, + file_path=None, + ) + + def remove(self, download_id: str, delete_files: bool = False) -> bool: + """ + Remove a torrent from Transmission. + + Args: + download_id: Torrent info_hash + delete_files: Whether to also delete files + + Returns: + True if successful. + """ + try: + self._client.remove_torrent( + download_id, + delete_data=delete_files, + ) + logger.info( + f"Removed torrent from Transmission: {download_id}" + + (" (with files)" if delete_files else "") + ) + return True + except Exception as e: + error_type = type(e).__name__ + logger.error(f"Transmission remove failed ({error_type}): {e}") + return False + + def get_download_path(self, download_id: str) -> Optional[str]: + """ + Get the path where torrent files are located. + + Args: + download_id: Torrent info_hash + + Returns: + Content path (file or directory), or None. + """ + try: + torrent = self._client.get_torrent(download_id) + download_dir = torrent.download_dir + name = torrent.name + return f"{download_dir}/{name}" + except Exception as e: + error_type = type(e).__name__ + logger.debug(f"Transmission get_download_path failed ({error_type}): {e}") + return None + + def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]: + """ + Check if a torrent for this URL already exists in Transmission. + + Args: + url: Magnet link or .torrent URL + + Returns: + Tuple of (info_hash, status) if found, None if not found. + """ + try: + # Try to extract hash from magnet URL + expected_hash = extract_hash_from_magnet(url) + + # If not a magnet, try to fetch and parse the .torrent file + if not expected_hash and not url.startswith("magnet:"): + logger.debug(f"Fetching torrent file to check for existing: {url[:80]}...") + try: + resp = requests.get(url, timeout=30) + resp.raise_for_status() + expected_hash = extract_info_hash_from_torrent(resp.content) + except Exception as e: + logger.debug(f"Could not fetch torrent file: {e}") + return None + + if not expected_hash: + logger.debug("Could not extract hash from URL") + return None + + # Check if this torrent exists in Transmission + try: + torrent = self._client.get_torrent(expected_hash) + status = self.get_status(expected_hash) + logger.debug(f"Found existing torrent in Transmission: {expected_hash} (state: {status.state})") + return (expected_hash, status) + except KeyError: + # Torrent not found + return None + + except Exception as e: + logger.debug(f"Error checking for existing torrent: {e}") + return None diff --git a/cwa_book_downloader/release_sources/prowlarr/handler.py b/cwa_book_downloader/release_sources/prowlarr/handler.py new file mode 100644 index 00000000..7db0c22f --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/handler.py @@ -0,0 +1,321 @@ +""" +Prowlarr download handler. + +Handles downloads from Prowlarr via external download clients. +Supported torrent clients: qBittorrent, Transmission, Deluge. +Supported usenet clients: NZBGet, SABnzbd. +""" + +import shutil +from pathlib import Path +from threading import Event +from typing import Callable, Optional + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.core.models import DownloadTask +from cwa_book_downloader.release_sources import DownloadHandler, register_handler +from cwa_book_downloader.release_sources.prowlarr.cache import get_release, remove_release +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadState, + get_client, + list_configured_clients, +) + +logger = setup_logger(__name__) + +# How often to poll the download client for status (seconds) +POLL_INTERVAL = 2 + + +def _determine_protocol(result: dict) -> str: + """Determine download protocol from Prowlarr result.""" + # Prowlarr provides protocol directly - just use it + protocol = result.get("protocol", "").lower() + if protocol == "torrent": + return "torrent" + if protocol == "usenet": + return "usenet" + return "unknown" + + +@register_handler("prowlarr") +class ProwlarrHandler(DownloadHandler): + """Handler for Prowlarr downloads via configured torrent or usenet client.""" + + def download( + self, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """ + Execute a Prowlarr download. + + Args: + task: Download task with task_id (Prowlarr source_id/GUID) + cancel_flag: Event to check for cancellation + progress_callback: Called with progress percentage (0-100) + status_callback: Called with (status, message) for status updates + + Returns: + Path to downloaded file if successful, None otherwise + """ + try: + # Look up the cached release + prowlarr_result = get_release(task.task_id) + if not prowlarr_result: + status_callback("error", "Release not found in cache (may have expired)") + return None + + # Extract download URL + download_url = prowlarr_result.get("downloadUrl") or prowlarr_result.get("magnetUrl") + if not download_url: + status_callback("error", "No download URL available") + return None + + # Determine protocol + protocol = _determine_protocol(prowlarr_result) + if protocol == "unknown": + status_callback("error", "Could not determine download protocol") + return None + + # Get the appropriate download client + client = get_client(protocol) + if not client: + configured = list_configured_clients() + if not configured: + status_callback("error", "No download clients configured. Configure qBittorrent or NZBGet in settings.") + else: + status_callback("error", f"No {protocol} client configured") + return None + + # Check if this download already exists in the client + status_callback("resolving", f"Checking {client.name}...") + existing = client.find_existing(download_url) + + if existing: + download_id, existing_status = existing + logger.info(f"Found existing download in {client.name}: {download_id}") + + # If already complete, skip straight to file handling + if existing_status.complete: + logger.info(f"Existing download is complete, copying file directly") + status_callback("processing", "Found existing download, copying to library...") + + source_path = client.get_download_path(download_id) + if not source_path: + status_callback("error", "Could not locate existing download file") + return None + + result = self._handle_completed_file( + source_path=Path(source_path), + protocol=protocol, + task=task, + status_callback=status_callback, + ) + + if result: + remove_release(task.task_id) + return result + + # Existing but still downloading - join the progress polling + logger.info(f"Existing download in progress, joining poll loop") + status_callback("downloading", f"Resuming existing download...") + else: + # No existing download - add new + status_callback("resolving", f"Sending to {client.name}...") + try: + download_id = client.add_download( + url=download_url, + name=task.title or "Unknown", + ) + except Exception as e: + logger.error(f"Failed to add to {client.name}: {e}") + status_callback("error", f"Failed to add to {client.name}: {e}") + return None + + logger.info(f"Added to {client.name}: {download_id} for '{task.title}'") + + # Poll for progress + return self._poll_and_complete( + client=client, + download_id=download_id, + protocol=protocol, + task=task, + cancel_flag=cancel_flag, + progress_callback=progress_callback, + status_callback=status_callback, + ) + + except Exception as e: + logger.error(f"Prowlarr download error: {e}") + status_callback("error", str(e)) + return None + + def _poll_and_complete( + self, + client, + download_id: str, + protocol: str, + task: DownloadTask, + cancel_flag: Event, + progress_callback: Callable[[float], None], + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Poll the download client for progress and handle completion.""" + try: + while not cancel_flag.is_set(): + status = client.get_status(download_id) + progress_callback(status.progress) + + # Check for completion + if status.complete: + if status.state == DownloadState.ERROR: + status_callback("error", status.message or "Download failed") + return None + # Download complete - break to handle file + break + + # Check for error state + if status.state == DownloadState.ERROR: + status_callback("error", status.message or "Download failed") + client.remove(download_id, delete_files=True) + return None + + # Build status message + # If client provided a specific message (e.g., "Stalled", "Fetching metadata"), + # use that. Otherwise, build a progress message. + if status.message: + msg = status.message + else: + msg = f"{status.progress:.0f}%" + if status.download_speed and status.download_speed > 0: + speed_mb = status.download_speed / 1024 / 1024 + msg += f" ({speed_mb:.1f} MB/s)" + if status.eta and status.eta > 0: + if status.eta < 60: + msg += f" - {status.eta}s left" + elif status.eta < 3600: + msg += f" - {status.eta // 60}m left" + else: + msg += f" - {status.eta // 3600}h {(status.eta % 3600) // 60}m left" + + status_callback("downloading", msg) + + # Wait for next poll (interruptible by cancel) + if cancel_flag.wait(timeout=POLL_INTERVAL): + break + + # Handle cancellation + if cancel_flag.is_set(): + logger.info(f"Download cancelled, removing from {client.name}: {download_id}") + client.remove(download_id, delete_files=True) + status_callback("cancelled", "Cancelled") + return None + + # Handle completed file + source_path = client.get_download_path(download_id) + if not source_path: + status_callback("error", "Could not locate downloaded file") + return None + + result = self._handle_completed_file( + source_path=Path(source_path), + protocol=protocol, + task=task, + status_callback=status_callback, + ) + + # Clean up cache on success + if result: + remove_release(task.task_id) + + return result + + except Exception as e: + logger.error(f"Error during download polling: {e}") + status_callback("error", str(e)) + try: + client.remove(download_id, delete_files=True) + except Exception as cleanup_error: + logger.error(f"Failed to cleanup download {download_id} after error: {cleanup_error}") + return None + + def _handle_completed_file( + self, + source_path: Path, + protocol: str, + task: DownloadTask, + status_callback: Callable[[str, Optional[str]], None], + ) -> Optional[str]: + """Stage completed download for orchestrator post-processing. + + For directories (multi-file torrents), copies the entire directory. + The orchestrator will find and filter book files. + """ + try: + status_callback("processing", "Staging file...") + + # Torrents: copy to preserve seeding. Usenet: configurable. + if protocol == "torrent": + use_copy = True + else: + use_copy = config.get("PROWLARR_USENET_ACTION", "move") == "copy" + + from cwa_book_downloader.download.orchestrator import get_staging_dir + staging_dir = get_staging_dir() + + if source_path.is_dir(): + # Multi-file download: stage entire directory + # Orchestrator will extract book files + staged_path = staging_dir / source_path.name + if staged_path.exists(): + counter = 1 + while staged_path.exists(): + staged_path = staging_dir / f"{source_path.name}_{counter}" + counter += 1 + + if use_copy: + shutil.copytree(str(source_path), str(staged_path)) + else: + shutil.move(str(source_path), str(staged_path)) + logger.debug(f"Staged directory: {staged_path.name}") + else: + # Single file download + staged_path = staging_dir / source_path.name + if staged_path.exists(): + counter = 1 + while staged_path.exists(): + staged_path = staging_dir / f"{source_path.stem}_{counter}{source_path.suffix}" + counter += 1 + + if use_copy: + shutil.copy2(str(source_path), str(staged_path)) + else: + shutil.move(str(source_path), str(staged_path)) + logger.debug(f"Staged: {staged_path.name}") + + return str(staged_path) + + except PermissionError as e: + logger.error(f"Permission denied staging file: {e}") + status_callback("error", f"Permission denied: {e}") + return None + except Exception as e: + logger.error(f"Staging failed: {e}") + status_callback("error", f"Failed to stage file: {e}") + return None + + def cancel(self, task_id: str) -> bool: + """ + Cancel an in-progress download. + + Note: Actual cancellation is handled via the cancel_flag in download(). + This method is for cleanup if the cancel_flag mechanism fails. + """ + logger.debug(f"Cancel requested for Prowlarr task: {task_id}") + # Remove from cache if present + remove_release(task_id) + return True diff --git a/cwa_book_downloader/release_sources/prowlarr/settings.py b/cwa_book_downloader/release_sources/prowlarr/settings.py new file mode 100644 index 00000000..227342fd --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/settings.py @@ -0,0 +1,582 @@ +""" +Prowlarr settings registration. + +Registers Prowlarr settings as a group with multiple tabs: +- Configuration: Prowlarr connection settings + indexer selection +- Download Clients: Torrent and usenet client settings +""" + +from typing import Any, Dict, List + +from cwa_book_downloader.core.settings_registry import ( + register_group, + register_settings, + CheckboxField, + HeadingField, + TextField, + PasswordField, + ActionButton, + SelectField, + MultiSelectField, +) + + +# ==================== Dynamic Options Loaders ==================== + +def _get_indexer_options() -> List[Dict[str, str]]: + """ + Fetch available indexers from Prowlarr for the multi-select field. + + Returns list of {value: "id", label: "name (protocol)"} options. + """ + from cwa_book_downloader.core.config import config + from cwa_book_downloader.core.logger import setup_logger + + logger = setup_logger(__name__) + + url = config.get("PROWLARR_URL", "") + api_key = config.get("PROWLARR_API_KEY", "") + + if not url or not api_key: + return [] + + try: + from cwa_book_downloader.release_sources.prowlarr.api import ProwlarrClient + + client = ProwlarrClient(url, api_key) + indexers = client.get_enabled_indexers() + + options = [] + for idx in indexers: + idx_id = idx.get("id") + name = idx.get("name", "Unknown") + protocol = idx.get("protocol", "") + has_books = idx.get("has_books", False) + + # Add indicator for book support + label = f"{name} ({protocol})" + if has_books: + label += " 📚" + + options.append({ + "value": str(idx_id), + "label": label, + }) + + return options + + except Exception as e: + logger.error(f"Failed to fetch Prowlarr indexers: {e}") + return [] + + +# ==================== Test Connection Callbacks ==================== + +def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the Prowlarr connection using current form values.""" + from cwa_book_downloader.core.config import config + from cwa_book_downloader.core.logger import setup_logger + from cwa_book_downloader.release_sources.prowlarr.api import ProwlarrClient + + logger = setup_logger(__name__) + current_values = current_values or {} + + url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "") + api_key = current_values.get("PROWLARR_API_KEY") or config.get("PROWLARR_API_KEY", "") + + if not url: + return {"success": False, "message": "Prowlarr URL is required"} + if not api_key: + return {"success": False, "message": "API key is required"} + + try: + client = ProwlarrClient(url, api_key) + success, message = client.test_connection() + return {"success": success, "message": message} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_qbittorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the qBittorrent connection using current form values.""" + from cwa_book_downloader.core.config import config + + current_values = current_values or {} + + url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "") + username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "") + password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "") + + if not url: + return {"success": False, "message": "qBittorrent URL is required"} + + try: + from qbittorrentapi import Client + + client = Client(host=url, username=username, password=password) + client.auth_log_in() + version = client.app.version + return {"success": True, "message": f"Connected to qBittorrent {version}"} + except ImportError: + return {"success": False, "message": "qbittorrent-api package not installed"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the Transmission connection using current form values.""" + from cwa_book_downloader.core.config import config + from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + parse_transmission_url, + ) + + current_values = current_values or {} + + url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") + username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "") + password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") + + if not url: + return {"success": False, "message": "Transmission URL is required"} + + try: + from transmission_rpc import Client + + # Parse URL to extract host, port, and path + host, port, path = parse_transmission_url(url) + + client = Client( + host=host, + port=port, + path=path, + username=username if username else None, + password=password if password else None, + ) + session = client.get_session() + version = session.version + return {"success": True, "message": f"Connected to Transmission {version}"} + except ImportError: + return {"success": False, "message": "transmission-rpc package not installed"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the Deluge connection using current form values.""" + from cwa_book_downloader.core.config import config + + current_values = current_values or {} + + host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost") + port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "58846") + username = current_values.get("DELUGE_USERNAME") or config.get("DELUGE_USERNAME", "") + password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "") + + if not host: + return {"success": False, "message": "Deluge host is required"} + if not password: + return {"success": False, "message": "Deluge password is required"} + + try: + from deluge_client import DelugeRPCClient + + client = DelugeRPCClient( + host=host, + port=int(port), + username=username, + password=password, + ) + client.connect() + version = client.call('daemon.info') + return {"success": True, "message": f"Connected to Deluge {version}"} + except ImportError: + return {"success": False, "message": "deluge-client package not installed"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the NZBGet connection using current form values.""" + import requests + from cwa_book_downloader.core.config import config + + current_values = current_values or {} + + url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "") + username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget") + password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "") + + if not url: + return {"success": False, "message": "NZBGet URL is required"} + + try: + rpc_url = f"{url.rstrip('/')}/jsonrpc" + payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} + response = requests.post(rpc_url, json=payload, auth=(username, password), timeout=30) + response.raise_for_status() + result = response.json() + if "error" in result and result["error"]: + raise Exception(result["error"].get("message", "RPC error")) + version = result.get("result", {}).get("Version", "unknown") + return {"success": True, "message": f"Connected to NZBGet {version}"} + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to NZBGet"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +def _test_sabnzbd_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: + """Test the SABnzbd connection using current form values.""" + import requests + from cwa_book_downloader.core.config import config + + current_values = current_values or {} + + url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "") + api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "") + + if not url: + return {"success": False, "message": "SABnzbd URL is required"} + if not api_key: + return {"success": False, "message": "API key is required"} + + try: + api_url = f"{url.rstrip('/')}/api" + params = {"apikey": api_key, "mode": "version", "output": "json"} + response = requests.get(api_url, params=params, timeout=30) + response.raise_for_status() + result = response.json() + version = result.get("version", "unknown") + return {"success": True, "message": f"Connected to SABnzbd {version}"} + except requests.exceptions.ConnectionError: + return {"success": False, "message": "Could not connect to SABnzbd"} + except requests.exceptions.Timeout: + return {"success": False, "message": "Connection timed out"} + except Exception as e: + return {"success": False, "message": f"Connection failed: {str(e)}"} + + +# ==================== Register Group ==================== + +register_group( + name="prowlarr", + display_name="Prowlarr", + icon="download", + order=40, +) + + +# ==================== Configuration Tab ==================== + +@register_settings( + name="prowlarr_config", + display_name="Configuration", + order=41, + group="prowlarr", +) +def prowlarr_config_settings(): + """Prowlarr connection and indexer settings.""" + return [ + HeadingField( + key="prowlarr_heading", + title="Prowlarr Integration", + description="Search for books across your indexers via Prowlarr.", + link_url="https://prowlarr.com", + link_text="prowlarr.com", + ), + CheckboxField( + key="PROWLARR_ENABLED", + label="Enable Prowlarr source", + default=False, + description="Enable searching for books via Prowlarr indexers", + ), + TextField( + key="PROWLARR_URL", + label="Prowlarr URL", + description="Base URL of your Prowlarr instance", + placeholder="http://prowlarr:9696", + required=True, + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), + PasswordField( + key="PROWLARR_API_KEY", + label="API Key", + description="Found in Prowlarr: Settings > General > API Key", + required=True, + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), + ActionButton( + key="test_prowlarr", + label="Test Connection", + description="Verify your Prowlarr configuration", + style="primary", + callback=_test_prowlarr_connection, + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), + MultiSelectField( + key="PROWLARR_INDEXERS", + label="Indexers to Search", + description="Select which indexers to search. 📚 = has book categories. Leave empty to search all.", + options=_get_indexer_options, + default=[], + show_when={"field": "PROWLARR_URL", "notEmpty": True}, + ), + ] + + +# ==================== Download Clients Tab ==================== + +@register_settings( + name="prowlarr_clients", + display_name="Download Clients", + order=42, + group="prowlarr", +) +def prowlarr_clients_settings(): + """Download client settings for Prowlarr.""" + return [ + # --- Torrent Client Selection --- + HeadingField( + key="torrent_heading", + title="Torrent Client", + description="Select and configure a torrent client for downloading torrents from Prowlarr.", + ), + SelectField( + key="PROWLARR_TORRENT_CLIENT", + label="Torrent Client", + description="Choose which torrent client to use", + options=[ + {"value": "", "label": "None"}, + {"value": "qbittorrent", "label": "qBittorrent"}, + {"value": "transmission", "label": "Transmission"}, + {"value": "deluge", "label": "Deluge"}, + ], + default="", + ), + + # --- qBittorrent Settings --- + TextField( + key="QBITTORRENT_URL", + label="qBittorrent URL", + description="Web UI URL of your qBittorrent instance", + placeholder="http://qbittorrent:8080", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TextField( + key="QBITTORRENT_USERNAME", + label="Username", + description="qBittorrent Web UI username", + placeholder="admin", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + PasswordField( + key="QBITTORRENT_PASSWORD", + label="Password", + description="qBittorrent Web UI password", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + ActionButton( + key="test_qbittorrent", + label="Test Connection", + description="Verify your qBittorrent configuration", + style="primary", + callback=_test_qbittorrent_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + TextField( + key="QBITTORRENT_CATEGORY", + label="Category", + description="Category to assign to downloads in qBittorrent", + placeholder="cwabd", + default="cwabd", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"}, + ), + + # --- Transmission Settings --- + TextField( + key="TRANSMISSION_URL", + label="Transmission URL", + description="URL of your Transmission instance", + placeholder="http://transmission:9091", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + TextField( + key="TRANSMISSION_USERNAME", + label="Username", + description="Transmission RPC username (if authentication enabled)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + PasswordField( + key="TRANSMISSION_PASSWORD", + label="Password", + description="Transmission RPC password", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + ActionButton( + key="test_transmission", + label="Test Connection", + description="Verify your Transmission configuration", + style="primary", + callback=_test_transmission_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + TextField( + key="TRANSMISSION_CATEGORY", + label="Label", + description="Label to assign to downloads in Transmission", + placeholder="cwabd", + default="cwabd", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"}, + ), + + # --- Deluge Settings --- + TextField( + key="DELUGE_HOST", + label="Deluge Host", + description="Hostname or IP of your Deluge daemon", + placeholder="localhost", + default="localhost", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_PORT", + label="Deluge Port", + description="Deluge daemon RPC port (default: 58846). IMPORTANT: Ensure \"Allow Remote Connections\" is enabled in Deluge settings.", + placeholder="58846", + default="58846", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_USERNAME", + label="Username", + description="Deluge daemon username (from auth file)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + PasswordField( + key="DELUGE_PASSWORD", + label="Password", + description="Deluge daemon password (from auth file)", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + ActionButton( + key="test_deluge", + label="Test Connection", + description="Verify your Deluge configuration", + style="primary", + callback=_test_deluge_connection, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + TextField( + key="DELUGE_CATEGORY", + label="Label", + description="Label to assign to downloads in Deluge", + placeholder="cwabd", + default="cwabd", + show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"}, + ), + # Note: Torrent client download path must be mounted identically in both containers. + # Torrents are always copied (not moved) to preserve seeding capability. + + # --- Usenet Client Selection --- + HeadingField( + key="usenet_heading", + title="Usenet Client", + description="Select and configure a usenet client for downloading NZBs from Prowlarr.", + ), + SelectField( + key="PROWLARR_USENET_CLIENT", + label="Usenet Client", + description="Choose which usenet client to use", + options=[ + {"value": "", "label": "None"}, + {"value": "nzbget", "label": "NZBGet"}, + {"value": "sabnzbd", "label": "SABnzbd"}, + ], + default="", + ), + + # --- NZBGet Settings --- + TextField( + key="NZBGET_URL", + label="NZBGet URL", + description="URL of your NZBGet instance", + placeholder="http://nzbget:6789", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + TextField( + key="NZBGET_USERNAME", + label="Username", + description="NZBGet control username", + placeholder="nzbget", + default="nzbget", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + PasswordField( + key="NZBGET_PASSWORD", + label="Password", + description="NZBGet control password", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + ActionButton( + key="test_nzbget", + label="Test Connection", + description="Verify your NZBGet configuration", + style="primary", + callback=_test_nzbget_connection, + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + TextField( + key="NZBGET_CATEGORY", + label="Category", + description="Category to assign to downloads in NZBGet", + placeholder="Books", + default="Books", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"}, + ), + + # --- SABnzbd Settings --- + TextField( + key="SABNZBD_URL", + label="SABnzbd URL", + description="URL of your SABnzbd instance", + placeholder="http://sabnzbd:8080", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + PasswordField( + key="SABNZBD_API_KEY", + label="API Key", + description="Found in SABnzbd: Config > General > API Key", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + ActionButton( + key="test_sabnzbd", + label="Test Connection", + description="Verify your SABnzbd configuration", + style="primary", + callback=_test_sabnzbd_connection, + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + TextField( + key="SABNZBD_CATEGORY", + label="Category", + description="Category to assign to downloads in SABnzbd", + placeholder="cwabd", + default="cwabd", + show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"}, + ), + + # Note: Usenet client download path must be mounted identically in both containers. + SelectField( + key="PROWLARR_USENET_ACTION", + label="Completion Action", + description="What to do with usenet files after download completes", + options=[ + {"value": "move", "label": "Move to ingest"}, + {"value": "copy", "label": "Copy to ingest"}, + ], + default="move", + show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, + ), + ] diff --git a/cwa_book_downloader/release_sources/prowlarr/source.py b/cwa_book_downloader/release_sources/prowlarr/source.py new file mode 100644 index 00000000..566d05ad --- /dev/null +++ b/cwa_book_downloader/release_sources/prowlarr/source.py @@ -0,0 +1,410 @@ +""" +Prowlarr release source implementation. + +Implements the ReleaseSource interface to search Prowlarr indexers +for book releases (torrents and usenet). +""" + +import re +from typing import List, Optional + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.logger import setup_logger +from cwa_book_downloader.metadata_providers import BookMetadata +from cwa_book_downloader.release_sources import ( + Release, + ReleaseSource, + register_source, + ReleaseColumnConfig, + ColumnSchema, + ColumnRenderType, + ColumnAlign, + ColumnColorHint, + LeadingCellConfig, + LeadingCellType, +) +from cwa_book_downloader.release_sources.prowlarr.api import ProwlarrClient +from cwa_book_downloader.release_sources.prowlarr.cache import cache_release + +logger = setup_logger(__name__) + + +def _parse_size(size_bytes: Optional[int]) -> Optional[str]: + """Convert bytes to human-readable size string.""" + if size_bytes is None or size_bytes <= 0: + return None + + units = ["B", "KB", "MB", "GB", "TB"] + size = float(size_bytes) + unit_index = 0 + + while size >= 1024 and unit_index < len(units) - 1: + size /= 1024 + unit_index += 1 + + if unit_index == 0: + return f"{int(size)} {units[unit_index]}" + + return f"{size:.1f} {units[unit_index]}" + + +# Common ebook formats in priority order +EBOOK_FORMATS = ["epub", "mobi", "azw3", "azw", "pdf", "cbz", "cbr", "fb2", "djvu", "lit", "pdb", "txt"] + + +def _extract_format(title: str) -> Optional[str]: + """ + Extract format from release title with smart parsing. + + Priority: + 1. File extension at end of title or in quotes (e.g., ".azw3") + 2. File extension anywhere in title + 3. Format keyword in brackets/parentheses (e.g., "[EPUB]", "(PDF)") + 4. Fallback to first format keyword found + """ + title_lower = title.lower() + + # 1. Look for file extensions (most reliable) - pattern: .format at word boundary or end + # This catches ".azw3", ".epub", etc. + for fmt in EBOOK_FORMATS: + # Match .format at end of string or followed by non-alphanumeric + pattern = rf'\.{fmt}(?:["\'\s\]\)]|$)' + if re.search(pattern, title_lower): + return fmt + + # 2. Look for format in brackets/parentheses (common in release names) + # e.g., "[EPUB]", "(PDF)", "{mobi}" + for fmt in EBOOK_FORMATS: + pattern = rf'[\[\(\{{]{fmt}[\]\)\}}]' + if re.search(pattern, title_lower): + return fmt + + # 3. Look for format as standalone word (not part of another word) + # e.g., "epub" but not "republic" + for fmt in EBOOK_FORMATS: + # Match format as whole word + pattern = rf'\b{fmt}\b' + if re.search(pattern, title_lower): + return fmt + + return None + + +def _get_protocol(result: dict) -> str: + """ + Get protocol from Prowlarr result. + + Uses the protocol field directly if available, otherwise infers from URL. + Returns user-friendly labels: "torrent" or "nzb". + """ + # Prowlarr provides protocol directly - use it + protocol = result.get("protocol", "").lower() + if protocol == "usenet": + return "nzb" + if protocol == "torrent": + return "torrent" + + # Fallback: infer from download URL + download_url = result.get("downloadUrl") or result.get("magnetUrl") or "" + url_lower = download_url.lower() + if url_lower.startswith("magnet:") or ".torrent" in url_lower: + return "torrent" + if ".nzb" in url_lower: + return "nzb" + + return "unknown" + + +def _extract_language(title: str) -> Optional[str]: + """ + Extract language from release title. + + Common patterns: + - [German], (French), {Spanish} + - German, French, etc. as standalone words + - Language codes like [DE], [FR], [ES] + """ + title_lower = title.lower() + + # 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", + } + + for lang_pattern, lang_code in languages.items(): + if lang_pattern in title_lower: + return lang_code + + return None + + +def _prowlarr_result_to_release(result: dict) -> Release: + """ + Convert a Prowlarr search result to a Release object. + + Uses structured fields from Prowlarr when available: + - protocol: Direct from Prowlarr + - fileName: For format detection (more reliable than title) + - categories: To confirm ebook type + - grabs: Download count + """ + title = result.get("title", "Unknown") + size_bytes = result.get("size") + download_url = result.get("downloadUrl") or result.get("magnetUrl") + info_url = result.get("infoUrl") or result.get("guid") + indexer = result.get("indexer", "Unknown") + protocol = _get_protocol(result) + seeders = result.get("seeders") + leechers = result.get("leechers") + # Format peers display string: "seeders / leechers" + peers_display = f"{seeders} / {leechers}" if (seeders is not None and leechers is not None) else None + grabs = result.get("grabs") + + # For format detection, prefer fileName over title (often cleaner) + file_name = result.get("fileName", "") + format_detected = _extract_format(file_name) if file_name else None + if not format_detected: + format_detected = _extract_format(title) + + # Extract language from title (Prowlarr doesn't provide this structured) + language = _extract_language(title) + + # Build the source_id from GUID or generate from indexer + title + source_id = result.get("guid") or f"{indexer}:{hash(title)}" + + # Cache the raw Prowlarr result so handler can look it up by source_id + cache_release(source_id, result) + + return Release( + source="prowlarr", + source_id=source_id, + title=title, + format=format_detected, + language=language, + size=_parse_size(size_bytes), + size_bytes=size_bytes, + download_url=download_url, + info_url=info_url, + protocol=protocol, + indexer=indexer, + seeders=seeders if protocol == "torrent" else None, + peers=peers_display if protocol == "torrent" else None, + extra={ + "publish_date": result.get("publishDate"), + "categories": result.get("categories", []), + "indexer_id": result.get("indexerId"), + "files": result.get("files"), + "grabs": grabs, + }, + ) + + +@register_source("prowlarr") +class ProwlarrSource(ReleaseSource): + """ + Prowlarr release source. + + Searches Prowlarr indexers for book releases (torrents and usenet). + """ + + name = "prowlarr" + display_name = "Prowlarr" + + @classmethod + def get_column_config(cls) -> ReleaseColumnConfig: + """Column configuration for Prowlarr releases.""" + return ReleaseColumnConfig( + columns=[ + ColumnSchema( + key="indexer", + label="Indexer", + render_type=ColumnRenderType.TEXT, + align=ColumnAlign.LEFT, + width="minmax(80px, 1fr)", + hide_mobile=True, + ), + ColumnSchema( + key="protocol", + label="Type", + render_type=ColumnRenderType.BADGE, + align=ColumnAlign.CENTER, + width="60px", + 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="-", + ), + ColumnSchema( + key="format", + label="Format", + render_type=ColumnRenderType.BADGE, + align=ColumnAlign.CENTER, + width="70px", + hide_mobile=False, + color_hint=ColumnColorHint(type="map", value="format"), + uppercase=True, + ), + ColumnSchema( + key="size", + label="Size", + render_type=ColumnRenderType.SIZE, + align=ColumnAlign.CENTER, + width="80px", + hide_mobile=False, + ), + ], + grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 70px 80px", + leading_cell=LeadingCellConfig(type=LeadingCellType.NONE), # No leading cell for Prowlarr + ) + + def _get_client(self) -> Optional[ProwlarrClient]: + """Get a configured Prowlarr client or None if not configured.""" + url = config.get("PROWLARR_URL", "") + api_key = config.get("PROWLARR_API_KEY", "") + + if not url or not api_key: + return None + + return ProwlarrClient(url, api_key) + + def _get_selected_indexer_ids(self) -> Optional[List[int]]: + """ + Get list of selected indexer IDs from config. + + Returns None if no indexers are selected (search all). + Returns list of IDs if specific indexers are selected. + """ + selected = config.get("PROWLARR_INDEXERS", "") + if not selected: + return None + + # Handle both list (from JSON config) and string (from env var) + try: + if isinstance(selected, list): + # Already a list from JSON config + ids = [int(x) for x in selected if x] + else: + # Comma-separated string from env var + ids = [int(x.strip()) for x in selected.split(",") if x.strip()] + return ids if ids else None + except (ValueError, TypeError) as e: + logger.warning(f"Invalid PROWLARR_INDEXERS format: {selected} ({e})") + return None + + def search( + self, + book: BookMetadata, + expand_search: bool = False, + languages: Optional[List[str]] = None + ) -> List[Release]: + """ + Search Prowlarr for releases matching the book. + + Makes separate API calls for each selected indexer to ensure + all indexers are properly queried regardless of their capabilities. + + Args: + book: Book metadata to search for + expand_search: Ignored - Prowlarr always uses title+author search + languages: Ignored - Prowlarr doesn't support language filtering + + Returns: + List of Release objects + """ + client = self._get_client() + if not client: + logger.warning("Prowlarr not configured - skipping search") + return [] + + # Build search query + query_parts = [] + if book.title: + query_parts.append(book.title) + if book.authors: + # Use first author only - authors may be a list or a single string + # that contains multiple comma-separated names (from frontend) + first_author = book.authors[0] + # If first author contains comma, split and use only the primary author + if "," in first_author: + first_author = first_author.split(",")[0].strip() + query_parts.append(first_author) + + query = " ".join(query_parts) + if not query: + # Try ISBN as fallback + query = book.isbn_13 or book.isbn_10 or "" + + if not query: + logger.warning("No search query available for book") + return [] + + # Get selected indexer IDs from config + indexer_ids = self._get_selected_indexer_ids() + + if not indexer_ids: + logger.warning("No indexers selected - configure indexers in Prowlarr settings") + return [] + + # Book categories: 7000 (Books parent), 7020 (EBook), 7030 (Comics), etc. + # We search the parent category which includes all subcategories + book_categories = [7000] + + logger.debug(f"Searching Prowlarr: query='{query}', indexers={indexer_ids}") + + all_results = [] + try: + # Make separate API call for each indexer + for indexer_id in indexer_ids: + try: + raw_results = client.search(query=query, indexer_ids=[indexer_id], categories=book_categories) + if raw_results: + all_results.extend(raw_results) + except Exception as e: + logger.warning(f"Search failed for indexer {indexer_id}: {e}") + continue + + results = [_prowlarr_result_to_release(r) for r in all_results] + + # Log consolidated summary + if results: + torrent_count = sum(1 for r in results if r.protocol == "torrent") + nzb_count = sum(1 for r in results if r.protocol == "nzb") + # Get unique indexer names + indexers = sorted(set(r.indexer for r in results if r.indexer)) + indexer_str = ", ".join(indexers) if indexers else "unknown" + logger.info(f"Prowlarr: {len(results)} results ({torrent_count} torrent, {nzb_count} nzb) from {indexer_str}") + else: + logger.debug("Prowlarr: no results found") + + return results + + except Exception as e: + logger.error(f"Prowlarr search failed: {e}") + return [] + + def is_available(self) -> bool: + """Check if Prowlarr is enabled and configured.""" + if not config.get("PROWLARR_ENABLED", False): + return False + url = config.get("PROWLARR_URL", "") + api_key = config.get("PROWLARR_API_KEY", "") + return bool(url and api_key) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 81ad569e..27f43f67 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -15,3 +15,5 @@ services: - ./.local/ingest:/cwa-book-ingest - ./.local/log:/var/log/cwa-book-downloader - ./.local/tmp:/tmp/cwa-book-downloader + # Mount source code for development (no rebuild needed for Python code changes) + - ./cwa_book_downloader:/app/cwa_book_downloader:ro diff --git a/docker-compose.test-clients.yml b/docker-compose.test-clients.yml new file mode 100644 index 00000000..b94f80c0 --- /dev/null +++ b/docker-compose.test-clients.yml @@ -0,0 +1,151 @@ +# Test stack for download client development +# Includes cwabd + all download clients on same network with shared volumes +# +# Usage: +# docker compose -f docker-compose.test-clients.yml up -d +# # Access cwabd at http://localhost:8084 +# # Configure clients in Settings > Prowlarr > Download Clients +# +# Web UIs: +# - cwabd: http://localhost:8084 +# - qBittorrent: http://localhost:8080 (admin / adminadmin - check logs for temp password) +# - Transmission: http://localhost:9091 (admin / admin) +# - Deluge: http://localhost:8112 (password: deluge) +# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789) +# - SABnzbd: http://localhost:8085 (complete setup wizard for API key) +# +# Hot-reload: Python code changes are picked up automatically (source mounted) +# Rebuild needed only for: requirements changes, frontend changes, Dockerfile changes + +services: + # ============ MAIN APPLICATION ============ + + cwabd: + build: + context: . + dockerfile: Dockerfile + target: cwa-bd + container_name: test-cwabd + environment: + TZ: UTC + DEBUG: "true" + # All client configuration is done via Settings UI + # Use Docker service names for URLs: + # - Transmission: http://transmission:9091 + # - Deluge host: deluge (port 58846) + # - SABnzbd: http://sabnzbd:8080 + ports: + - "8084:8084" + volumes: + # Config and state + - ./.local/test-clients/cwabd/config:/config + - ./.local/test-clients/cwabd/log:/var/log/cwa-book-downloader + # Ingest directory (where completed books go) + - ./.local/test-clients/ingest:/cwa-book-ingest + # Staging directory + - ./.local/test-clients/tmp:/tmp/cwa-book-downloader + # CRITICAL: Mount client download directories so cwabd can access completed files + - ./.local/test-clients/downloads:/downloads + # Mount source code for hot-reload (no rebuild needed for Python changes) + - ./cwa_book_downloader:/app/cwa_book_downloader:ro + # Mount tests for running pytest in container + - ./tests:/app/tests:ro + - ./pyproject.toml:/app/pyproject.toml:ro + # Mount client configs for integration tests to read credentials + - ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro + - ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro + depends_on: + - nzbget + - sabnzbd + - qbittorrent + - transmission + - deluge + restart: unless-stopped + + # ============ USENET CLIENTS ============ + + nzbget: + image: lscr.io/linuxserver/nzbget:latest + container_name: test-nzbget + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - ./.local/test-clients/nzbget/config:/config + - ./.local/test-clients/downloads:/downloads + - ./.local/test-clients/nzbget/custom-cont-init.d:/custom-cont-init.d:ro + ports: + - "6789:6789" # Web UI / JSON-RPC + restart: unless-stopped + + sabnzbd: + image: lscr.io/linuxserver/sabnzbd:latest + container_name: test-sabnzbd + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + volumes: + - ./.local/test-clients/sabnzbd/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "8085:8080" # Web UI (external:internal) + restart: unless-stopped + + # ============ TORRENT CLIENTS ============ + + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: test-qbittorrent + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - WEBUI_PORT=8080 + volumes: + - ./.local/test-clients/qbittorrent/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "8080:8080" # Web UI / API + - "6882:6881" + - "6882:6881/udp" + restart: unless-stopped + + transmission: + image: lscr.io/linuxserver/transmission:latest + container_name: test-transmission + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - USER=admin + - PASS=admin + volumes: + - ./.local/test-clients/transmission/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "9091:9091" # Web UI / RPC + - "51413:51413" + - "51413:51413/udp" + restart: unless-stopped + + deluge: + image: lscr.io/linuxserver/deluge:latest + container_name: test-deluge + environment: + - PUID=1000 + - PGID=1000 + - TZ=UTC + - DELUGE_LOGLEVEL=error + volumes: + - ./.local/test-clients/deluge/config:/config + - ./.local/test-clients/downloads:/downloads + ports: + - "8112:8112" # Web UI + - "58846:58846" # Daemon RPC + - "6881:6881" + - "6881:6881/udp" + restart: unless-stopped + +# All services automatically on same network (test-clients_default) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..80ba6a7d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "cwa-book-downloader" +version = "0.1.0" +description = "Calibre-Web Automated Book Downloader" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = [ + "-v", + "--tb=short", +] +markers = [ + "integration: marks tests that require running services (deselect with '-m \"not integration\"')", + "slow: marks tests as slow (deselect with '-m \"not slow\"')", + "e2e: marks end-to-end tests that require the full application stack", +] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_ignores = true +ignore_missing_imports = true diff --git a/requirements-base.txt b/requirements-base.txt index 7330e208..5524383d 100644 --- a/requirements-base.txt +++ b/requirements-base.txt @@ -12,3 +12,6 @@ gevent-websocket psutil emoji rarfile +qbittorrent-api +transmission-rpc +deluge-client diff --git a/scripts/test_clients.py b/scripts/test_clients.py new file mode 100755 index 00000000..1a7b4370 --- /dev/null +++ b/scripts/test_clients.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +Test script for download client implementations. + +Usage: + 1. Start the test stack: + docker compose -f docker-compose.test-clients.yml up -d + + 2. Wait for containers to initialize (first run takes ~30s) + + 3. Run this script to verify clients are accessible: + python scripts/test_clients.py + + 4. Access cwabd at http://localhost:8084 + - Go to Settings > Prowlarr > Download Clients + - Select a client from the dropdown + - Click "Test Connection" to verify + +Web UIs: + - cwabd: http://localhost:8084 + - qBittorrent: http://localhost:8080 + - Transmission: http://localhost:9091 + - Deluge: http://localhost:8112 + - NZBGet: http://localhost:6789 + - SABnzbd: http://localhost:8085 + +Prerequisites (for running this script locally): + pip install requests transmission-rpc deluge-client qbittorrent-api + +First-Time Setup: + qBittorrent: + - Check container logs for temporary password: docker logs test-qbittorrent + - Login at http://localhost:8080, change password to something known + - Default username is 'admin' + + Transmission: + - No setup needed, credentials pre-configured (admin/admin) + + Deluge: + 1. Access Web UI at http://localhost:8112 (default password: deluge) + 2. Add auth line to .local/test-clients/deluge/config/auth: + echo "admin:admin:10" >> .local/test-clients/deluge/config/auth + 3. Restart: docker restart test-deluge + + NZBGet: + - No setup needed, credentials pre-configured (admin/admin) + + SABnzbd: + - Complete the setup wizard at http://localhost:8085 + - API key will be auto-detected by this script + - In cwabd, copy API key from SABnzbd Config > General +""" + +import sys +import time + +# Test configuration - matches docker-compose.test-clients.yml +CONFIG = { + # Usenet clients + "nzbget": { + "url": "http://localhost:6789", + "username": "admin", + "password": "admin", + }, + "sabnzbd": { + "url": "http://localhost:8085", + "api_key": None, # Will be read from config on first run + }, + # Torrent clients + "qbittorrent": { + "url": "http://localhost:8080", + "username": "admin", + "password": "5NCngsHXm", # Temp password from: docker logs test-qbittorrent | grep password + }, + "transmission": { + "url": "http://localhost:9091", + "username": "admin", + "password": "admin", + }, + "deluge": { + "host": "localhost", + "port": 58846, + "username": "admin", + "password": "admin", + }, +} + +# Test magnet link (Ubuntu ISO - legal, small metadata) +TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" + + +def test_nzbget(): + """Test NZBGet connection.""" + import requests + + print("\n" + "=" * 50) + print("Testing NZBGet") + print("=" * 50) + + url = CONFIG["nzbget"]["url"] + username = CONFIG["nzbget"]["username"] + password = CONFIG["nzbget"]["password"] + + try: + # Test connection via JSON-RPC + rpc_url = f"{url}/jsonrpc" + response = requests.post( + rpc_url, + json={"method": "version", "params": []}, + auth=(username, password), + timeout=10, + ) + response.raise_for_status() + result = response.json() + version = result.get("result", "unknown") + print(f" Connected to NZBGet {version}") + + # Test status + response = requests.post( + rpc_url, + json={"method": "status", "params": []}, + auth=(username, password), + timeout=10, + ) + status = response.json().get("result", {}) + print(f" Server state: {'Paused' if status.get('ServerPaused') else 'Running'}") + print(f" Downloads in queue: {status.get('DownloadedSizeMB', 0)} MB downloaded") + + print(" SUCCESS: NZBGet is working!") + return True + + except requests.exceptions.ConnectionError: + print(" ERROR: Could not connect to NZBGet") + print(" Is the container running? docker ps | grep nzbget") + return False + except Exception as e: + print(f" ERROR: {e}") + return False + + +def test_sabnzbd(): + """Test SABnzbd connection.""" + import requests + + print("\n" + "=" * 50) + print("Testing SABnzbd") + print("=" * 50) + + url = CONFIG["sabnzbd"]["url"] + api_key = CONFIG["sabnzbd"]["api_key"] + + # Try to get API key from config if not set + if not api_key: + try: + import os + ini_path = ".local/test-clients/sabnzbd/config/sabnzbd.ini" + if os.path.exists(ini_path): + with open(ini_path) as f: + for line in f: + if line.startswith("api_key"): + api_key = line.split("=")[1].strip() + print(f" Found API key in config: {api_key[:8]}...") + break + except Exception as e: + print(f" Could not read API key from config: {e}") + + if not api_key: + print(" ERROR: No API key configured") + print(" Please access http://localhost:8085 and complete initial setup") + print(" Then copy the API key from Config > General") + return False + + try: + # Test connection + response = requests.get( + f"{url}/api", + params={"apikey": api_key, "mode": "version", "output": "json"}, + timeout=10, + ) + response.raise_for_status() + result = response.json() + version = result.get("version", "unknown") + print(f" Connected to SABnzbd {version}") + + # Test queue status + response = requests.get( + f"{url}/api", + params={"apikey": api_key, "mode": "queue", "output": "json"}, + timeout=10, + ) + queue = response.json().get("queue", {}) + print(f" Queue status: {queue.get('status', 'unknown')}") + print(f" Items in queue: {len(queue.get('slots', []))}") + + print(" SUCCESS: SABnzbd is working!") + return True + + except requests.exceptions.ConnectionError: + print(" ERROR: Could not connect to SABnzbd") + print(" Is the container running? docker ps | grep sabnzbd") + return False + except Exception as e: + print(f" ERROR: {e}") + return False + + +def test_qbittorrent(): + """Test qBittorrent connection.""" + print("\n" + "=" * 50) + print("Testing qBittorrent") + print("=" * 50) + + try: + import qbittorrentapi + + url = CONFIG["qbittorrent"]["url"] + username = CONFIG["qbittorrent"]["username"] + password = CONFIG["qbittorrent"]["password"] + + # Parse URL for host/port + from urllib.parse import urlparse + parsed = urlparse(url) + + client = qbittorrentapi.Client( + host=parsed.hostname, + port=parsed.port or 8080, + username=username, + password=password, + ) + + # Test connection + client.auth_log_in() + version = client.app.version + print(f" Connected to qBittorrent {version}") + + # Get torrent list + torrents = client.torrents_info() + print(f" Active torrents: {len(torrents)}") + + # Test adding a torrent (then remove it) + print(" Testing add/remove torrent...") + result = client.torrents_add(urls=TEST_MAGNET, is_paused=True) + if result == "Ok.": + # Wait a moment for it to be added + time.sleep(1) + torrents = client.torrents_info() + if torrents: + test_torrent = torrents[-1] # Most recently added + print(f" Added test torrent: {test_torrent.name[:50]}...") + print(f" Status: {test_torrent.state}") + + # Remove it + client.torrents_delete(torrent_hashes=test_torrent.hash, delete_files=True) + print(" Removed test torrent") + else: + print(f" Add result: {result}") + + print(" SUCCESS: qBittorrent is working!") + return True + + except ImportError: + print(" ERROR: qbittorrent-api not installed") + print(" Run: pip install qbittorrent-api") + return False + except Exception as e: + print(f" ERROR: {e}") + if "Forbidden" in str(e) or "401" in str(e): + print("\n Authentication failed. Check password:") + print(" 1. docker logs test-qbittorrent | grep password") + print(" 2. Login to http://localhost:8080 and set a known password") + return False + + +def test_transmission(): + """Test Transmission connection.""" + print("\n" + "=" * 50) + print("Testing Transmission") + print("=" * 50) + + try: + from transmission_rpc import Client + from urllib.parse import urlparse + + url = CONFIG["transmission"]["url"] + parsed = urlparse(url) + + client = Client( + host=parsed.hostname, + port=parsed.port or 9091, + username=CONFIG["transmission"]["username"], + password=CONFIG["transmission"]["password"], + ) + + # Test connection + session = client.get_session() + print(f" Connected to Transmission {session.version}") + + # Get torrent list + torrents = client.get_torrents() + print(f" Active torrents: {len(torrents)}") + + # Test adding a torrent (then remove it) + print(" Testing add/remove torrent...") + torrent = client.add_torrent(TEST_MAGNET, paused=True) + print(f" Added test torrent: {torrent.name[:50]}...") + + # Get status + status = client.get_torrent(torrent.id) + print(f" Status: {status.status} ({status.percent_done * 100:.1f}%)") + + # Remove it + client.remove_torrent(torrent.id, delete_data=True) + print(" Removed test torrent") + + print(" SUCCESS: Transmission is working!") + return True + + except ImportError: + print(" ERROR: transmission-rpc not installed") + print(" Run: pip install transmission-rpc") + return False + except Exception as e: + print(f" ERROR: {e}") + return False + + +def test_deluge(): + """Test Deluge connection.""" + print("\n" + "=" * 50) + print("Testing Deluge") + print("=" * 50) + + try: + from deluge_client import DelugeRPCClient + + client = DelugeRPCClient( + host=CONFIG["deluge"]["host"], + port=CONFIG["deluge"]["port"], + username=CONFIG["deluge"]["username"], + password=CONFIG["deluge"]["password"], + ) + + # Test connection + client.connect() + version = client.call("daemon.info") + print(f" Connected to Deluge {version}") + + # Get torrent list + torrents = client.call("core.get_torrents_status", {}, ["name"]) + print(f" Active torrents: {len(torrents)}") + + # Test adding a torrent (then remove it) + print(" Testing add/remove torrent...") + torrent_id = client.call("core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True}) + + if torrent_id: + print(f" Added test torrent: {torrent_id[:20]}...") + + # Get status + status = client.call("core.get_torrent_status", torrent_id, ["state", "progress"]) + state = status.get(b"state", b"unknown") + if isinstance(state, bytes): + state = state.decode() + print(f" Status: {state}") + + # Remove it + client.call("core.remove_torrent", torrent_id, True) + print(" Removed test torrent") + else: + print(" WARNING: Could not add test torrent") + + print(" SUCCESS: Deluge is working!") + return True + + except ImportError: + print(" ERROR: deluge-client not installed") + print(" Run: pip install deluge-client") + return False + except Exception as e: + print(f" ERROR: {e}") + if "Connection refused" in str(e): + print(" Is the container running? docker ps | grep deluge") + elif "Bad login" in str(e) or "auth" in str(e).lower(): + print("\n Deluge auth setup required:") + print(" 1. Add 'admin:admin:10' to .local/test-clients/deluge/config/auth") + print(" 2. Restart: docker restart test-deluge") + print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)") + return False + + +def main(): + print("Download Client Test Suite") + print("=" * 50) + print("Make sure containers are running:") + print(" docker compose -f docker-compose.test-clients.yml up -d") + + results = {} + + # Test usenet clients + print("\n" + "=" * 50) + print("USENET CLIENTS") + print("=" * 50) + results["nzbget"] = test_nzbget() + results["sabnzbd"] = test_sabnzbd() + + # Test torrent clients + print("\n" + "=" * 50) + print("TORRENT CLIENTS") + print("=" * 50) + results["qbittorrent"] = test_qbittorrent() + results["transmission"] = test_transmission() + results["deluge"] = test_deluge() + + # Summary + print("\n" + "=" * 50) + print("SUMMARY") + print("=" * 50) + + for client, success in results.items(): + status = "PASS" if success else "FAIL" + print(f" {client}: {status}") + + passed = sum(results.values()) + total = len(results) + print(f"\n Total: {passed}/{total} passed") + + return 0 if passed == total else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index ec28f16f..bf7543ed 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -33,20 +33,14 @@ import './styles.css'; function App() { const { toasts, showToast, removeToast } = useToast(); - // WebSocket URL based on current location - const wsUrl = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' - ? 'http://localhost:8084' - : window.location.origin; - // Realtime status with WebSocket and polling fallback + // Socket connection is managed by SocketProvider in main.tsx const { status: currentStatus, isUsingWebSocket, forceRefresh: fetchStatus } = useRealtimeStatus({ - wsUrl, pollInterval: 5000, - reconnectAttempts: 3, }); // Download tracking for universal mode @@ -139,7 +133,6 @@ function App() { return { ongoing, completed, errored }; }, [currentStatus]); - const activeCount = statusCounts.ongoing; // Compute visibility states const hasResults = books.length > 0; @@ -365,6 +358,9 @@ function App() { setSelectedBook({ ...metadataBook, description: fullBook.description || metadataBook.description, + series_name: fullBook.series_name, + series_position: fullBook.series_position, + series_count: fullBook.series_count, }); } catch (error) { console.error('Failed to load book description, using search data:', error); @@ -427,6 +423,9 @@ function App() { setReleaseBook({ ...book, description: fullBook.description || book.description, + series_name: fullBook.series_name, + series_position: fullBook.series_position, + series_count: fullBook.series_count, }); } catch (error) { console.error('Failed to load book description, using search data:', error); @@ -445,7 +444,9 @@ function App() { await downloadRelease({ source: release.source, source_id: release.source_id, - title: release.title, + title: book.title, // Use book metadata title, not release/torrent title + author: book.author, // Pass author from metadata + year: book.year, // Pass year from metadata format: release.format, size: release.size, size_bytes: release.size_bytes, @@ -455,7 +456,6 @@ function App() { seeders: release.seeders, extra: release.extra, preview: book.preview, // Pass book cover from metadata - author: book.author, // Pass author from metadata }); await fetchStatus(); } catch (error) { @@ -474,6 +474,30 @@ function App() { const searchMode = config?.search_mode || 'direct'; + // Handle "View Series" - trigger search with series field and series order sort + const handleSearchSeries = useCallback((seriesName: string) => { + // Clear UI state + setSearchInput(''); + setSelectedBook(null); + setReleaseBook(null); + clearTracking(); + + // Set sort to series_order (but don't show advanced panel or persist series value) + const newFilters = { ...advancedFilters, sort: 'series_order' }; + setAdvancedFilters(newFilters); + + // Trigger search with series field (passed directly, not persisted in UI) + const query = buildSearchQuery({ + searchInput: '', + showAdvanced: true, + advancedFilters: newFilters, + bookLanguages, + defaultLanguage: defaultLanguageCodes, + searchMode, + }); + handleSearch(query, config, { ...searchFieldValues, series: seriesName }); + }, [setSearchInput, clearTracking, searchFieldValues, advancedFilters, setAdvancedFilters, bookLanguages, defaultLanguageCodes, searchMode, config, handleSearch]); + const mainAppContent = (
setSelectedBook(null)} onDownload={handleDownload} onFindDownloads={handleFindDownloads} + onSearchSeries={handleSearchSeries} buttonState={getButtonState(selectedBook.id)} /> )} @@ -589,6 +614,7 @@ function App() { bookLanguages={bookLanguages} currentStatus={currentStatus} defaultReleaseSource={config?.default_release_source} + onSearchSeries={handleSearchSeries} /> )} @@ -605,10 +631,8 @@ function App() { isOpen={downloadsSidebarOpen} onClose={() => setDownloadsSidebarOpen(false)} status={currentStatus} - onRefresh={fetchStatus} onClearCompleted={handleClearCompleted} onCancel={handleCancel} - activeCount={activeCount} /> void; onDownload: (book: Book) => Promise; onFindDownloads?: (book: Book) => void; // For Universal mode + onSearchSeries?: (seriesName: string) => void; // Callback to search for series buttonState: ButtonStateInfo; } -export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, buttonState }: DetailsModalProps) => { +export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSearchSeries, buttonState }: DetailsModalProps) => { const [isQueuing, setIsQueuing] = useState(false); const [isClosing, setIsClosing] = useState(false); @@ -258,6 +259,37 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, butto )} + {/* Series info - Universal mode only */} + {isMetadata && book.series_name && ( +
+

Series

+
+

+ {book.series_position != null ? ( + <>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name} + ) : ( + book.series_name + )} +

+ {onSearchSeries && ( + + )} +
+
+ )} + {/* Extended info (publisher, etc.) - Direct Download mode only */} {!isMetadata && extendedInfoEntries.length > 0 && (
diff --git a/src/frontend/src/components/DownloadsSidebar.tsx b/src/frontend/src/components/DownloadsSidebar.tsx index db4ecc8c..0521ca63 100644 --- a/src/frontend/src/components/DownloadsSidebar.tsx +++ b/src/frontend/src/components/DownloadsSidebar.tsx @@ -5,10 +5,8 @@ interface DownloadsSidebarProps { isOpen: boolean; onClose: () => void; status: StatusData; - onRefresh: () => void; onClearCompleted: () => void; onCancel: (id: string) => void; - activeCount: number; } const STATUS_STYLES: Record = { @@ -101,10 +99,8 @@ export const DownloadsSidebar = ({ isOpen, onClose, status, - onRefresh, onClearCompleted, onCancel, - activeCount, }: DownloadsSidebarProps) => { // Handle ESC key to close sidebar useEffect(() => { @@ -180,13 +176,21 @@ export const DownloadsSidebar = ({ e.stopPropagation(); onCancel(book.id); }} - className="absolute top-1 right-1 z-10 flex items-center justify-center w-6 h-6 rounded-full hover:bg-red-100 dark:hover:bg-red-900/30 text-gray-500 hover:text-red-600 transition-colors" - title={isInProgress ? "Cancel download" : "Clear from list"} - aria-label={isInProgress ? "Cancel download" : "Clear from list"} + className={`absolute top-1 right-1 z-10 flex items-center justify-center rounded-full transition-colors ${ + isInProgress + ? 'px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30' + : 'w-6 h-6 text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30' + }`} + title={isInProgress ? "Stop download" : "Clear from list"} + aria-label={isInProgress ? "Stop download" : "Clear from list"} > - - - + {isInProgress ? ( + 'Stop' + ) : ( + + + + )} {/* Main content area */} @@ -295,10 +299,12 @@ export const DownloadsSidebar = ({ > {/* Header */}
-

Downloads

+

+ Downloads{allDownloadItems.length > 0 && ` (${allDownloadItems.length})`} +

- {/* Controls */} -
- - -
- {/* Queue Items */}
- {/* Footer with active count */} - {activeCount > 0 && ( -
+
- )} + Clear Completed + +
); diff --git a/src/frontend/src/components/Dropdown.tsx b/src/frontend/src/components/Dropdown.tsx index 1cb3919c..ff74f471 100644 --- a/src/frontend/src/components/Dropdown.tsx +++ b/src/frontend/src/components/Dropdown.tsx @@ -10,6 +10,8 @@ interface DropdownProps { panelClassName?: string; disabled?: boolean; renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode; + /** Disable max-height and overflow scrolling (for panels with nested dropdowns) */ + noScrollLimit?: boolean; } export const Dropdown = ({ @@ -22,6 +24,7 @@ export const Dropdown = ({ panelClassName = '', disabled = false, renderTrigger, + noScrollLimit = false, }: DropdownProps) => { const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -89,7 +92,7 @@ export const Dropdown = ({ return (
{label && ( -
@@ -500,32 +514,6 @@ const ConfigureSourceCTA = ({ sourceName }: { sourceName: string }) => ( ); -// Coming soon state component -const ComingSoonState = ({ sourceName }: { sourceName: string }) => ( -
-
- - - -
-

- {sourceName} Coming Soon -

-

- Support for {sourceName} is currently in development and will be available in a future update. -

-
-); // Error state component const ErrorState = ({ message }: { message: string }) => ( @@ -552,15 +540,6 @@ const ErrorState = ({ message }: { message: string }) => ( ); -// Sources that are coming soon (show "Coming Soon" message) -const COMING_SOON_SOURCES: Record = { - prowlarr: 'Prowlarr', -}; - -// Known sources that require configuration (show "Configure" CTA) -const CONFIGURABLE_SOURCES: Record = { - // Add sources here that are implemented but need configuration -}; export const ReleaseModal = ({ book, @@ -571,6 +550,7 @@ export const ReleaseModal = ({ bookLanguages, currentStatus, defaultReleaseSource, + onSearchSeries, }: ReleaseModalProps) => { const [isClosing, setIsClosing] = useState(false); @@ -590,12 +570,20 @@ export const ReleaseModal = ({ const [releasesBySource, setReleasesBySource] = useState>({}); const [loadingBySource, setLoadingBySource] = useState>({}); const [errorBySource, setErrorBySource] = useState>({}); + const [expandedBySource, setExpandedBySource] = useState>({}); + + // Search status from WebSocket (for showing progress during slow searches like IRC) + const [searchStatus, setSearchStatus] = useState(null); + const { socket } = useSocket(); + const lastStatusTimeRef = useRef(0); + const pendingStatusRef = useRef(null); + const statusTimeoutRef = useRef(null); // Filters - initialized from config settings // Empty string means "show all supported formats" (filtered by supportedFormats) // A specific value means "show only that format" const [formatFilter, setFormatFilter] = useState(''); - const [languageFilter, setLanguageFilter] = useState(''); + const [languageFilter, setLanguageFilter] = useState([LANGUAGE_OPTION_DEFAULT]); // Description expansion const [descriptionExpanded, setDescriptionExpanded] = useState(false); @@ -638,10 +626,73 @@ export const ReleaseModal = ({ setReleasesBySource({}); setLoadingBySource({}); setErrorBySource({}); + setExpandedBySource({}); setFormatFilter(''); - setLanguageFilter(''); + setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); + setSearchStatus(null); + lastStatusTimeRef.current = 0; + pendingStatusRef.current = null; + if (statusTimeoutRef.current) { + clearTimeout(statusTimeoutRef.current); + statusTimeoutRef.current = null; + } }, [book?.id]); + // Set up WebSocket listener for search status updates + useEffect(() => { + if (!book || !socket) return; + + const MIN_DISPLAY_TIME = 1500; // Minimum ms to show each status message + + const handleSearchStatus = (data: SearchStatusData) => { + // Only handle status for the current active tab + if (data.source !== activeTab) return; + + const now = Date.now(); + const elapsed = now - lastStatusTimeRef.current; + + // If enough time has passed, update immediately + if (elapsed >= MIN_DISPLAY_TIME) { + setSearchStatus(data); + lastStatusTimeRef.current = now; + pendingStatusRef.current = null; + } else { + // Queue the update for later + pendingStatusRef.current = data; + + // Clear any existing timeout + if (statusTimeoutRef.current) { + clearTimeout(statusTimeoutRef.current); + } + + // Schedule update after remaining time + statusTimeoutRef.current = setTimeout(() => { + if (pendingStatusRef.current) { + setSearchStatus(pendingStatusRef.current); + lastStatusTimeRef.current = Date.now(); + pendingStatusRef.current = null; + } + }, MIN_DISPLAY_TIME - elapsed); + } + }; + + socket.on('search_status', handleSearchStatus); + + return () => { + socket.off('search_status', handleSearchStatus); + if (statusTimeoutRef.current) { + clearTimeout(statusTimeoutRef.current); + } + }; + }, [book, socket, activeTab]); + + // Clear search status when loading finishes + useEffect(() => { + if (!loadingBySource[activeTab]) { + setSearchStatus(null); + } + }, [loadingBySource, activeTab]); + // Check if description text overflows (needs "more" button) useEffect(() => { const el = descriptionRef.current; @@ -681,17 +732,21 @@ export const ReleaseModal = ({ setSourcesLoading(true); const sources = await getReleaseSources(); setAvailableSources(sources); - // Set active tab: use defaultReleaseSource if available, otherwise first source + // Set active tab: prefer defaultReleaseSource if enabled, otherwise first enabled source if (sources.length > 0) { - const defaultSource = defaultReleaseSource && sources.some(s => s.name === defaultReleaseSource) + const enabledSources = sources.filter(s => s.enabled); + const defaultIsEnabled = defaultReleaseSource && enabledSources.some(s => s.name === defaultReleaseSource); + const defaultSource = defaultIsEnabled ? defaultReleaseSource - : sources[0].name; + : enabledSources.length > 0 + ? enabledSources[0].name + : sources[0].name; // Fallback to first source if none enabled setActiveTab(defaultSource); } } catch (err) { console.error('Failed to fetch release sources:', err); // Fallback: assume direct_download is available - setAvailableSources([{ name: 'direct_download', display_name: "Anna's Archive" }]); + setAvailableSources([{ name: 'direct_download', display_name: "Anna's Archive", enabled: true }]); setActiveTab('direct_download'); } finally { setSourcesLoading(false); @@ -702,6 +757,7 @@ export const ReleaseModal = ({ }, [book, defaultReleaseSource]); // Fetch releases when active tab changes (with caching) + // Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch useEffect(() => { if (!book || !activeTab || !book.provider || !book.provider_id) return; @@ -725,7 +781,6 @@ export const ReleaseModal = ({ try { const response = await getReleases(provider, bookId, activeTab, book.title, book.author); - // Store in module-level cache setCachedReleases(provider, bookId, activeTab, response); setReleasesBySource((prev) => ({ ...prev, [activeTab]: response })); } catch (err) { @@ -739,46 +794,80 @@ export const ReleaseModal = ({ fetchReleases(); }, [book, activeTab, releasesBySource, loadingBySource, errorBySource]); + // Handler for expanding search (title+author instead of ISBN) + // Fetches additional results and merges with existing ISBN results + const handleExpandSearch = useCallback(async () => { + if (!activeTab || !book?.provider || !book?.provider_id) return; + + const provider = book.provider; + const bookId = book.provider_id; + + // Mark as loading and expanded + setLoadingBySource((prev) => ({ ...prev, [activeTab]: true })); + setExpandedBySource((prev) => ({ ...prev, [activeTab]: true })); + + try { + // Fetch with expand_search=true (title+author search) + const expandedResponse = await getReleases( + provider, bookId, activeTab, book.title, book.author, true + ); + + // Merge with existing results, deduplicating by source_id + setReleasesBySource((prev) => { + const existing = prev[activeTab]; + if (!existing) { + return { ...prev, [activeTab]: expandedResponse }; + } + + const seenIds = new Set(existing.releases.map(r => r.source_id)); + const newReleases = expandedResponse.releases.filter(r => !seenIds.has(r.source_id)); + + return { + ...prev, + [activeTab]: { + ...existing, + releases: [...existing.releases, ...newReleases], + }, + }; + }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to expand search'; + setErrorBySource((prev) => ({ ...prev, [activeTab]: message })); + } finally { + setLoadingBySource((prev) => ({ ...prev, [activeTab]: false })); + } + }, [activeTab, book]); + // Build list of tabs to show - // Status: 'available' = working, 'coming_soon' = in development, 'not_configured' = needs setup - // Order: 1) Default source, 2) Other configured sources, 3) Unconfigured sources, 4) Coming soon + // All sources come from backend with their enabled status + // Order: 1) Default source, 2) Other enabled sources, 3) Disabled sources const allTabs = useMemo(() => { - type TabInfo = { name: string; displayName: string; status: 'available' | 'coming_soon' | 'not_configured' }; + type TabInfo = { name: string; displayName: string; enabled: boolean }; - const configuredTabs: TabInfo[] = []; - const unconfiguredTabs: TabInfo[] = []; - const comingSoonTabs: TabInfo[] = []; + const enabledTabs: TabInfo[] = []; + const disabledTabs: TabInfo[] = []; - // Add available/configured sources + // Separate sources by enabled status availableSources.forEach((src) => { - configuredTabs.push({ name: src.name, displayName: src.display_name, status: 'available' }); - }); - - // Add configurable but not configured sources - Object.entries(CONFIGURABLE_SOURCES).forEach(([name, displayName]) => { - if (!availableSources.find((s) => s.name === name)) { - unconfiguredTabs.push({ name, displayName, status: 'not_configured' }); + const tab = { name: src.name, displayName: src.display_name, enabled: src.enabled }; + if (src.enabled) { + enabledTabs.push(tab); + } else { + disabledTabs.push(tab); } }); - // Add coming soon sources - Object.entries(COMING_SOON_SOURCES).forEach(([name, displayName]) => { - if (!availableSources.find((s) => s.name === name)) { - comingSoonTabs.push({ name, displayName, status: 'coming_soon' }); - } - }); - - // Sort configured tabs so default source appears first + // Sort enabled tabs so default source appears first if (defaultReleaseSource) { - configuredTabs.sort((a, b) => { + enabledTabs.sort((a, b) => { if (a.name === defaultReleaseSource) return -1; if (b.name === defaultReleaseSource) return 1; return 0; }); } - // Combine in order: configured (with default first), unconfigured, coming soon - return [...configuredTabs, ...unconfiguredTabs, ...comingSoonTabs]; + // Combine: enabled sources first (with default first), then disabled + return [...enabledTabs, ...disabledTabs]; }, [availableSources, defaultReleaseSource]); // Update tab indicator position when active tab changes @@ -816,19 +905,6 @@ export const ReleaseModal = ({ return Array.from(formats).sort(); }, [releasesBySource, activeTab, supportedFormats]); - // Get unique languages from current releases for filter dropdown - const availableLanguages = useMemo(() => { - const releases = releasesBySource[activeTab]?.releases || []; - const languages = new Set(); - - releases.forEach((r) => { - // Check for language in release extra data or other fields - const lang = r.extra?.language as string | undefined; - if (lang) languages.add(lang.toLowerCase()); - }); - return Array.from(languages).sort(); - }, [releasesBySource, activeTab]); - // Build select options for format filter const formatOptions = useMemo(() => { const options = [{ value: '', label: 'All Formats' }]; @@ -838,24 +914,15 @@ export const ReleaseModal = ({ return options; }, [availableFormats]); - // Build select options for language filter - const languageOptions = useMemo(() => { - const defaultLabel = defaultLanguages.length > 0 ? 'Default Lang' : 'All Lang'; - const options = [{ value: '', label: defaultLabel }]; - availableLanguages.forEach((lang) => { - const langInfo = bookLanguages.find( - (l) => l.code.toLowerCase() === lang.toLowerCase() - ); - options.push({ value: lang, label: langInfo?.language || lang.toUpperCase() }); - }); - return options; - }, [availableLanguages, bookLanguages, defaultLanguages.length]); + // Resolve language filter to actual language codes for filtering + const resolvedLanguageCodes = useMemo(() => { + return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages); + }, [languageFilter, bookLanguages, defaultLanguages]); // Filter releases based on settings and user selection const filteredReleases = useMemo(() => { const releases = releasesBySource[activeTab]?.releases || []; const supportedLower = supportedFormats.map((f) => f.toLowerCase()); - const defaultLangLower = defaultLanguages.map((l) => l.toLowerCase()); return releases.filter((r) => { // Format filtering: always filter by supported formats @@ -870,24 +937,20 @@ export const ReleaseModal = ({ } } - // Language filtering (if release has language info) + // Language filtering using resolved language codes + // null or includes 'all' means show all languages + // Otherwise filter to the specific language codes const releaseLang = r.extra?.language as string | undefined; - if (releaseLang && languageFilter) { - if (releaseLang.toLowerCase() !== languageFilter.toLowerCase()) { - return false; - } - } else if (releaseLang && !languageFilter && defaultLangLower.length > 0) { - // If no user filter but we have default languages, filter to those - // Skip this filter if defaultLanguages is empty or contains special "any" value - const hasAnyLanguage = defaultLangLower.some((l) => l === '' || l === 'any'); - if (!hasAnyLanguage && !defaultLangLower.includes(releaseLang.toLowerCase())) { + if (releaseLang && resolvedLanguageCodes && !resolvedLanguageCodes.includes(LANGUAGE_OPTION_ALL)) { + const releaseLangLower = releaseLang.toLowerCase(); + if (!resolvedLanguageCodes.some(code => code.toLowerCase() === releaseLangLower)) { return false; } } return true; }); - }, [releasesBySource, activeTab, formatFilter, languageFilter, supportedFormats, defaultLanguages]); + }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, supportedFormats]); // Get column config from response or use default const columnConfig = useMemo((): ReleaseColumnConfig => { @@ -955,7 +1018,7 @@ export const ReleaseModal = ({ const currentTabLoading = loadingBySource[activeTab] ?? false; const currentTabError = errorBySource[activeTab] ?? null; - const currentTabStatus = allTabs.find((t) => t.name === activeTab)?.status ?? 'not_configured'; + const currentTabEnabled = allTabs.find((t) => t.name === activeTab)?.enabled ?? false; return (
) : ( -
+
No cover
)} @@ -1072,6 +1135,34 @@ export const ReleaseModal = ({ )}
+ {/* Series info */} + {book.series_name && ( +
+ + {book.series_position != null ? ( + <>#{Number.isInteger(book.series_position) ? book.series_position : book.series_position}{book.series_count ? ` of ${book.series_count}` : ''} in {book.series_name} + ) : ( + <>Part of {book.series_name} + )} + + {onSearchSeries && ( + + )} +
+ )} + {/* Description */} {book.description && (
@@ -1163,13 +1254,16 @@ export const ReleaseModal = ({
{/* Filter funnel button - stays fixed */} - {(availableFormats.length > 0 || availableLanguages.length > 0) && ( + {(availableFormats.length > 0 || bookLanguages.length > 0) && ( { - const hasActiveFilter = formatFilter !== '' || languageFilter !== ''; + // 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 ( )}
)} @@ -1249,19 +1366,29 @@ export const ReleaseModal = ({ {/* Release list content */}
- {currentTabStatus === 'coming_soon' ? ( - t.name === activeTab)?.displayName || activeTab} - /> - ) : currentTabStatus === 'not_configured' ? ( + {!currentTabEnabled ? ( t.name === activeTab)?.displayName || activeTab} /> - ) : currentTabLoading ? ( - + ) : currentTabLoading && filteredReleases.length === 0 ? ( + // Initial loading - show full skeleton +
+ + {/* Search status - bottom center */} + {searchStatus && searchStatus.source === activeTab && ( +
+
+ {searchStatus.phase !== 'complete' && searchStatus.phase !== 'error' && ( +
+ )} + {searchStatus.message} +
+
+ )} +
) : currentTabError ? ( - ) : filteredReleases.length === 0 ? ( + ) : filteredReleases.length === 0 && !currentTabLoading ? ( ) : ( -
- {filteredReleases.map((release, index) => ( - handleDownload(release)} - buttonState={getButtonState(release.source_id)} - columns={columnConfig.columns} - gridTemplate={columnConfig.grid_template} - leadingCell={columnConfig.leading_cell} - /> - ))} -
+ <> +
+ {filteredReleases.map((release, index) => ( + handleDownload(release)} + buttonState={getButtonState(release.source_id)} + columns={columnConfig.columns} + gridTemplate={columnConfig.grid_template} + leadingCell={columnConfig.leading_cell} + onlineServers={columnConfig.online_servers} + /> + ))} +
+ {/* Expand search button or loading indicator */} + {activeTab === 'direct_download' && !expandedBySource[activeTab] && !currentTabLoading && ( +
+ +
+ )} + {/* Expanding search - show skeleton below existing results */} + {currentTabLoading && filteredReleases.length > 0 && ( + + )} + )}
diff --git a/src/frontend/src/components/ResultsSection.tsx b/src/frontend/src/components/ResultsSection.tsx index 18524308..337de96a 100644 --- a/src/frontend/src/components/ResultsSection.tsx +++ b/src/frontend/src/components/ResultsSection.tsx @@ -166,7 +166,7 @@ export const ResultsSection = ({ {viewMode === 'list' ? ( - + ) : (
) : ( ); })} diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx index 0b3119fb..164b45b2 100644 --- a/src/frontend/src/components/resultsViews/CardView.tsx +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -15,9 +15,10 @@ interface CardViewProps { onGetReleases: (book: Book) => Promise; buttonState: ButtonStateInfo; animationDelay?: number; + showSeriesPosition?: boolean; } -export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0 }: CardViewProps) => { +export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonState, animationDelay = 0, showSeriesPosition = false }: CardViewProps) => { const { searchMode } = useSearchMode(); const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [isLoadingReleases, setIsLoadingReleases] = useState(false); @@ -57,6 +58,12 @@ export const CardView = ({ book, onDetails, onDownload, onGetReleases, buttonSta onMouseLeave={() => setIsHovered(false)} >
+ {/* Series position badge */} + {showSeriesPosition && book.series_position != null && ( +
+ #{book.series_position} +
+ )} {book.preview && !imageError ? ( <> {!imageLoaded && ( diff --git a/src/frontend/src/components/resultsViews/CompactView.tsx b/src/frontend/src/components/resultsViews/CompactView.tsx index bdcde4fc..8015c633 100644 --- a/src/frontend/src/components/resultsViews/CompactView.tsx +++ b/src/frontend/src/components/resultsViews/CompactView.tsx @@ -16,9 +16,10 @@ interface CompactViewProps { buttonState: ButtonStateInfo; showDetailsButton?: boolean; animationDelay?: number; + showSeriesPosition?: boolean; } -export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0 }: CompactViewProps) => { +export const CompactView = ({ book, onDetails, onDownload, onGetReleases, buttonState, showDetailsButton = false, animationDelay = 0, showSeriesPosition = false }: CompactViewProps) => { const { searchMode } = useSearchMode(); const [isLoadingDetails, setIsLoadingDetails] = useState(false); const [isLoadingReleases, setIsLoadingReleases] = useState(false); @@ -58,6 +59,12 @@ export const CompactView = ({ book, onDetails, onDownload, onGetReleases, button onMouseLeave={() => setIsHovered(false)} >
+ {/* Series position badge */} + {showSeriesPosition && book.series_position != null && ( +
+ #{book.series_position} +
+ )} {book.preview && !imageError ? ( <> {!imageLoaded && ( diff --git a/src/frontend/src/components/resultsViews/ListView.tsx b/src/frontend/src/components/resultsViews/ListView.tsx index 925f6250..9386738a 100644 --- a/src/frontend/src/components/resultsViews/ListView.tsx +++ b/src/frontend/src/components/resultsViews/ListView.tsx @@ -12,6 +12,7 @@ interface ListViewProps { onGetReleases: (book: Book) => Promise; getButtonState: (bookId: string) => ButtonStateInfo; getUniversalButtonState: (bookId: string) => ButtonStateInfo; + showSeriesPosition?: boolean; } const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { @@ -47,7 +48,7 @@ const ListViewThumbnail = ({ preview, title }: { preview?: string; title?: strin ); }; -export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState }: ListViewProps) => { +export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButtonState, getUniversalButtonState, showSeriesPosition = false }: ListViewProps) => { const { searchMode } = useSearchMode(); const [detailsLoadingId, setDetailsLoadingId] = useState(null); const [releasesLoadingId, setReleasesLoadingId] = useState(null); @@ -120,8 +121,13 @@ export const ListView = ({ books, onDetails, onDownload, onGetReleases, getButto {/* Title and Author */}
-

- {book.title || 'Untitled'} +

+ {showSeriesPosition && book.series_position != null && ( + + #{book.series_position} + + )} + {book.title || 'Untitled'}

{book.author || 'Unknown author'} diff --git a/src/frontend/src/components/settings/SettingsContent.tsx b/src/frontend/src/components/settings/SettingsContent.tsx index ad443415..b5be3f06 100644 --- a/src/frontend/src/components/settings/SettingsContent.tsx +++ b/src/frontend/src/components/settings/SettingsContent.tsx @@ -47,6 +47,14 @@ function isFieldVisible( const currentValue = values[showWhen.field]; + // Handle notEmpty condition - show when field has any non-empty value + if (showWhen.notEmpty) { + if (Array.isArray(currentValue)) { + return currentValue.length > 0; + } + return currentValue !== undefined && currentValue !== null && currentValue !== ''; + } + // Handle array of allowed values or single value return Array.isArray(showWhen.value) ? showWhen.value.includes(currentValue as string) diff --git a/src/frontend/src/components/settings/fields/MultiSelectField.tsx b/src/frontend/src/components/settings/fields/MultiSelectField.tsx index 5e4a6c50..3128886c 100644 --- a/src/frontend/src/components/settings/fields/MultiSelectField.tsx +++ b/src/frontend/src/components/settings/fields/MultiSelectField.tsx @@ -68,10 +68,11 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele } }, [selected, field.options]); - // Update sortedOptions when field.options changes (e.g., different field) + // Update sortedOptions when field itself changes (different field or options refreshed) useEffect(() => { setSortedOptions(sortOptionsWithSelectedFirst(field.options, selected)); - }, [field.key]); + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally omit 'selected' to avoid re-sorting on user toggle + }, [field.key, field.options]); // Verify collapse need after render (handles edge cases where few options still fit) useEffect(() => { diff --git a/src/frontend/src/contexts/SocketContext.tsx b/src/frontend/src/contexts/SocketContext.tsx new file mode 100644 index 00000000..6debad8b --- /dev/null +++ b/src/frontend/src/contexts/SocketContext.tsx @@ -0,0 +1,64 @@ +import { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react'; +import { io, Socket } from 'socket.io-client'; + +interface SocketContextValue { + socket: Socket | null; + connected: boolean; +} + +const SocketContext = createContext({ socket: null, connected: false }); + +export const useSocket = () => useContext(SocketContext); + +interface SocketProviderProps { + children: ReactNode; +} + +export const SocketProvider = ({ children }: SocketProviderProps) => { + const [connected, setConnected] = useState(false); + const socketRef = useRef(null); + + useEffect(() => { + // In dev mode (port 5173), connect directly to backend to avoid Vite proxy issues + const wsUrl = window.location.port === '5173' + ? 'http://localhost:8084' + : window.location.origin; + + console.log('SocketProvider: Connecting to', wsUrl); + + const socket = io(wsUrl, { + path: '/socket.io', + transports: ['polling', 'websocket'], + withCredentials: false, + }); + + socketRef.current = socket; + + socket.on('connect', () => { + console.log('✅ Socket connected via', socket.io.engine.transport.name); + setConnected(true); + }); + + socket.on('disconnect', (reason) => { + console.log('Socket disconnected:', reason); + setConnected(false); + }); + + socket.on('connect_error', (err) => { + console.error('Socket connection error:', err.message); + setConnected(false); + }); + + return () => { + console.log('SocketProvider: Disconnecting'); + socket.disconnect(); + socketRef.current = null; + }; + }, []); + + return ( + + {children} + + ); +}; diff --git a/src/frontend/src/hooks/useRealtimeStatus.ts b/src/frontend/src/hooks/useRealtimeStatus.ts index d705ef4f..a7690276 100644 --- a/src/frontend/src/hooks/useRealtimeStatus.ts +++ b/src/frontend/src/hooks/useRealtimeStatus.ts @@ -1,12 +1,10 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { io, Socket } from 'socket.io-client'; import { StatusData } from '../types'; import { getStatus } from '../services/api'; +import { useSocket } from '../contexts/SocketContext'; interface UseRealtimeStatusOptions { - wsUrl: string; pollInterval?: number; - reconnectAttempts?: number; } interface UseRealtimeStatusReturn { @@ -19,26 +17,18 @@ interface UseRealtimeStatusReturn { /** * Hook for real-time status updates with WebSocket and polling fallback - * - * This hook attempts to connect via WebSocket first. If WebSocket connection - * fails or disconnects, it automatically falls back to polling. It will - * periodically retry WebSocket connections. + * + * Uses shared socket from SocketContext. Falls back to polling if socket + * is not connected. */ export const useRealtimeStatus = ({ - wsUrl, - pollInterval = 2000, // Reduced from 5s for better UX when WebSocket unavailable - reconnectAttempts = 3, -}: UseRealtimeStatusOptions): UseRealtimeStatusReturn => { + pollInterval = 2000, +}: UseRealtimeStatusOptions = {}): UseRealtimeStatusReturn => { + const { socket, connected } = useSocket(); const [status, setStatus] = useState({}); - const [connected, setConnected] = useState(false); - const [isUsingWebSocket, setIsUsingWebSocket] = useState(false); const [error, setError] = useState(null); - - const socketRef = useRef(null); + const pollIntervalRef = useRef(null); - const reconnectAttemptsRef = useRef(0); - const reconnectTimeoutRef = useRef(null); - const isConnectingRef = useRef(false); // Polling function const pollStatus = useCallback(async () => { @@ -55,14 +45,9 @@ export const useRealtimeStatus = ({ // Start polling const startPolling = useCallback(() => { if (pollIntervalRef.current) return; - + console.log('Starting polling fallback'); - setIsUsingWebSocket(false); - - // Poll immediately pollStatus(); - - // Then poll at intervals pollIntervalRef.current = setInterval(pollStatus, pollInterval); }, [pollStatus, pollInterval]); @@ -75,198 +60,86 @@ export const useRealtimeStatus = ({ } }, []); - // Attempt to reconnect WebSocket - const attemptReconnect = useCallback(() => { - if (reconnectAttemptsRef.current >= reconnectAttempts) { - console.log('Max reconnect attempts reached, using polling permanently'); + // Set up socket event listeners + useEffect(() => { + if (!socket) { + startPolling(); return; } - reconnectAttemptsRef.current += 1; - const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000); - - console.log(`Attempting WebSocket reconnect ${reconnectAttemptsRef.current}/${reconnectAttempts} in ${delay}ms`); - - reconnectTimeoutRef.current = setTimeout(() => { - if (!isConnectingRef.current && !socketRef.current?.connected) { - initializeWebSocket(); - } - }, delay); - }, [reconnectAttempts]); + // Listen for status updates + const handleStatusUpdate = (data: StatusData) => { + console.debug('[WS] status_update received', Object.keys(data)); + setStatus(data); + setError(null); + }; - // Initialize WebSocket connection - const initializeWebSocket = useCallback(() => { - if (isConnectingRef.current || socketRef.current?.connected) { - return; - } + // Listen for download progress + const handleDownloadProgress = (data: { book_id: string; progress: number; status: string }) => { + console.debug('[WS] download_progress:', data.book_id, `${data.progress.toFixed(1)}%`); + setStatus(prev => { + const newStatus = { ...prev }; - isConnectingRef.current = true; - console.log('Initializing WebSocket connection to:', wsUrl); - - try { - const socket = io(wsUrl, { - // Try websocket first, fall back to polling if needed - transports: ['websocket', 'polling'], - // Explicitly set the path to match backend - path: '/socket.io', - // Connection timeout - timeout: 10000, - // Reconnection settings - reconnection: true, - reconnectionAttempts: 5, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - // Upgrade settings for reverse proxies - upgrade: true, - rememberUpgrade: true, - // Force new connection instead of reusing - forceNew: false, - // Enable multiplexing - multiplex: true, - // Auto-connect - autoConnect: true, - }); - - socketRef.current = socket; - - socket.on('connect', () => { - console.log('✅ WebSocket connected successfully via', socket.io.engine.transport.name); - setConnected(true); - setIsUsingWebSocket(true); - setError(null); - reconnectAttemptsRef.current = 0; - isConnectingRef.current = false; - - // Stop polling when WebSocket connects - stopPolling(); - - // Request initial status via WebSocket - socket.emit('request_status'); - }); - - socket.on('disconnect', (reason: string) => { - console.log('WebSocket disconnected. Reason:', reason); - setConnected(false); - setIsUsingWebSocket(false); - isConnectingRef.current = false; - - // Start polling as fallback - startPolling(); - - // Attempt to reconnect WebSocket for most disconnect reasons - // 'io server disconnect' = server initiated disconnect - // 'transport close' = network error or server unreachable - // 'transport error' = transport failed (like websocket failed to connect) - if (reason !== 'io client disconnect') { - console.log('Attempting to reconnect WebSocket after disconnect:', reason); - attemptReconnect(); + if (newStatus.downloading?.[data.book_id]) { + newStatus.downloading = { + ...newStatus.downloading, + [data.book_id]: { + ...newStatus.downloading[data.book_id], + progress: data.progress, + }, + }; } - }); - socket.on('connect_error', (err: Error) => { - console.error('WebSocket connection error:', err.message); - setError(`WebSocket error: ${err.message}`); - setConnected(false); - setIsUsingWebSocket(false); - isConnectingRef.current = false; - - // Start polling immediately on connection error - startPolling(); - - // Attempt to reconnect WebSocket - attemptReconnect(); + return newStatus; }); + }; - // Listen for status updates (full status refresh) - socket.on('status_update', (data: StatusData) => { - console.debug('[WS] status_update received', Object.keys(data)); - setStatus(data); - setError(null); - }); + socket.on('status_update', handleStatusUpdate); + socket.on('download_progress', handleDownloadProgress); - // Listen for real-time progress updates (incremental) - socket.on('download_progress', (data: { book_id: string; progress: number; status: string }) => { - console.debug('[WS] download_progress:', data.book_id, `${data.progress.toFixed(1)}%`); - setStatus(prev => { - const newStatus = { ...prev }; - - // Update progress in downloading state - if (newStatus.downloading?.[data.book_id]) { - newStatus.downloading = { - ...newStatus.downloading, - [data.book_id]: { - ...newStatus.downloading[data.book_id], - progress: data.progress, - }, - }; - } - // Also check resolving state in case status update hasn't arrived yet - else if (newStatus.resolving?.[data.book_id]) { - // Book is resolving - progress will apply when it moves to downloading - } - - return newStatus; - }); - }); - - socket.on('error', (err: Error) => { - console.error('WebSocket error:', err); - setError('WebSocket error occurred'); - }); - - } catch (err) { - console.error('Failed to initialize WebSocket:', err); - setError('Failed to initialize WebSocket'); - isConnectingRef.current = false; + // Request initial status when socket connects + if (connected) { + stopPolling(); + socket.emit('request_status'); + } else { startPolling(); } - }, [wsUrl, stopPolling, startPolling, attemptReconnect]); + + return () => { + socket.off('status_update', handleStatusUpdate); + socket.off('download_progress', handleDownloadProgress); + }; + }, [socket, connected, startPolling, stopPolling]); + + // Handle connection state changes + useEffect(() => { + if (connected) { + stopPolling(); + } else { + startPolling(); + } + }, [connected, startPolling, stopPolling]); // Force refresh function const forceRefresh = useCallback(async () => { - if (socketRef.current?.connected) { - // Request update via WebSocket - socketRef.current.emit('request_status'); + if (socket?.connected) { + socket.emit('request_status'); } else { - // Poll immediately await pollStatus(); } - }, [pollStatus]); + }, [socket, pollStatus]); - // Initialize on mount + // Cleanup polling on unmount useEffect(() => { - // Try WebSocket first - initializeWebSocket(); - - // If WebSocket doesn't connect within 3 seconds, start polling - const fallbackTimeout = setTimeout(() => { - if (!socketRef.current?.connected) { - console.log('WebSocket connection timeout, starting polling'); - startPolling(); - } - }, 3000); - - // Cleanup return () => { - clearTimeout(fallbackTimeout); - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - stopPolling(); - - if (socketRef.current) { - socketRef.current.disconnect(); - socketRef.current = null; - } }; - }, [initializeWebSocket, startPolling, stopPolling]); + }, [stopPolling]); return { status, connected, - isUsingWebSocket, + isUsingWebSocket: connected, error, forceRefresh, }; diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index 894fc10e..713563a1 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -88,11 +88,15 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { if (searchMode === 'universal') { const params = new URLSearchParams(query); const searchQuery = params.get('query') || ''; - const sort = params.get('sort') || 'relevance'; // Use explicitly passed fieldValues if provided, otherwise fall back to state const effectiveFieldValues = fieldValues ?? searchFieldValues; const hasFieldValues = Object.values(effectiveFieldValues).some(v => v !== '' && v !== false); + // Auto-set sort to series_order when searching by series field + const seriesValue = effectiveFieldValues.series; + const hasSeriesSearch = typeof seriesValue === 'string' && seriesValue.trim() !== ''; + const sort = hasSeriesSearch ? 'series_order' : (params.get('sort') || 'relevance'); + // Debug logging console.log('[useSearch] Universal mode search:', { query, @@ -111,6 +115,11 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { return; } + // Update UI sort dropdown to reflect series_order when searching by series + if (hasSeriesSearch) { + setAdvancedFilters(prev => ({ ...prev, sort: 'series_order' })); + } + setIsSearching(true); setLastSearchQuery(query); diff --git a/src/frontend/src/main.tsx b/src/frontend/src/main.tsx index 3e807b79..dba69e24 100644 --- a/src/frontend/src/main.tsx +++ b/src/frontend/src/main.tsx @@ -1,6 +1,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; +import { SocketProvider } from './contexts/SocketContext'; import App from './App'; const root = document.getElementById('root'); @@ -9,7 +10,9 @@ if (!root) throw new Error('Root element not found'); createRoot(root).render( - + + + ); diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index dcb9c3fe..d6ac6c1f 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -142,6 +142,8 @@ export const downloadRelease = async (release: { source: string; source_id: string; title: string; + author?: string; // Author from metadata provider + year?: string; // Year from metadata provider format?: string; size?: string; size_bytes?: number; @@ -151,7 +153,6 @@ export const downloadRelease = async (release: { seeders?: number; extra?: Record; preview?: string; // Book cover from metadata provider - author?: string; // Author from metadata provider }): Promise => { await fetchJSON(`${API_BASE}/releases/download`, { method: 'POST', @@ -232,7 +233,9 @@ export const getReleases = async ( bookId: string, source?: string, title?: string, - author?: string + author?: string, + expandSearch?: boolean, + languages?: string[] ): Promise => { const params = new URLSearchParams({ provider, @@ -247,5 +250,11 @@ export const getReleases = async ( if (author) { params.set('author', author); } + if (expandSearch) { + params.set('expand_search', 'true'); + } + if (languages && languages.length > 0) { + params.set('languages', languages.join(',')); + } return fetchJSON(`${API_BASE}/releases?${params.toString()}`); }; diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 3e3503b8..c41a0691 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -41,6 +41,10 @@ export interface Book { genres?: string[]; source_url?: string; // Link to book on provider's site display_fields?: DisplayField[]; // Provider-specific display data + // Series info (if book is part of a series) + series_name?: string; // Name of the series + series_position?: number; // This book's position (e.g., 3, 1.5 for novellas) + series_count?: number; // Total books in the series } // Status response types @@ -184,6 +188,7 @@ export const isMetadataBook = (book: Book): book is Book & { export interface ReleaseSource { name: string; // e.g., 'direct_download', 'prowlarr' display_name: string; // e.g., 'Direct Download', 'Prowlarr' + enabled: boolean; // Whether the source is available for use } // Column schema types for plugin-driven release list UI @@ -221,6 +226,8 @@ export interface ReleaseColumnConfig { columns: ColumnSchema[]; 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 + cache_ttl_seconds?: number; // How long to cache results (default: 300 = 5 min) } // A downloadable release from any source @@ -259,3 +266,12 @@ export interface ReleasesResponse { errors?: string[]; column_config?: ReleaseColumnConfig | null; // Plugin-driven column configuration } + +// Search status update from WebSocket (for ReleaseModal loading state) +export interface SearchStatusData { + source: string; // Release source name (e.g., 'irc', 'direct_download') + provider: string; // Metadata provider (may be empty) + book_id: string; // Book ID (may be empty) + message: string; // Human-readable status message + phase: 'connecting' | 'searching' | 'downloading' | 'parsing' | 'complete' | 'error'; +} diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index 2b362f54..91faa532 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -19,7 +19,8 @@ export interface SelectOption { // Conditional visibility configuration export interface ShowWhenCondition { field: string; // The field key to check - value: string | string[]; // The value(s) that make this field visible + value?: string | string[]; // The value(s) that make this field visible + notEmpty?: boolean; // If true, show when field has any non-empty value } // Conditional disable configuration diff --git a/src/frontend/src/utils/bookTransformers.ts b/src/frontend/src/utils/bookTransformers.ts index 330691ff..807a0ca1 100644 --- a/src/frontend/src/utils/bookTransformers.ts +++ b/src/frontend/src/utils/bookTransformers.ts @@ -24,6 +24,10 @@ export interface MetadataBookData { value: string; icon?: string; }>; + // Series info + series_name?: string; + series_position?: number; + series_count?: number; } /** @@ -48,6 +52,9 @@ export function transformMetadataToBook(data: MetadataBookData): Book { genres: data.genres, source_url: data.source_url, display_fields: data.display_fields, + series_name: data.series_name, + series_position: data.series_position, + series_count: data.series_count, info: { ...(data.isbn_13 && { ISBN: data.isbn_13 }), ...(data.isbn_10 && !data.isbn_13 && { ISBN: data.isbn_10 }), diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts index 506ac787..4f5b648a 100644 --- a/src/frontend/vite.config.ts +++ b/src/frontend/vite.config.ts @@ -21,7 +21,8 @@ export default defineConfig({ changeOrigin: true, secure: false, }, - // Proxy debug endpoint (uses /api/debug so it's automatically proxied above) + // Note: Socket.IO connects directly to backend (port 8084) in dev mode + // to avoid Vite WebSocket proxy issues. No proxy needed here. }, }, build: { diff --git a/testing/E2E_test.py b/testing/E2E_test.py deleted file mode 100644 index 94bea282..00000000 --- a/testing/E2E_test.py +++ /dev/null @@ -1,153 +0,0 @@ -import requests -import time -import os -import hashlib - -# Thee server is already running, so let's grab some of the env vars: -# Use absolute import since the script is run from the root directory -import env as SERVER_ENV -from models import BookInfo - -# Now let's test the server: -port = SERVER_ENV.FLASK_PORT -server_url = f"http://localhost:{port}" -book_title = "077484a10743e5dd5d151013e8c732f4" # "Moby Dick" -# Directory where downloads should appear -download_paths = SERVER_ENV.DOWNLOAD_PATHS -download_dir = SERVER_ENV.INGEST_DIR -# Timeout for waiting for download -download_timeout_seconds = 60 * 5 -# Polling interval -poll_interval_seconds = 5 - -# Helper function to check download status -def check_download_status(book_id): - print(f"Polling status for {book_id}...") - start_time = time.time() - while time.time() - start_time < download_timeout_seconds: - try: - status_response = requests.get(f"{server_url}/api/status") - status_response.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx) - status_data = status_response.json() - except requests.exceptions.RequestException as e: - print(f"Error fetching status: {e}. Retrying...") - time.sleep(poll_interval_seconds) - continue - except ValueError: # Includes JSONDecodeError - print(f"Error decoding status JSON. Response text: {status_response.text}. Retrying...") - time.sleep(poll_interval_seconds) - continue - - # Check success conditions based on download_path - for status_key in ["available", "done", "complete"]: - if status_key in status_data and book_id in status_data[status_key]: - book_status_info = status_data[status_key].get(book_id) - # Check if the status info is a dictionary and has a non-empty download_path - if isinstance(book_status_info, dict) and book_status_info.get('download_path'): - print(f"Book {book_id} has download path '{book_status_info['download_path']}' in status '{status_key}'.") - return True, book_status_info - - # Check for error status - if "error" in status_data and book_id in status_data["error"]: - book_error_info = status_data["error"].get(book_id, "Unknown error") - print(f"Book {book_id} failed with error: {book_error_info}") - return False, book_error_info - - #print(f"Polling status for {book_id}... Status: {status_data}") - time.sleep(poll_interval_seconds) - - print(f"Timeout waiting for book {book_id} download path to appear.") - return False, None - -# --- Test Execution --- -print("--- Starting E2E Test ---") - -# Step 1 : Search for a book -print(f"Step 1: Searching for book '{book_title}' (moby dick)...") -search_params = {'query': book_title} -search_response = requests.get(f"{server_url}/api/search", params=search_params) -search_response.raise_for_status() -search_results = search_response.json() - -assert isinstance(search_results, list), f"Expected search results to be a list, got {type(search_results)}" -assert len(search_results) > 0, f"No books found for query: {book_title}" -print(f"Found {len(search_results)} potential matches.") - -# Assume the first result is the one we want -book_to_test = search_results[0] -book_id = book_to_test.get('id') -assert book_id, "First search result is missing an 'id'" -print(f"Selected book ID for testing: {book_id}") - -# Step 2 : Get book details -print(f"Step 2: Getting details for book ID: {book_id}...") -info_params = {'id': book_id} -info_response = requests.get(f"{server_url}/api/info", params=info_params) -info_response.raise_for_status() -book_details = info_response.json() - -assert isinstance(book_details, dict), f"Expected book details to be a dict, got {type(book_details)}" -assert book_details.get('id') == book_id, "Book details ID mismatch" -print(f"Successfully retrieved details for '{book_details.get('title', 'N/A')}'") - -# Step 3 : Queue the book for download -print(f"Step 3: Queuing download for book ID: {book_id}...") -download_params = {'id': book_id} -download_response = requests.get(f"{server_url}/api/download", params=download_params) -download_response.raise_for_status() -download_status = download_response.json() - -assert download_status.get('status') == 'queued', f"Expected status 'queued', got {download_status}" -print(f"Book {book_id} successfully queued for download.") - -# Step 4 : Check the download status until available or timeout -print(f"Step 4: Checking download status for book ID: {book_id} (timeout: {download_timeout_seconds}s)...") -is_available, final_status = check_download_status(book_id) - -assert is_available, f"Book download failed or timed out. Final status check: {final_status}" -print(f"Book {book_id} download confirmed as available.") - -# Step 5 : Verify the file exists locally (optional but good) -print(f"Step 5: Verifying downloaded file exists...") -# Depend if env.USE_BOOK_TITLE is true or false, the filename will be different -if SERVER_ENV.USE_BOOK_TITLE: - # Build expected filename using BookInfo - book_info = BookInfo( - id=book_id, - title=book_details.get('title', ''), - author=book_details.get('author'), - year=book_details.get('year'), - format='epub' - ) - expected_filename = book_info.get_filename() -else: - expected_filename = f"{book_id}.epub" - -if book_details.get("content"): - content = book_details.get("content") - for key, path in SERVER_ENV.DOWNLOAD_PATHS.items(): - if key in content: - download_dir = path - break -expected_filepath = os.path.join(download_dir, expected_filename) - -assert os.path.exists(expected_filepath), f"Expected downloaded file not found at: {expected_filepath}" -print(f"Verified file exists: {expected_filepath}") - -# Step 6 : Download the book -print(f"Step 6: Downloading book {book_id}...") -download_response = requests.get(f"{server_url}/api/localdownload?id={book_id}") -download_response.raise_for_status() -# Write book to temp file : -temp_file_path = os.path.join("/tmp", f"{book_id}.epub") -with open(temp_file_path, 'wb') as f: - f.write(download_response.content) - -# Compare the downloaded file to the expected file -# compare shasum of the two files -expected_sha256 = hashlib.sha256(open(expected_filepath, 'rb').read()).hexdigest() -downloaded_sha256 = hashlib.sha256(open(temp_file_path, 'rb').read()).hexdigest() -assert expected_sha256 == downloaded_sha256, f"Downloaded file SHA256 mismatch. Expected: {expected_sha256}, Got: {downloaded_sha256}" -print(f"Downloaded file SHA256 matches expected: {expected_sha256}") - -print("--- E2E Test Completed Successfully ---") diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 00000000..20befcbe --- /dev/null +++ b/tests/README.md @@ -0,0 +1,229 @@ +# Test Suite + +This directory contains the test suite for CWA Book Downloader. Tests are organized by scope and component. + +## Quick Start + +```bash +# Run all unit tests (fast, no external dependencies) +docker exec test-cwabd python3 -m pytest tests/ -v -m "not integration and not e2e" + +# Run E2E API tests +docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e + +# Run everything except integration tests +docker exec test-cwabd python3 -m pytest tests/ -v -m "not integration" +``` + +## Test Structure + +``` +tests/ +├── e2e/ # End-to-end API tests +│ ├── conftest.py # Fixtures (APIClient, DownloadTracker) +│ ├── test_api.py # Core API endpoint tests +│ ├── test_download_flow.py # Full download journey tests +│ └── test_prowlarr_flow.py # Prowlarr-specific tests +│ +├── prowlarr/ # Prowlarr plugin tests +│ ├── conftest.py # Shared fixtures +│ ├── test_clients.py # DownloadClient base, registry, DownloadStatus +│ ├── test_qbittorrent_client.py # qBittorrent client unit tests +│ ├── test_transmission_client.py # Transmission client unit tests +│ ├── test_nzbget_client.py # NZBGet client unit tests +│ ├── test_sabnzbd_client.py # SABnzbd client unit tests +│ ├── test_handler.py # ProwlarrHandler unit tests +│ ├── test_torrent_utils.py # Bencode, hash extraction, URL parsing +│ ├── test_bencode.py # Bencode encoding/decoding +│ ├── test_source.py # Release source (size parsing, format detection) +│ ├── test_cache.py # Release cache +│ ├── test_integration_clients.py # Integration tests (require Docker stack) +│ └── test_integration_handler.py # Handler integration tests +│ +└── README.md # This file +``` + +## Test Types + +### Unit Tests +Fast tests that mock external dependencies. Run these frequently during development. + +```bash +docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m "not integration" +``` + +**What they test:** +- Download client logic (status mapping, URL handling, error cases) +- Bencode encoding/decoding for torrent files +- Hash extraction from magnet links and .torrent files +- Protocol detection (torrent vs usenet) +- Release cache operations +- Handler download flow logic + +### E2E Tests +Test the full application through its HTTP API. Require the app to be running. + +```bash +docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +``` + +**What they test:** +- Health check endpoint +- Configuration endpoint +- Metadata provider search (Hardcover, etc.) +- Release source listing +- Download queue operations (add, cancel, reorder, clear) +- Settings API +- Prowlarr integration + +### Integration Tests +Test against real services (qBittorrent, Transmission, etc.). Require the full Docker test stack. + +```bash +# Start the test stack first +docker compose -f docker-compose.test-clients.yml up -d + +# Run integration tests +docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m integration +``` + +**What they test:** +- Real connections to download clients +- Adding/removing actual torrents +- Status polling from real clients + +## Test Markers + +| Marker | Description | When to Skip | +|--------|-------------|--------------| +| `integration` | Requires running services (qBittorrent, etc.) | Default skip with `-m "not integration"` | +| `e2e` | End-to-end API tests | When app isn't running | +| `slow` | Tests that take longer (network calls, polling) | Quick feedback with `-m "not slow"` | + +## Common Commands + +```bash +# Run specific test file +docker exec test-cwabd python3 -m pytest tests/prowlarr/test_clients.py -v + +# Run specific test class +docker exec test-cwabd python3 -m pytest tests/e2e/test_api.py::TestHealthEndpoint -v + +# Run specific test +docker exec test-cwabd python3 -m pytest tests/e2e/test_api.py::TestHealthEndpoint::test_health_returns_ok -v + +# Run with short traceback (cleaner output) +docker exec test-cwabd python3 -m pytest tests/ -v --tb=short -m "not integration" + +# Run and stop on first failure +docker exec test-cwabd python3 -m pytest tests/ -v -x -m "not integration" + +# Run with coverage (if pytest-cov installed) +docker exec test-cwabd python3 -m pytest tests/ --cov=cwa_book_downloader -m "not integration" +``` + +## Writing New Tests + +### Unit Test Example + +```python +from unittest.mock import MagicMock, patch + +class TestMyFeature: + def test_something(self, monkeypatch): + # Mock config values + monkeypatch.setattr( + "cwa_book_downloader.module.config.get", + lambda key, default="": {"KEY": "value"}.get(key, default), + ) + + # Test your code + result = my_function() + assert result == expected +``` + +### E2E Test Example + +```python +import pytest +from .conftest import APIClient, DownloadTracker + +@pytest.mark.e2e +class TestMyEndpoint: + def test_endpoint_works(self, api_client: APIClient): + resp = api_client.get("/api/my-endpoint") + assert resp.status_code == 200 + + def test_with_cleanup(self, api_client: APIClient, download_tracker: DownloadTracker): + # Track IDs for automatic cleanup after test + download_tracker.track("some-id") + # ... test code ... +``` + +## Test Fixtures + +### E2E Fixtures (`tests/e2e/conftest.py`) + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `api_client` | session | HTTP client for API calls | +| `download_tracker` | function | Tracks downloads for cleanup | +| `server_config` | session | Cached server configuration | + +### Prowlarr Fixtures (`tests/prowlarr/conftest.py`) + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `transmission_client` | module | Real Transmission client (integration) | +| `qbittorrent_client` | module | Real qBittorrent client (integration) | +| `deluge_client` | module | Real Deluge client (integration) | +| `nzbget_client` | module | Real NZBGet client (integration) | +| `sabnzbd_client` | module | Real SABnzbd client (integration) | + +## Expected Skips + +Some tests skip when external services aren't available. This is normal: + +- **"No metadata providers available"** - Metadata provider not responding +- **"Prowlarr not configured"** - Prowlarr settings not set up +- **"No releases found"** - No indexers configured in Prowlarr +- **"Legacy search source unavailable"** - Direct download source offline +- **"Transmission/qBittorrent not available"** - Docker test stack not running + +## Troubleshooting + +### Tests can't connect to app +```bash +# Check the app is running +docker ps | grep test-cwabd + +# Check app logs +docker logs test-cwabd +``` + +### Import errors +```bash +# Make sure you're running inside the container +docker exec test-cwabd python3 -m pytest ... + +# Not from your local machine +pytest ... # This won't work +``` + +### Integration tests failing +```bash +# Make sure test stack is running +docker compose -f docker-compose.test-clients.yml up -d + +# Check client containers +docker ps | grep -E "qbittorrent|transmission|deluge|nzbget|sabnzbd" +``` + +### Stale test data +```bash +# Clear the queue between test runs +docker exec test-cwabd python3 -c " +from cwa_book_downloader.core.queue import book_queue +book_queue.clear() +" +``` diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..64d52433 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,86 @@ +""" +Pytest configuration and shared fixtures. +""" + +import os +import sys +import tempfile + +# Set environment variables BEFORE importing the application +# These override the defaults that try to use system paths like /var/log +_temp_base = tempfile.mkdtemp(prefix="cwabd_test_") + +# LOG_ROOT is the base - LOG_DIR is computed as LOG_ROOT / "cwa-book-downloader" +# So we set LOG_ROOT to our temp directory to get LOG_DIR = _temp_base/cwa-book-downloader +os.environ["LOG_ROOT"] = _temp_base +os.environ["CONFIG_DIR"] = os.path.join(_temp_base, "config") +os.environ["INGEST_DIR"] = os.path.join(_temp_base, "ingest") +os.environ["TMP_DIR"] = os.path.join(_temp_base, "tmp") + +# Create the directories that will be used +os.makedirs(os.path.join(_temp_base, "cwa-book-downloader"), exist_ok=True) # LOG_DIR +os.makedirs(os.path.join(_temp_base, "config"), exist_ok=True) +os.makedirs(os.path.join(_temp_base, "ingest"), exist_ok=True) +os.makedirs(os.path.join(_temp_base, "tmp"), exist_ok=True) + +# Add the project root to Python path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest + + +@pytest.fixture +def sample_prowlarr_result(): + """Sample Prowlarr API search result.""" + return { + "guid": "abc123-guid", + "title": "The Great Book by Author Name.epub", + "indexer": "MyIndexer", + "protocol": "torrent", + "size": 5242880, # 5 MB + "downloadUrl": "magnet:?xt=urn:btih:abc123", + "infoUrl": "https://example.com/book/123", + "seeders": 10, + "leechers": 2, + "publishDate": "2024-01-15T12:00:00Z", + "categories": [{"id": 7020, "name": "Books/EBook"}], + "indexerId": 1, + } + + +@pytest.fixture +def sample_nzb_result(): + """Sample Prowlarr API NZB result.""" + return { + "guid": "nzb456-guid", + "title": "Another Book [PDF] by Writer", + "indexer": "NZBIndexer", + "protocol": "usenet", + "size": 10485760, # 10 MB + "downloadUrl": "https://example.com/download.nzb", + "infoUrl": "https://example.com/nzb/456", + "grabs": 50, + "publishDate": "2024-02-20T10:30:00Z", + "categories": [{"id": 7020, "name": "Books/EBook"}], + "indexerId": 2, + } + + +@pytest.fixture +def mock_config(monkeypatch): + """Fixture to mock config values.""" + config_values = {} + + def mock_get(key, default=""): + return config_values.get(key, default) + + def set_config(key, value): + config_values[key] = value + + # Create a mock config module + class MockConfig: + get = staticmethod(mock_get) + set = staticmethod(set_config) + _values = config_values + + return MockConfig diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 00000000..db93ad00 --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1 @@ +"""E2E test package.""" diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 00000000..0e6e4d0a --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,172 @@ +""" +E2E Test Configuration and Fixtures. + +These tests require the full application stack to be running. +Run with: docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +""" + +import os +import time +from typing import Generator, List, Optional +from dataclasses import dataclass, field + +import pytest +import requests + + +# Default test configuration +DEFAULT_BASE_URL = "http://localhost:8084" +DEFAULT_TIMEOUT = 10 +POLL_INTERVAL = 2 +DOWNLOAD_TIMEOUT = 300 # 5 minutes max for downloads + + +@dataclass +class APIClient: + """HTTP client for E2E API testing.""" + + base_url: str + timeout: int = DEFAULT_TIMEOUT + session: requests.Session = field(default_factory=requests.Session) + + def get(self, path: str, **kwargs) -> requests.Response: + """Make a GET request.""" + kwargs.setdefault("timeout", self.timeout) + return self.session.get(f"{self.base_url}{path}", **kwargs) + + def post(self, path: str, **kwargs) -> requests.Response: + """Make a POST request.""" + kwargs.setdefault("timeout", self.timeout) + return self.session.post(f"{self.base_url}{path}", **kwargs) + + def put(self, path: str, **kwargs) -> requests.Response: + """Make a PUT request.""" + kwargs.setdefault("timeout", self.timeout) + return self.session.put(f"{self.base_url}{path}", **kwargs) + + def delete(self, path: str, **kwargs) -> requests.Response: + """Make a DELETE request.""" + kwargs.setdefault("timeout", self.timeout) + return self.session.delete(f"{self.base_url}{path}", **kwargs) + + def wait_for_health(self, max_wait: int = 30) -> bool: + """Wait for the server to be healthy.""" + start = time.time() + while time.time() - start < max_wait: + try: + resp = self.get("/api/health") + if resp.status_code == 200: + return True + except requests.exceptions.ConnectionError: + pass + time.sleep(1) + return False + + +@dataclass +class DownloadTracker: + """Tracks downloads for cleanup after tests.""" + + client: APIClient + queued_ids: List[str] = field(default_factory=list) + + def track(self, book_id: str) -> str: + """Track a book ID for cleanup.""" + self.queued_ids.append(book_id) + return book_id + + def cleanup(self) -> None: + """Cancel all tracked downloads.""" + for book_id in self.queued_ids: + try: + self.client.delete(f"/api/download/{book_id}/cancel") + except Exception: + pass # Best effort cleanup + self.queued_ids.clear() + + def wait_for_status( + self, + book_id: str, + target_states: List[str], + timeout: int = DOWNLOAD_TIMEOUT, + ) -> Optional[dict]: + """ + Poll status until book reaches one of the target states. + + Args: + book_id: The book/task ID to check + target_states: List of states to wait for (e.g., ["complete", "error"]) + timeout: Maximum seconds to wait + + Returns: + Status dict if target state reached, None if timeout + """ + start = time.time() + while time.time() - start < timeout: + try: + resp = self.client.get("/api/status") + if resp.status_code != 200: + time.sleep(POLL_INTERVAL) + continue + + status_data = resp.json() + + # Check each status category + for state in target_states: + if state in status_data and book_id in status_data[state]: + return { + "state": state, + "data": status_data[state][book_id], + } + + # Check for error state + if "error" in status_data and book_id in status_data["error"]: + return { + "state": "error", + "data": status_data["error"][book_id], + } + + except Exception: + pass + + time.sleep(POLL_INTERVAL) + + return None + + +@pytest.fixture(scope="session") +def base_url() -> str: + """Get the base URL for the API server.""" + return os.environ.get("E2E_BASE_URL", DEFAULT_BASE_URL) + + +@pytest.fixture(scope="session") +def api_client(base_url: str) -> Generator[APIClient, None, None]: + """Create an API client for the test session.""" + client = APIClient(base_url=base_url) + + # Wait for server to be healthy + if not client.wait_for_health(): + pytest.skip("Server not available - ensure the app is running") + + yield client + + # Cleanup session + client.session.close() + + +@pytest.fixture +def download_tracker(api_client: APIClient) -> Generator[DownloadTracker, None, None]: + """Create a download tracker that cleans up after each test.""" + tracker = DownloadTracker(client=api_client) + yield tracker + tracker.cleanup() + + +@pytest.fixture(scope="session") +def server_config(api_client: APIClient) -> dict: + """Get server configuration.""" + resp = api_client.get("/api/config") + if resp.status_code != 200: + return {} + return resp.json() diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py new file mode 100644 index 00000000..0a46ddc0 --- /dev/null +++ b/tests/e2e/test_api.py @@ -0,0 +1,388 @@ +""" +E2E API Tests. + +Tests the full application flow through the HTTP API. + +Run with: docker exec test-cwabd python3 -m pytest tests/e2e/ -v -m e2e +""" + +import pytest + +from .conftest import APIClient, DownloadTracker + + +@pytest.mark.e2e +class TestHealthEndpoint: + """Tests for the health check endpoint.""" + + def test_health_returns_ok(self, api_client: APIClient): + """Test that health endpoint returns 200.""" + resp = api_client.get("/api/health") + + assert resp.status_code == 200 + data = resp.json() + assert data.get("status") == "ok" + + def test_health_includes_status(self, api_client: APIClient): + """Test that health endpoint includes status field.""" + resp = api_client.get("/api/health") + + data = resp.json() + assert "status" in data + assert data["status"] == "ok" + + +@pytest.mark.e2e +class TestConfigEndpoint: + """Tests for the configuration endpoint.""" + + def test_config_returns_expected_fields(self, api_client: APIClient): + """Test that config includes expected configuration fields.""" + resp = api_client.get("/api/config") + + assert resp.status_code == 200 + data = resp.json() + # Config should be a dict with various settings + assert isinstance(data, dict) + # Should have some standard config fields + assert "supported_formats" in data or "book_languages" in data + + def test_config_returns_supported_formats(self, api_client: APIClient): + """Test that config includes supported formats.""" + resp = api_client.get("/api/config") + + data = resp.json() + assert "supported_formats" in data + assert isinstance(data["supported_formats"], list) + # Should include common ebook formats + formats = data["supported_formats"] + assert "epub" in formats or "EPUB" in [f.upper() for f in formats] + + +@pytest.mark.e2e +class TestReleaseSourcesEndpoint: + """Tests for the release sources endpoint.""" + + def test_release_sources_returns_list(self, api_client: APIClient): + """Test that release sources endpoint returns available sources.""" + resp = api_client.get("/api/release-sources") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, list) + + def test_release_sources_have_required_fields(self, api_client: APIClient): + """Test that each release source has required fields.""" + resp = api_client.get("/api/release-sources") + + data = resp.json() + for source in data: + assert "name" in source + assert "display_name" in source or "label" in source + + +@pytest.mark.e2e +class TestMetadataProvidersEndpoint: + """Tests for the metadata providers endpoint.""" + + def test_providers_returns_data(self, api_client: APIClient): + """Test that providers endpoint returns provider data.""" + resp = api_client.get("/api/metadata/providers") + + assert resp.status_code == 200 + data = resp.json() + # May be list or dict depending on implementation + assert isinstance(data, (list, dict)) + + def test_providers_have_required_fields(self, api_client: APIClient): + """Test that each provider has required fields.""" + resp = api_client.get("/api/metadata/providers") + + data = resp.json() + # Handle both list and dict formats + if isinstance(data, dict): + providers = list(data.values()) if data else [] + else: + providers = data + + for provider in providers: + if isinstance(provider, dict): + # Should have name or be identifiable + assert "name" in provider or "id" in provider or "label" in provider + + +@pytest.mark.e2e +class TestMetadataSearch: + """Tests for metadata search functionality.""" + + def test_search_requires_query(self, api_client: APIClient): + """Test that search requires a query parameter.""" + resp = api_client.get("/api/metadata/search") + + # Should return error for missing query + assert resp.status_code in [400, 422] + + def test_search_returns_results(self, api_client: APIClient): + """Test that search returns results for a known book.""" + resp = api_client.get("/api/metadata/search", params={"query": "1984 Orwell"}) + + # May return 200 with results or 503 if provider unavailable + if resp.status_code == 200: + data = resp.json() + # Response may be list directly, or dict with results key + assert "results" in data or isinstance(data, list) or "query" in data + + def test_search_with_provider_filter(self, api_client: APIClient): + """Test searching with a specific provider.""" + # Get available providers first + providers_resp = api_client.get("/api/metadata/providers") + if providers_resp.status_code != 200: + pytest.skip("Could not get providers") + + providers_data = providers_resp.json() + if not providers_data: + pytest.skip("No providers available") + + # Handle both list and dict formats + if isinstance(providers_data, dict): + # Dict format: get first provider name from keys or values + if providers_data: + first_key = list(providers_data.keys())[0] + provider_info = providers_data[first_key] + provider_name = provider_info.get("name", first_key) if isinstance(provider_info, dict) else first_key + else: + pytest.skip("No providers available") + else: + # List format + provider_name = providers_data[0].get("name") if providers_data else None + + if not provider_name: + pytest.skip("Could not determine provider name") + + resp = api_client.get( + "/api/metadata/search", + params={"query": "Moby Dick", "provider": provider_name}, + ) + + # Should return 200 or 503 (provider unavailable) + assert resp.status_code in [200, 503] + + +@pytest.mark.e2e +class TestStatusEndpoint: + """Tests for the status endpoint.""" + + def test_status_returns_categories(self, api_client: APIClient): + """Test that status endpoint returns expected categories.""" + resp = api_client.get("/api/status") + + assert resp.status_code == 200 + data = resp.json() + # Should have standard status categories + assert isinstance(data, dict) + + def test_active_downloads_endpoint(self, api_client: APIClient): + """Test the active downloads endpoint.""" + resp = api_client.get("/api/downloads/active") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, (list, dict)) + + +@pytest.mark.e2e +class TestQueueEndpoint: + """Tests for queue management endpoints.""" + + def test_queue_order_returns_data(self, api_client: APIClient): + """Test that queue order endpoint returns queue data.""" + resp = api_client.get("/api/queue/order") + + assert resp.status_code == 200 + data = resp.json() + # May return list directly or dict with queue key + if isinstance(data, dict): + assert "queue" in data + assert isinstance(data["queue"], list) + else: + assert isinstance(data, list) + + def test_clear_queue(self, api_client: APIClient, download_tracker: DownloadTracker): + """Test clearing the queue.""" + resp = api_client.delete("/api/queue/clear") + + # Should succeed (may be 200 or 204) + assert resp.status_code in [200, 204] + + +@pytest.mark.e2e +class TestSettingsEndpoint: + """Tests for settings endpoints.""" + + def test_settings_returns_tabs(self, api_client: APIClient): + """Test that settings endpoint returns tab structure.""" + resp = api_client.get("/api/settings") + + # Settings may be disabled if config dir not writable + if resp.status_code == 403: + pytest.skip("Settings disabled (config dir not writable)") + + assert resp.status_code == 200 + data = resp.json() + assert isinstance(data, (list, dict)) + + def test_get_specific_settings_tab(self, api_client: APIClient): + """Test getting a specific settings tab.""" + # First get available tabs + resp = api_client.get("/api/settings") + if resp.status_code == 403: + pytest.skip("Settings disabled") + + data = resp.json() + if not data: + pytest.skip("No settings tabs available") + + # Get the first tab + if isinstance(data, list): + tab_name = data[0].get("name") or data[0].get("id") + else: + tab_name = list(data.keys())[0] if data else None + + if not tab_name: + pytest.skip("Could not determine tab name") + + resp = api_client.get(f"/api/settings/{tab_name}") + assert resp.status_code in [200, 404] + + +@pytest.mark.e2e +class TestDownloadFlow: + """Tests for the complete download flow.""" + + def test_download_requires_id(self, api_client: APIClient): + """Test that download endpoint requires an ID.""" + resp = api_client.get("/api/download") + + assert resp.status_code in [400, 422] + + def test_download_invalid_id_returns_error(self, api_client: APIClient): + """Test that invalid ID returns appropriate error.""" + resp = api_client.get("/api/download", params={"id": "nonexistent-id-12345"}) + + # Should return 404 or error status + assert resp.status_code in [400, 404, 500] + + def test_cancel_nonexistent_download(self, api_client: APIClient): + """Test cancelling a download that doesn't exist.""" + resp = api_client.delete("/api/download/nonexistent-id-xyz/cancel") + + # Should handle gracefully (may return 200, 204, or 404) + assert resp.status_code in [200, 204, 404] + + +@pytest.mark.e2e +class TestReleaseDownloadFlow: + """Tests for the release-based download flow (new API).""" + + def test_release_download_requires_source_id(self, api_client: APIClient): + """Test that release download requires source_id.""" + resp = api_client.post("/api/releases/download", json={}) + + assert resp.status_code == 400 + data = resp.json() + assert "error" in data + + def test_release_download_with_minimal_data( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test queueing a release with minimal valid data.""" + # This will queue but likely fail during download (no real source) + test_id = "e2e-test-release-minimal" + resp = api_client.post( + "/api/releases/download", + json={ + "source": "test_source", + "source_id": test_id, + "title": "E2E Test Book", + }, + ) + + if resp.status_code == 200: + download_tracker.track(test_id) + data = resp.json() + assert data.get("status") == "queued" + + +@pytest.mark.e2e +class TestReleasesSearch: + """Tests for searching releases.""" + + def test_releases_requires_params(self, api_client: APIClient): + """Test that releases endpoint requires provider and book_id.""" + resp = api_client.get("/api/releases") + + assert resp.status_code == 400 + data = resp.json() + assert "error" in data + + def test_releases_with_invalid_provider(self, api_client: APIClient): + """Test releases with invalid provider.""" + resp = api_client.get( + "/api/releases", + params={"provider": "nonexistent_provider", "book_id": "123"}, + ) + + assert resp.status_code == 400 + data = resp.json() + assert "error" in data + + +@pytest.mark.e2e +class TestCoverProxy: + """Tests for the cover image proxy.""" + + def test_cover_without_url_returns_error(self, api_client: APIClient): + """Test that cover endpoint without URL returns error.""" + resp = api_client.get("/api/covers/test-id") + + # Should return error for missing URL + assert resp.status_code in [400, 404] + + +@pytest.mark.e2e +class TestLegacySearchEndpoint: + """Tests for the legacy search endpoint (backwards compatibility).""" + + def test_legacy_search_without_query(self, api_client: APIClient): + """Test legacy search behavior without query parameter.""" + resp = api_client.get("/api/search") + + # May return 400 (error) or 200 with empty results depending on implementation + assert resp.status_code in [200, 400, 422] + + def test_legacy_search_returns_results(self, api_client: APIClient): + """Test legacy search with a query.""" + resp = api_client.get("/api/search", params={"query": "Pride Prejudice"}) + + # May return results or 503 if source unavailable + if resp.status_code == 200: + data = resp.json() + assert isinstance(data, list) + + +@pytest.mark.e2e +class TestLegacyInfoEndpoint: + """Tests for the legacy info endpoint.""" + + def test_legacy_info_requires_id(self, api_client: APIClient): + """Test that legacy info requires ID parameter.""" + resp = api_client.get("/api/info") + + assert resp.status_code in [400, 422] + + def test_legacy_info_invalid_id(self, api_client: APIClient): + """Test legacy info with invalid ID.""" + resp = api_client.get("/api/info", params={"id": "invalid-id-xyz"}) + + # Should return 404 or error + assert resp.status_code in [400, 404, 500] diff --git a/tests/e2e/test_download_flow.py b/tests/e2e/test_download_flow.py new file mode 100644 index 00000000..70da8f0a --- /dev/null +++ b/tests/e2e/test_download_flow.py @@ -0,0 +1,387 @@ +""" +E2E Download Flow Tests. + +These tests verify the complete download journey from search to file retrieval. +They require external services to be available and may take longer to run. + +Run with: docker exec test-cwabd python3 -m pytest tests/e2e/test_download_flow.py -v -m e2e +""" + +import os +import hashlib +import time + +import pytest + +from .conftest import APIClient, DownloadTracker, DOWNLOAD_TIMEOUT + + +def _find_available_provider(api_client: APIClient) -> str | None: + """Find a working metadata provider.""" + resp = api_client.get("/api/metadata/providers") + if resp.status_code != 200: + return None + + providers_data = resp.json() + + # Handle both dict and list formats + if isinstance(providers_data, dict): + # Dict format: keys are provider names + provider_names = list(providers_data.keys()) + else: + # List format + provider_names = [p.get("name") for p in providers_data if isinstance(p, dict) and p.get("name")] + + for name in provider_names: + if name: + # Try a simple search to verify it works + test_resp = api_client.get( + "/api/metadata/search", + params={"query": "test", "provider": name}, + timeout=30, + ) + if test_resp.status_code == 200: + return name + return None + + +def _find_available_release_source(api_client: APIClient) -> str | None: + """Find a working release source.""" + resp = api_client.get("/api/release-sources") + if resp.status_code != 200: + return None + + sources = resp.json() + for source in sources: + name = source.get("name") + # Skip prowlarr unless configured + if name and name != "prowlarr": + return name + return None + + +@pytest.mark.e2e +@pytest.mark.slow +class TestMetadataToReleaseFlow: + """Test the flow from metadata search to release listing.""" + + def test_search_to_releases_flow(self, api_client: APIClient): + """Test searching metadata then finding releases.""" + # Find a working provider + provider = _find_available_provider(api_client) + if not provider: + pytest.skip("No metadata providers available") + + # Search for a public domain book + search_resp = api_client.get( + "/api/metadata/search", + params={"query": "Moby Dick Herman Melville", "provider": provider}, + timeout=30, + ) + + if search_resp.status_code != 200: + pytest.skip(f"Search failed: {search_resp.status_code}") + + search_data = search_resp.json() + results = search_data.get("results", search_data) + + # Handle dict format where results might be nested + if isinstance(results, dict): + # Results might be under a key like the query or "results" + for key, value in results.items(): + if isinstance(value, list) and value: + results = value + break + + if not results or not isinstance(results, list): + pytest.skip("No search results returned") + + # Get the first result + first_result = results[0] + book_id = first_result.get("id") or first_result.get("provider_id") + assert book_id, "Search result missing ID" + + # Now search for releases + releases_resp = api_client.get( + "/api/releases", + params={ + "provider": provider, + "book_id": book_id, + "title": first_result.get("title", ""), + "author": first_result.get("author", ""), + }, + timeout=60, + ) + + # Releases may fail if sources are unavailable + if releases_resp.status_code == 200: + releases_data = releases_resp.json() + assert "releases" in releases_data + assert "book" in releases_data + + +@pytest.mark.e2e +@pytest.mark.slow +class TestFullDownloadJourney: + """ + Test the complete download journey. + + This test: + 1. Searches for a book + 2. Finds releases + 3. Queues a download + 4. Waits for completion + 5. Verifies the file exists + """ + + def test_complete_download_flow( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test the complete search -> download -> verify flow.""" + # Find a working provider + provider = _find_available_provider(api_client) + if not provider: + pytest.skip("No metadata providers available") + + # Search for a public domain book + search_resp = api_client.get( + "/api/metadata/search", + params={"query": "Pride and Prejudice Jane Austen", "provider": provider}, + timeout=30, + ) + + if search_resp.status_code != 200: + pytest.skip(f"Metadata search unavailable: {search_resp.status_code}") + + search_data = search_resp.json() + results = search_data.get("results", search_data) + + # Handle dict format where results might be nested + if isinstance(results, dict): + for key, value in results.items(): + if isinstance(value, list) and value: + results = value + break + + if not results or not isinstance(results, list): + pytest.skip("No search results") + + first_result = results[0] + book_id = first_result.get("id") or first_result.get("provider_id") + + # Get releases + releases_resp = api_client.get( + "/api/releases", + params={ + "provider": provider, + "book_id": book_id, + "title": first_result.get("title", ""), + }, + timeout=60, + ) + + if releases_resp.status_code != 200: + pytest.skip(f"Releases unavailable: {releases_resp.status_code}") + + releases_data = releases_resp.json() + releases = releases_data.get("releases", []) + + if not releases: + pytest.skip("No releases available") + + # Find an epub release (prefer smaller files) + target_release = None + for release in releases: + fmt = release.get("format", "").lower() + if fmt == "epub": + target_release = release + break + + if not target_release: + # Fall back to first release + target_release = releases[0] + + # Queue the download + source_id = target_release.get("source_id") or target_release.get("id") + download_tracker.track(source_id) + + queue_resp = api_client.post( + "/api/releases/download", + json={ + "source": target_release.get("source", "direct_download"), + "source_id": source_id, + "title": target_release.get("title", "Test Book"), + "format": target_release.get("format"), + "size": target_release.get("size"), + }, + ) + + assert queue_resp.status_code == 200, f"Failed to queue: {queue_resp.text}" + queue_data = queue_resp.json() + assert queue_data.get("status") == "queued" + + # Wait for download to complete (or error) + result = download_tracker.wait_for_status( + source_id, + target_states=["complete", "done", "available"], + timeout=DOWNLOAD_TIMEOUT, + ) + + if result is None: + # Check if it errored + status_resp = api_client.get("/api/status") + if status_resp.status_code == 200: + status_data = status_resp.json() + if "error" in status_data and source_id in status_data["error"]: + error_info = status_data["error"][source_id] + pytest.skip(f"Download failed: {error_info}") + pytest.fail("Download timed out") + + assert result["state"] in ["complete", "done", "available"] + + +@pytest.mark.e2e +@pytest.mark.slow +class TestLegacyDownloadFlow: + """Test the legacy download API (for backwards compatibility).""" + + def test_legacy_search_and_download( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test the legacy search -> info -> download flow.""" + # Use the legacy search endpoint + search_resp = api_client.get( + "/api/search", + params={"query": "Frankenstein Mary Shelley"}, + timeout=30, + ) + + if search_resp.status_code == 503: + pytest.skip("Legacy search source unavailable") + + if search_resp.status_code != 200: + pytest.skip(f"Legacy search failed: {search_resp.status_code}") + + results = search_resp.json() + if not results: + pytest.skip("No legacy search results") + + first_result = results[0] + book_id = first_result.get("id") + assert book_id, "Result missing ID" + + # Get book info + info_resp = api_client.get("/api/info", params={"id": book_id}) + + if info_resp.status_code != 200: + pytest.skip(f"Info endpoint failed: {info_resp.status_code}") + + # Queue download (legacy endpoint) + download_tracker.track(book_id) + download_resp = api_client.get("/api/download", params={"id": book_id}) + + if download_resp.status_code != 200: + pytest.skip(f"Download queue failed: {download_resp.status_code}") + + download_data = download_resp.json() + assert download_data.get("status") == "queued" + + +@pytest.mark.e2e +class TestDownloadCancellation: + """Test download cancellation functionality.""" + + def test_cancel_queued_download( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test cancelling a queued download.""" + # Queue a fake download + test_id = f"cancel-test-{int(time.time())}" + download_tracker.track(test_id) + + queue_resp = api_client.post( + "/api/releases/download", + json={ + "source": "test_source", + "source_id": test_id, + "title": "Cancel Test Book", + }, + ) + + if queue_resp.status_code != 200: + pytest.skip("Could not queue test download") + + # Give it a moment + time.sleep(1) + + # Cancel it + cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel") + + assert cancel_resp.status_code in [200, 204] + + def test_cancel_removes_from_queue( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test that cancellation removes item from queue.""" + test_id = f"cancel-verify-{int(time.time())}" + download_tracker.track(test_id) + + # Queue it + api_client.post( + "/api/releases/download", + json={ + "source": "test_source", + "source_id": test_id, + "title": "Cancel Verify Test", + }, + ) + + time.sleep(0.5) + + # Cancel it + api_client.delete(f"/api/download/{test_id}/cancel") + + time.sleep(0.5) + + # Check it's not in the queue + queue_resp = api_client.get("/api/queue/order") + if queue_resp.status_code == 200: + queue_order = queue_resp.json() + assert test_id not in queue_order + + +@pytest.mark.e2e +class TestQueuePriority: + """Test queue priority functionality.""" + + def test_set_priority( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test setting download priority.""" + test_id = f"priority-test-{int(time.time())}" + download_tracker.track(test_id) + + # Queue it + queue_resp = api_client.post( + "/api/releases/download", + json={ + "source": "test_source", + "source_id": test_id, + "title": "Priority Test", + "priority": 0, + }, + ) + + if queue_resp.status_code != 200: + pytest.skip("Could not queue download") + + time.sleep(0.5) + + # Update priority + priority_resp = api_client.put( + f"/api/queue/{test_id}/priority", + json={"priority": 10}, + ) + + # Should succeed or return 404 if already processed + assert priority_resp.status_code in [200, 404] diff --git a/tests/e2e/test_prowlarr_flow.py b/tests/e2e/test_prowlarr_flow.py new file mode 100644 index 00000000..d8dde286 --- /dev/null +++ b/tests/e2e/test_prowlarr_flow.py @@ -0,0 +1,336 @@ +""" +E2E Tests for Prowlarr Integration. + +These tests verify the Prowlarr release source and download client flow. +Requires Prowlarr and a download client (qBittorrent, Transmission, etc.) to be configured. + +Run with: docker exec test-cwabd python3 -m pytest tests/e2e/test_prowlarr_flow.py -v -m e2e +""" + +import time + +import pytest + +from .conftest import APIClient, DownloadTracker + + +def _is_prowlarr_configured(api_client: APIClient) -> bool: + """Check if Prowlarr is configured and available.""" + resp = api_client.get("/api/release-sources") + if resp.status_code != 200: + return False + + sources = resp.json() + for source in sources: + if source.get("name") == "prowlarr": + return True + return False + + +def _get_prowlarr_settings(api_client: APIClient) -> dict | None: + """Get Prowlarr settings if available.""" + resp = api_client.get("/api/settings/prowlarr") + if resp.status_code == 200: + return resp.json() + return None + + +def _get_first_provider_name(api_client: APIClient) -> str | None: + """Get the first available provider name.""" + providers_resp = api_client.get("/api/metadata/providers") + if providers_resp.status_code != 200: + return None + + providers_data = providers_resp.json() + if not providers_data: + return None + + # Handle both dict and list formats + if isinstance(providers_data, dict): + return list(providers_data.keys())[0] if providers_data else None + else: + return providers_data[0].get("name") if providers_data else None + + +@pytest.mark.e2e +class TestProwlarrConfiguration: + """Tests for Prowlarr configuration.""" + + def test_prowlarr_in_release_sources(self, api_client: APIClient): + """Test that Prowlarr appears in release sources.""" + resp = api_client.get("/api/release-sources") + + assert resp.status_code == 200 + sources = resp.json() + source_names = [s.get("name") for s in sources] + assert "prowlarr" in source_names + + def test_prowlarr_settings_tab_exists(self, api_client: APIClient): + """Test that Prowlarr settings tab exists.""" + resp = api_client.get("/api/settings") + + if resp.status_code == 403: + pytest.skip("Settings disabled") + + assert resp.status_code == 200 + data = resp.json() + + # Settings may have nested structure with groups/tabs + if isinstance(data, dict): + # Could have groups containing tabs, or be flat + if "groups" in data: + # Nested: look in groups for prowlarr tabs + all_tab_names = [] + for group in data.get("groups", []): + if isinstance(group, dict): + for tab in group.get("tabs", []): + if isinstance(tab, dict): + all_tab_names.append(tab.get("name") or tab.get("id", "")) + tab_names = all_tab_names + else: + tab_names = list(data.keys()) + else: + tab_names = [t.get("name") or t.get("id") for t in data if isinstance(t, dict)] + + # Prowlarr settings should exist (may be under different name) + prowlarr_tabs = [n for n in tab_names if n and "prowlarr" in n.lower()] + # Also check if we can directly access the prowlarr_clients settings + prowlarr_resp = api_client.get("/api/settings/prowlarr_clients") + has_prowlarr_settings = prowlarr_resp.status_code == 200 + + assert prowlarr_tabs or has_prowlarr_settings, f"No prowlarr settings found. Tab names: {tab_names}" + + +@pytest.mark.e2e +@pytest.mark.slow +class TestProwlarrSearch: + """Tests for searching via Prowlarr.""" + + def test_prowlarr_search_with_metadata(self, api_client: APIClient): + """Test searching Prowlarr with metadata from a provider.""" + if not _is_prowlarr_configured(api_client): + pytest.skip("Prowlarr not configured") + + provider = _get_first_provider_name(api_client) + if not provider: + pytest.skip("No metadata providers") + + # Search for a book + search_resp = api_client.get( + "/api/metadata/search", + params={"query": "The Great Gatsby", "provider": provider}, + timeout=30, + ) + + if search_resp.status_code != 200: + pytest.skip("Metadata search unavailable") + + search_data = search_resp.json() + results = search_data.get("results", search_data) + + # Handle dict format where results might be nested + if isinstance(results, dict) and "results" not in results: + # Results might be the actual result list under a different key + for key, value in results.items(): + if isinstance(value, list) and value: + results = value + break + + if not results or (isinstance(results, dict) and not results): + pytest.skip("No metadata results") + + # Get first result + if isinstance(results, list): + book = results[0] + else: + pytest.skip("Unexpected results format") + + book_id = book.get("id") or book.get("provider_id") + + # Now search releases specifically from Prowlarr + releases_resp = api_client.get( + "/api/releases", + params={ + "provider": provider, + "book_id": book_id, + "source": "prowlarr", + "title": book.get("title", ""), + "author": book.get("author", ""), + }, + timeout=60, + ) + + # Prowlarr may not be reachable + if releases_resp.status_code == 503: + pytest.skip("Prowlarr not reachable") + + if releases_resp.status_code == 200: + data = releases_resp.json() + assert "releases" in data + # Releases may be empty if Prowlarr has no indexers configured + + +@pytest.mark.e2e +class TestProwlarrClientSettings: + """Tests for Prowlarr download client settings.""" + + def test_client_settings_structure(self, api_client: APIClient): + """Test that client settings have expected structure.""" + resp = api_client.get("/api/settings/prowlarr_clients") + + if resp.status_code == 403: + pytest.skip("Settings disabled") + if resp.status_code == 404: + pytest.skip("Prowlarr clients settings tab not found") + + assert resp.status_code == 200 + data = resp.json() + + # Should have fields for client configuration + assert isinstance(data, (dict, list)) + + def test_can_save_client_settings(self, api_client: APIClient): + """Test that client settings can be saved.""" + # Get current settings + get_resp = api_client.get("/api/settings/prowlarr_clients") + + if get_resp.status_code in [403, 404]: + pytest.skip("Settings not available") + + current = get_resp.json() + + # Try to save the same settings back (no-op save) + if isinstance(current, dict) and "fields" in current: + # Extract just the values + values = {} + for field in current.get("fields", []): + key = field.get("key") or field.get("name") + if key: + values[key] = field.get("value", "") + + put_resp = api_client.put( + "/api/settings/prowlarr_clients", + json=values, + ) + # Should succeed (200) or be a no-op + assert put_resp.status_code in [200, 204, 400] + + +@pytest.mark.e2e +@pytest.mark.slow +class TestProwlarrDownload: + """Tests for downloading via Prowlarr.""" + + def test_queue_prowlarr_release( + self, api_client: APIClient, download_tracker: DownloadTracker + ): + """Test queueing a Prowlarr release for download.""" + if not _is_prowlarr_configured(api_client): + pytest.skip("Prowlarr not configured") + + provider = _get_first_provider_name(api_client) + if not provider: + pytest.skip("No providers") + + # Search metadata + search_resp = api_client.get( + "/api/metadata/search", + params={"query": "Dracula Bram Stoker", "provider": provider}, + timeout=30, + ) + + if search_resp.status_code != 200: + pytest.skip("Metadata search failed") + + search_data = search_resp.json() + results = search_data.get("results", search_data) + + # Handle different result formats + if isinstance(results, dict): + for key, value in results.items(): + if isinstance(value, list) and value: + results = value + break + + if not results or not isinstance(results, list): + pytest.skip("No results") + + book = results[0] + book_id = book.get("id") or book.get("provider_id") + + # Search Prowlarr releases + releases_resp = api_client.get( + "/api/releases", + params={ + "provider": provider, + "book_id": book_id, + "source": "prowlarr", + "title": book.get("title", ""), + }, + timeout=60, + ) + + if releases_resp.status_code != 200: + pytest.skip(f"Releases search failed: {releases_resp.status_code}") + + releases = releases_resp.json().get("releases", []) + if not releases: + pytest.skip("No Prowlarr releases found") + + # Get the first release + release = releases[0] + source_id = release.get("source_id") or release.get("id") + download_tracker.track(source_id) + + # Queue it + queue_resp = api_client.post( + "/api/releases/download", + json={ + "source": "prowlarr", + "source_id": source_id, + "title": release.get("title", book.get("title", "Test")), + "format": release.get("format"), + "size": release.get("size"), + "extra": release.get("extra", {}), + }, + ) + + # May fail if no download client configured + if queue_resp.status_code == 200: + data = queue_resp.json() + assert data.get("status") == "queued" + + # Wait briefly and check status + time.sleep(3) + + status_resp = api_client.get("/api/status") + if status_resp.status_code == 200: + status_data = status_resp.json() + # Should be in one of the status categories + found = False + for category in status_data.values(): + if isinstance(category, dict) and source_id in category: + found = True + break + # It's ok if not found (may have already processed/errored) + + +@pytest.mark.e2e +class TestProwlarrClientConnection: + """Tests for testing download client connections.""" + + def test_connection_test_action(self, api_client: APIClient): + """Test the connection test action for download clients.""" + # This tests the action button functionality in settings + resp = api_client.post( + "/api/settings/prowlarr_clients/action/test_torrent_connection" + ) + + # May succeed, fail, or not exist depending on configuration + # We just verify it returns a response + assert resp.status_code in [200, 400, 404, 500] + + if resp.status_code == 200: + data = resp.json() + # Should have success/message structure + assert "success" in data or "message" in data or "result" in data diff --git a/tests/prowlarr/__init__.py b/tests/prowlarr/__init__.py new file mode 100644 index 00000000..440149d2 --- /dev/null +++ b/tests/prowlarr/__init__.py @@ -0,0 +1 @@ +"""Tests for the Prowlarr plugin.""" diff --git a/tests/prowlarr/test_bencode.py b/tests/prowlarr/test_bencode.py new file mode 100644 index 00000000..3ae4c992 --- /dev/null +++ b/tests/prowlarr/test_bencode.py @@ -0,0 +1,198 @@ +""" +Tests for bencode encoding/decoding in the torrent utilities. +""" + +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + bencode_encode as _bencode_encode, + bencode_decode as _bencode_decode, + extract_info_hash_from_torrent as _extract_info_hash_from_torrent, +) + + +class TestBencodeDecode: + """Tests for bencode decoding.""" + + def test_decode_integer(self): + """Test decoding integers.""" + result, remaining = _bencode_decode(b"i42e") + assert result == 42 + assert remaining == b"" + + def test_decode_negative_integer(self): + """Test decoding negative integers.""" + result, remaining = _bencode_decode(b"i-42e") + assert result == -42 + + def test_decode_zero(self): + """Test decoding zero.""" + result, remaining = _bencode_decode(b"i0e") + assert result == 0 + + def test_decode_string(self): + """Test decoding byte strings.""" + result, remaining = _bencode_decode(b"5:hello") + assert result == b"hello" + assert remaining == b"" + + def test_decode_empty_string(self): + """Test decoding empty string.""" + result, remaining = _bencode_decode(b"0:") + assert result == b"" + + def test_decode_list(self): + """Test decoding lists.""" + result, remaining = _bencode_decode(b"li1ei2ei3ee") + assert result == [1, 2, 3] + assert remaining == b"" + + def test_decode_empty_list(self): + """Test decoding empty list.""" + result, remaining = _bencode_decode(b"le") + assert result == [] + + def test_decode_nested_list(self): + """Test decoding nested lists.""" + result, remaining = _bencode_decode(b"lli1eeli2eee") + assert result == [[1], [2]] + + def test_decode_dict(self): + """Test decoding dictionaries.""" + result, remaining = _bencode_decode(b"d3:key5:valuee") + assert result == {b"key": b"value"} + assert remaining == b"" + + def test_decode_empty_dict(self): + """Test decoding empty dictionary.""" + result, remaining = _bencode_decode(b"de") + assert result == {} + + def test_decode_complex_structure(self): + """Test decoding complex nested structures.""" + # Dict with string, int, and list values + data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee" + result, remaining = _bencode_decode(data) + assert result == { + b"age": 25, + b"name": b"John", + b"items": [1, 2, 3], + } + + +class TestBencodeEncode: + """Tests for bencode encoding.""" + + def test_encode_integer(self): + """Test encoding integers.""" + assert _bencode_encode(42) == b"i42e" + assert _bencode_encode(-42) == b"i-42e" + assert _bencode_encode(0) == b"i0e" + + def test_encode_bytes(self): + """Test encoding byte strings.""" + assert _bencode_encode(b"hello") == b"5:hello" + assert _bencode_encode(b"") == b"0:" + + def test_encode_string(self): + """Test encoding regular strings (UTF-8 encoded).""" + assert _bencode_encode("hello") == b"5:hello" + assert _bencode_encode("") == b"0:" + + def test_encode_list(self): + """Test encoding lists.""" + assert _bencode_encode([1, 2, 3]) == b"li1ei2ei3ee" + assert _bencode_encode([]) == b"le" + + def test_encode_dict(self): + """Test encoding dictionaries.""" + result = _bencode_encode({b"key": b"value"}) + assert result == b"d3:key5:valuee" + + def test_encode_dict_keys_sorted(self): + """Test that dictionary keys are sorted.""" + # Keys should be sorted: b < z + result = _bencode_encode({b"z": 1, b"a": 2, b"m": 3}) + # a=2, m=3, z=1 + assert result == b"d1:ai2e1:mi3e1:zi1ee" + + def test_encode_nested_structure(self): + """Test encoding nested structures.""" + data = {b"list": [1, 2, 3], b"num": 42} + result = _bencode_encode(data) + # Keys sorted: "list" < "num" + assert result == b"d4:listli1ei2ei3ee3:numi42ee" + + +class TestBencodeRoundTrip: + """Tests for encoding then decoding (roundtrip).""" + + def test_roundtrip_integer(self): + """Test roundtrip for integers.""" + original = 12345 + encoded = _bencode_encode(original) + decoded, _ = _bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_bytes(self): + """Test roundtrip for byte strings.""" + original = b"hello world" + encoded = _bencode_encode(original) + decoded, _ = _bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_list(self): + """Test roundtrip for lists.""" + original = [1, 2, b"three", [4, 5]] + encoded = _bencode_encode(original) + decoded, _ = _bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_dict(self): + """Test roundtrip for dictionaries.""" + original = {b"name": b"test", b"value": 123} + encoded = _bencode_encode(original) + decoded, _ = _bencode_decode(encoded) + assert decoded == original + + +class TestExtractInfoHash: + """Tests for extracting info hash from torrent files.""" + + def test_extract_hash_from_simple_torrent(self): + """Test extracting hash from a simple torrent structure.""" + # Create a minimal valid torrent structure + info_dict = {b"name": b"test.txt", b"length": 100} + torrent = {b"info": info_dict} + torrent_bytes = _bencode_encode(torrent) + + result = _extract_info_hash_from_torrent(torrent_bytes) + + # Should return a 40-character hex string + assert result is not None + assert len(result) == 40 + assert all(c in "0123456789abcdef" for c in result) + + def test_extract_hash_returns_none_for_invalid(self): + """Test that invalid data returns None.""" + assert _extract_info_hash_from_torrent(b"not a torrent") is None + assert _extract_info_hash_from_torrent(b"") is None + + def test_extract_hash_returns_none_without_info(self): + """Test that torrent without info dict returns None.""" + torrent = {b"announce": b"http://tracker.example.com"} + torrent_bytes = _bencode_encode(torrent) + + result = _extract_info_hash_from_torrent(torrent_bytes) + assert result is None + + def test_extract_hash_is_consistent(self): + """Test that same torrent always produces same hash.""" + info_dict = {b"name": b"consistent.txt", b"length": 500} + torrent = {b"info": info_dict} + torrent_bytes = _bencode_encode(torrent) + + hash1 = _extract_info_hash_from_torrent(torrent_bytes) + hash2 = _extract_info_hash_from_torrent(torrent_bytes) + + assert hash1 == hash2 diff --git a/tests/prowlarr/test_cache.py b/tests/prowlarr/test_cache.py new file mode 100644 index 00000000..c328c611 --- /dev/null +++ b/tests/prowlarr/test_cache.py @@ -0,0 +1,121 @@ +""" +Tests for the Prowlarr release cache. +""" + +import time +import pytest + +# Import the cache module +from cwa_book_downloader.release_sources.prowlarr import cache + + +class TestProwlarrCache: + """Tests for release caching functionality.""" + + def setup_method(self): + """Clear cache before each test.""" + # Clear the internal cache + cache._cache.clear() + + def test_cache_release_stores_data(self): + """Test that cache_release stores data correctly.""" + release_data = {"title": "Test Book", "size": 1024} + cache.cache_release("test-id", release_data) + + assert "test-id" in cache._cache + stored_data, timestamp = cache._cache["test-id"] + assert stored_data == release_data + assert isinstance(timestamp, float) + + def test_get_release_returns_cached_data(self): + """Test that get_release returns cached data.""" + release_data = {"title": "Test Book", "format": "epub"} + cache.cache_release("get-test-id", release_data) + + result = cache.get_release("get-test-id") + assert result == release_data + + def test_get_release_returns_none_for_missing_id(self): + """Test that get_release returns None for non-existent IDs.""" + result = cache.get_release("non-existent-id") + assert result is None + + def test_get_release_returns_none_for_expired(self, monkeypatch): + """Test that get_release returns None for expired entries.""" + release_data = {"title": "Old Book"} + cache.cache_release("expired-id", release_data) + + # Make the entry appear old by modifying the TTL check + original_ttl = cache.RELEASE_CACHE_TTL + monkeypatch.setattr(cache, "RELEASE_CACHE_TTL", 0) + + # Wait a tiny bit to ensure expiration + time.sleep(0.01) + + result = cache.get_release("expired-id") + assert result is None + + # Restore TTL + monkeypatch.setattr(cache, "RELEASE_CACHE_TTL", original_ttl) + + def test_remove_release_deletes_entry(self): + """Test that remove_release removes cached entries.""" + cache.cache_release("remove-id", {"title": "Book to Remove"}) + assert "remove-id" in cache._cache + + cache.remove_release("remove-id") + assert "remove-id" not in cache._cache + + def test_remove_release_ignores_missing_ids(self): + """Test that remove_release doesn't raise for missing IDs.""" + # Should not raise any exception + cache.remove_release("never-existed") + + def test_cleanup_expired_removes_old_entries(self, monkeypatch): + """Test that cleanup_expired removes old entries.""" + # Add some entries + cache.cache_release("keep-id", {"title": "Keep This"}) + cache.cache_release("old-id", {"title": "Remove This"}) + + # Make old-id appear expired by manipulating its timestamp + cache._cache["old-id"] = ({"title": "Remove This"}, time.time() - 7200) # 2 hours old + + removed = cache.cleanup_expired() + + assert removed == 1 + assert "keep-id" in cache._cache + assert "old-id" not in cache._cache + + def test_get_cache_stats_returns_correct_info(self): + """Test that get_cache_stats returns accurate information.""" + cache.cache_release("stat-1", {"title": "Book 1"}) + cache.cache_release("stat-2", {"title": "Book 2"}) + + stats = cache.get_cache_stats() + + assert stats["size"] == 2 + assert "stat-1" in stats["entries"] + assert "stat-2" in stats["entries"] + + def test_cache_is_thread_safe(self): + """Test that cache operations are thread-safe.""" + import threading + + errors = [] + + def cache_operations(): + try: + for i in range(100): + cache.cache_release(f"thread-{threading.current_thread().name}-{i}", {"data": i}) + cache.get_release(f"thread-{threading.current_thread().name}-{i}") + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=cache_operations, name=f"T{i}") for i in range(5)] + + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(errors) == 0, f"Thread errors: {errors}" diff --git a/tests/prowlarr/test_clients.py b/tests/prowlarr/test_clients.py new file mode 100644 index 00000000..832fe23e --- /dev/null +++ b/tests/prowlarr/test_clients.py @@ -0,0 +1,394 @@ +""" +Tests for the download client infrastructure. +""" + +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadStatus, + DownloadState, + DownloadClient, + register_client, + get_client, + list_configured_clients, + get_all_clients, + _CLIENTS, +) + + +class TestDownloadState: + """Tests for the DownloadState enum.""" + + def test_all_states_exist(self): + """Test that all expected states are defined.""" + assert DownloadState.DOWNLOADING.value == "downloading" + assert DownloadState.COMPLETE.value == "complete" + assert DownloadState.ERROR.value == "error" + assert DownloadState.SEEDING.value == "seeding" + assert DownloadState.PAUSED.value == "paused" + assert DownloadState.QUEUED.value == "queued" + assert DownloadState.CHECKING.value == "checking" + assert DownloadState.PROCESSING.value == "processing" + assert DownloadState.UNKNOWN.value == "unknown" + + def test_state_from_string(self): + """Test creating state from string value.""" + assert DownloadState("downloading") == DownloadState.DOWNLOADING + assert DownloadState("complete") == DownloadState.COMPLETE + assert DownloadState("error") == DownloadState.ERROR + + def test_invalid_state_raises(self): + """Test that invalid state string raises ValueError.""" + with pytest.raises(ValueError): + DownloadState("invalid_state") + + +class TestDownloadStatus: + """Tests for the DownloadStatus dataclass.""" + + def test_create_download_status(self): + """Test creating a DownloadStatus.""" + status = DownloadStatus( + progress=50.0, + state="downloading", + message="Downloading...", + complete=False, + file_path=None, + download_speed=1024000, + eta=120, + ) + + assert status.progress == 50.0 + # State is normalized to enum + assert status.state == DownloadState.DOWNLOADING + assert status.state_value == "downloading" + assert status.message == "Downloading..." + assert status.complete is False + assert status.file_path is None + assert status.download_speed == 1024000 + assert status.eta == 120 + + def test_download_status_defaults(self): + """Test DownloadStatus default values.""" + status = DownloadStatus( + progress=100.0, + state="complete", + message=None, + complete=True, + file_path="/downloads/book.epub", + ) + + assert status.download_speed is None + assert status.eta is None + + def test_download_status_completed(self): + """Test creating a completed status.""" + status = DownloadStatus( + progress=100.0, + state="complete", + message="Download finished", + complete=True, + file_path="/downloads/book.epub", + ) + + assert status.complete is True + assert status.file_path == "/downloads/book.epub" + + def test_download_status_state_normalization(self): + """Test that string states are normalized to enum.""" + status = DownloadStatus( + progress=50.0, + state="downloading", + message=None, + complete=False, + file_path=None, + ) + # String should be converted to enum + assert status.state == DownloadState.DOWNLOADING + assert status.state_value == "downloading" + + def test_download_status_with_enum_state(self): + """Test creating status with enum state directly.""" + status = DownloadStatus( + progress=100.0, + state=DownloadState.COMPLETE, + message="Done", + complete=True, + file_path="/path/to/file", + ) + assert status.state == DownloadState.COMPLETE + assert status.state_value == "complete" + + def test_download_status_progress_clamping(self): + """Test that progress is clamped to [0, 100].""" + # Progress over 100 should be clamped + status1 = DownloadStatus( + progress=150.0, + state="complete", + message=None, + complete=True, + file_path=None, + ) + assert status1.progress == 100.0 + + # Negative progress should be clamped to 0 + status2 = DownloadStatus( + progress=-10.0, + state="error", + message=None, + complete=False, + file_path=None, + ) + assert status2.progress == 0.0 + + def test_download_status_immutable(self): + """Test that DownloadStatus is immutable (frozen).""" + status = DownloadStatus( + progress=50.0, + state="downloading", + message="Test", + complete=False, + file_path=None, + ) + with pytest.raises(AttributeError): + status.progress = 75.0 + + def test_download_status_state_value_with_unknown_string(self): + """Test state_value with an unknown state string.""" + # Create status with an unrecognized state string + status = DownloadStatus( + progress=50.0, + state="fetching_metadata", # Not a standard DownloadState + message=None, + complete=False, + file_path=None, + ) + # Unknown states remain as strings + assert status.state_value == "fetching_metadata" + + def test_download_status_all_fields(self): + """Test status with all optional fields.""" + status = DownloadStatus( + progress=75.5, + state=DownloadState.DOWNLOADING, + message="Downloading at 1 MB/s", + complete=False, + file_path=None, + download_speed=1048576, + eta=300, + ) + assert status.progress == 75.5 + assert status.download_speed == 1048576 + assert status.eta == 300 + assert status.message == "Downloading at 1 MB/s" + + +class TestClientRegistry: + """Tests for the client registry functions.""" + + def setup_method(self): + """Save original clients before each test.""" + self._original_clients = dict(_CLIENTS) + + def teardown_method(self): + """Restore original clients after each test.""" + _CLIENTS.clear() + _CLIENTS.update(self._original_clients) + + def test_register_client_decorator(self): + """Test that register_client decorator registers the client.""" + # Use valid protocol (torrent or usenet) + test_protocol = "torrent" + + @register_client(test_protocol) + class TestRegistryClient(DownloadClient): + protocol = test_protocol + name = "test_registry_client" + + @staticmethod + def is_configured(): + return True + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + assert test_protocol in _CLIENTS + assert TestRegistryClient in _CLIENTS[test_protocol] + + def test_multiple_clients_same_protocol(self): + """Test registering multiple clients for the same protocol.""" + # Use valid protocol (usenet to avoid conflict with real clients) + test_protocol = "usenet" + initial_count = len(_CLIENTS.get(test_protocol, [])) + + @register_client(test_protocol) + class MultiClient1(DownloadClient): + protocol = test_protocol + name = "multi_client1" + + @staticmethod + def is_configured(): + return False + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + @register_client(test_protocol) + class MultiClient2(DownloadClient): + protocol = test_protocol + name = "multi_client2" + + @staticmethod + def is_configured(): + return False + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + # Should have 2 more clients than initially + assert len(_CLIENTS[test_protocol]) >= initial_count + 2 + + def test_get_client_returns_configured(self): + """Test that get_client returns the first configured client.""" + # Use valid protocol + test_protocol = "torrent" + + @register_client(test_protocol) + class UnconfiguredTestClient(DownloadClient): + protocol = test_protocol + name = "unconfigured_test" + + @staticmethod + def is_configured(): + return False + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + @register_client(test_protocol) + class ConfiguredTestClient(DownloadClient): + protocol = test_protocol + name = "configured_test" + + @staticmethod + def is_configured(): + return True + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + client = get_client(test_protocol) + assert client is not None + # There may be other configured clients, just check we get one + assert client.is_configured() + + def test_get_client_returns_none_when_none_configured(self): + """Test that get_client returns None for a protocol with no configured clients.""" + # Instead of trying to create a new protocol, just check that + # get_client returns None for a completely unknown protocol + client = get_client("nonexistent_protocol_xyz") + assert client is None + + def test_get_client_unknown_protocol(self): + """Test that get_client returns None for unknown protocols.""" + client = get_client("nonexistent_protocol") + assert client is None + + def test_get_all_clients(self): + """Test that get_all_clients returns the registry.""" + all_clients = get_all_clients() + assert isinstance(all_clients, dict) + + +class TestDownloadClientInterface: + """Tests for the DownloadClient abstract interface.""" + + def test_find_existing_default_returns_none(self): + """Test that default find_existing returns None.""" + + class MinimalTestClient(DownloadClient): + protocol = "torrent" # Must use valid protocol + name = "minimal_test" + + @staticmethod + def is_configured(): + return True + + def test_connection(self): + return True, "OK" + + def add_download(self, url, name, category="test"): + return "id" + + def get_status(self, download_id): + return DownloadStatus(0, "unknown", None, False, None) + + def remove(self, download_id, delete_files=False): + return True + + def get_download_path(self, download_id): + return None + + client = MinimalTestClient() + result = client.find_existing("magnet:?xt=urn:btih:abc123") + assert result is None diff --git a/tests/prowlarr/test_handler.py b/tests/prowlarr/test_handler.py new file mode 100644 index 00000000..cd79f9cc --- /dev/null +++ b/tests/prowlarr/test_handler.py @@ -0,0 +1,672 @@ +""" +Unit tests for the Prowlarr download handler. + +These tests mock the download clients to test the handler logic +without requiring running services. +""" + +import os +import tempfile +from pathlib import Path +from threading import Event +from typing import List, Optional, Tuple +from unittest.mock import MagicMock, patch, PropertyMock +import pytest + +from cwa_book_downloader.core.models import DownloadTask +from cwa_book_downloader.release_sources.prowlarr.handler import ( + ProwlarrHandler, + _determine_protocol, +) +from cwa_book_downloader.release_sources.prowlarr.clients import ( + DownloadStatus, + DownloadState, +) + + +class ProgressRecorder: + """Records progress and status updates during download.""" + + def __init__(self): + self.progress_values: List[float] = [] + self.status_updates: List[Tuple[str, Optional[str]]] = [] + + def progress_callback(self, progress: float): + self.progress_values.append(progress) + + def status_callback(self, status: str, message: Optional[str]): + self.status_updates.append((status, message)) + + @property + def last_status(self) -> Optional[str]: + return self.status_updates[-1][0] if self.status_updates else None + + @property + def last_message(self) -> Optional[str]: + return self.status_updates[-1][1] if self.status_updates else None + + @property + def statuses(self) -> List[str]: + return [s[0] for s in self.status_updates] + + +class TestDetermineProtocol: + """Tests for the _determine_protocol function.""" + + def test_determine_protocol_torrent(self): + """Test detecting torrent protocol.""" + result = {"protocol": "torrent"} + assert _determine_protocol(result) == "torrent" + + def test_determine_protocol_usenet(self): + """Test detecting usenet protocol.""" + result = {"protocol": "usenet"} + assert _determine_protocol(result) == "usenet" + + def test_determine_protocol_unknown(self): + """Test unknown protocol.""" + result = {"protocol": "ftp"} + assert _determine_protocol(result) == "unknown" + + def test_determine_protocol_empty(self): + """Test empty protocol.""" + result = {} + assert _determine_protocol(result) == "unknown" + + def test_determine_protocol_case_insensitive(self): + """Test protocol detection is case insensitive.""" + assert _determine_protocol({"protocol": "TORRENT"}) == "torrent" + assert _determine_protocol({"protocol": "Usenet"}) == "usenet" + assert _determine_protocol({"protocol": "USENET"}) == "usenet" + + +class TestProwlarrHandlerDownloadErrors: + """Tests for error handling in ProwlarrHandler.download().""" + + def test_download_fails_without_cached_release(self): + """Test that download fails when release is not in cache.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value=None, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="non-existent-id", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "cache" in recorder.last_message.lower() + + def test_download_fails_without_download_url(self): + """Test that download fails when release has no download URL.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "title": "Test Release", + # No downloadUrl or magnetUrl + }, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="no-url-release", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "url" in recorder.last_message.lower() + + def test_download_fails_unknown_protocol(self): + """Test that download fails with unknown protocol.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "ftp", + "downloadUrl": "ftp://example.com/file.zip", + }, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="unknown-protocol", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "protocol" in recorder.last_message.lower() + + def test_download_fails_no_client_configured(self): + """Test that download fails when no client is configured.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "downloadUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=None, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.list_configured_clients", + return_value=[], + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="no-client", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "client" in recorder.last_message.lower() + + +class TestProwlarrHandlerExistingDownload: + """Tests for handling existing downloads.""" + + def test_uses_existing_complete_download(self): + """Test that handler uses existing complete download.""" + with tempfile.TemporaryDirectory() as tmp_dir: + # Create a test file + source_file = Path(tmp_dir) / "source" / "book.epub" + source_file.parent.mkdir(parents=True) + source_file.write_text("test content") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = ( + "existing_id", + DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_file), + ), + ) + mock_client.get_download_path.return_value = str(source_file) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release", + ), patch( + "cwa_book_downloader.download.orchestrator.get_staging_dir", + return_value=staging_dir, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="existing-complete", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is not None + assert "processing" in recorder.statuses + # Should NOT have called add_download + mock_client.add_download.assert_not_called() + + +class TestProwlarrHandlerPolling: + """Tests for download polling behavior.""" + + def test_polls_until_complete(self): + """Test that handler polls until download is complete.""" + with tempfile.TemporaryDirectory() as tmp_dir: + source_file = Path(tmp_dir) / "source" / "book.epub" + source_file.parent.mkdir(parents=True) + source_file.write_text("test content") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + + poll_count = [0] + + def mock_get_status(download_id): + poll_count[0] += 1 + if poll_count[0] >= 3: + return DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_file), + ) + return DownloadStatus( + progress=poll_count[0] * 30, + state=DownloadState.DOWNLOADING, + message=None, + complete=False, + file_path=None, + download_speed=1024000, + eta=60, + ) + + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.side_effect = mock_get_status + mock_client.get_download_path.return_value = str(source_file) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release", + ), patch( + "cwa_book_downloader.download.orchestrator.get_staging_dir", + return_value=staging_dir, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, # Speed up tests + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="poll-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is not None + assert poll_count[0] >= 3 + assert len(recorder.progress_values) >= 3 + + def test_handles_error_during_download(self): + """Test that handler handles error state during download.""" + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=50, + state=DownloadState.ERROR, + message="Disk full", + complete=False, + file_path=None, + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="error-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + mock_client.remove.assert_called_once() + + +class TestProwlarrHandlerCancellation: + """Tests for download cancellation.""" + + def test_cancellation_removes_download(self): + """Test that cancellation removes the download from client.""" + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=50, + state=DownloadState.DOWNLOADING, + message="Downloading", + complete=False, + file_path=None, + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="cancel-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + # Set cancel immediately + cancel_flag.set() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert "cancelled" in recorder.statuses + mock_client.remove.assert_called_with("download_id", delete_files=True) + + +class TestProwlarrHandlerCancel: + """Tests for ProwlarrHandler.cancel().""" + + def test_cancel_removes_from_cache(self): + """Test that cancel removes release from cache.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release" + ) as mock_remove: + handler = ProwlarrHandler() + result = handler.cancel("test-task-id") + + assert result is True + mock_remove.assert_called_once_with("test-task-id") + + def test_cancel_handles_missing_task(self): + """Test that cancel handles non-existent task gracefully.""" + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release" + ): + handler = ProwlarrHandler() + result = handler.cancel("nonexistent-task-id") + + assert result is True + + +class TestProwlarrHandlerFileStaging: + """Tests for file staging behavior.""" + + def test_stages_single_file(self): + """Test staging a single file download.""" + with tempfile.TemporaryDirectory() as tmp_dir: + source_file = Path(tmp_dir) / "source" / "book.epub" + source_file.parent.mkdir(parents=True) + source_file.write_text("test content") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_file), + ) + mock_client.get_download_path.return_value = str(source_file) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release", + ), patch( + "cwa_book_downloader.download.orchestrator.get_staging_dir", + return_value=staging_dir, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="staging-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is not None + staged_file = Path(result) + assert staged_file.exists() + assert staged_file.read_text() == "test content" + + def test_stages_directory(self): + """Test staging a directory download.""" + with tempfile.TemporaryDirectory() as tmp_dir: + source_dir = Path(tmp_dir) / "source" / "book_folder" + source_dir.mkdir(parents=True) + (source_dir / "book.epub").write_text("epub content") + (source_dir / "cover.jpg").write_bytes(b"image data") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_dir), + ) + mock_client.get_download_path.return_value = str(source_dir) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release", + ), patch( + "cwa_book_downloader.download.orchestrator.get_staging_dir", + return_value=staging_dir, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="dir-staging-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is not None + staged_dir = Path(result) + assert staged_dir.is_dir() + assert (staged_dir / "book.epub").exists() + assert (staged_dir / "cover.jpg").exists() + + def test_handles_duplicate_filename(self): + """Test handling of duplicate filename during staging.""" + with tempfile.TemporaryDirectory() as tmp_dir: + source_file = Path(tmp_dir) / "source" / "book.epub" + source_file.parent.mkdir(parents=True) + source_file.write_text("new content") + + staging_dir = Path(tmp_dir) / "staging" + staging_dir.mkdir() + # Create existing file with same name + (staging_dir / "book.epub").write_text("old content") + + mock_client = MagicMock() + mock_client.name = "test_client" + mock_client.find_existing.return_value = None + mock_client.add_download.return_value = "download_id" + mock_client.get_status.return_value = DownloadStatus( + progress=100, + state=DownloadState.COMPLETE, + message="Complete", + complete=True, + file_path=str(source_file), + ) + mock_client.get_download_path.return_value = str(source_file) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_release", + return_value={ + "protocol": "torrent", + "magnetUrl": "magnet:?xt=urn:btih:abc123", + }, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.get_client", + return_value=mock_client, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.remove_release", + ), patch( + "cwa_book_downloader.download.orchestrator.get_staging_dir", + return_value=staging_dir, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.handler.POLL_INTERVAL", + 0.01, + ): + handler = ProwlarrHandler() + task = DownloadTask( + task_id="dup-staging-test", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is not None + # Should have a different name (with counter) + staged_file = Path(result) + assert staged_file.exists() + assert staged_file.name != "book.epub" + assert staged_file.read_text() == "new content" diff --git a/tests/prowlarr/test_integration_clients.py b/tests/prowlarr/test_integration_clients.py new file mode 100644 index 00000000..7456d88d --- /dev/null +++ b/tests/prowlarr/test_integration_clients.py @@ -0,0 +1,543 @@ +""" +Integration tests for download clients. + +These tests require the Docker test stack to be running: + docker compose -f docker-compose.test-clients.yml up -d + +Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_clients.py -v -m integration + +These tests use the actual Docker stack configuration. Before running: +1. Start the test stack: docker compose -f docker-compose.test-clients.yml up -d +2. Configure clients via the cwabd UI at http://localhost:8084/settings +""" + +import subprocess +import time +import pytest + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.settings_registry import save_config_file +from cwa_book_downloader.release_sources.prowlarr.clients import DownloadStatus + + +# Test magnet link (Ubuntu ISO - legal, small metadata) +TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" + + +# ============ Configuration Setup Functions ============ + +def _setup_transmission_config(): + """Set up Transmission configuration via config files and refresh config.""" + save_config_file("prowlarr_clients", { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://transmission:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "admin", + "TRANSMISSION_CATEGORY": "test", + }) + config.refresh() + + +def _setup_qbittorrent_config(): + """Set up qBittorrent configuration via config files and refresh config.""" + # qBittorrent generates a temporary password on startup - try to extract it + password = _get_qbittorrent_temp_password() + save_config_file("prowlarr_clients", { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "QBITTORRENT_URL": "http://qbittorrent:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": password or "adminadmin", + "QBITTORRENT_CATEGORY": "test", + }) + config.refresh() + + +def _get_qbittorrent_temp_password(): + """Extract qBittorrent's temporary password from container logs.""" + import re + # Try mounted config path (from docker-compose volumes) + log_paths = [ + "/qbittorrent-config/qBittorrent/logs/qbittorrent.log", + "/config/qBittorrent/logs/qbittorrent.log", + ] + for log_path in log_paths: + try: + with open(log_path, "r") as f: + content = f.read() + # Look for: "temporary password is provided for this session: XXXXXX" + match = re.search(r"temporary password[^:]*:\s*(\S+)", content) + if match: + return match.group(1) + except Exception: + continue + return None + + +def _setup_deluge_config(): + """Set up Deluge configuration via config files and refresh config.""" + save_config_file("prowlarr_clients", { + "PROWLARR_TORRENT_CLIENT": "deluge", + "DELUGE_HOST": "deluge", + "DELUGE_PORT": "58846", + "DELUGE_USERNAME": "admin", + "DELUGE_PASSWORD": "admin", + "DELUGE_CATEGORY": "test", + }) + config.refresh() + + +def _setup_nzbget_config(): + """Set up NZBGet configuration via config files and refresh config.""" + save_config_file("prowlarr_clients", { + "PROWLARR_USENET_CLIENT": "nzbget", + "NZBGET_URL": "http://nzbget:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "tegbzn6789", + "NZBGET_CATEGORY": "test", + }) + config.refresh() + + +def _setup_sabnzbd_config(): + """Set up SABnzbd configuration via config files and refresh config.""" + api_key = _get_sabnzbd_api_key() + if not api_key: + return False + save_config_file("prowlarr_clients", { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "SABNZBD_URL": "http://sabnzbd:8080", + "SABNZBD_API_KEY": api_key, + "SABNZBD_CATEGORY": "test", + }) + config.refresh() + return True + + +def _get_sabnzbd_api_key(): + """Extract SABnzbd API key from config file.""" + import re + # Try mounted config paths (from docker-compose volumes) + config_paths = [ + "/sabnzbd-config/sabnzbd.ini", + "/config/sabnzbd.ini", + ] + for config_path in config_paths: + try: + with open(config_path, "r") as f: + content = f.read() + match = re.search(r"api_key\s*=\s*(\S+)", content) + if match: + return match.group(1) + except Exception: + continue + return None + + +# ============ Client Factory Functions ============ + +def _try_get_transmission_client(): + """Try to get a working Transmission client, or None if unavailable.""" + _setup_transmission_config() + try: + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import TransmissionClient + client = TransmissionClient() + client.test_connection() + return client + except Exception: + return None + + +def _try_get_qbittorrent_client(): + """Try to get a working qBittorrent client, or None if unavailable.""" + _setup_qbittorrent_config() + try: + from cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent import QBittorrentClient + client = QBittorrentClient() + success, _ = client.test_connection() + if success: + return client + except Exception: + pass + return None + + +def _try_get_deluge_client(): + """Try to get a working Deluge client, or None if unavailable.""" + _setup_deluge_config() + try: + from cwa_book_downloader.release_sources.prowlarr.clients.deluge import DelugeClient + client = DelugeClient() + success, _ = client.test_connection() + if success: + return client + except Exception: + pass + return None + + +def _try_get_nzbget_client(): + """Try to get a working NZBGet client, or None if unavailable.""" + _setup_nzbget_config() + try: + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import NZBGetClient + client = NZBGetClient() + client.test_connection() + return client + except Exception: + return None + + +def _try_get_sabnzbd_client(): + """Try to get a working SABnzbd client, or None if unavailable.""" + if not _setup_sabnzbd_config(): + return None + try: + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import SABnzbdClient + client = SABnzbdClient() + success, _ = client.test_connection() + if success: + return client + except Exception: + pass + return None + + +# ============ Fixtures ============ + +@pytest.fixture(scope="module") +def transmission_client(): + """Get Transmission client if available, skip test otherwise.""" + client = _try_get_transmission_client() + if client is None: + pytest.skip("Transmission not available - ensure docker-compose.test-clients.yml is running") + return client + + +@pytest.fixture(scope="module") +def qbittorrent_client(): + """Get qBittorrent client if available, skip test otherwise.""" + client = _try_get_qbittorrent_client() + if client is None: + pytest.skip("qBittorrent not available - ensure docker-compose.test-clients.yml is running and check temp password") + return client + + +@pytest.fixture(scope="module") +def deluge_client(): + """Get Deluge client if available, skip test otherwise.""" + client = _try_get_deluge_client() + if client is None: + pytest.skip("Deluge not available - ensure docker-compose.test-clients.yml is running") + return client + + +@pytest.fixture(scope="module") +def nzbget_client(): + """Get NZBGet client if available, skip test otherwise.""" + client = _try_get_nzbget_client() + if client is None: + pytest.skip("NZBGet not available - ensure docker-compose.test-clients.yml is running") + return client + + +@pytest.fixture(scope="module") +def sabnzbd_client(): + """Get SABnzbd client if available, skip test otherwise.""" + client = _try_get_sabnzbd_client() + if client is None: + pytest.skip("SABnzbd not available - ensure docker-compose.test-clients.yml is running and setup wizard completed") + return client + + +@pytest.mark.integration +class TestTransmissionIntegration: + """Integration tests for Transmission client. + + Uses the Docker test stack's Transmission instance (http://transmission:9091). + """ + + def test_test_connection(self, transmission_client): + """Test connection to Transmission.""" + success, message = transmission_client.test_connection() + + assert success, f"Connection failed: {message}" + assert "Transmission" in message + + def test_add_and_remove_torrent(self, transmission_client): + """Test adding and removing a torrent.""" + client = transmission_client + + # Add torrent + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO", + ) + + assert download_id is not None + + # Wait a moment + time.sleep(2) + + try: + # Check status + status = client.get_status(download_id) + assert isinstance(status, DownloadStatus) + assert status.progress >= 0 + finally: + # Remove it + result = client.remove(download_id, delete_files=True) + assert result is True + + def test_find_existing_torrent(self, transmission_client): + """Test finding an existing torrent.""" + client = transmission_client + + # Add torrent + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO Find", + ) + time.sleep(2) + + try: + result = client.find_existing(TEST_MAGNET) + assert result is not None + found_id, status = result + assert found_id == download_id + assert isinstance(status, DownloadStatus) + finally: + client.remove(download_id, delete_files=True) + + def test_status_fields(self, transmission_client): + """Test that status contains all required fields.""" + client = transmission_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Status Fields", + ) + time.sleep(2) + + try: + status = client.get_status(download_id) + + # Check all required fields exist + assert hasattr(status, "progress") + assert hasattr(status, "state") + assert hasattr(status, "message") + assert hasattr(status, "complete") + assert hasattr(status, "file_path") + assert hasattr(status, "download_speed") + assert hasattr(status, "eta") + + # Progress should be a number between 0 and 100 + assert 0 <= status.progress <= 100 + + # State should be a known value + valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata"} + assert status.state in valid_states + + # Complete should be boolean + assert isinstance(status.complete, bool) + finally: + client.remove(download_id, delete_files=True) + + +@pytest.mark.integration +class TestQBittorrentIntegration: + """Integration tests for qBittorrent client. + + Uses the Docker test stack's qBittorrent instance (http://qbittorrent:8080). + Note: qBittorrent generates a temporary password on startup. + """ + + def test_test_connection(self, qbittorrent_client): + """Test connection to qBittorrent.""" + success, message = qbittorrent_client.test_connection() + + assert success, f"Connection failed: {message}" + assert "qBittorrent" in message + + def test_add_and_remove_torrent(self, qbittorrent_client): + """Test adding and removing a torrent.""" + client = qbittorrent_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO qBit", + ) + + assert download_id is not None + + time.sleep(3) # qBittorrent needs a moment to process + + try: + status = client.get_status(download_id) + assert isinstance(status, DownloadStatus) + assert status.progress >= 0 + finally: + result = client.remove(download_id, delete_files=True) + assert result is True + + def test_find_existing_torrent(self, qbittorrent_client): + """Test finding an existing torrent.""" + client = qbittorrent_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO Find qBit", + ) + time.sleep(3) + + try: + result = client.find_existing(TEST_MAGNET) + assert result is not None + found_id, status = result + assert found_id == download_id + assert isinstance(status, DownloadStatus) + finally: + client.remove(download_id, delete_files=True) + + def test_status_fields(self, qbittorrent_client): + """Test that status contains all required fields.""" + client = qbittorrent_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Status Fields qBit", + ) + time.sleep(3) + + try: + status = client.get_status(download_id) + + assert hasattr(status, "progress") + assert hasattr(status, "state") + assert hasattr(status, "message") + assert hasattr(status, "complete") + assert hasattr(status, "file_path") + + assert 0 <= status.progress <= 100 + + valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "stalled"} + assert status.state in valid_states + + assert isinstance(status.complete, bool) + finally: + client.remove(download_id, delete_files=True) + + +@pytest.mark.integration +class TestDelugeIntegration: + """Integration tests for Deluge client. + + Uses the Docker test stack's Deluge instance (deluge:58846). + Default credentials from auth file: admin/admin + """ + + def test_test_connection(self, deluge_client): + """Test connection to Deluge.""" + success, message = deluge_client.test_connection() + + assert success, f"Connection failed: {message}" + assert "Deluge" in message + + def test_add_and_remove_torrent(self, deluge_client): + """Test adding and removing a torrent.""" + client = deluge_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO Deluge", + ) + + assert download_id is not None + + time.sleep(3) + + try: + status = client.get_status(download_id) + assert isinstance(status, DownloadStatus) + assert status.progress >= 0 + finally: + result = client.remove(download_id, delete_files=True) + assert result is True + + def test_find_existing_torrent(self, deluge_client): + """Test finding an existing torrent.""" + client = deluge_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Ubuntu ISO Find Deluge", + ) + time.sleep(3) + + try: + result = client.find_existing(TEST_MAGNET) + assert result is not None + found_id, status = result + assert found_id == download_id + assert isinstance(status, DownloadStatus) + finally: + client.remove(download_id, delete_files=True) + + def test_status_fields(self, deluge_client): + """Test that status contains all required fields.""" + client = deluge_client + + download_id = client.add_download( + url=TEST_MAGNET, + name="Test Status Fields Deluge", + ) + time.sleep(3) + + try: + status = client.get_status(download_id) + + assert hasattr(status, "progress") + assert hasattr(status, "state") + assert hasattr(status, "message") + assert hasattr(status, "complete") + assert hasattr(status, "file_path") + + assert 0 <= status.progress <= 100 + + valid_states = {"downloading", "complete", "error", "seeding", "paused", "queued", "fetching_metadata", "checking"} + assert status.state in valid_states + + assert isinstance(status.complete, bool) + finally: + client.remove(download_id, delete_files=True) + + +@pytest.mark.integration +class TestNZBGetIntegration: + """Integration tests for NZBGet client. + + Uses the Docker test stack's NZBGet instance (http://nzbget:6789). + Default credentials: nzbget/tegbzn6789 + """ + + def test_test_connection(self, nzbget_client): + """Test connection to NZBGet.""" + success, message = nzbget_client.test_connection() + + assert success, f"Connection failed: {message}" + assert "NZBGet" in message + + +@pytest.mark.integration +class TestSABnzbdIntegration: + """Integration tests for SABnzbd client. + + Uses the Docker test stack's SABnzbd instance (http://sabnzbd:8080). + Requires API key from config after setup wizard completion. + """ + + def test_test_connection(self, sabnzbd_client): + """Test connection to SABnzbd.""" + success, message = sabnzbd_client.test_connection() + + assert success, f"Connection failed: {message}" + assert "SABnzbd" in message diff --git a/tests/prowlarr/test_integration_handler.py b/tests/prowlarr/test_integration_handler.py new file mode 100644 index 00000000..e896b807 --- /dev/null +++ b/tests/prowlarr/test_integration_handler.py @@ -0,0 +1,282 @@ +""" +Integration tests for the Prowlarr download handler. + +These tests verify the end-to-end download flow through the Prowlarr handler. + +Run with: docker exec test-cwabd python3 -m pytest /app/tests/prowlarr/test_integration_handler.py -v -m integration +""" + +import time +from threading import Event +from typing import List, Optional, Tuple +import pytest + +from cwa_book_downloader.core.config import config +from cwa_book_downloader.core.settings_registry import save_config_file +from cwa_book_downloader.core.models import DownloadTask +from cwa_book_downloader.release_sources.prowlarr.handler import ProwlarrHandler, _determine_protocol +from cwa_book_downloader.release_sources.prowlarr.cache import cache_release, get_release, remove_release, _cache + + +# Test magnet link +TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso" + + +def _setup_transmission_config(): + """Set up Transmission configuration via config files and refresh config.""" + save_config_file("prowlarr_clients", { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://transmission:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "admin", + "TRANSMISSION_CATEGORY": "test", + }) + config.refresh() + + +def _is_transmission_available(): + """Check if Transmission is available.""" + _setup_transmission_config() + try: + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import TransmissionClient + client = TransmissionClient() + success, _ = client.test_connection() + return success + except Exception: + return False + + +class ProgressRecorder: + """Records progress and status updates during download.""" + + def __init__(self): + self.progress_values: List[float] = [] + self.status_updates: List[Tuple[str, Optional[str]]] = [] + + def progress_callback(self, progress: float): + self.progress_values.append(progress) + + def status_callback(self, status: str, message: Optional[str]): + self.status_updates.append((status, message)) + + @property + def last_status(self) -> Optional[str]: + return self.status_updates[-1][0] if self.status_updates else None + + @property + def last_message(self) -> Optional[str]: + return self.status_updates[-1][1] if self.status_updates else None + + @property + def statuses(self) -> List[str]: + return [s[0] for s in self.status_updates] + + +class TestDetermineProtocol: + """Tests for the _determine_protocol function.""" + + def test_determine_protocol_torrent(self): + """Test detecting torrent protocol.""" + result = {"protocol": "torrent"} + assert _determine_protocol(result) == "torrent" + + def test_determine_protocol_usenet(self): + """Test detecting usenet protocol.""" + result = {"protocol": "usenet"} + assert _determine_protocol(result) == "usenet" + + def test_determine_protocol_unknown(self): + """Test unknown protocol.""" + result = {"protocol": "ftp"} + assert _determine_protocol(result) == "unknown" + + def test_determine_protocol_empty(self): + """Test empty protocol.""" + result = {} + assert _determine_protocol(result) == "unknown" + + +@pytest.mark.integration +class TestHandlerCacheOperations: + """Tests for handler cache-related behavior.""" + + def test_download_fails_without_cached_release(self): + """Test that download fails when release is not in cache.""" + _setup_transmission_config() + handler = ProwlarrHandler() + + task = DownloadTask( + task_id="non-existent-id-12345", + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "cache" in recorder.last_message.lower() + + def test_download_fails_without_download_url(self): + """Test that download fails when release has no download URL.""" + _setup_transmission_config() + handler = ProwlarrHandler() + + task_id = "no-url-release-test" + cache_release(task_id, { + "protocol": "torrent", + "title": "Test Release", + # No downloadUrl or magnetUrl + }) + + try: + task = DownloadTask( + task_id=task_id, + source="prowlarr", + title="Test Book", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + assert result is None + assert recorder.last_status == "error" + assert "url" in recorder.last_message.lower() + finally: + remove_release(task_id) + + def test_cancel_removes_from_cache(self): + """Test that cancel removes release from cache.""" + handler = ProwlarrHandler() + + task_id = "cancel-test-id-unique" + cache_release(task_id, {"title": "Test"}) + + assert get_release(task_id) is not None + + result = handler.cancel(task_id) + + assert result is True + assert get_release(task_id) is None + + def test_cancel_handles_missing_task(self): + """Test that cancel handles non-existent task gracefully.""" + handler = ProwlarrHandler() + + result = handler.cancel("definitely-non-existent-task-id") + + assert result is True # Should still return True + + +@pytest.fixture(scope="module") +def transmission_available(): + """Check if Transmission is available, skip if not.""" + if not _is_transmission_available(): + pytest.skip("Transmission not available - ensure docker-compose.test-clients.yml is running") + return True + + +@pytest.mark.integration +class TestProwlarrHandlerWithTransmission: + """Integration tests for ProwlarrHandler with Transmission.""" + + def test_download_starts_and_can_be_cancelled(self, transmission_available): + """Test that download starts and can be cancelled.""" + _setup_transmission_config() + handler = ProwlarrHandler() + + # Cache a valid release + task_id = f"test-cancel-release-{time.time()}" + cache_release(task_id, { + "protocol": "torrent", + "title": "Ubuntu Test ISO", + "magnetUrl": TEST_MAGNET, + }) + + task = DownloadTask( + task_id=task_id, + source="prowlarr", + title="Ubuntu Test ISO", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + # Start download in a thread and cancel after a short delay + import threading + + def cancel_after_delay(): + time.sleep(4) # Let it start + cancel_flag.set() + + cancel_thread = threading.Thread(target=cancel_after_delay) + cancel_thread.start() + + result = handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + cancel_thread.join() + + # Download was cancelled + assert result is None + # Should have some status updates + assert len(recorder.status_updates) > 0 + # Should see resolving or downloading status (not just error) + assert "resolving" in recorder.statuses or "downloading" in recorder.statuses or "cancelled" in recorder.statuses + + def test_handler_sends_to_transmission(self, transmission_available): + """Test that handler properly sends downloads to Transmission.""" + _setup_transmission_config() + handler = ProwlarrHandler() + + task_id = f"transmission-test-{time.time()}" + cache_release(task_id, { + "protocol": "torrent", + "title": "Integration Test Torrent", + "magnetUrl": TEST_MAGNET, + }) + + task = DownloadTask( + task_id=task_id, + source="prowlarr", + title="Integration Test Torrent", + ) + cancel_flag = Event() + recorder = ProgressRecorder() + + import threading + + def cancel_soon(): + time.sleep(5) + cancel_flag.set() + + t = threading.Thread(target=cancel_soon) + t.start() + + handler.download( + task=task, + cancel_flag=cancel_flag, + progress_callback=recorder.progress_callback, + status_callback=recorder.status_callback, + ) + + t.join() + + # Should have seen resolving status (means it tried to send to client) + assert "resolving" in recorder.statuses or "downloading" in recorder.statuses diff --git a/tests/prowlarr/test_nzbget_client.py b/tests/prowlarr/test_nzbget_client.py new file mode 100644 index 00000000..a74e3495 --- /dev/null +++ b/tests/prowlarr/test_nzbget_client.py @@ -0,0 +1,595 @@ +""" +Unit tests for the NZBGet client. + +These tests mock the requests library to test the client logic +without requiring a running NZBGet instance. +""" + +from unittest.mock import MagicMock, patch +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients import DownloadStatus + + +class TestNZBGetClientIsConfigured: + """Tests for NZBGetClient.is_configured().""" + + def test_is_configured_when_all_set(self, monkeypatch): + """Test is_configured returns True when properly configured.""" + config_values = { + "PROWLARR_USENET_CLIENT": "nzbget", + "NZBGET_URL": "http://localhost:6789", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + assert NZBGetClient.is_configured() is True + + def test_is_configured_wrong_client(self, monkeypatch): + """Test is_configured returns False when different client selected.""" + config_values = { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "NZBGET_URL": "http://localhost:6789", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + assert NZBGetClient.is_configured() is False + + def test_is_configured_no_url(self, monkeypatch): + """Test is_configured returns False when URL not set.""" + config_values = { + "PROWLARR_USENET_CLIENT": "nzbget", + "NZBGET_URL": "", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + assert NZBGetClient.is_configured() is False + + +class TestNZBGetClientTestConnection: + """Tests for NZBGetClient.test_connection().""" + + def test_test_connection_success(self, monkeypatch): + """Test successful connection.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "tegbzn6789", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = {"result": {"Version": "21.1"}} + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + return_value=mock_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + success, message = client.test_connection() + + assert success is True + assert "21.1" in message + + def test_test_connection_failure(self, monkeypatch): + """Test failed connection.""" + import requests + + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "wrong", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + side_effect=requests.exceptions.ConnectionError("Connection refused"), + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + success, message = client.test_connection() + + assert success is False + assert "connect" in message.lower() + + def test_test_connection_timeout(self, monkeypatch): + """Test connection timeout.""" + import requests + + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + side_effect=requests.exceptions.Timeout("Timeout"), + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + success, message = client.test_connection() + + assert success is False + assert "timed" in message.lower() # "Connection timed out" + + +class TestNZBGetClientRPCCall: + """Tests for NZBGetClient._rpc_call().""" + + def test_rpc_call_success(self, monkeypatch): + """Test successful RPC call.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = {"result": "test_result"} + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + return_value=mock_response, + ) as mock_post: + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + result = client._rpc_call("testmethod", ["arg1", "arg2"]) + + assert result == "test_result" + # Verify the request was made correctly + call_args = mock_post.call_args + assert call_args.kwargs["auth"] == ("nzbget", "password") + + def test_rpc_call_error_response(self, monkeypatch): + """Test RPC call with error response.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = {"error": {"message": "Invalid method"}} + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + return_value=mock_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + with pytest.raises(Exception) as exc_info: + client._rpc_call("invalid_method") + + assert "Invalid method" in str(exc_info.value) + + +class TestNZBGetClientGetStatus: + """Tests for NZBGetClient.get_status().""" + + def test_get_status_downloading(self, monkeypatch): + """Test status for downloading NZB.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [ + { + "NZBID": 123, + "FileSizeHi": 0, + "FileSizeLo": 100000000, # 100MB + "RemainingSizeHi": 0, + "RemainingSizeLo": 50000000, # 50MB remaining + "Status": "DOWNLOADING", + "DownloadRate": 1024000, + "RemainingSec": 50, + } + ] + return [] + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.progress == 50.0 + assert status.state_value == "downloading" + assert status.complete is False + assert status.download_speed == 1024000 + assert status.eta == 50 + + def test_get_status_complete_in_history(self, monkeypatch): + """Test status for completed NZB in history.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [] # Not in queue + if method == "history": + return [ + { + "NZBID": 123, + "Status": "SUCCESS", + "DestDir": "/downloads/completed/book", + } + ] + return [] + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.progress == 100.0 + assert status.state_value == "complete" + assert status.complete is True + assert status.file_path == "/downloads/completed/book" + + def test_get_status_failed_in_history(self, monkeypatch): + """Test status for failed NZB in history.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [] + if method == "history": + return [ + { + "NZBID": 123, + "Status": "FAILURE/PAR", + "DestDir": "", + } + ] + return [] + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.state_value == "error" + assert "failed" in status.message.lower() + + def test_get_status_not_found(self, monkeypatch): + """Test status for non-existent NZB.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + return [] # Empty queue and history + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("999") + + assert status.state_value == "error" + assert "not found" in status.message.lower() + + def test_get_status_queued(self, monkeypatch): + """Test status for queued NZB.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "listgroups": + return [ + { + "NZBID": 123, + "FileSizeHi": 0, + "FileSizeLo": 100000000, + "RemainingSizeHi": 0, + "RemainingSizeLo": 100000000, + "Status": "QUEUED", + "DownloadRate": 0, + "RemainingSec": 0, + } + ] + return [] + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + status = client.get_status("123") + + assert status.state_value == "queued" + + +class TestNZBGetClientAddDownload: + """Tests for NZBGetClient.add_download().""" + + def test_add_download_success(self, monkeypatch): + """Test adding an NZB from URL.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + # Mock requests.get for fetching NZB + mock_get_response = MagicMock() + mock_get_response.content = b"test" + + # Mock the RPC call result + mock_post_response = MagicMock() + mock_post_response.json.return_value = {"result": 456} + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.get", + return_value=mock_get_response, + ), patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.post", + return_value=mock_post_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + result = client.add_download( + "https://example.com/download.nzb", + "Test Book", + ) + + assert result == "456" + + def test_add_download_fetch_failure(self, monkeypatch): + """Test handling of NZB fetch failure.""" + import requests + + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.requests.get", + side_effect=requests.RequestException("Failed to fetch"), + ): + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + client = NZBGetClient() + with pytest.raises(Exception) as exc_info: + client.add_download("https://example.com/download.nzb", "Test") + + assert "fetch" in str(exc_info.value).lower() + + +class TestNZBGetClientRemove: + """Tests for NZBGetClient.remove().""" + + def test_remove_success(self, monkeypatch): + """Test successful NZB removal.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_rpc_call(method, params=None): + if method == "editqueue": + return True + return None + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + result = client.remove("123", delete_files=True) + + assert result is True + + def test_remove_with_delete_files(self, monkeypatch): + """Test removal uses correct command based on delete_files.""" + config_values = { + "NZBGET_URL": "http://localhost:6789", + "NZBGET_USERNAME": "nzbget", + "NZBGET_PASSWORD": "password", + "NZBGET_CATEGORY": "Books", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.nzbget.config.get", + lambda key, default="": config_values.get(key, default), + ) + + calls = [] + + def mock_rpc_call(method, params=None): + if method == "editqueue": + calls.append((method, params)) + return True + return None + + from cwa_book_downloader.release_sources.prowlarr.clients.nzbget import ( + NZBGetClient, + ) + + with patch.object(NZBGetClient, "__init__", lambda x: None): + client = NZBGetClient() + client.url = "http://localhost:6789" + client.username = "nzbget" + client.password = "password" + client._category = "Books" + client._rpc_call = mock_rpc_call + + # Test with delete_files=True + client.remove("123", delete_files=True) + assert calls[-1][1][0] == "GroupFinalDelete" + + # Test with delete_files=False + client.remove("456", delete_files=False) + assert calls[-1][1][0] == "GroupDelete" diff --git a/tests/prowlarr/test_qbittorrent_client.py b/tests/prowlarr/test_qbittorrent_client.py new file mode 100644 index 00000000..5f4ce2b6 --- /dev/null +++ b/tests/prowlarr/test_qbittorrent_client.py @@ -0,0 +1,554 @@ +""" +Unit tests for the qBittorrent client. + +These tests mock the qbittorrentapi library to test the client logic +without requiring a running qBittorrent instance. +""" + +import sys +from unittest.mock import MagicMock, patch +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients import DownloadStatus + + +class MockTorrent: + """Mock qBittorrent torrent object.""" + + def __init__( + self, + hash_val="abc123", + name="Test Torrent", + progress=0.5, + state="downloading", + dlspeed=1024000, + eta=3600, + content_path="/downloads/test.txt", + ): + self.hash = hash_val + self.name = name + self.progress = progress + self.state = state + self.dlspeed = dlspeed + self.eta = eta + self.content_path = content_path + + +class TestQBittorrentClientIsConfigured: + """Tests for QBittorrentClient.is_configured().""" + + def test_is_configured_when_all_set(self, monkeypatch): + """Test is_configured returns True when properly configured.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "QBITTORRENT_URL": "http://localhost:8080", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent import ( + QBittorrentClient, + ) + + assert QBittorrentClient.is_configured() is True + + def test_is_configured_wrong_client(self, monkeypatch): + """Test is_configured returns False when different client selected.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "transmission", + "QBITTORRENT_URL": "http://localhost:8080", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent import ( + QBittorrentClient, + ) + + assert QBittorrentClient.is_configured() is False + + def test_is_configured_no_url(self, monkeypatch): + """Test is_configured returns False when URL not set.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "QBITTORRENT_URL": "", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent import ( + QBittorrentClient, + ) + + assert QBittorrentClient.is_configured() is False + + +class TestQBittorrentClientTestConnection: + """Tests for QBittorrentClient.test_connection().""" + + def test_test_connection_success(self, monkeypatch): + """Test successful connection.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_instance.app.version = "4.6.0" + mock_client_class = MagicMock(return_value=mock_client_instance) + + # Mock the import inside the module + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + # Need to reimport after patching + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + success, message = client.test_connection() + + assert success is True + assert "4.6.0" in message + + def test_test_connection_failure(self, monkeypatch): + """Test failed connection.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "wrong", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_instance.auth_log_in.side_effect = Exception("401 Unauthorized") + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + success, message = client.test_connection() + + assert success is False + assert "401" in message or "failed" in message.lower() + + +class TestQBittorrentClientGetStatus: + """Tests for QBittorrentClient.get_status().""" + + def test_get_status_downloading(self, monkeypatch): + """Test status for downloading torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent(progress=0.5, state="downloading", dlspeed=1024000, eta=3600) + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.progress == 50.0 + assert status.state_value == "downloading" + assert status.complete is False + assert status.download_speed == 1024000 + assert status.eta == 3600 + + def test_get_status_complete(self, monkeypatch): + """Test status for completed torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent( + progress=1.0, + state="uploading", + content_path="/downloads/completed.epub", + ) + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.progress == 100.0 + assert status.complete is True + assert status.file_path == "/downloads/completed.epub" + + def test_get_status_not_found(self, monkeypatch): + """Test status for non-existent torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("nonexistent") + + assert status.state_value == "error" + assert "not found" in status.message.lower() + + def test_get_status_stalled(self, monkeypatch): + """Test status for stalled torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent(progress=0.3, state="stalledDL") + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.state_value == "downloading" + assert "stalled" in status.message.lower() + + def test_get_status_paused(self, monkeypatch): + """Test status for paused torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent(progress=0.5, state="pausedDL") + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.state_value == "paused" + + def test_get_status_error_state(self, monkeypatch): + """Test status for errored torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent(progress=0.1, state="error") + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + status = client.get_status("abc123") + + assert status.state_value == "error" + + +class TestQBittorrentClientAddDownload: + """Tests for QBittorrentClient.add_download().""" + + def test_add_download_magnet_success(self, monkeypatch): + """Test adding a magnet link.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent(hash_val="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0") + mock_client_instance = MagicMock() + mock_client_instance.torrents_add.return_value = "Ok." + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test" + result = client.add_download(magnet, "Test Download") + + assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + + def test_add_download_creates_category(self, monkeypatch): + """Test that add_download creates category if needed.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + # Use a valid 40-character hex hash + valid_hash = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + mock_torrent = MockTorrent(hash_val=valid_hash) + mock_client_instance = MagicMock() + mock_client_instance.torrents_add.return_value = "Ok." + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + magnet = f"magnet:?xt=urn:btih:{valid_hash}&dn=test" + client.add_download(magnet, "Test") + + mock_client_instance.torrents_create_category.assert_called_once_with(name="cwabd") + + +class TestQBittorrentClientRemove: + """Tests for QBittorrentClient.remove().""" + + def test_remove_success(self, monkeypatch): + """Test successful torrent removal.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + result = client.remove("abc123", delete_files=True) + + assert result is True + mock_client_instance.torrents_delete.assert_called_once_with( + torrent_hashes="abc123", delete_files=True + ) + + def test_remove_failure(self, monkeypatch): + """Test failed torrent removal.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_instance.torrents_delete.side_effect = Exception("Not found") + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + result = client.remove("abc123") + + assert result is False + + +class TestQBittorrentClientFindExisting: + """Tests for QBittorrentClient.find_existing().""" + + def test_find_existing_found(self, monkeypatch): + """Test finding existing torrent by magnet hash.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_torrent = MockTorrent( + hash_val="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0", + progress=0.5, + state="downloading", + ) + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [mock_torrent] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test" + result = client.find_existing(magnet) + + assert result is not None + download_id, status = result + assert download_id == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + assert isinstance(status, DownloadStatus) + + def test_find_existing_not_found(self, monkeypatch): + """Test finding non-existent torrent.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_instance.torrents_info.return_value = [] + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + magnet = "magnet:?xt=urn:btih:abc123&dn=test" + result = client.find_existing(magnet) + + assert result is None + + def test_find_existing_invalid_url(self, monkeypatch): + """Test find_existing with invalid URL returns None.""" + config_values = { + "QBITTORRENT_URL": "http://localhost:8080", + "QBITTORRENT_USERNAME": "admin", + "QBITTORRENT_PASSWORD": "password", + "QBITTORRENT_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_client_instance = MagicMock() + mock_client_class = MagicMock(return_value=mock_client_instance) + + with patch.dict('sys.modules', {'qbittorrentapi': MagicMock(Client=mock_client_class)}): + import importlib + import cwa_book_downloader.release_sources.prowlarr.clients.qbittorrent as qb_module + importlib.reload(qb_module) + + client = qb_module.QBittorrentClient() + result = client.find_existing("not-a-magnet-link") + + assert result is None diff --git a/tests/prowlarr/test_sabnzbd_client.py b/tests/prowlarr/test_sabnzbd_client.py new file mode 100644 index 00000000..5a615a6c --- /dev/null +++ b/tests/prowlarr/test_sabnzbd_client.py @@ -0,0 +1,693 @@ +""" +Unit tests for the SABnzbd client. + +These tests mock the requests library to test the client logic +without requiring a running SABnzbd instance. +""" + +from unittest.mock import MagicMock, patch +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients import DownloadStatus + + +class TestSABnzbdClientIsConfigured: + """Tests for SABnzbdClient.is_configured().""" + + def test_is_configured_when_all_set(self, monkeypatch): + """Test is_configured returns True when properly configured.""" + config_values = { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + assert SABnzbdClient.is_configured() is True + + def test_is_configured_wrong_client(self, monkeypatch): + """Test is_configured returns False when different client selected.""" + config_values = { + "PROWLARR_USENET_CLIENT": "nzbget", + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + assert SABnzbdClient.is_configured() is False + + def test_is_configured_no_url(self, monkeypatch): + """Test is_configured returns False when URL not set.""" + config_values = { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "SABNZBD_URL": "", + "SABNZBD_API_KEY": "abc123", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + assert SABnzbdClient.is_configured() is False + + def test_is_configured_no_api_key(self, monkeypatch): + """Test is_configured returns False when API key not set.""" + config_values = { + "PROWLARR_USENET_CLIENT": "sabnzbd", + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + assert SABnzbdClient.is_configured() is False + + +class TestSABnzbdClientTestConnection: + """Tests for SABnzbdClient.test_connection().""" + + def test_test_connection_success(self, monkeypatch): + """Test successful connection.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = {"version": "4.2.1"} + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.requests.get", + return_value=mock_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + client = SABnzbdClient() + success, message = client.test_connection() + + assert success is True + assert "4.2.1" in message + + def test_test_connection_failure(self, monkeypatch): + """Test failed connection.""" + import requests + + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "wrong", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.requests.get", + side_effect=requests.exceptions.ConnectionError("Connection refused"), + ): + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + client = SABnzbdClient() + success, message = client.test_connection() + + assert success is False + assert "connect" in message.lower() + + +class TestSABnzbdClientGetStatus: + """Tests for SABnzbdClient.get_status().""" + + def test_get_status_downloading(self, monkeypatch): + """Test status for downloading NZB.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return { + "queue": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "status": "Downloading", + "percentage": "50", + "timeleft": "0:05:30", + "kbpersec": "1000", + "speed": "1 MB/s", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("SABnzbd_nzo_abc123") + + assert status.progress == 50.0 + assert status.state_value == "downloading" + assert status.complete is False + assert status.eta == 330 # 5 min 30 sec + assert status.download_speed == 1024000 # 1000 KB/s in bytes + + def test_get_status_complete_in_history(self, monkeypatch): + """Test status for completed NZB in history.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"queue": {"slots": []}} + if mode == "history": + return { + "history": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "status": "Completed", + "storage": "/downloads/complete/book", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("SABnzbd_nzo_abc123") + + assert status.progress == 100.0 + assert status.state_value == "complete" + assert status.complete is True + assert status.file_path == "/downloads/complete/book" + + def test_get_status_failed(self, monkeypatch): + """Test status for failed NZB.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"queue": {"slots": []}} + if mode == "history": + return { + "history": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "status": "Failed", + "fail_message": "Download failed - not enough servers", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("SABnzbd_nzo_abc123") + + assert status.state_value == "error" + assert "failed" in status.message.lower() + + def test_get_status_not_found(self, monkeypatch): + """Test status for non-existent NZB.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"queue": {"slots": []}} + if mode == "history": + return {"history": {"slots": []}} + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("nonexistent") + + assert status.state_value == "error" + assert "not found" in status.message.lower() + + def test_get_status_queued(self, monkeypatch): + """Test status for queued NZB.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return { + "queue": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "status": "Queued", + "percentage": "0", + "timeleft": "", + "kbpersec": "", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("SABnzbd_nzo_abc123") + + assert status.state_value == "queued" + + def test_get_status_extracting(self, monkeypatch): + """Test status for extracting NZB.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return { + "queue": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_abc123", + "status": "Extracting", + "percentage": "100", + "timeleft": "", + "kbpersec": "", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + status = client.get_status("SABnzbd_nzo_abc123") + + assert status.state_value == "processing" + + +class TestSABnzbdClientAddDownload: + """Tests for SABnzbdClient.add_download().""" + + def test_add_download_success(self, monkeypatch): + """Test adding an NZB from URL.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = { + "status": True, + "nzo_ids": ["SABnzbd_nzo_xyz789"], + } + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.requests.get", + return_value=mock_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + client = SABnzbdClient() + result = client.add_download( + "https://example.com/download.nzb", + "Test Book", + ) + + assert result == "SABnzbd_nzo_xyz789" + + def test_add_download_no_nzo_id(self, monkeypatch): + """Test add_download when SABnzbd returns no nzo_id.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + mock_response = MagicMock() + mock_response.json.return_value = { + "status": True, + "nzo_ids": [], + } + + with patch( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.requests.get", + return_value=mock_response, + ): + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + client = SABnzbdClient() + with pytest.raises(Exception) as exc_info: + client.add_download("https://example.com/download.nzb", "Test") + + assert "nzo_id" in str(exc_info.value).lower() + + +class TestSABnzbdClientRemove: + """Tests for SABnzbdClient.remove().""" + + def test_remove_from_queue_success(self, monkeypatch): + """Test successful removal from queue.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"status": True} + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.remove("SABnzbd_nzo_abc123", delete_files=True) + + assert result is True + + def test_remove_from_history(self, monkeypatch): + """Test removal from history when not in queue.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + call_count = {"queue": 0, "history": 0} + + def mock_api_call(mode, params=None): + if mode == "queue" and params and params.get("name") == "delete": + call_count["queue"] += 1 + return {"status": False} # Not in queue + if mode == "history" and params and params.get("name") == "delete": + call_count["history"] += 1 + return {"status": True} # Found in history + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.remove("SABnzbd_nzo_abc123") + + assert result is True + assert call_count["history"] == 1 + + +class TestSABnzbdClientFindExisting: + """Tests for SABnzbdClient.find_existing().""" + + def test_find_existing_in_queue(self, monkeypatch): + """Test finding existing NZB in queue.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return { + "queue": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_found", + "filename": "Test_Book.nzb", + "status": "Downloading", + "percentage": "50", + "timeleft": "", + "kbpersec": "", + } + ] + } + } + return {"history": {"slots": []}} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.find_existing("https://example.com/Test_Book.nzb") + + assert result is not None + nzo_id, status = result + assert nzo_id == "SABnzbd_nzo_found" + + def test_find_existing_in_history(self, monkeypatch): + """Test finding existing NZB in history.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"queue": {"slots": []}} + if mode == "history": + return { + "history": { + "slots": [ + { + "nzo_id": "SABnzbd_nzo_history", + "name": "Test Book", + "status": "Completed", + "storage": "/downloads/Test Book", + } + ] + } + } + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.find_existing("https://example.com/Test%20Book.nzb") + + assert result is not None + nzo_id, status = result + assert nzo_id == "SABnzbd_nzo_history" + + def test_find_existing_not_found(self, monkeypatch): + """Test find_existing when NZB not found.""" + config_values = { + "SABNZBD_URL": "http://localhost:8080", + "SABNZBD_API_KEY": "abc123", + "SABNZBD_CATEGORY": "cwabd", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd.config.get", + lambda key, default="": config_values.get(key, default), + ) + + def mock_api_call(mode, params=None): + if mode == "queue": + return {"queue": {"slots": []}} + if mode == "history": + return {"history": {"slots": []}} + return {} + + from cwa_book_downloader.release_sources.prowlarr.clients.sabnzbd import ( + SABnzbdClient, + ) + + with patch.object(SABnzbdClient, "__init__", lambda x: None): + client = SABnzbdClient() + client.url = "http://localhost:8080" + client.api_key = "abc123" + client._category = "cwabd" + client._api_call = mock_api_call + + result = client.find_existing("https://example.com/unknown.nzb") + + assert result is None diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py new file mode 100644 index 00000000..413d4eae --- /dev/null +++ b/tests/prowlarr/test_source.py @@ -0,0 +1,222 @@ +""" +Tests for the Prowlarr source module. + +Tests the utility functions for parsing release metadata. +""" + +import pytest + +# Import the functions to test +from cwa_book_downloader.release_sources.prowlarr.source import ( + _parse_size, + _extract_format, + _get_protocol, + _extract_language, +) + + +class TestParseSize: + """Tests for the _parse_size function.""" + + def test_parse_size_bytes(self): + """Test parsing small byte sizes.""" + assert _parse_size(100) == "100 B" + assert _parse_size(512) == "512 B" + + def test_parse_size_kilobytes(self): + """Test parsing kilobyte sizes.""" + assert _parse_size(1024) == "1.0 KB" + assert _parse_size(2048) == "2.0 KB" + assert _parse_size(1536) == "1.5 KB" + + def test_parse_size_megabytes(self): + """Test parsing megabyte sizes.""" + assert _parse_size(1048576) == "1.0 MB" + assert _parse_size(5242880) == "5.0 MB" + assert _parse_size(1572864) == "1.5 MB" + + def test_parse_size_gigabytes(self): + """Test parsing gigabyte sizes.""" + assert _parse_size(1073741824) == "1.0 GB" + assert _parse_size(2147483648) == "2.0 GB" + + def test_parse_size_terabytes(self): + """Test parsing terabyte sizes.""" + assert _parse_size(1099511627776) == "1.0 TB" + + def test_parse_size_none(self): + """Test that None returns None.""" + assert _parse_size(None) is None + + def test_parse_size_zero(self): + """Test that zero returns None.""" + assert _parse_size(0) is None + + def test_parse_size_negative(self): + """Test that negative values return None.""" + assert _parse_size(-100) is None + + +class TestExtractFormat: + """Tests for the _extract_format function.""" + + def test_extract_format_from_extension(self): + """Test extracting format from file extension.""" + assert _extract_format("The Book.epub") == "epub" + assert _extract_format("The Book.mobi") == "mobi" + assert _extract_format("The Book.pdf") == "pdf" + assert _extract_format("The Book.azw3") == "azw3" + + def test_extract_format_from_brackets(self): + """Test extracting format from brackets.""" + assert _extract_format("The Book [EPUB]") == "epub" + assert _extract_format("The Book (PDF)") == "pdf" + assert _extract_format("The Book {MOBI}") == "mobi" + + def test_extract_format_from_word(self): + """Test extracting format as standalone word.""" + assert _extract_format("The Book epub version") == "epub" + assert _extract_format("mobi edition of the book") == "mobi" + + def test_extract_format_priority_extension_over_bracket(self): + """Test that file extension takes priority over brackets.""" + # Extension is more reliable + assert _extract_format("The Book [PDF].epub") == "epub" + + def test_extract_format_case_insensitive(self): + """Test that format extraction is case insensitive.""" + assert _extract_format("The Book.EPUB") == "epub" + assert _extract_format("The Book [PDF]") == "pdf" + assert _extract_format("The Book.Mobi") == "mobi" + + def test_extract_format_none_when_no_format(self): + """Test that None is returned when no format found.""" + assert _extract_format("The Book by Author") is None + assert _extract_format("") is None + + def test_extract_format_cbz_cbr(self): + """Test comic book formats.""" + assert _extract_format("Comic Issue 1.cbz") == "cbz" + assert _extract_format("Comic Issue 2.cbr") == "cbr" + + def test_extract_format_fb2(self): + """Test FB2 format (common in Russian ebooks).""" + assert _extract_format("Russian Book.fb2") == "fb2" + assert _extract_format("Book [FB2]") == "fb2" + + def test_extract_format_djvu(self): + """Test DjVu format.""" + assert _extract_format("Scanned Book.djvu") == "djvu" + + def test_extract_format_avoids_false_positives(self): + """Test that format extraction doesn't match partial words.""" + # "republic" should not match "pdf" or other formats + assert _extract_format("The Republic by Plato") is None + # "literal" should not match "lit" + assert _extract_format("Literal Translation") is None + + +class TestGetProtocol: + """Tests for the _get_protocol function.""" + + def test_get_protocol_from_protocol_field_torrent(self): + """Test extracting torrent protocol from protocol field.""" + result = {"protocol": "torrent", "downloadUrl": "https://example.com"} + assert _get_protocol(result) == "torrent" + + def test_get_protocol_from_protocol_field_usenet(self): + """Test extracting usenet protocol from protocol field.""" + result = {"protocol": "usenet", "downloadUrl": "https://example.com"} + assert _get_protocol(result) == "nzb" + + def test_get_protocol_from_magnet_url(self): + """Test inferring torrent from magnet URL.""" + result = {"downloadUrl": "magnet:?xt=urn:btih:abc123"} + assert _get_protocol(result) == "torrent" + + def test_get_protocol_from_torrent_url(self): + """Test inferring torrent from .torrent URL.""" + result = {"downloadUrl": "https://example.com/file.torrent"} + assert _get_protocol(result) == "torrent" + + def test_get_protocol_from_nzb_url(self): + """Test inferring NZB from .nzb URL.""" + result = {"downloadUrl": "https://example.com/file.nzb"} + assert _get_protocol(result) == "nzb" + + def test_get_protocol_fallback_to_magnet_url(self): + """Test fallback to magnetUrl field.""" + result = {"magnetUrl": "magnet:?xt=urn:btih:abc123"} + assert _get_protocol(result) == "torrent" + + def test_get_protocol_unknown(self): + """Test unknown protocol for unclear URLs.""" + result = {"downloadUrl": "https://example.com/download"} + assert _get_protocol(result) == "unknown" + + def test_get_protocol_case_insensitive(self): + """Test protocol detection is case insensitive.""" + result = {"protocol": "TORRENT"} + assert _get_protocol(result) == "torrent" + + result = {"protocol": "Usenet"} + assert _get_protocol(result) == "nzb" + + +class TestExtractLanguage: + """Tests for the _extract_language function.""" + + def test_extract_language_english(self): + """Test extracting English language.""" + assert _extract_language("The Book [English]") == "en" + assert _extract_language("Book (eng)") == "en" + assert _extract_language("Book [EN]") == "en" + + def test_extract_language_german(self): + """Test extracting German language.""" + assert _extract_language("Das Buch [German]") == "de" + assert _extract_language("Buch (Deutsch)") == "de" + assert _extract_language("Buch [DE]") == "de" + + def test_extract_language_french(self): + """Test extracting French language.""" + assert _extract_language("Le Livre [French]") == "fr" + assert _extract_language("Livre (Français)") == "fr" + assert _extract_language("Livre [FR]") == "fr" + + def test_extract_language_spanish(self): + """Test extracting Spanish language.""" + assert _extract_language("El Libro [Spanish]") == "es" + assert _extract_language("Libro (Español)") == "es" + assert _extract_language("Libro [ES]") == "es" + + def test_extract_language_italian(self): + """Test extracting Italian language.""" + assert _extract_language("Il Libro [Italian]") == "it" + assert _extract_language("Libro (Italiano)") == "it" + + def test_extract_language_russian(self): + """Test extracting Russian language.""" + assert _extract_language("Book [Russian]") == "ru" + assert _extract_language("Book [RU]") == "ru" + + def test_extract_language_japanese(self): + """Test extracting Japanese language.""" + assert _extract_language("Book [Japanese]") == "ja" + assert _extract_language("Book [JA]") == "ja" + + def test_extract_language_chinese(self): + """Test extracting Chinese language.""" + assert _extract_language("Book [Chinese]") == "zh" + assert _extract_language("Book [ZH]") == "zh" + + def test_extract_language_none_when_not_found(self): + """Test that None is returned when no language found.""" + assert _extract_language("The Book by Author") is None + assert _extract_language("") is None + + def test_extract_language_case_insensitive(self): + """Test that language extraction is case insensitive.""" + assert _extract_language("Book [GERMAN]") == "de" + assert _extract_language("Book [german]") == "de" + assert _extract_language("Book [German]") == "de" diff --git a/tests/prowlarr/test_torrent_utils.py b/tests/prowlarr/test_torrent_utils.py new file mode 100644 index 00000000..2256a5e4 --- /dev/null +++ b/tests/prowlarr/test_torrent_utils.py @@ -0,0 +1,382 @@ +""" +Tests for torrent utility functions. + +Tests: +- parse_transmission_url +- bencode_encode/decode +- extract_info_hash_from_torrent +- extract_hash_from_magnet +""" + +import pytest + +from cwa_book_downloader.release_sources.prowlarr.clients.torrent_utils import ( + parse_transmission_url, + bencode_decode, + bencode_encode, + extract_info_hash_from_torrent, + extract_hash_from_magnet, +) + + +class TestParseTransmissionUrl: + """Tests for parse_transmission_url function.""" + + def test_parse_simple_url(self): + """Test parsing a simple URL with host and port.""" + host, port, path = parse_transmission_url("http://localhost:9091") + assert host == "localhost" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_url_with_custom_port(self): + """Test parsing URL with custom port.""" + host, port, path = parse_transmission_url("http://myserver:8080") + assert host == "myserver" + assert port == 8080 + assert path == "/transmission/rpc" + + def test_parse_url_with_path(self): + """Test parsing URL with existing path.""" + host, port, path = parse_transmission_url("http://localhost:9091/transmission/rpc") + assert host == "localhost" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_url_with_partial_path(self): + """Test parsing URL with partial path appends /rpc.""" + host, port, path = parse_transmission_url("http://localhost:9091/custom") + assert host == "localhost" + assert port == 9091 + assert path == "/custom/transmission/rpc" + + def test_parse_url_with_trailing_slash(self): + """Test parsing URL with trailing slash.""" + host, port, path = parse_transmission_url("http://localhost:9091/") + assert host == "localhost" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_url_without_port(self): + """Test parsing URL without port uses default 9091.""" + host, port, path = parse_transmission_url("http://transmission") + assert host == "transmission" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_https_url(self): + """Test parsing HTTPS URL.""" + host, port, path = parse_transmission_url("https://secure.transmission.local:9091") + assert host == "secure.transmission.local" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_url_with_ip_address(self): + """Test parsing URL with IP address.""" + host, port, path = parse_transmission_url("http://192.168.1.100:9091") + assert host == "192.168.1.100" + assert port == 9091 + assert path == "/transmission/rpc" + + def test_parse_empty_url_uses_defaults(self): + """Test parsing empty URL uses localhost defaults.""" + host, port, path = parse_transmission_url("") + assert host == "localhost" + assert port == 9091 + assert path == "/transmission/rpc" + + +class TestBencodeDecode: + """Tests for bencode decoding.""" + + def test_decode_integer(self): + """Test decoding integers.""" + result, remaining = bencode_decode(b"i42e") + assert result == 42 + assert remaining == b"" + + def test_decode_negative_integer(self): + """Test decoding negative integers.""" + result, remaining = bencode_decode(b"i-42e") + assert result == -42 + + def test_decode_zero(self): + """Test decoding zero.""" + result, remaining = bencode_decode(b"i0e") + assert result == 0 + + def test_decode_large_integer(self): + """Test decoding large integers.""" + result, remaining = bencode_decode(b"i999999999999e") + assert result == 999999999999 + + def test_decode_string(self): + """Test decoding byte strings.""" + result, remaining = bencode_decode(b"5:hello") + assert result == b"hello" + assert remaining == b"" + + def test_decode_empty_string(self): + """Test decoding empty string.""" + result, remaining = bencode_decode(b"0:") + assert result == b"" + + def test_decode_unicode_string(self): + """Test decoding unicode bytes.""" + data = "tëst".encode("utf-8") + encoded = f"{len(data)}:".encode() + data + result, remaining = bencode_decode(encoded) + assert result == data + + def test_decode_list(self): + """Test decoding lists.""" + result, remaining = bencode_decode(b"li1ei2ei3ee") + assert result == [1, 2, 3] + assert remaining == b"" + + def test_decode_empty_list(self): + """Test decoding empty list.""" + result, remaining = bencode_decode(b"le") + assert result == [] + + def test_decode_nested_list(self): + """Test decoding nested lists.""" + result, remaining = bencode_decode(b"lli1eeli2eee") + assert result == [[1], [2]] + + def test_decode_mixed_list(self): + """Test decoding list with mixed types.""" + result, remaining = bencode_decode(b"l5:helloi42ee") + assert result == [b"hello", 42] + + def test_decode_dict(self): + """Test decoding dictionaries.""" + result, remaining = bencode_decode(b"d3:key5:valuee") + assert result == {b"key": b"value"} + assert remaining == b"" + + def test_decode_empty_dict(self): + """Test decoding empty dictionary.""" + result, remaining = bencode_decode(b"de") + assert result == {} + + def test_decode_complex_structure(self): + """Test decoding complex nested structures.""" + # Dict with string, int, and list values + data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee" + result, remaining = bencode_decode(data) + assert result == { + b"age": 25, + b"name": b"John", + b"items": [1, 2, 3], + } + + def test_decode_invalid_data_raises(self): + """Test that invalid data raises ValueError.""" + with pytest.raises(ValueError): + bencode_decode(b"x") + + +class TestBencodeEncode: + """Tests for bencode encoding.""" + + def test_encode_integer(self): + """Test encoding integers.""" + assert bencode_encode(42) == b"i42e" + assert bencode_encode(-42) == b"i-42e" + assert bencode_encode(0) == b"i0e" + + def test_encode_bytes(self): + """Test encoding byte strings.""" + assert bencode_encode(b"hello") == b"5:hello" + assert bencode_encode(b"") == b"0:" + + def test_encode_string(self): + """Test encoding regular strings (UTF-8 encoded).""" + assert bencode_encode("hello") == b"5:hello" + assert bencode_encode("") == b"0:" + + def test_encode_list(self): + """Test encoding lists.""" + assert bencode_encode([1, 2, 3]) == b"li1ei2ei3ee" + assert bencode_encode([]) == b"le" + + def test_encode_dict(self): + """Test encoding dictionaries.""" + result = bencode_encode({b"key": b"value"}) + assert result == b"d3:key5:valuee" + + def test_encode_dict_keys_sorted(self): + """Test that dictionary keys are sorted.""" + # Keys should be sorted: a < m < z + result = bencode_encode({b"z": 1, b"a": 2, b"m": 3}) + assert result == b"d1:ai2e1:mi3e1:zi1ee" + + def test_encode_nested_structure(self): + """Test encoding nested structures.""" + data = {b"list": [1, 2, 3], b"num": 42} + result = bencode_encode(data) + assert result == b"d4:listli1ei2ei3ee3:numi42ee" + + def test_encode_invalid_type_raises(self): + """Test that invalid types raise ValueError.""" + with pytest.raises(ValueError): + bencode_encode(3.14) # floats not supported + + +class TestBencodeRoundTrip: + """Tests for encoding then decoding (roundtrip).""" + + def test_roundtrip_integer(self): + """Test roundtrip for integers.""" + original = 12345 + encoded = bencode_encode(original) + decoded, _ = bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_bytes(self): + """Test roundtrip for byte strings.""" + original = b"hello world" + encoded = bencode_encode(original) + decoded, _ = bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_list(self): + """Test roundtrip for lists.""" + original = [1, 2, b"three", [4, 5]] + encoded = bencode_encode(original) + decoded, _ = bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_dict(self): + """Test roundtrip for dictionaries.""" + original = {b"name": b"test", b"value": 123} + encoded = bencode_encode(original) + decoded, _ = bencode_decode(encoded) + assert decoded == original + + def test_roundtrip_complex_torrent_like_structure(self): + """Test roundtrip for a structure similar to a torrent file.""" + original = { + b"announce": b"http://tracker.example.com/announce", + b"info": { + b"name": b"TestFile.txt", + b"length": 1024, + b"piece length": 16384, + b"pieces": b"\x00" * 20, # SHA1 hashes + }, + } + encoded = bencode_encode(original) + decoded, _ = bencode_decode(encoded) + assert decoded == original + + +class TestExtractInfoHash: + """Tests for extracting info hash from torrent files.""" + + def test_extract_hash_from_simple_torrent(self): + """Test extracting hash from a simple torrent structure.""" + info_dict = {b"name": b"test.txt", b"length": 100} + torrent = {b"info": info_dict} + torrent_bytes = bencode_encode(torrent) + + result = extract_info_hash_from_torrent(torrent_bytes) + + # Should return a 40-character hex string + assert result is not None + assert len(result) == 40 + assert all(c in "0123456789abcdef" for c in result) + + def test_extract_hash_returns_none_for_invalid(self): + """Test that invalid data returns None.""" + assert extract_info_hash_from_torrent(b"not a torrent") is None + assert extract_info_hash_from_torrent(b"") is None + + def test_extract_hash_returns_none_without_info(self): + """Test that torrent without info dict returns None.""" + torrent = {b"announce": b"http://tracker.example.com"} + torrent_bytes = bencode_encode(torrent) + + result = extract_info_hash_from_torrent(torrent_bytes) + assert result is None + + def test_extract_hash_is_consistent(self): + """Test that same torrent always produces same hash.""" + info_dict = {b"name": b"consistent.txt", b"length": 500} + torrent = {b"info": info_dict} + torrent_bytes = bencode_encode(torrent) + + hash1 = extract_info_hash_from_torrent(torrent_bytes) + hash2 = extract_info_hash_from_torrent(torrent_bytes) + + assert hash1 == hash2 + + def test_extract_hash_different_for_different_torrents(self): + """Test that different torrents produce different hashes.""" + torrent1 = {b"info": {b"name": b"file1.txt", b"length": 100}} + torrent2 = {b"info": {b"name": b"file2.txt", b"length": 100}} + + hash1 = extract_info_hash_from_torrent(bencode_encode(torrent1)) + hash2 = extract_info_hash_from_torrent(bencode_encode(torrent2)) + + assert hash1 != hash2 + + +class TestExtractHashFromMagnet: + """Tests for extracting hash from magnet links.""" + + def test_extract_hash_from_hex_magnet(self): + """Test extracting 40-char hex hash from magnet link.""" + magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test" + result = extract_hash_from_magnet(magnet) + assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + + def test_extract_hash_from_base32_magnet(self): + """Test extracting 32-char base32 hash from magnet link.""" + # Base32 encoded hash: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + magnet = "magnet:?xt=urn:btih:ABCDEFGHIJKLMNOPQRSTUVWXYZ234567&dn=test" + result = extract_hash_from_magnet(magnet) + # Should be converted to hex (lowercase) + assert result is not None + assert len(result) == 40 + assert all(c in "0123456789abcdef" for c in result) + + def test_extract_hash_uppercase_hex(self): + """Test that uppercase hex is converted to lowercase.""" + magnet = "magnet:?xt=urn:btih:3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0&dn=test" + result = extract_hash_from_magnet(magnet) + assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + + def test_extract_hash_no_btih(self): + """Test that magnets without btih return None.""" + magnet = "magnet:?dn=test" + result = extract_hash_from_magnet(magnet) + assert result is None + + def test_extract_hash_invalid_format(self): + """Test that invalid hash format returns None.""" + magnet = "magnet:?xt=urn:btih:invalid&dn=test" + result = extract_hash_from_magnet(magnet) + assert result is None + + def test_extract_hash_not_magnet(self): + """Test that non-magnet URLs return None.""" + result = extract_hash_from_magnet("https://example.com/file.torrent") + assert result is None + + def test_extract_hash_empty_string(self): + """Test that empty string returns None.""" + result = extract_hash_from_magnet("") + assert result is None + + def test_extract_hash_complex_magnet(self): + """Test extracting from complex magnet with many parameters.""" + magnet = ( + "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + "&dn=Ubuntu+22.04" + "&tr=udp://tracker.example.com:80" + "&tr=udp://tracker2.example.com:6969" + "&xl=12345" + ) + result = extract_hash_from_magnet(magnet) + assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" diff --git a/tests/prowlarr/test_transmission_client.py b/tests/prowlarr/test_transmission_client.py new file mode 100644 index 00000000..aa1438e5 --- /dev/null +++ b/tests/prowlarr/test_transmission_client.py @@ -0,0 +1,593 @@ +""" +Unit tests for the Transmission client. + +These tests mock the transmission-rpc library to test the client logic +without requiring a running Transmission instance. +""" + +from unittest.mock import MagicMock, patch +from datetime import timedelta +import pytest +import sys + +from cwa_book_downloader.release_sources.prowlarr.clients import DownloadStatus + + +class MockTorrentStatus: + """Mock for Transmission's torrent status enum.""" + + def __init__(self, value): + self.value = value + + +class MockTorrent: + """Mock Transmission torrent object.""" + + def __init__( + self, + hash_string="abc123", + name="Test Torrent", + percent_done=0.5, + status="downloading", + rate_download=1024000, + eta=None, + download_dir="/downloads", + ): + self.hashString = hash_string + self.name = name + self.percent_done = percent_done + self.status = MockTorrentStatus(status) + self.rate_download = rate_download + self.download_dir = download_dir + if eta is not None: + self.eta = timedelta(seconds=eta) + else: + self.eta = None + + +class MockSession: + """Mock Transmission session object.""" + + def __init__(self, version="4.0.0"): + self.version = version + + +def make_config_getter(values): + """Create a config.get function that returns values from a dict.""" + def getter(key, default=""): + return values.get(key, default) + return getter + + +def create_mock_transmission_rpc_module(): + """Create a mock transmission_rpc module.""" + mock_module = MagicMock() + mock_module.Client = MagicMock() + return mock_module + + +class TestTransmissionClientIsConfigured: + """Tests for TransmissionClient.is_configured().""" + + def test_is_configured_when_all_set(self, monkeypatch): + """Test is_configured returns True when properly configured.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "http://localhost:9091", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + assert TransmissionClient.is_configured() is True + + def test_is_configured_wrong_client(self, monkeypatch): + """Test is_configured returns False when different client selected.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "qbittorrent", + "TRANSMISSION_URL": "http://localhost:9091", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + assert TransmissionClient.is_configured() is False + + def test_is_configured_no_url(self, monkeypatch): + """Test is_configured returns False when URL not set.""" + config_values = { + "PROWLARR_TORRENT_CLIENT": "transmission", + "TRANSMISSION_URL": "", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + assert TransmissionClient.is_configured() is False + + +class TestTransmissionClientTestConnection: + """Tests for TransmissionClient.test_connection().""" + + def test_test_connection_success(self, monkeypatch): + """Test successful connection.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + mock_client_instance.get_session.return_value = MockSession(version="4.0.5") + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + # Force reimport to use mock + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + success, message = client.test_connection() + + assert success is True + assert "4.0.5" in message + + def test_test_connection_failure(self, monkeypatch): + """Test failed connection.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "wrong", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + mock_client_instance.get_session.side_effect = Exception("Connection refused") + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + success, message = client.test_connection() + + assert success is False + assert "failed" in message.lower() + + +class TestTransmissionClientGetStatus: + """Tests for TransmissionClient.get_status().""" + + def test_get_status_downloading(self, monkeypatch): + """Test status for downloading torrent.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(percent_done=0.5, status="downloading", rate_download=1024000) + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + status = client.get_status("abc123") + + assert status.progress == 50.0 + assert status.state_value == "downloading" + assert status.complete is False + assert status.download_speed == 1024000 + + def test_get_status_seeding(self, monkeypatch): + """Test status for seeding (complete) torrent.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent( + percent_done=1.0, + status="seeding", + download_dir="/downloads", + ) + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + status = client.get_status("abc123") + + assert status.progress == 100.0 + assert status.complete is True + assert "/downloads/Test Torrent" in status.file_path + + def test_get_status_not_found(self, monkeypatch): + """Test status for non-existent torrent.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.side_effect = KeyError("not found") + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + status = client.get_status("nonexistent") + + assert status.state_value == "error" + assert "not found" in status.message.lower() + + def test_get_status_paused(self, monkeypatch): + """Test status for paused torrent.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(percent_done=0.3, status="stopped") + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + status = client.get_status("abc123") + + assert status.state_value == "paused" + + def test_get_status_with_eta(self, monkeypatch): + """Test status includes ETA when available.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(percent_done=0.5, status="downloading", eta=3600) + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + status = client.get_status("abc123") + + assert status.eta == 3600 + + +class TestTransmissionClientAddDownload: + """Tests for TransmissionClient.add_download().""" + + def test_add_download_magnet_success(self, monkeypatch): + """Test adding a magnet link.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(hash_string="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0") + mock_client_instance = MagicMock() + mock_client_instance.add_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + magnet = "magnet:?xt=urn:btih:3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0&dn=test" + result = client.add_download(magnet, "Test Download") + + assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + mock_client_instance.add_torrent.assert_called_once() + + def test_add_download_uses_labels(self, monkeypatch): + """Test that add_download sets labels/category.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "mybooks", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent(hash_string="abc123") + mock_client_instance = MagicMock() + mock_client_instance.add_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + magnet = "magnet:?xt=urn:btih:abc123&dn=test" + client.add_download(magnet, "Test") + + # Verify labels were passed + call_kwargs = mock_client_instance.add_torrent.call_args + assert call_kwargs.kwargs.get("labels") == ["mybooks"] + + +class TestTransmissionClientRemove: + """Tests for TransmissionClient.remove().""" + + def test_remove_success(self, monkeypatch): + """Test successful torrent removal.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + result = client.remove("abc123", delete_files=True) + + assert result is True + mock_client_instance.remove_torrent.assert_called_once_with( + "abc123", delete_data=True + ) + + def test_remove_failure(self, monkeypatch): + """Test failed torrent removal.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + mock_client_instance.remove_torrent.side_effect = Exception("Not found") + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + result = client.remove("abc123") + + assert result is False + + +class TestTransmissionClientFindExisting: + """Tests for TransmissionClient.find_existing().""" + + def test_find_existing_found(self, monkeypatch): + """Test finding existing torrent by magnet hash.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_torrent = MockTorrent( + hash_string="3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0", + percent_done=0.5, + status="downloading", + ) + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.return_value = mock_torrent + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test" + result = client.find_existing(magnet) + + assert result is not None + download_id, status = result + assert download_id == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0" + assert isinstance(status, DownloadStatus) + + def test_find_existing_not_found(self, monkeypatch): + """Test finding non-existent torrent.""" + config_values = { + "TRANSMISSION_URL": "http://localhost:9091", + "TRANSMISSION_USERNAME": "admin", + "TRANSMISSION_PASSWORD": "password", + "TRANSMISSION_CATEGORY": "test", + } + monkeypatch.setattr( + "cwa_book_downloader.release_sources.prowlarr.clients.transmission.config.get", + make_config_getter(config_values), + ) + + mock_client_instance = MagicMock() + mock_client_instance.get_torrent.side_effect = KeyError("not found") + + mock_transmission_rpc = create_mock_transmission_rpc_module() + mock_transmission_rpc.Client.return_value = mock_client_instance + + with patch.dict("sys.modules", {"transmission_rpc": mock_transmission_rpc}): + if "cwa_book_downloader.release_sources.prowlarr.clients.transmission" in sys.modules: + del sys.modules["cwa_book_downloader.release_sources.prowlarr.clients.transmission"] + + from cwa_book_downloader.release_sources.prowlarr.clients.transmission import ( + TransmissionClient, + ) + + client = TransmissionClient() + magnet = "magnet:?xt=urn:btih:abc123&dn=test" + result = client.find_existing(magnet) + + assert result is None