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
This commit is contained in:
Alex
2025-12-31 12:22:33 +00:00
committed by GitHub
parent 91dd479edb
commit 875b705ed3
27 changed files with 577 additions and 173 deletions
+1
View File
@@ -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")
+66 -22
View File
@@ -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"],
),
]
@@ -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
+94 -56
View File
@@ -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 = []
+7 -3
View File
@@ -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}")
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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:
@@ -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,
)
@@ -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}")
@@ -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],
)
@@ -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},
),
]
@@ -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)
+13 -1
View File
@@ -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<ContentType>('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}
/>
<AdvancedFilters
@@ -591,6 +600,7 @@ function App() {
metadataSearchFields={config?.metadata_search_fields}
searchFieldValues={searchFieldValues}
onSearchFieldChange={updateSearchFieldValue}
contentType={contentType}
/>
{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}
+2 -2
View File
@@ -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)',
+47
View File
@@ -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<HeaderHandle, HeaderProps>(({
@@ -51,6 +55,9 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
onLogout,
onShowToast,
onRemoveToast,
searchMode = 'direct',
contentType = 'ebook',
onContentTypeChange,
}, ref) => {
const searchBarRef = useRef<SearchBarHandle>(null);
@@ -254,6 +261,46 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
}}
>
<div className="py-1">
{/* Content Type Toggle - only shown in Universal search mode */}
{onContentTypeChange && searchMode === 'universal' && (
<>
<div className="px-4 py-2">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2">Search for</div>
<div className="flex flex-col rounded-lg overflow-hidden border border-[var(--border-muted)]">
<button
type="button"
onClick={() => onContentTypeChange('ebook')}
className={`w-full px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors ${
contentType === 'ebook'
? 'bg-emerald-600 text-white'
: 'bg-transparent hover:bg-[var(--hover-surface)]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" />
</svg>
Books
</button>
<button
type="button"
onClick={() => onContentTypeChange('audiobook')}
className={`w-full px-3 py-2 text-sm font-medium flex items-center gap-2 transition-colors border-t border-[var(--border-muted)] ${
contentType === 'audiobook'
? 'bg-emerald-600 text-white'
: 'bg-transparent hover:bg-[var(--hover-surface)]'
}`}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
Audiobooks
</button>
</div>
</div>
<div className="border-t border-[var(--border-muted)] my-1"></div>
</>
)}
<a
href="https://github.com/calibrain/calibre-web-automated-book-downloader/issues"
target="_blank"
+84 -34
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, SearchStatusData } from '../types';
import { Book, Release, ReleaseSource, ReleasesResponse, Language, StatusData, ButtonStateInfo, ColumnSchema, ReleaseColumnConfig, LeadingCellConfig, SearchStatusData, ContentType } from '../types';
import { getReleases, getReleaseSources } from '../services/api';
import { useSocket } from '../contexts/SocketContext';
import { Dropdown } from './Dropdown';
@@ -98,8 +98,10 @@ const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
interface ReleaseModalProps {
book: Book | null;
onClose: () => void;
onDownload: (book: Book, release: Release) => Promise<void>;
onDownload: (book: Book, release: Release, contentType: ContentType) => Promise<void>;
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<string>();
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<void> => {
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 ? (
<ErrorState message={currentTabError} />
) : filteredReleases.length === 0 && !currentTabLoading ? (
<EmptyState
message={
formatFilter
? `No ${formatFilter.toUpperCase()} releases found. Try a different format.`
: 'No releases found for this book.'
}
/>
<>
<EmptyState
message={
formatFilter
? `No ${formatFilter.toUpperCase()} releases found. Try a different format.`
: 'No releases found for this book.'
}
/>
{/* 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 ?? ''
) && (
<div className="py-3 text-center">
<button
type="button"
onClick={handleExpandSearch}
className="px-3 py-1.5 text-sm text-gray-500 dark:text-gray-400 rounded-full hover-action transition-all duration-200"
>
Expand search
</button>
</div>
)}
</>
) : (
<>
{/* Key includes filter to force remount when filter changes */}
@@ -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<string, string | number | boolean>;
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'
}`}>
<img src={logoUrl} alt="Logo" className="h-8 w-8" />
<h1 className="text-2xl font-semibold">Book Search & Download</h1>
<h1 className="text-2xl font-semibold">
{contentType === 'audiobook' ? 'Audiobook Search & Download' : 'Book Search & Download'}
</h1>
</div>
<div className={`flex flex-col gap-3 search-wrapper transition-all duration-500 ${
isInitialState ? '' : 'hidden'
@@ -56,6 +56,12 @@ const getIcon = (iconName?: string) => {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21v-8.25M15.75 21v-8.25M8.25 21v-8.25M3 9l9-6 9 6m-1.5 12V10.332A48.36 48.36 0 0012 9.75c-2.551 0-5.056.2-7.5.582V21M3 21h18M12 6.75h.008v.008H12V6.75z" />
</svg>
);
case 'search':
return (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
);
case 'beaker':
case 'wrench':
return (
@@ -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<string, string[]>();
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<string>();
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 <span className="opacity-60">Select categories...</span>;
}
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 (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
{summaryFormatter()}
</div>
);
}
return (
<DropdownList
options={dropdownOptions}
value={displayValue}
onChange={handleDropdownChange}
multiple
showCheckboxes
keepOpenOnSelect
placeholder="Select categories..."
widthClassName="w-full"
summaryFormatter={summaryFormatter}
/>
);
}
const [isExpanded, setIsExpanded] = useState(false);
// Initialize based on option count to avoid flash of expanded content
const [needsCollapse, setNeedsCollapse] = useState(
@@ -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 (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
{selectedOption?.label || 'Select...'}
</div>
);
}
return (
<select
<DropdownList
options={dropdownOptions}
value={effectiveValue}
onChange={(e) => onChange(e.target.value)}
disabled={isDisabled}
className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] text-sm appearance-none
focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500
disabled:opacity-60 disabled:cursor-not-allowed
transition-colors pr-10"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E")`,
backgroundPosition: 'right 0.5rem center',
backgroundRepeat: 'no-repeat',
backgroundSize: '1.5em 1.5em',
}}
>
{!effectiveValue && (
<option value="" disabled hidden>
Select...
</option>
)}
{field.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
onChange={handleChange}
placeholder="Select..."
widthClassName="w-full"
/>
);
};
+5 -4
View File
@@ -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<Book[]>([]);
@@ -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);
+9 -2
View File
@@ -97,7 +97,8 @@ export const searchMetadata = async (
limit: number = 40,
sort: string = 'relevance',
fields: Record<string, string | number | boolean> = {},
page: number = 1
page: number = 1,
contentType: string = 'ebook'
): Promise<MetadataSearchResult> => {
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<string, unknown>;
preview?: string; // Book cover from metadata provider
content_type?: string; // "ebook" or "audiobook" - for directory routing
}): Promise<void> => {
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<ReleasesResponse> => {
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<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`);
};
+5
View File
@@ -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
+2
View File
@@ -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