mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:20:30 +01:00
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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}`);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user