From 875b705ed38832b8486437593984870ca6fd4f88 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 31 Dec 2025 12:22:33 +0000 Subject: [PATCH] Audiobook mode (#380) - Added a `content_type` field to switch metadata providers, prowlarr search category, and file formats on the frontend. - Switch between Book / Audiobook in the header dropdown. - Only Prowlarr declares itself as a supported audiobook source. Internally switches to category 3030 for searches. - Updated torrent client handling to accept and process magnet links --- cwa_book_downloader/config/env.py | 1 + cwa_book_downloader/config/settings.py | 88 +++++++--- cwa_book_downloader/core/settings_registry.py | 11 +- cwa_book_downloader/download/archive.py | 150 +++++++++++------- cwa_book_downloader/main.py | 10 +- .../metadata_providers/__init__.py | 17 +- .../release_sources/__init__.py | 7 +- .../release_sources/direct_download.py | 5 +- .../release_sources/irc/source.py | 5 +- .../prowlarr/clients/deluge.py | 5 +- .../prowlarr/clients/qbittorrent.py | 4 +- .../prowlarr/clients/torrent_utils.py | 42 ++++- .../prowlarr/clients/transmission.py | 4 +- .../release_sources/prowlarr/settings.py | 18 +++ .../release_sources/prowlarr/source.py | 26 ++- src/frontend/src/App.tsx | 14 +- src/frontend/src/components/Dropdown.tsx | 4 +- src/frontend/src/components/Header.tsx | 47 ++++++ src/frontend/src/components/ReleaseModal.tsx | 118 ++++++++++---- src/frontend/src/components/SearchSection.tsx | 8 +- .../components/settings/SettingsSidebar.tsx | 6 + .../settings/fields/MultiSelectField.tsx | 78 +++++++++ .../settings/fields/SelectField.tsx | 55 ++++--- src/frontend/src/hooks/useSearch.ts | 9 +- src/frontend/src/services/api.ts | 11 +- src/frontend/src/types/index.ts | 5 + src/frontend/src/types/settings.ts | 2 + 27 files changed, 577 insertions(+), 173 deletions(-) diff --git a/cwa_book_downloader/config/env.py b/cwa_book_downloader/config/env.py index b4dbc59..d9c91bc 100644 --- a/cwa_book_downloader/config/env.py +++ b/cwa_book_downloader/config/env.py @@ -94,6 +94,7 @@ AA_DONATOR_KEY = os.getenv("AA_DONATOR_KEY", "").strip() _AA_BASE_URL = os.getenv("AA_BASE_URL", "auto").strip() _AA_ADDITIONAL_URLS = os.getenv("AA_ADDITIONAL_URLS", "").strip() _SUPPORTED_FORMATS = os.getenv("SUPPORTED_FORMATS", "epub,mobi,azw3,fb2,djvu,cbz,cbr").lower() +_SUPPORTED_AUDIOBOOK_FORMATS = os.getenv("SUPPORTED_AUDIOBOOK_FORMATS", "m4b,mp3").lower() _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") diff --git a/cwa_book_downloader/config/settings.py b/cwa_book_downloader/config/settings.py index d8a082c..b094f8a 100644 --- a/cwa_book_downloader/config/settings.py +++ b/cwa_book_downloader/config/settings.py @@ -70,6 +70,8 @@ AA_AVAILABLE_URLS = [url.strip() for url in AA_AVAILABLE_URLS if url.strip()] # File format settings SUPPORTED_FORMATS = env._SUPPORTED_FORMATS.split(",") logger.debug(f"SUPPORTED_FORMATS: {SUPPORTED_FORMATS}") +SUPPORTED_AUDIOBOOK_FORMATS = env._SUPPORTED_AUDIOBOOK_FORMATS.split(",") +logger.debug(f"SUPPORTED_AUDIOBOOK_FORMATS: {SUPPORTED_AUDIOBOOK_FORMATS}") # Complex language processing logic kept in config.py BOOK_LANGUAGE = env._BOOK_LANGUAGE.split(',') @@ -151,6 +153,13 @@ _FORMAT_OPTIONS = [ {"value": "rar", "label": "RAR"}, ] +_AUDIOBOOK_FORMAT_OPTIONS = [ + {"value": "m4b", "label": "M4B"}, + {"value": "mp3", "label": "MP3"}, + {"value": "zip", "label": "ZIP"}, + {"value": "rar", "label": "RAR"}, +] + def _get_metadata_provider_options(): """Build metadata provider options dynamically from enabled providers only.""" @@ -171,6 +180,13 @@ def _get_metadata_provider_options(): return options +def _get_metadata_provider_options_with_none(): + """Build metadata provider options with a 'Use main provider' option first.""" + options = [{"value": "", "label": "Use book provider"}] + options.extend(_get_metadata_provider_options()) + return options + + def _get_release_source_options(): """Build release source options dynamically from registered sources.""" from cwa_book_downloader.release_sources import list_available_sources @@ -237,10 +253,43 @@ def general_settings(): description="Adds a navigation button to your book manager instance (Calibre-Web Automated, Booklore, etc).", placeholder="http://calibre-web:8083", ), + HeadingField( + key="search_defaults_heading", + title="Default Search Filters", + description="Default filters applied to searches. Can be overridden using advanced search options.", + ), + MultiSelectField( + key="SUPPORTED_FORMATS", + label="Supported Book Formats", + description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.", + options=_FORMAT_OPTIONS, + default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], + ), + MultiSelectField( + key="SUPPORTED_AUDIOBOOK_FORMATS", + label="Supported Audiobook Formats", + description="Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found.", + options=_AUDIOBOOK_FORMAT_OPTIONS, + default=["m4b", "mp3"], + ), + MultiSelectField( + key="BOOK_LANGUAGE", + label="Default Book Languages", + description="Default language filter for searches.", + options=_LANGUAGE_OPTIONS, + default=["en"], + ), + ] + + +@register_settings("search_mode", "Search Mode", icon="search", order=1) +def search_mode_settings(): + """Configure how you search for and download books.""" + return [ HeadingField( key="search_mode_heading", title="Search Mode", - description="Direct searches Anna's Archive and downloads immediately. Universal searches book metadata first, letting you choose from multiple release sources including Anna's Archive and Prowlarr.", + description="Direct mode searches Anna's Archive and downloads immediately. Universal mode searches book metadata first, letting you choose from multiple release sources including Anna's Archive and Prowlarr.", ), SelectField( key="SEARCH_MODE", @@ -255,7 +304,7 @@ def general_settings(): { "value": "universal", "label": "Universal", - "description": "Metadata-based search with downloads from all sources.", + "description": "Metadata-based search with downloads from all sources. Book and Audiobook support.", }, ], default="direct", @@ -269,14 +318,28 @@ def general_settings(): env_supported=False, # UI-only setting show_when={"field": "SEARCH_MODE", "value": "direct"}, ), + HeadingField( + key="universal_mode_heading", + title="Universal Mode Settings", + description="Configure metadata providers and release sources for Universal search mode.", + show_when={"field": "SEARCH_MODE", "value": "universal"}, + ), SelectField( key="METADATA_PROVIDER", - label="Metadata Provider", + label="Book Metadata Provider", description="Choose which metadata provider to use for book searches.", options=_get_metadata_provider_options, # Callable - evaluated lazily to avoid circular imports default="openlibrary", show_when={"field": "SEARCH_MODE", "value": "universal"}, ), + SelectField( + key="METADATA_PROVIDER_AUDIOBOOK", + label="Audiobook Metadata Provider", + description="Metadata provider for audiobook searches. Uses the book provider if not set.", + options=_get_metadata_provider_options_with_none, # Callable - includes "Use main provider" option + default="", + show_when={"field": "SEARCH_MODE", "value": "universal"}, + ), SelectField( key="DEFAULT_RELEASE_SOURCE", label="Default Release Source", @@ -286,25 +349,6 @@ def general_settings(): env_supported=False, # UI-only setting, not configurable via ENV show_when={"field": "SEARCH_MODE", "value": "universal"}, ), - HeadingField( - key="search_defaults_heading", - title="Default Search Options", - description="Default filters applied to searches. Can be overridden using advanced search options.", - ), - MultiSelectField( - key="SUPPORTED_FORMATS", - label="Supported Formats", - description="Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found.", - options=_FORMAT_OPTIONS, - default=["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"], - ), - MultiSelectField( - key="BOOK_LANGUAGE", - label="Default Book Languages", - description="Default language filter for searches.", - options=_LANGUAGE_OPTIONS, - default=["en"], - ), ] diff --git a/cwa_book_downloader/core/settings_registry.py b/cwa_book_downloader/core/settings_registry.py index 24aa91d..53cbd3d 100644 --- a/cwa_book_downloader/core/settings_registry.py +++ b/cwa_book_downloader/core/settings_registry.py @@ -78,6 +78,7 @@ class MultiSelectField(FieldBase): # Options can be a list or a callable that returns a list (for lazy evaluation) options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable default: List[str] = field(default_factory=list) + variant: str = "pills" # "pills" (default) or "dropdown" for checkbox dropdown style @dataclass @@ -308,7 +309,8 @@ def _get_config_dir() -> Path: def _get_config_file_path(tab_name: str) -> Path: """Get the config file path for a settings tab.""" config_dir = _get_config_dir() - if tab_name == "general": + # Core settings tabs share the main settings.json file + if tab_name in ("general", "search_mode"): return config_dir / "settings.json" else: plugins_dir = config_dir / "plugins" @@ -537,10 +539,15 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T result["min"] = field.min_value result["max"] = field.max_value result["step"] = field.step - elif isinstance(field, (SelectField, MultiSelectField)): + elif isinstance(field, SelectField): # Support callable options for lazy evaluation (avoids circular imports) options = field.options() if callable(field.options) else field.options result["options"] = options + elif isinstance(field, MultiSelectField): + # Support callable options for lazy evaluation (avoids circular imports) + options = field.options() if callable(field.options) else field.options + result["options"] = options + result["variant"] = field.variant elif isinstance(field, OrderableListField): # Support callable options for lazy evaluation (avoids circular imports) options = field.options() if callable(field.options) else field.options diff --git a/cwa_book_downloader/download/archive.py b/cwa_book_downloader/download/archive.py index df2bfb0..eb65cb8 100644 --- a/cwa_book_downloader/download/archive.py +++ b/cwa_book_downloader/download/archive.py @@ -22,6 +22,15 @@ def _get_supported_formats() -> List[str]: return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()] return [fmt.lower() for fmt in formats] + +def _get_supported_audiobook_formats() -> List[str]: + """Get current supported audiobook formats from config singleton.""" + formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"]) + # 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 @@ -56,60 +65,80 @@ def is_archive(file_path: Path) -> bool: return suffix in ("zip", "rar") -def _is_book_file(file_path: Path) -> bool: - """Check if file matches user's SUPPORTED_FORMATS setting.""" +def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> bool: + """Check if file matches user's supported formats setting based on content type.""" ext = file_path.suffix.lower().lstrip(".") - supported_formats = _get_supported_formats() + if content_type and content_type.lower() == "audiobook": + supported_formats = _get_supported_audiobook_formats() + else: + supported_formats = _get_supported_formats() return ext in supported_formats -def _filter_book_files(extracted_files: List[Path]) -> Tuple[List[Path], List[Path], List[Path]]: +# 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'} + +# All known audio extensions (superset of what user might enable for audiobooks) +ALL_AUDIO_EXTENSIONS = {'.m4b', '.mp3', '.m4a', '.aac', '.flac', '.ogg', '.wma', '.wav', '.opus'} + + +def _filter_files( + extracted_files: List[Path], + content_type: Optional[str] = None, +) -> Tuple[List[Path], List[Path], List[Path]]: """ - Filter extracted files to only book formats. + Filter extracted files based on content type. + + For audiobooks: filters to audio formats using SUPPORTED_AUDIOBOOK_FORMATS + For books: filters to book formats using SUPPORTED_FORMATS Returns: - 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) + Tuple of (matched_files, rejected_format_files, other_files) + - matched_files: Match user's supported formats for this content type + - rejected_format_files: Valid formats for this type but not enabled by user + - other_files: Unrelated 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'} + is_audiobook = content_type and content_type.lower() == "audiobook" + known_extensions = ALL_AUDIO_EXTENSIONS if is_audiobook else ALL_EBOOK_EXTENSIONS - book_files = [] - rejected_ebook_files = [] - non_book_files = [] + matched_files = [] + rejected_format_files = [] + other_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) + if _is_supported_file(file_path, content_type): + matched_files.append(file_path) + elif file_path.suffix.lower() in known_extensions: + rejected_format_files.append(file_path) else: - non_book_files.append(file_path) + other_files.append(file_path) - return book_files, rejected_ebook_files, non_book_files + return matched_files, rejected_format_files, other_files def extract_archive( archive_path: Path, output_dir: Path, + content_type: Optional[str] = None, ) -> Tuple[List[Path], List[str], List[Path]]: """ - Extract book files from an archive. + Extract files from an archive based on content type. - Extracts all files, then filters to only keep recognized book formats. - Non-book files (HTML, images, etc.) are deleted. + Extracts all files, then filters based on content type: + - Audiobooks: keeps files matching SUPPORTED_AUDIOBOOK_FORMATS + - Books: keeps files matching SUPPORTED_FORMATS + Non-matching files (HTML, images, etc.) are deleted. Args: archive_path: Path to the archive file output_dir: Directory to extract files to + content_type: Content type (e.g., "audiobook") to determine which formats to keep Returns: - Tuple of (book_files, warnings, rejected_ebook_files) - - book_files: Paths to extracted files matching SUPPORTED_FORMATS + Tuple of (matched_files, warnings, rejected_files) + - matched_files: Paths to extracted files matching supported formats - warnings: List of warning messages - - rejected_ebook_files: Ebook files that were rejected (format not enabled) + - rejected_files: Files that were rejected (format not enabled) Raises: ArchiveExtractionError: If extraction fails @@ -125,33 +154,36 @@ def extract_archive( else: raise ArchiveExtractionError(f"Unsupported archive format: {suffix}") - # Filter to only book files, delete non-book files - book_files, rejected_ebook_files, non_book_files = _filter_book_files(extracted_files) + is_audiobook = content_type and content_type.lower() == "audiobook" + file_type_label = "audiobook" if is_audiobook else "book" - # Delete rejected ebook files (valid formats but not enabled by user) - for rejected_file in rejected_ebook_files: + # Filter files based on content type + matched_files, rejected_files, other_files = _filter_files(extracted_files, content_type) + + # Delete rejected files (valid formats but not enabled by user) + for rejected_file in rejected_files: try: rejected_file.unlink() - logger.debug(f"Deleted rejected ebook file: {rejected_file.name}") + logger.debug(f"Deleted rejected {file_type_label} file: {rejected_file.name}") except OSError as e: - logger.warning(f"Failed to delete rejected ebook file {rejected_file}: {e}") + logger.warning(f"Failed to delete rejected {file_type_label} 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)}") + if rejected_files: + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) + warnings.append(f"Skipped {len(rejected_files)} {file_type_label}(s) with unsupported format: {', '.join(rejected_exts)}") - # Delete non-book files (images, html, etc) - for non_book_file in non_book_files: + # Delete other files (images, html, etc) + for other_file in other_files: try: - non_book_file.unlink() - logger.debug(f"Deleted non-book file: {non_book_file.name}") + other_file.unlink() + logger.debug(f"Deleted non-{file_type_label} file: {other_file.name}") except OSError as e: - logger.warning(f"Failed to delete non-book file {non_book_file}: {e}") + logger.warning(f"Failed to delete non-{file_type_label} file {other_file}: {e}") - if non_book_files: - warnings.append(f"Skipped {len(non_book_files)} non-book file(s)") + if other_files: + warnings.append(f"Skipped {len(other_files)} non-{file_type_label} file(s)") - return book_files, warnings, rejected_ebook_files + return matched_files, warnings, rejected_files def _extract_zip( @@ -287,62 +319,68 @@ def process_archive( task: Optional["DownloadTask"] = None, ) -> ArchiveResult: """ - Process an archive file: extract, filter to book files, move to ingest. + Process an archive file: extract, filter to supported files, move to ingest. This is the main entry point for archive handling, usable by any download handler. + Filters files based on content type: + - Audiobooks: keeps files matching SUPPORTED_AUDIOBOOK_FORMATS + - Books: keeps files matching SUPPORTED_FORMATS Args: archive_path: Path to the downloaded archive file temp_dir: Base temp directory for extraction (e.g., TMP_DIR) - ingest_dir: Final destination directory for book files + ingest_dir: Final destination directory for files archive_id: Unique identifier for temp directory naming - task: Optional download task for filename generation + task: Optional download task for filename generation and content type Returns: ArchiveResult with success status, final paths, and status message """ extract_dir = temp_dir / f"extract_{archive_id}" + content_type = task.content_type if task else None + is_audiobook = content_type and content_type.lower() == "audiobook" + file_type_label = "audiobook" if is_audiobook else "book" try: # Create temp extraction directory os.makedirs(extract_dir, exist_ok=True) os.makedirs(ingest_dir, exist_ok=True) - # Extract to temp directory (filters to book files only) - extracted_files, warnings, rejected_ebook_files = extract_archive(archive_path, extract_dir) + # Extract to temp directory (filters based on content type) + extracted_files, warnings, rejected_files = extract_archive(archive_path, extract_dir, content_type) 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)) + if rejected_files: + # Found files but they weren't in supported formats + rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files)) rejected_list = ", ".join(rejected_exts) - supported_formats = _get_supported_formats() + supported_formats = _get_supported_audiobook_formats() if is_audiobook else _get_supported_formats() logger.warning( - f"Found {len(rejected_ebook_files)} ebook(s) in archive but format not supported. " + f"Found {len(rejected_files)} {file_type_label}(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.", + error=f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). Enable in Settings > Formats.", ) return ArchiveResult( success=False, final_paths=[], message="", - error="No book files found in archive", + error=f"No {file_type_label} files found in archive", ) for warning in warnings: logger.debug(warning) - logger.info(f"Extracted {len(extracted_files)} book file(s) from archive") + logger.info(f"Extracted {len(extracted_files)} {file_type_label} file(s) from archive") # Move book files to ingest folder final_paths = [] diff --git a/cwa_book_downloader/main.py b/cwa_book_downloader/main.py index 3739cdb..208196e 100644 --- a/cwa_book_downloader/main.py +++ b/cwa_book_downloader/main.py @@ -519,6 +519,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "book_languages": _SUPPORTED_BOOK_LANGUAGE, "default_language": app_config.BOOK_LANGUAGE, "supported_formats": app_config.SUPPORTED_FORMATS, + "supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS, "search_mode": app_config.get("SEARCH_MODE", "direct"), "metadata_sort_options": get_provider_sort_options(), "metadata_search_fields": get_provider_search_fields(), @@ -1099,6 +1100,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: from dataclasses import asdict query = request.args.get('query', '').strip() + content_type = request.args.get('content_type', 'ebook').strip() try: limit = min(int(request.args.get('limit', 40)), 100) @@ -1117,7 +1119,7 @@ def api_metadata_search() -> Union[Response, Tuple[Response, int]]: except ValueError: sort_order = SortOrder.RELEVANCE - provider = get_configured_provider() + provider = get_configured_provider(content_type=content_type) if not provider: return jsonify({ "error": "No metadata provider configured", @@ -1266,6 +1268,8 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: # 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 + # Content type for audiobook vs ebook search + content_type = request.args.get('content_type', 'ebook').strip() if not provider or not book_id: return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400 @@ -1304,8 +1308,8 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: try: source = get_source(source_name) source_instances[source_name] = source - logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search})") - releases = source.search(book, expand_search=expand_search, languages=languages) + logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search}, content_type={content_type})") + releases = source.search(book, expand_search=expand_search, languages=languages, content_type=content_type) all_releases.extend(releases) except ValueError: errors.append(f"Unknown source: {source_name}") diff --git a/cwa_book_downloader/metadata_providers/__init__.py b/cwa_book_downloader/metadata_providers/__init__.py index 0040c0d..c52957d 100644 --- a/cwa_book_downloader/metadata_providers/__init__.py +++ b/cwa_book_downloader/metadata_providers/__init__.py @@ -373,11 +373,15 @@ def get_enabled_providers() -> List[str]: return enabled -def get_configured_provider() -> Optional[MetadataProvider]: +def get_configured_provider(content_type: str = "ebook") -> Optional[MetadataProvider]: """Get the currently configured metadata provider, if any. Uses the METADATA_PROVIDER config setting to determine which provider - to instantiate. Returns None if no provider is configured or not enabled. + to instantiate. For audiobook content type, uses METADATA_PROVIDER_AUDIOBOOK + if configured, otherwise falls back to METADATA_PROVIDER. + + Args: + content_type: Content type - "ebook" or "audiobook" (default: "ebook") Returns: MetadataProvider instance or None. @@ -387,7 +391,14 @@ def get_configured_provider() -> Optional[MetadataProvider]: # Refresh config to ensure we have the latest saved settings app_config.refresh() - metadata_provider = app_config.get("METADATA_PROVIDER", "") + # For audiobooks, try audiobook-specific provider first, then fall back to main provider + if content_type == "audiobook": + metadata_provider = app_config.get("METADATA_PROVIDER_AUDIOBOOK", "") + if not metadata_provider: + metadata_provider = app_config.get("METADATA_PROVIDER", "") + else: + metadata_provider = app_config.get("METADATA_PROVIDER", "") + if not metadata_provider: return None diff --git a/cwa_book_downloader/release_sources/__init__.py b/cwa_book_downloader/release_sources/__init__.py index 1aa52c6..8c7c3af 100644 --- a/cwa_book_downloader/release_sources/__init__.py +++ b/cwa_book_downloader/release_sources/__init__.py @@ -212,13 +212,15 @@ class ReleaseSource(ABC): """Interface for searching a release source.""" name: str # "direct", "prowlarr" display_name: str # "Direct Download", "Prowlarr" + supported_content_types: List[str] = ["ebook", "audiobook"] # Content types this source supports @abstractmethod def search( self, book: BookMetadata, expand_search: bool = False, - languages: Optional[List[str]] = None + languages: Optional[List[str]] = None, + content_type: str = "ebook" ) -> List[Release]: """Search for releases of a book. @@ -229,6 +231,8 @@ class ReleaseSource(ABC): 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. + content_type: Content type - "ebook" or "audiobook" (default: "ebook"). + Sources may use this to adjust search categories/filters. """ pass @@ -357,6 +361,7 @@ def list_available_sources() -> List[dict]: "name": name, "display_name": instance.display_name, "enabled": instance.is_available(), + "supported_content_types": getattr(instance, 'supported_content_types', ["ebook", "audiobook"]), }) return result diff --git a/cwa_book_downloader/release_sources/direct_download.py b/cwa_book_downloader/release_sources/direct_download.py index acdb493..65fe6d1 100644 --- a/cwa_book_downloader/release_sources/direct_download.py +++ b/cwa_book_downloader/release_sources/direct_download.py @@ -945,6 +945,7 @@ class DirectDownloadSource(ReleaseSource): """ name = "direct_download" display_name = "Anna's Archive" + supported_content_types = ["ebook"] # Direct downloads only support ebooks def __init__(self): # Tracks which search method was used in the last search() call @@ -1002,7 +1003,8 @@ class DirectDownloadSource(ReleaseSource): self, book: BookMetadata, expand_search: bool = False, - languages: Optional[List[str]] = None + languages: Optional[List[str]] = None, + content_type: str = "ebook" ) -> List[Release]: """ Search for releases using the book's metadata. @@ -1014,6 +1016,7 @@ class DirectDownloadSource(ReleaseSource): book: Book metadata from provider expand_search: If True, skip ISBN and use title+author directly languages: Language codes to filter by (overrides book.language/config) + content_type: Ignored - Direct download uses format filtering instead """ # Language filter: explicit param > book.language > config default lang_filter = languages or ([book.language] if book.language else config.BOOK_LANGUAGE) diff --git a/cwa_book_downloader/release_sources/irc/source.py b/cwa_book_downloader/release_sources/irc/source.py index 42369b8..f315a8f 100644 --- a/cwa_book_downloader/release_sources/irc/source.py +++ b/cwa_book_downloader/release_sources/irc/source.py @@ -66,6 +66,7 @@ class IRCReleaseSource(ReleaseSource): name = "irc" display_name = "IRC Highway" + supported_content_types = ["ebook"] # IRC only supports ebooks def __init__(self): # Track online servers from most recent search @@ -116,7 +117,8 @@ class IRCReleaseSource(ReleaseSource): self, book: BookMetadata, expand_search: bool = False, - languages: Optional[List[str]] = None + languages: Optional[List[str]] = None, + content_type: str = "ebook" ) -> List[Release]: """Search IRC Highway for books matching metadata. @@ -124,6 +126,7 @@ class IRCReleaseSource(ReleaseSource): book: Book metadata (title, authors, etc.) expand_search: Ignored - IRC always uses title+author search languages: Ignored - IRC doesn't support language filtering + content_type: Ignored - IRC doesn't differentiate content types Returns: List of matching releases diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py b/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py index d210184..e4dcad7 100644 --- a/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py +++ b/cwa_book_downloader/release_sources/prowlarr/clients/deluge.py @@ -118,10 +118,11 @@ class DelugeClient(DownloadClient): options = {} if torrent_info.is_magnet: - # Add magnet link + # Use magnet URL if available, otherwise original URL + magnet_url = torrent_info.magnet_url or url torrent_id = self._client.call( 'core.add_torrent_magnet', - url, + magnet_url, options, ) else: diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py b/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py index 44d6742..43ab268 100644 --- a/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py +++ b/cwa_book_downloader/release_sources/prowlarr/clients/qbittorrent.py @@ -100,8 +100,10 @@ class QBittorrentClient(DownloadClient): rename=name, ) else: + # Use magnet URL if available, otherwise original URL + add_url = torrent_info.magnet_url or url result = self._client.torrents_add( - urls=url, + urls=add_url, category=category, rename=name, ) diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py b/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py index 6cf8828..d6b63ff 100644 --- a/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py +++ b/cwa_book_downloader/release_sources/prowlarr/clients/torrent_utils.py @@ -37,6 +37,9 @@ class TorrentInfo: is_magnet: bool """True if the URL was a magnet link.""" + magnet_url: Optional[str] = None + """The actual magnet URL, if available.""" + def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: """ @@ -46,6 +49,9 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: extracts the hash directly. For .torrent URLs, optionally fetches the file and parses it to extract the hash. + Also handles the case where a download URL redirects to a magnet link + or returns a magnet link in the response body. + Args: url: Magnet link or .torrent URL fetch_torrent: If True, fetch .torrent URLs to extract hash. @@ -59,7 +65,7 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: # Try to extract hash from magnet URL if is_magnet: info_hash = extract_hash_from_magnet(url) - return TorrentInfo(info_hash=info_hash, torrent_data=None, is_magnet=True) + return TorrentInfo(info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=url) # Not a magnet - try to fetch and parse the .torrent file if not fetch_torrent: @@ -67,9 +73,41 @@ def extract_torrent_info(url: str, fetch_torrent: bool = True) -> TorrentInfo: try: logger.debug(f"Fetching torrent file from: {url[:80]}...") - resp = requests.get(url, timeout=30) + + # Use allow_redirects=False to handle magnet link redirects manually + # Some indexers redirect download URLs to magnet links + resp = requests.get(url, timeout=30, allow_redirects=False) + + # Check if this is a redirect to a magnet link + if resp.status_code in (301, 302, 303, 307, 308): + redirect_url = resp.headers.get("Location", "") + if redirect_url.startswith("magnet:"): + logger.debug(f"Download URL redirected to magnet link") + info_hash = extract_hash_from_magnet(redirect_url) + return TorrentInfo( + info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=redirect_url + ) + # Not a magnet redirect, follow it manually + logger.debug(f"Following redirect to: {redirect_url[:80]}...") + resp = requests.get(redirect_url, timeout=30) + resp.raise_for_status() torrent_data = resp.content + + # Check if response is actually a magnet link (text response) + # Some indexers return magnet links as plain text instead of redirecting + if len(torrent_data) < 2000: # Magnet links are typically short + try: + text_content = torrent_data.decode("utf-8", errors="ignore").strip() + if text_content.startswith("magnet:"): + logger.debug("Download URL returned magnet link as response body") + info_hash = extract_hash_from_magnet(text_content) + return TorrentInfo( + info_hash=info_hash, torrent_data=None, is_magnet=True, magnet_url=text_content + ) + except Exception: + pass # Not text, continue with torrent parsing + info_hash = extract_info_hash_from_torrent(torrent_data) if info_hash: logger.debug(f"Extracted hash from torrent file: {info_hash}") diff --git a/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py b/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py index af46906..bd6f58e 100644 --- a/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py +++ b/cwa_book_downloader/release_sources/prowlarr/clients/transmission.py @@ -93,8 +93,10 @@ class TransmissionClient(DownloadClient): labels=[category], ) else: + # Use magnet URL if available, otherwise original URL + add_url = torrent_info.magnet_url or url torrent = self._client.add_torrent( - torrent=url, + torrent=add_url, labels=[category], ) diff --git a/cwa_book_downloader/release_sources/prowlarr/settings.py b/cwa_book_downloader/release_sources/prowlarr/settings.py index 3eed864..7398c03 100644 --- a/cwa_book_downloader/release_sources/prowlarr/settings.py +++ b/cwa_book_downloader/release_sources/prowlarr/settings.py @@ -323,6 +323,24 @@ def prowlarr_config_settings(): default=[], show_when={"field": "PROWLARR_ENABLED", "value": True}, ), + MultiSelectField( + key="PROWLARR_SEARCH_CATEGORIES", + label="Search Categories", + description="Categories to include in search. Select multiple to broaden results. Leave empty to search all categories.", + options=[ + {"value": "7000", "label": "Books (All)"}, + {"value": "7010", "label": "Mags", "childOf": "7000"}, + {"value": "7020", "label": "Ebook", "childOf": "7000"}, + {"value": "7030", "label": "Comics", "childOf": "7000"}, + {"value": "7040", "label": "Technical", "childOf": "7000"}, + {"value": "7050", "label": "Other", "childOf": "7000"}, + {"value": "7060", "label": "Foreign", "childOf": "7000"}, + {"value": "3030", "label": "Audio/Audiobook"}, + ], + default=["7000"], + variant="dropdown", + show_when={"field": "PROWLARR_ENABLED", "value": True}, + ), ] diff --git a/cwa_book_downloader/release_sources/prowlarr/source.py b/cwa_book_downloader/release_sources/prowlarr/source.py index ed657fa..50432b1 100644 --- a/cwa_book_downloader/release_sources/prowlarr/source.py +++ b/cwa_book_downloader/release_sources/prowlarr/source.py @@ -295,7 +295,8 @@ class ProwlarrSource(ReleaseSource): self, book: BookMetadata, expand_search: bool = False, - languages: Optional[List[str]] = None + languages: Optional[List[str]] = None, + content_type: str = "ebook" ) -> List[Release]: """ Search Prowlarr for releases matching the book. @@ -307,6 +308,7 @@ class ProwlarrSource(ReleaseSource): book: Book metadata to search for expand_search: If True, skip category filtering (broader search) languages: Ignored - Prowlarr doesn't support language filtering + content_type: "ebook" or "audiobook" - determines search categories Returns: List of Release objects @@ -345,6 +347,16 @@ class ProwlarrSource(ReleaseSource): logger.warning("No indexers selected - configure indexers in Prowlarr settings") return [] + # Get search categories based on content type + # Audiobooks always use 3030 (Audio/Audiobook), ebooks use configured categories + if content_type == "audiobook": + search_categories = ["3030"] # Audio/Audiobook category + else: + search_categories = config.get("PROWLARR_SEARCH_CATEGORIES", ["7000"]) + # Handle both list and comma-separated string formats + if isinstance(search_categories, str): + search_categories = [c.strip() for c in search_categories.split(",") if c.strip()] + if expand_search: if not self._category_filtered_indexers: logger.debug("No category-filtered indexers to expand") @@ -354,9 +366,10 @@ class ProwlarrSource(ReleaseSource): self.last_search_type = "expanded" else: indexers_to_search = indexer_ids - categories = [7000] + # Use configured categories, or None if empty (all categories) + categories = [int(c) for c in search_categories] if search_categories else None self._category_filtered_indexers = [] - self.last_search_type = "categories" + self.last_search_type = "expanded" if not search_categories else "categories" logger.debug(f"Searching Prowlarr: query='{query}', indexers={indexers_to_search}, categories={categories}") @@ -366,11 +379,10 @@ class ProwlarrSource(ReleaseSource): try: raw_results = client.search(query=query, indexer_ids=[indexer_id], categories=categories) - if raw_results and categories: + # Track indexers that returned no results with category filter + # so "Expand search" can retry them without the filter + if categories and not raw_results: self._category_filtered_indexers.append(indexer_id) - elif not raw_results and categories: - logger.debug(f"Indexer {indexer_id}: retrying without category filter") - raw_results = client.search(query=query, indexer_ids=[indexer_id], categories=None) if raw_results: all_results.extend(raw_results) diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index d090a42..e07abbd 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { Release, StatusData, AppConfig, + ContentType, } from './types'; import { getBookInfo, getMetadataBookInfo, downloadBook, downloadRelease, cancelDownload, clearCompleted, getConfig } from './services/api'; import { useToast } from './hooks/useToast'; @@ -68,6 +69,9 @@ function App() { showToast, }); + // Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it + const [contentType, setContentType] = useState('ebook'); + // Search state and handlers const { books, @@ -95,6 +99,7 @@ function App() { setIsAuthenticated, authRequired, onSearchReset: clearTracking, + contentType, }); // Wire up logout callback to clear search state @@ -450,7 +455,7 @@ function App() { }; // Handle download from ReleaseModal - const handleReleaseDownload = async (book: Book, release: Release) => { + const handleReleaseDownload = async (book: Book, release: Release, releaseContentType: ContentType) => { try { trackRelease(book.id, release.source_id); @@ -469,6 +474,7 @@ function App() { seeders: release.seeders, extra: release.extra, preview: book.preview, // Pass book cover from metadata + content_type: releaseContentType, // For audiobook directory routing }); await fetchStatus(); } catch (error) { @@ -548,6 +554,9 @@ function App() { isLoading={isSearching} onShowToast={showToast} onRemoveToast={removeToast} + searchMode={searchMode} + contentType={contentType} + onContentTypeChange={setContentType} /> {isInitialState && !featureNoticeDismissed && ( @@ -639,6 +649,8 @@ function App() { onClose={() => setReleaseBook(null)} onDownload={handleReleaseDownload} supportedFormats={supportedFormats} + supportedAudiobookFormats={config?.supported_audiobook_formats || []} + contentType={contentType} defaultLanguages={defaultLanguageCodes} bookLanguages={bookLanguages} currentStatus={currentStatus} diff --git a/src/frontend/src/components/Dropdown.tsx b/src/frontend/src/components/Dropdown.tsx index ff74f47..51fc12b 100644 --- a/src/frontend/src/components/Dropdown.tsx +++ b/src/frontend/src/components/Dropdown.tsx @@ -103,7 +103,7 @@ export const Dropdown = ({ type="button" onClick={toggleOpen} disabled={disabled} - className={`w-full px-2.5 py-1.5 text-sm rounded-md border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`} + className={`w-full px-3 py-2 text-sm rounded-lg border flex items-center justify-between text-left focus:outline-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 ${buttonClassName}`} style={{ background: 'var(--bg-soft)', color: 'var(--text)', @@ -130,7 +130,7 @@ export const Dropdown = ({ ref={panelRef} className={`absolute ${align === 'right' ? 'right-0' : 'left-0'} ${ panelDirection === 'down' ? 'mt-2' : 'bottom-full mb-2' - } rounded-md border shadow-lg z-20 ${panelClassName || widthClassName}`} + } rounded-lg border shadow-lg z-20 ${panelClassName || widthClassName}`} style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)', diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 3b29733..6464314 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react'; import { SearchBar, SearchBarHandle } from './SearchBar'; +import { ContentType } from '../types'; export interface HeaderHandle { submitSearch: () => void; @@ -30,6 +31,9 @@ interface HeaderProps { onLogout?: () => void; onShowToast?: (message: string, type: 'success' | 'error' | 'info', persistent?: boolean) => string; onRemoveToast?: (id: string) => void; + searchMode?: string; + contentType?: ContentType; + onContentTypeChange?: (type: ContentType) => void; } export const Header = forwardRef(({ @@ -51,6 +55,9 @@ export const Header = forwardRef(({ onLogout, onShowToast, onRemoveToast, + searchMode = 'direct', + contentType = 'ebook', + onContentTypeChange, }, ref) => { const searchBarRef = useRef(null); @@ -254,6 +261,46 @@ export const Header = forwardRef(({ }} >
+ {/* Content Type Toggle - only shown in Universal search mode */} + {onContentTypeChange && searchMode === 'universal' && ( + <> +
+
Search for
+
+ + +
+
+
+ + )} + void; - onDownload: (book: Book, release: Release) => Promise; + onDownload: (book: Book, release: Release, contentType: ContentType) => Promise; supportedFormats: string[]; + supportedAudiobookFormats?: string[]; // Audiobook formats (m4b, mp3) + contentType: ContentType; // 'ebook' or 'audiobook' defaultLanguages: string[]; bookLanguages: Language[]; currentStatus: StatusData; @@ -528,12 +530,18 @@ export const ReleaseModal = ({ onClose, onDownload, supportedFormats, + supportedAudiobookFormats = [], + contentType = 'ebook', defaultLanguages, bookLanguages, currentStatus, defaultReleaseSource, onSearchSeries, }: ReleaseModalProps) => { + // Use audiobook formats when in audiobook mode + const effectiveFormats = contentType === 'audiobook' && supportedAudiobookFormats.length > 0 + ? supportedAudiobookFormats + : supportedFormats; const [isClosing, setIsClosing] = useState(false); // Available sources from plugin registry @@ -714,10 +722,18 @@ export const ReleaseModal = ({ setSourcesLoading(true); const sources = await getReleaseSources(); setAvailableSources(sources); - // Set active tab: prefer defaultReleaseSource if enabled, otherwise first enabled source - if (sources.length > 0) { - const enabledSources = sources.filter(s => s.enabled); - const defaultIsEnabled = defaultReleaseSource && enabledSources.some(s => s.name === defaultReleaseSource); + + // Filter sources by content type support + const supportedSources = sources.filter(s => { + const types = s.supported_content_types || ['ebook', 'audiobook']; + return types.includes(contentType); + }); + + // Set active tab: prefer defaultReleaseSource if enabled and supports content type + if (supportedSources.length > 0) { + const enabledSources = supportedSources.filter(s => s.enabled); + const defaultIsEnabled = defaultReleaseSource && + enabledSources.some(s => s.name === defaultReleaseSource); let defaultSource: string; if (defaultIsEnabled) { @@ -725,22 +741,32 @@ export const ReleaseModal = ({ } else if (enabledSources.length > 0) { defaultSource = enabledSources[0].name; } else { - defaultSource = sources[0].name; // Fallback to first source if none enabled + defaultSource = supportedSources[0].name; // Fallback to first supported source } setActiveTab(defaultSource); + } else if (sources.length > 0) { + // No sources support this content type - fall back to first source + setActiveTab(sources[0].name); } } 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", enabled: true }]); - setActiveTab('direct_download'); + // Fallback: assume direct_download is available (for ebooks) + setAvailableSources([{ + name: 'direct_download', + display_name: "Anna's Archive", + enabled: true, + supported_content_types: ['ebook'] + }]); + if (contentType === 'ebook') { + setActiveTab('direct_download'); + } } finally { setSourcesLoading(false); } }; fetchSources(); - }, [book, defaultReleaseSource]); + }, [book, defaultReleaseSource, contentType]); // Fetch releases when active tab changes (with caching) // Initial fetch always uses ISBN-first search; expansion is handled by handleExpandSearch @@ -766,7 +792,7 @@ export const ReleaseModal = ({ setErrorBySource((prev) => ({ ...prev, [activeTab]: null })); try { - const response = await getReleases(provider, bookId, activeTab, book.title, book.author); + const response = await getReleases(provider, bookId, activeTab, book.title, book.author, undefined, undefined, contentType); setCachedReleases(provider, bookId, activeTab, response); setReleasesBySource((prev) => ({ ...prev, [activeTab]: response })); } catch (err) { @@ -778,7 +804,7 @@ export const ReleaseModal = ({ }; fetchReleases(); - }, [book, activeTab, releasesBySource, loadingBySource, errorBySource]); + }, [book, activeTab, releasesBySource, loadingBySource, errorBySource, contentType]); // Handler for expanding search (title+author instead of ISBN) // Fetches additional results and merges with existing ISBN results @@ -801,7 +827,7 @@ export const ReleaseModal = ({ // Fetch with expand_search=true (title+author search) const expandedResponse = await getReleases( - provider, bookId, activeTab, book.title, book.author, true, languagesParam + provider, bookId, activeTab, book.title, book.author, true, languagesParam, contentType ); // Merge with existing results, deduplicating by source_id @@ -828,19 +854,25 @@ export const ReleaseModal = ({ } finally { setLoadingBySource((prev) => ({ ...prev, [activeTab]: false })); } - }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages]); + }, [activeTab, book, languageFilter, bookLanguages, defaultLanguages, contentType]); // Build list of tabs to show // All sources come from backend with their enabled status - // Order: 1) Default source, 2) Other enabled sources, 3) Disabled sources + // Filter by supported content types, then order: 1) Default source, 2) Other enabled sources, 3) Disabled sources const allTabs = useMemo(() => { type TabInfo = { name: string; displayName: string; enabled: boolean }; const enabledTabs: TabInfo[] = []; const disabledTabs: TabInfo[] = []; - // Separate sources by enabled status + // Filter sources by content type and separate by enabled status availableSources.forEach((src) => { + // Check if source supports the current content type + const supportedTypes = src.supported_content_types || ['ebook', 'audiobook']; + if (!supportedTypes.includes(contentType)) { + return; // Skip sources that don't support this content type + } + const tab = { name: src.name, displayName: src.display_name, enabled: src.enabled }; if (src.enabled) { enabledTabs.push(tab); @@ -860,7 +892,7 @@ export const ReleaseModal = ({ // Combine: enabled sources first (with default first), then disabled return [...enabledTabs, ...disabledTabs]; - }, [availableSources, defaultReleaseSource]); + }, [availableSources, defaultReleaseSource, contentType]); // Update tab indicator position when active tab changes useEffect(() => { @@ -883,19 +915,19 @@ export const ReleaseModal = ({ const availableFormats = useMemo(() => { const releases = releasesBySource[activeTab]?.releases || []; const formats = new Set(); - const supportedLower = supportedFormats.map((f) => f.toLowerCase()); + const effectiveLower = effectiveFormats.map((f) => f.toLowerCase()); releases.forEach((r) => { if (r.format) { const fmt = r.format.toLowerCase(); // Only include formats that are in the supported list - if (supportedLower.includes(fmt)) { + if (effectiveLower.includes(fmt)) { formats.add(fmt); } } }); return Array.from(formats).sort(); - }, [releasesBySource, activeTab, supportedFormats]); + }, [releasesBySource, activeTab, effectiveFormats]); // Build select options for format filter const formatOptions = useMemo(() => { @@ -914,7 +946,7 @@ export const ReleaseModal = ({ // Filter releases based on settings and user selection const filteredReleases = useMemo(() => { const releases = releasesBySource[activeTab]?.releases || []; - const supportedLower = supportedFormats.map((f) => f.toLowerCase()); + const effectiveLower = effectiveFormats.map((f) => f.toLowerCase()); return releases.filter((r) => { // Format filtering @@ -925,7 +957,7 @@ export const ReleaseModal = ({ if (!fmt || fmt !== formatFilter.toLowerCase()) return false; } else if (fmt) { // No specific filter - show only supported formats - if (!supportedLower.includes(fmt)) return false; + if (!effectiveLower.includes(fmt)) return false; } // Releases with no format pass through when no filter is set (show all) @@ -937,7 +969,7 @@ export const ReleaseModal = ({ return true; }); - }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, supportedFormats, defaultLanguages]); + }, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages]); // Get column config from response or use default const columnConfig = useMemo((): ReleaseColumnConfig => { @@ -996,7 +1028,7 @@ export const ReleaseModal = ({ async (release: Release): Promise => { if (book) { try { - await onDownload(book, release); + await onDownload(book, release, contentType); // Close modal after successful queue handleClose(); } catch { @@ -1004,7 +1036,7 @@ export const ReleaseModal = ({ } } }, - [book, onDownload, handleClose] + [book, onDownload, contentType, handleClose] ); if (!book && !isClosing) return null; @@ -1340,7 +1372,7 @@ export const ReleaseModal = ({ : langCodes; const response = await getReleases( - provider, bookId, activeTab, book.title, book.author, false, languagesParam + provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType ); setCachedReleases(provider, bookId, activeTab, response); setReleasesBySource((prev) => ({ ...prev, [activeTab]: response })); @@ -1377,13 +1409,31 @@ export const ReleaseModal = ({ ) : currentTabError ? ( ) : filteredReleases.length === 0 && !currentTabLoading ? ( - + <> + + {/* Expand search button - also shown in empty state */} + {!expandedBySource[activeTab] && + releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type && + !['title_author', 'expanded'].includes( + releasesBySource[activeTab]?.search_info?.[activeTab]?.search_type ?? '' + ) && ( +
+ +
+ )} + ) : ( <> {/* Key includes filter to force remount when filter changes */} diff --git a/src/frontend/src/components/SearchSection.tsx b/src/frontend/src/components/SearchSection.tsx index 2e83ed0..3950c13 100644 --- a/src/frontend/src/components/SearchSection.tsx +++ b/src/frontend/src/components/SearchSection.tsx @@ -1,4 +1,4 @@ -import { AdvancedFilterState, Language, MetadataSearchField } from '../types'; +import { AdvancedFilterState, Language, MetadataSearchField, ContentType } from '../types'; import { buildSearchQuery } from '../utils/buildSearchQuery'; import { useSearchMode } from '../contexts/SearchModeContext'; import { AdvancedFilters } from './AdvancedFilters'; @@ -22,6 +22,7 @@ interface SearchSectionProps { metadataSearchFields?: MetadataSearchField[]; searchFieldValues?: Record; onSearchFieldChange?: (key: string, value: string | number | boolean) => void; + contentType?: ContentType; } export const SearchSection = ({ @@ -41,6 +42,7 @@ export const SearchSection = ({ metadataSearchFields, searchFieldValues, onSearchFieldChange, + contentType = 'ebook', }: SearchSectionProps) => { const { searchMode } = useSearchMode(); @@ -69,7 +71,9 @@ export const SearchSection = ({ isInitialState ? 'opacity-100 mb-6 sm:mb-8' : 'opacity-0 h-0 mb-0 overflow-hidden' }`}> Logo -

Book Search & Download

+

+ {contentType === 'audiobook' ? 'Audiobook Search & Download' : 'Book Search & Download'} +

{ ); + case 'search': + return ( + + + + ); case 'beaker': case 'wrench': return ( diff --git a/src/frontend/src/components/settings/fields/MultiSelectField.tsx b/src/frontend/src/components/settings/fields/MultiSelectField.tsx index 3128886..e60f3a1 100644 --- a/src/frontend/src/components/settings/fields/MultiSelectField.tsx +++ b/src/frontend/src/components/settings/fields/MultiSelectField.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react'; import { MultiSelectFieldConfig } from '../../../types/settings'; +import { DropdownList } from '../../DropdownList'; interface MultiSelectFieldProps { field: MultiSelectFieldConfig; @@ -30,6 +31,83 @@ export const MultiSelectField = ({ field, value, onChange, disabled }: MultiSele const selected = value ?? []; // disabled prop is already computed by SettingsContent.getDisabledState() const isDisabled = disabled ?? false; + + // Dropdown variant - use DropdownList with checkboxes + if (field.variant === 'dropdown') { + // Build parent -> children map for cascading selection + const parentChildMap = new Map(); + field.options.forEach((opt) => { + if (opt.childOf) { + const children = parentChildMap.get(opt.childOf) || []; + children.push(opt.value); + parentChildMap.set(opt.childOf, children); + } + }); + + // Check which children are implicitly selected via parent + const implicitlySelected = new Set(); + selected.forEach((val) => { + const children = parentChildMap.get(val); + if (children) { + children.forEach((child) => implicitlySelected.add(child)); + } + }); + + // Build options with disabled state for implicitly selected children + const dropdownOptions = field.options.map((opt) => ({ + value: opt.value, + label: opt.label, + disabled: implicitlySelected.has(opt.value), + })); + + // For display purposes, show both explicit and implicit selections + const displayValue = [...selected, ...Array.from(implicitlySelected)]; + + const handleDropdownChange = (newValue: string | string[]) => { + const arr = Array.isArray(newValue) ? newValue : [newValue]; + // Filter out implicitly selected values - only store explicit selections + const explicitOnly = arr.filter((v) => !implicitlySelected.has(v)); + onChange(explicitOnly); + }; + + // Custom summary formatter - only count explicit selections + const summaryFormatter = () => { + if (selected.length === 0) { + return Select categories...; + } + const selectedLabels = selected + .map((v) => field.options.find((o) => o.value === v)?.label) + .filter(Boolean); + if (selectedLabels.length === 1) { + return selectedLabels[0]; + } + const [first, second, ...rest] = selectedLabels; + const suffix = rest.length > 0 ? ` +${rest.length}` : ''; + return `${first}, ${second ?? ''}${suffix}`.trim(); + }; + + if (isDisabled) { + return ( +
+ {summaryFormatter()} +
+ ); + } + + return ( + + ); + } const [isExpanded, setIsExpanded] = useState(false); // Initialize based on option count to avoid flash of expanded content const [needsCollapse, setNeedsCollapse] = useState( diff --git a/src/frontend/src/components/settings/fields/SelectField.tsx b/src/frontend/src/components/settings/fields/SelectField.tsx index ad7ae89..6cad0a2 100644 --- a/src/frontend/src/components/settings/fields/SelectField.tsx +++ b/src/frontend/src/components/settings/fields/SelectField.tsx @@ -1,4 +1,5 @@ import { SelectFieldConfig } from '../../../types/settings'; +import { DropdownList } from '../../DropdownList'; interface SelectFieldProps { field: SelectFieldConfig; @@ -14,33 +15,35 @@ export const SelectField = ({ field, value, onChange, disabled }: SelectFieldPro // Use field's default value as fallback when value is empty const effectiveValue = value || field.default || ''; + // Convert options to DropdownList format + const dropdownOptions = field.options.map((opt) => ({ + value: opt.value, + label: opt.label, + })); + + const handleChange = (newValue: string | string[]) => { + // DropdownList may return string or string[] - we expect string for single select + const val = Array.isArray(newValue) ? newValue[0] ?? '' : newValue; + onChange(val); + }; + + if (isDisabled) { + // When disabled, show a static display instead of the dropdown + const selectedOption = field.options.find((opt) => opt.value === effectiveValue); + return ( +
+ {selectedOption?.label || 'Select...'} +
+ ); + } + return ( - + onChange={handleChange} + placeholder="Select..." + widthClassName="w-full" + /> ); }; diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index 2b0692d..e539e72 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Book, AppConfig, AdvancedFilterState } from '../types'; +import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types'; import { searchBooks, searchMetadata, AuthenticationError } from '../services/api'; import { LANGUAGE_OPTION_DEFAULT } from '../utils/languageFilters'; import { DEFAULT_SUPPORTED_FORMATS } from '../data/languages'; @@ -12,6 +12,7 @@ interface UseSearchOptions { setIsAuthenticated: (value: boolean) => void; authRequired: boolean; onSearchReset?: () => void; + contentType?: ContentType; } // Search field values for universal mode (provider-specific fields) @@ -44,7 +45,7 @@ interface UseSearchReturn { } export function useSearch(options: UseSearchOptions): UseSearchReturn { - const { showToast, setIsAuthenticated, authRequired, onSearchReset } = options; + const { showToast, setIsAuthenticated, authRequired, onSearchReset, contentType = 'ebook' } = options; const navigate = useNavigate(); const [books, setBooks] = useState([]); @@ -148,7 +149,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setTotalFound(0); try { - const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1); + const result = await searchMetadata(searchQuery, 40, sort, effectiveFieldValues, 1, contentType); if (result.books.length > 0) { setBooks(result.books); setHasMore(result.hasMore); @@ -243,7 +244,7 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { setIsLoadingMore(true); try { - const result = await searchMetadata(query, 40, sort, fieldValues, nextPage); + const result = await searchMetadata(query, 40, sort, fieldValues, nextPage, contentType); if (result.books.length > 0) { setBooks(prev => [...prev, ...result.books]); setHasMore(result.hasMore); diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 0a5266c..78d95d0 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -97,7 +97,8 @@ export const searchMetadata = async ( limit: number = 40, sort: string = 'relevance', fields: Record = {}, - page: number = 1 + page: number = 1, + contentType: string = 'ebook' ): Promise => { const hasFields = Object.values(fields).some(v => v !== '' && v !== false); @@ -112,6 +113,7 @@ export const searchMetadata = async ( params.set('limit', String(limit)); params.set('sort', sort); params.set('page', String(page)); + params.set('content_type', contentType); // Add custom search field values Object.entries(fields).forEach(([key, value]) => { @@ -163,6 +165,7 @@ export const downloadRelease = async (release: { seeders?: number; extra?: Record; preview?: string; // Book cover from metadata provider + content_type?: string; // "ebook" or "audiobook" - for directory routing }): Promise => { await fetchJSON(`${API_BASE}/releases/download`, { method: 'POST', @@ -245,7 +248,8 @@ export const getReleases = async ( title?: string, author?: string, expandSearch?: boolean, - languages?: string[] + languages?: string[], + contentType?: string ): Promise => { const params = new URLSearchParams({ provider, @@ -266,5 +270,8 @@ export const getReleases = async ( if (languages && languages.length > 0) { params.set('languages', languages.join(',')); } + if (contentType) { + params.set('content_type', contentType); + } return fetchJSON(`${API_BASE}/releases?${params.toString()}`); }; diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index ca163c0..d46bac8 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -142,6 +142,9 @@ export type MetadataSearchField = | CheckboxSearchField; // App configuration +// Content type for search (ebook vs audiobook) +export type ContentType = 'ebook' | 'audiobook'; + export interface AppConfig { calibre_web_url: string; debug: boolean; @@ -150,6 +153,7 @@ export interface AppConfig { book_languages: Language[]; default_language: string[]; supported_formats: string[]; + supported_audiobook_formats: string[]; // Audiobook formats (m4b, mp3) search_mode: SearchMode; metadata_sort_options: SortOption[]; metadata_search_fields: MetadataSearchField[]; @@ -189,6 +193,7 @@ 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 + supported_content_types?: string[]; // Content types this source supports (e.g., ['ebook', 'audiobook']) } // Column schema types for plugin-driven release list UI diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index 91faa53..46a9444 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -14,6 +14,7 @@ export type FieldType = export interface SelectOption { value: string; label: string; + childOf?: string; // Parent value - when parent is selected, this option is auto-selected and disabled } // Conditional visibility configuration @@ -83,6 +84,7 @@ export interface MultiSelectFieldConfig extends BaseField { type: 'MultiSelectField'; value: string[]; options: SelectOption[]; + variant?: 'pills' | 'dropdown'; // 'pills' (default) or 'dropdown' for checkbox dropdown style } // OrderableListField types - generic drag-and-drop reorderable list