File processing restructure and further feature additions (#390)

- Restructured the file processing settings to make more coherent
- Added hide settings UI for CWA non-users
- Added sort options for ReleaseModal listview entries
- Added separate Audiobook category selection for download clients -
This commit is contained in:
Alex
2026-01-03 10:44:34 +00:00
committed by GitHub
parent 8cb5335234
commit b0206f76f8
20 changed files with 1106 additions and 308 deletions
+10
View File
@@ -149,6 +149,16 @@ def security_settings():
disabled=not cwa_db_available,
disabled_reason="Mount your Calibre-Web app.db to /auth/app.db in docker compose to enable.",
),
CheckboxField(
key="RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings to Admins",
description=(
"Only users with admin role in Calibre-Web can access settings."
),
default=False,
env_supported=False,
show_when={"field": "USE_CWA_AUTH", "value": True},
),
]
return fields
+147 -134
View File
@@ -482,174 +482,128 @@ def download_settings():
"""Configure download behavior and file locations."""
return [
# === BOOKS SECTION ===
# Visible for ALL modes (Direct + Universal)
HeadingField(
key="books_heading",
title="Books",
description="Configure how ebooks, comics, and magazines are processed.",
),
SelectField(
key="PROCESSING_MODE",
label="Processing Mode",
description="Ingest mode processes and moves files to an ingest folder. Library mode organizes files with custom naming. Note: RAR/ZIP extraction is only supported in Ingest mode.",
options=[
{"value": "ingest", "label": "Ingest"},
{"value": "library", "label": "Library"},
],
default="ingest",
universal_only=True,
description="Configure where ebooks, comics, and magazines are saved.",
),
TextField(
key="INGEST_DIR",
label="Download Directory",
description="Directory where downloaded files are saved for processing. Recommended to use your library's ingest directory.",
key="DESTINATION",
label="Destination",
description="Directory where downloaded files are saved.",
default="/cwa-book-ingest",
required=True,
show_when={"field": "PROCESSING_MODE", "value": "ingest"},
),
CheckboxField(
key="USE_BOOK_TITLE",
label="Use Book Info as Filename",
description="Save files using Author, Title and Year instead of ID.",
default=True,
show_when={"field": "PROCESSING_MODE", "value": "ingest"},
),
CheckboxField(
key="USE_CONTENT_TYPE_DIRECTORIES",
label="Use Content-Type Directories",
description="Route content to separate directories based on type (fiction, non-fiction, comics, etc.).",
default=False,
env_supported=False,
show_when={"field": "PROCESSING_MODE", "value": "ingest"},
),
HeadingField(
key="content_type_directories_heading",
title="Content-Type Directories",
description="Leave empty to use the default ingest directory.",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
SelectField(
key="FILE_ORGANIZATION",
label="File Organization",
description="Choose how downloaded book files are named and organized. ",
options=[
{
"value": "none",
"label": "None",
"description": "Keep original filename from source"
},
{
"value": "rename",
"label": "Rename",
"description": "Rename files using a template"
},
{
"value": "organize",
"label": "Organize",
"description": "Create folders and rename files using a template. Do not use with ingest folders."
},
],
default="rename",
),
# Rename mode template - filename only
TextField(
key="INGEST_DIR_BOOK_FICTION",
label="Fiction Books",
placeholder="/cwa-book-ingest/fiction",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_BOOK_NON_FICTION",
label="Non-Fiction Books",
placeholder="/cwa-book-ingest/non-fiction",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_BOOK_UNKNOWN",
label="Unknown Books",
placeholder="/cwa-book-ingest/unknown",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_MAGAZINE",
label="Magazines",
placeholder="/cwa-book-ingest/magazines",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_COMIC_BOOK",
label="Comic Books",
placeholder="/cwa-book-ingest/comics",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_STANDARDS_DOCUMENT",
label="Standards Documents",
placeholder="/cwa-book-ingest/standards",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_MUSICAL_SCORE",
label="Musical Scores",
placeholder="/cwa-book-ingest/scores",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="INGEST_DIR_OTHER",
label="Other",
placeholder="/cwa-book-ingest/other",
show_when={"field": "USE_CONTENT_TYPE_DIRECTORIES", "value": True},
),
TextField(
key="LIBRARY_PATH",
label="Library Path",
description="Base path for your book library (e.g., /books).",
placeholder="/books",
required=True,
show_when={"field": "PROCESSING_MODE", "value": "library"},
universal_only=True,
),
TextField(
key="LIBRARY_TEMPLATE",
key="TEMPLATE_RENAME",
label="Naming Template",
description="Available: {Author}, {Title}, {Subtitle}, {Year}, {Series}, {SeriesPosition}, {PartNumber}. Use {Series/} for conditional folders.",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Subtitle} ({Year})",
show_when={"field": "PROCESSING_MODE", "value": "library"},
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "rename"},
),
# Organize mode template - folders allowed
TextField(
key="TEMPLATE_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "organize"},
),
CheckboxField(
key="HARDLINK_TORRENTS",
label="Hardlink Book Torrents",
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
default=False,
universal_only=True,
),
# === AUDIOBOOKS SECTION ===
# Universal mode only
HeadingField(
key="audiobooks_heading",
title="Audiobooks",
description="Configure how audiobooks are processed.",
description="Configure where audiobooks are saved.",
universal_only=True,
),
TextField(
key="DESTINATION_AUDIOBOOK",
label="Destination",
description="Leave empty to use Books destination.",
placeholder="/audiobooks",
universal_only=True,
),
SelectField(
key="PROCESSING_MODE_AUDIOBOOK",
label="Processing Mode",
description="Ingest mode moves files to an ingest folder. Library mode organizes files with custom naming.",
key="FILE_ORGANIZATION_AUDIOBOOK",
label="File Organization",
description="Choose how downloaded audiobook files are named and organized.",
options=[
{"value": "ingest", "label": "Ingest"},
{"value": "library", "label": "Library"},
{"value": "none", "label": "None", "description": "Keep original filename from source"},
{"value": "rename", "label": "Rename", "description": "Rename files using a template"},
{"value": "organize", "label": "Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."},
],
default="ingest",
default="rename",
universal_only=True,
),
# Rename mode template - filename only
TextField(
key="INGEST_DIR_AUDIOBOOK",
label="Download Directory",
description="Leave empty to use the Books download directory.",
placeholder="/audiobooks",
show_when={"field": "PROCESSING_MODE_AUDIOBOOK", "value": "ingest"},
universal_only=True,
),
TextField(
key="LIBRARY_PATH_AUDIOBOOK",
label="Library Path",
description="Base path for your audiobook library (e.g., /audiobooks).",
placeholder="/audiobooks",
required=True,
show_when={"field": "PROCESSING_MODE_AUDIOBOOK", "value": "library"},
universal_only=True,
),
TextField(
key="LIBRARY_TEMPLATE_AUDIOBOOK",
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description="Available: {Author}, {Title}, {Subtitle}, {Year}, {Series}, {SeriesPosition}, {PartNumber}. Use {PartNumber} for multi-file audiobooks.",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
universal_only=True,
),
# Organize mode template - folders allowed
TextField(
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "PROCESSING_MODE_AUDIOBOOK", "value": "library"},
universal_only=True,
),
# === OPTIONS SECTION ===
HeadingField(
key="library_options_heading",
title="Options",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
universal_only=True,
),
CheckboxField(
key="TORRENT_HARDLINK",
label="Hardlink Torrents in Library Mode",
description="Create hardlinks for torrent downloads instead of copying. Requires library and download paths on same filesystem.",
key="HARDLINK_TORRENTS_AUDIOBOOK",
label="Hardlink Audiobook Torrents",
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
default=True,
universal_only=True,
),
# === OPTIONS SECTION ===
HeadingField(
key="options_heading",
title="Options",
),
CheckboxField(
key="AUTO_OPEN_DOWNLOADS_SIDEBAR",
label="Auto-Open Downloads Sidebar",
@@ -821,6 +775,65 @@ def download_source_settings():
label="Anna's Archive Donator Key",
description="Optional donator key for faster downloads from Anna's Archive.",
),
HeadingField(
key="content_type_routing_heading",
title="Content-Type Routing",
description="Route downloads to different folders based on content type. Only applies to Anna's Archive downloads.",
),
CheckboxField(
key="AA_CONTENT_TYPE_ROUTING",
label="Enable Content-Type Routing",
description="Override destination based on Anna's Archive content type metadata.",
default=False,
),
TextField(
key="AA_CONTENT_TYPE_DIR_FICTION",
label="Fiction Books",
placeholder="/cwa-book-ingest/fiction",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_NON_FICTION",
label="Non-Fiction Books",
placeholder="/cwa-book-ingest/non-fiction",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_UNKNOWN",
label="Unknown Books",
placeholder="/cwa-book-ingest/unknown",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_MAGAZINE",
label="Magazines",
placeholder="/cwa-book-ingest/magazines",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_COMIC",
label="Comic Books",
placeholder="/cwa-book-ingest/comics",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_STANDARDS",
label="Standards Documents",
placeholder="/cwa-book-ingest/standards",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
label="Musical Scores",
placeholder="/cwa-book-ingest/scores",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
TextField(
key="AA_CONTENT_TYPE_DIR_OTHER",
label="Other",
placeholder="/cwa-book-ingest/other",
show_when={"field": "AA_CONTENT_TYPE_ROUTING", "value": True},
),
]
@@ -311,6 +311,108 @@ def sync_env_to_config() -> None:
save_config_file(tab.name, values_to_sync)
logger.debug(f"Synced {len(values_to_sync)} ENV values to {tab.name} config: {list(values_to_sync.keys())}")
# Migrate legacy settings to new format
migrate_legacy_settings()
def migrate_legacy_settings() -> None:
"""Migrate legacy settings to new unified file destination format.
Maps old settings to new:
- PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION
- INGEST_DIR / LIBRARY_PATH -> DESTINATION
- LIBRARY_TEMPLATE -> TEMPLATE
- USE_CONTENT_TYPE_DIRECTORIES -> AA_CONTENT_TYPE_ROUTING
- INGEST_DIR_* -> AA_CONTENT_TYPE_DIR_*
- TORRENT_HARDLINK -> HARDLINK_TORRENTS / HARDLINK_TORRENTS_AUDIOBOOK
"""
# Load existing downloads config
downloads_config = load_config_file("downloads")
source_config = load_config_file("download_sources")
# Skip migration if already using new settings
if "FILE_ORGANIZATION" in downloads_config or "DESTINATION" in downloads_config:
return
migrated_downloads = {}
migrated_sources = {}
# === BOOKS MIGRATION ===
old_mode = downloads_config.get("PROCESSING_MODE", "ingest")
old_ingest_dir = downloads_config.get("INGEST_DIR", "/cwa-book-ingest")
old_library_path = downloads_config.get("LIBRARY_PATH", "")
old_use_book_title = downloads_config.get("USE_BOOK_TITLE", True)
old_library_template = downloads_config.get("LIBRARY_TEMPLATE", "{Author}/{Title}")
# Map PROCESSING_MODE + USE_BOOK_TITLE -> FILE_ORGANIZATION
if old_mode == "library":
migrated_downloads["FILE_ORGANIZATION"] = "organize"
migrated_downloads["DESTINATION"] = old_library_path or "/books"
migrated_downloads["TEMPLATE"] = old_library_template
else:
if old_use_book_title:
migrated_downloads["FILE_ORGANIZATION"] = "rename"
migrated_downloads["TEMPLATE"] = "{Author} - {Title} ({Year})"
else:
migrated_downloads["FILE_ORGANIZATION"] = "none"
migrated_downloads["DESTINATION"] = old_ingest_dir
# === AUDIOBOOKS MIGRATION ===
old_mode_ab = downloads_config.get("PROCESSING_MODE_AUDIOBOOK", "ingest")
old_ingest_dir_ab = downloads_config.get("INGEST_DIR_AUDIOBOOK", "")
old_library_path_ab = downloads_config.get("LIBRARY_PATH_AUDIOBOOK", "")
old_library_template_ab = downloads_config.get("LIBRARY_TEMPLATE_AUDIOBOOK", "{Author}/{Title}")
if old_mode_ab == "library":
migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "organize"
migrated_downloads["DESTINATION_AUDIOBOOK"] = old_library_path_ab or ""
migrated_downloads["TEMPLATE_AUDIOBOOK"] = old_library_template_ab
else:
migrated_downloads["FILE_ORGANIZATION_AUDIOBOOK"] = "rename"
migrated_downloads["TEMPLATE_AUDIOBOOK"] = "{Author} - {Title}"
if old_ingest_dir_ab:
migrated_downloads["DESTINATION_AUDIOBOOK"] = old_ingest_dir_ab
# === HARDLINK MIGRATION ===
old_torrent_hardlink = downloads_config.get("TORRENT_HARDLINK")
if old_torrent_hardlink is not None:
# Books default to False (ingest folder use case)
# Audiobooks default to True (library folder use case)
# But if explicitly set, apply to both
migrated_downloads["HARDLINK_TORRENTS"] = old_torrent_hardlink
migrated_downloads["HARDLINK_TORRENTS_AUDIOBOOK"] = old_torrent_hardlink
# === CONTENT-TYPE ROUTING MIGRATION ===
old_use_content_type = downloads_config.get("USE_CONTENT_TYPE_DIRECTORIES", False)
if old_use_content_type:
migrated_sources["AA_CONTENT_TYPE_ROUTING"] = True
# Map old keys to new keys
content_type_mapping = {
"INGEST_DIR_BOOK_FICTION": "AA_CONTENT_TYPE_DIR_FICTION",
"INGEST_DIR_BOOK_NON_FICTION": "AA_CONTENT_TYPE_DIR_NON_FICTION",
"INGEST_DIR_BOOK_UNKNOWN": "AA_CONTENT_TYPE_DIR_UNKNOWN",
"INGEST_DIR_MAGAZINE": "AA_CONTENT_TYPE_DIR_MAGAZINE",
"INGEST_DIR_COMIC_BOOK": "AA_CONTENT_TYPE_DIR_COMIC",
"INGEST_DIR_STANDARDS_DOCUMENT": "AA_CONTENT_TYPE_DIR_STANDARDS",
"INGEST_DIR_MUSICAL_SCORE": "AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"INGEST_DIR_OTHER": "AA_CONTENT_TYPE_DIR_OTHER",
}
for old_key, new_key in content_type_mapping.items():
old_value = downloads_config.get(old_key, "")
if old_value:
migrated_sources[new_key] = old_value
# Save migrated settings
if migrated_downloads:
save_config_file("downloads", migrated_downloads)
logger.info(f"Migrated download settings: {list(migrated_downloads.keys())}")
if migrated_sources:
save_config_file("download_sources", migrated_sources)
logger.info(f"Migrated content-type routing settings: {list(migrated_sources.keys())}")
def get_setting_value(field: SettingsField, tab_name: str) -> Any:
if isinstance(field, (ActionButton, HeadingField)):
+90 -12
View File
@@ -21,7 +21,22 @@ CONTENT_TYPES = [
"other",
]
_CONTENT_TYPE_TO_CONFIG_KEY = {
# Maps AA content types to their config keys for content-type routing
# Used when AA_CONTENT_TYPE_ROUTING is enabled
_AA_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "AA_CONTENT_TYPE_DIR_FICTION",
"book (non-fiction)": "AA_CONTENT_TYPE_DIR_NON_FICTION",
"book (unknown)": "AA_CONTENT_TYPE_DIR_UNKNOWN",
"magazine": "AA_CONTENT_TYPE_DIR_MAGAZINE",
"comic book": "AA_CONTENT_TYPE_DIR_COMIC",
"audiobook": "AA_CONTENT_TYPE_DIR_AUDIOBOOK",
"standards document": "AA_CONTENT_TYPE_DIR_STANDARDS",
"musical score": "AA_CONTENT_TYPE_DIR_MUSICAL_SCORE",
"other": "AA_CONTENT_TYPE_DIR_OTHER",
}
# Legacy mapping - kept for backwards compatibility during migration
_LEGACY_CONTENT_TYPE_TO_CONFIG_KEY = {
"book (fiction)": "INGEST_DIR_BOOK_FICTION",
"book (non-fiction)": "INGEST_DIR_BOOK_NON_FICTION",
"book (unknown)": "INGEST_DIR_BOOK_UNKNOWN",
@@ -34,27 +49,90 @@ _CONTENT_TYPE_TO_CONFIG_KEY = {
}
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
"""Get the ingest directory for a content type, falling back to default."""
def get_destination(is_audiobook: bool = False) -> Path:
"""Get the base destination directory.
Args:
is_audiobook: If True, returns audiobook destination (with fallback to books destination)
Returns:
Path to the destination directory
"""
from cwa_book_downloader.core.config import config
default_ingest_dir = Path(config.get("INGEST_DIR", "/cwa-book-ingest"))
if is_audiobook:
# Audiobook destination with fallback to main destination
audiobook_dest = config.get("DESTINATION_AUDIOBOOK", "")
if audiobook_dest:
return Path(audiobook_dest)
# Main destination (also fallback for audiobooks)
# Check new setting first, then legacy INGEST_DIR
destination = config.get("DESTINATION", "") or config.get("INGEST_DIR", "/cwa-book-ingest")
return Path(destination)
def get_aa_content_type_dir(content_type: Optional[str] = None) -> Optional[Path]:
"""Get override directory for Anna's Archive content-type routing.
Only returns a path if AA_CONTENT_TYPE_ROUTING is enabled AND
a custom directory is configured for the given content type.
Args:
content_type: The AA content type (e.g., "book (fiction)", "magazine")
Returns:
Path to the override directory if configured, None otherwise
"""
from cwa_book_downloader.core.config import config
# Check if content-type routing is enabled
if not config.get("AA_CONTENT_TYPE_ROUTING", False):
# Also check legacy setting for backwards compatibility
if not config.get("USE_CONTENT_TYPE_DIRECTORIES", False):
return None
if not content_type:
return default_ingest_dir
return None
# Normalize content type for lookup
content_type_lower = content_type.lower().strip()
# Look up the config key for this content type
config_key = _CONTENT_TYPE_TO_CONFIG_KEY.get(content_type_lower)
if not config_key:
# Try new AA-specific config keys first
config_key = _AA_CONTENT_TYPE_TO_CONFIG_KEY.get(content_type_lower)
if config_key:
custom_dir = config.get(config_key, "")
if custom_dir:
return Path(custom_dir)
# Fall back to legacy config keys for backwards compatibility
legacy_key = _LEGACY_CONTENT_TYPE_TO_CONFIG_KEY.get(content_type_lower)
if legacy_key:
custom_dir = config.get(legacy_key, "")
if custom_dir:
return Path(custom_dir)
return None
def get_ingest_dir(content_type: Optional[str] = None) -> Path:
"""Get the ingest directory for a content type, falling back to default.
DEPRECATED: Use get_destination() and get_aa_content_type_dir() instead.
Kept for backwards compatibility during migration.
"""
from cwa_book_downloader.core.config import config
# Check new DESTINATION setting first, then legacy INGEST_DIR
default_ingest_dir = Path(config.get("DESTINATION", "") or config.get("INGEST_DIR", "/cwa-book-ingest"))
if not content_type:
return default_ingest_dir
# Get the custom directory from config (empty string means use default)
custom_dir = config.get(config_key, "")
if custom_dir:
return Path(custom_dir)
# Check for content-type override
override_dir = get_aa_content_type_dir(content_type)
if override_dir:
return override_dir
return default_ingest_dir
+81 -5
View File
@@ -9,7 +9,7 @@ from typing import List, Optional, Tuple
from cwa_book_downloader.core.logger import setup_logger
from cwa_book_downloader.core.config import config
from cwa_book_downloader.core.models import build_filename
from cwa_book_downloader.core.naming import parse_naming_template, sanitize_filename
logger = setup_logger(__name__)
@@ -31,6 +31,76 @@ def _get_supported_audiobook_formats() -> List[str]:
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def _get_file_organization(is_audiobook: bool) -> str:
"""Get the file organization mode for the content type."""
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
mode = config.get(key, "rename")
# Handle legacy settings migration
if mode not in ("none", "rename", "organize"):
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
legacy_mode = config.get(legacy_key, "ingest")
if legacy_mode == "library":
return "organize"
if config.get("USE_BOOK_TITLE", True):
return "rename"
return "none"
return mode
def _get_template(is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode."""
# Determine the correct key based on content type and organization mode
if is_audiobook:
if organization_mode == "organize":
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
else:
key = "TEMPLATE_AUDIOBOOK_RENAME"
else:
if organization_mode == "organize":
key = "TEMPLATE_ORGANIZE"
else:
key = "TEMPLATE_RENAME"
template = config.get(key, "")
# Fallback to legacy keys if new keys are empty
if not template:
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
template = config.get(legacy_key, "")
if not template:
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
template = config.get(legacy_key, "")
if not template:
return "{Author} - {Title} ({Year})"
return template
def _build_filename_from_task(task, extension: str, organization_mode: str) -> str:
"""Build a filename from task metadata using the configured template."""
content_type = task.content_type.lower() if task.content_type else ""
is_audiobook = "audiobook" in content_type
template = _get_template(is_audiobook, organization_mode)
metadata = {
"Author": task.author,
"Title": task.title,
"Subtitle": getattr(task, 'subtitle', None),
"Year": task.year,
"Series": getattr(task, 'series_name', None),
"SeriesPosition": getattr(task, 'series_position', None),
}
filename = parse_naming_template(template, metadata)
if filename:
return f"{sanitize_filename(filename)}.{extension}"
return ""
# Check for rarfile availability at module load
try:
import rarfile
@@ -384,15 +454,21 @@ def process_archive(
# Move book files to ingest folder
final_paths = []
# Determine file organization mode
is_audiobook = task and task.content_type and "audiobook" in task.content_type.lower()
organization_mode = _get_file_organization(is_audiobook) if task else "none"
for extracted_file in extracted_files:
# For multi-file archives (book packs, series), always preserve original filenames
# since metadata title only applies to the searched book, not the whole pack.
# For single files, respect USE_BOOK_TITLE setting.
if len(extracted_files) == 1 and config.USE_BOOK_TITLE and task:
# For single files, respect FILE_ORGANIZATION setting.
if len(extracted_files) == 1 and organization_mode != "none" and task:
# Use the extracted file's actual extension, not the archive's extension
# (task.download_path points to the archive, so we must use build_filename directly)
extracted_format = extracted_file.suffix.lower().lstrip('.')
filename = build_filename(task.title, task.author, task.year, extracted_format)
filename = _build_filename_from_task(task, extracted_format, organization_mode)
if not filename:
filename = extracted_file.name
else:
filename = extracted_file.name
+253 -116
View File
@@ -36,8 +36,8 @@ from cwa_book_downloader.release_sources import direct_download
from cwa_book_downloader.release_sources.direct_download import SearchUnavailable
from cwa_book_downloader.core.config import config
from cwa_book_downloader.config.env import TMP_DIR
from cwa_book_downloader.core.utils import get_ingest_dir
from cwa_book_downloader.core.naming import build_library_path, same_filesystem, assign_part_numbers
from cwa_book_downloader.core.utils import get_ingest_dir, get_destination, get_aa_content_type_dir
from cwa_book_downloader.core.naming import build_library_path, same_filesystem, assign_part_numbers, parse_naming_template, sanitize_filename
from cwa_book_downloader.download.archive import is_archive, process_archive
from cwa_book_downloader.release_sources import get_handler, get_source_display_name
from cwa_book_downloader.core.logger import setup_logger
@@ -112,6 +112,153 @@ def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
return staged_path
# =============================================================================
# File Organization Helpers
# =============================================================================
def _get_file_organization(is_audiobook: bool) -> str:
"""Get the file organization mode for the content type.
Returns:
One of: "none", "rename", "organize"
"""
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
mode = config.get(key, "rename")
# Handle legacy settings migration
if mode not in ("none", "rename", "organize"):
# Check legacy PROCESSING_MODE
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
legacy_mode = config.get(legacy_key, "ingest")
if legacy_mode == "library":
return "organize"
# Check legacy USE_BOOK_TITLE for ingest mode
if config.get("USE_BOOK_TITLE", True):
return "rename"
return "none"
return mode
def _get_template(is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode.
Returns:
Template string
"""
# Build the key based on content type and organization mode
if is_audiobook:
if organization_mode == "organize":
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
else:
key = "TEMPLATE_AUDIOBOOK_RENAME"
else:
if organization_mode == "organize":
key = "TEMPLATE_ORGANIZE"
else:
key = "TEMPLATE_RENAME"
template = config.get(key, "")
# Try legacy keys if new setting is empty
if not template:
# Try old unified TEMPLATE key
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
template = config.get(legacy_key, "")
if not template:
# Try even older LIBRARY_TEMPLATE key
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
template = config.get(legacy_key, "")
# Use sensible default if still empty
if not template:
if organization_mode == "organize":
return "{Author}/{Title} ({Year})"
else:
return "{Author} - {Title} ({Year})"
return template
def _should_hardlink(task: DownloadTask) -> bool:
"""Determine if a download should be hardlinked instead of copied.
Hardlinking only applies to torrent downloads (Prowlarr source).
Uses per-content-type settings.
Returns:
True if hardlinking should be used
"""
# Only Prowlarr downloads (torrents) can be hardlinked
if task.source != "prowlarr":
return False
# Only applies if we have an original download path from torrent client
if not task.original_download_path:
return False
# Check per-content-type setting
is_audiobook = task.content_type and "audiobook" in task.content_type.lower()
key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS"
# Check new setting first, then legacy TORRENT_HARDLINK
hardlink_enabled = config.get(key)
if hardlink_enabled is None:
# Fall back to legacy setting (but only if explicitly set)
hardlink_enabled = config.get("TORRENT_HARDLINK", False)
return bool(hardlink_enabled)
def _should_extract_archives(task: DownloadTask) -> bool:
"""Determine if archives should be extracted for this download.
Archives are NOT extracted when hardlinking is enabled (to preserve torrent seeding).
"""
if _should_hardlink(task):
return False
return True
def _get_final_destination(task: DownloadTask) -> Path:
"""Get the final destination directory for a download.
Handles:
- Per-content-type destinations (books vs audiobooks)
- AA content-type routing override (for Direct mode downloads)
Returns:
Path to the destination directory
"""
content_type = task.content_type.lower() if task.content_type else ""
is_audiobook = "audiobook" in content_type
# Get base destination
base_dest = get_destination(is_audiobook)
# For Anna's Archive (direct_download), check for content-type routing override
if task.source == "direct_download" and not is_audiobook:
override = get_aa_content_type_dir(task.content_type)
if override:
return override
return base_dest
def _build_metadata_dict(task: DownloadTask) -> dict:
"""Build metadata dictionary from task for template processing."""
return {
"Author": task.author,
"Title": task.title,
"Subtitle": task.subtitle,
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
}
def _get_supported_formats(content_type: str = None) -> List[str]:
"""Get current supported formats from config singleton based on content type."""
if content_type and content_type.lower() == "audiobook":
@@ -236,25 +383,39 @@ def process_directory(
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
logger.debug(f"Also found {len(rejected_files)} file(s) with unsupported formats: {', '.join(rejected_exts)}")
# Move each book file to ingest
# Move each book file to destination
final_paths = []
content_type = task.content_type.lower() if task.content_type else ""
is_audiobook = "audiobook" in content_type
organization_mode = _get_file_organization(is_audiobook)
for book_file in book_files:
# For multi-file downloads (book packs, series), always preserve original filenames
# since metadata title only applies to the searched book, not the whole pack.
# For single files, respect USE_BOOK_TITLE setting.
if len(book_files) == 1 and config.USE_BOOK_TITLE:
# For single files, respect FILE_ORGANIZATION setting.
if len(book_files) == 1 and organization_mode != "none":
# Update task format from actual file if not already set
# (Prowlarr releases may not know the format until download completes)
if not task.format:
task.format = book_file.suffix.lower().lstrip('.')
filename = task.get_filename() or book_file.name
# Apply template to generate filename
template = _get_template(is_audiobook, "rename")
metadata = _build_metadata_dict(task)
extension = book_file.suffix.lstrip('.') or task.format or ""
filename = parse_naming_template(template, metadata)
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
else:
filename = book_file.name
else:
filename = book_file.name
dest_path = ingest_dir / filename
final_path = _atomic_move(book_file, dest_path)
final_paths.append(final_path)
logger.debug(f"Moved to ingest: {final_path.name}")
logger.debug(f"Moved to destination: {final_path.name}")
shutil.rmtree(directory, ignore_errors=True)
@@ -608,113 +769,89 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
return None
def _process_library_mode(
def _process_organize_mode(
temp_file: Path,
task: DownloadTask,
status_callback,
) -> Optional[str]:
"""Process a download in Library Mode.
"""Process a download with file organization (organize mode with folders).
Library mode organizes files directly into your library with template-based
naming (e.g., "{Author}/{Series}/{Title}"). Files are transferred as-is
with no processing.
Use ingest mode if you need archive extraction or custom scripts.
Organizes files into folders based on template (e.g., "{Author}/{Series/}{Title}").
Supports hardlinking for torrent downloads when enabled.
Returns:
Path to library file if successful, None if library path not configured
Path to organized file if successful, None if failed
"""
# Check if this is an audiobook and use audiobook-specific settings if configured
content_type = task.content_type.lower() if task.content_type else ""
is_audiobook = "audiobook" in content_type
if is_audiobook:
# Use audiobook-specific settings, falling back to main settings
library_path = config.get("LIBRARY_PATH_AUDIOBOOK") or config.get("LIBRARY_PATH")
template = config.get("LIBRARY_TEMPLATE_AUDIOBOOK") or config.get("LIBRARY_TEMPLATE", "{Author}/{Title}")
else:
library_path = config.get("LIBRARY_PATH")
template = config.get("LIBRARY_TEMPLATE", "{Author}/{Title}")
# Get destination and template
destination = _get_final_destination(task)
template = _get_template(is_audiobook, "organize")
if not library_path:
logger.warning("Library mode enabled but no library path configured, falling back to ingest")
status_callback("resolving", "Library path not configured, using ingest")
# Validate destination path
if not destination.is_absolute():
logger.warning(f"Destination must be absolute: {destination}, falling back to flat mode")
status_callback("resolving", f"Destination must be absolute: {destination}")
return None
# Validate library path
library_path_obj = Path(library_path)
if not library_path_obj.is_absolute():
logger.warning(f"Library path must be absolute: {library_path}, falling back to ingest")
status_callback("resolving", f"Library path must be absolute: {library_path}")
return None
if not library_path_obj.exists():
if not destination.exists():
try:
library_path_obj.mkdir(parents=True, exist_ok=True)
destination.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as e:
logger.warning(f"Cannot create library path: {e}, falling back to ingest")
status_callback("resolving", f"Cannot create library path: {e}")
logger.warning(f"Cannot create destination: {e}")
status_callback("resolving", f"Cannot create destination: {e}")
return None
if not os.access(library_path_obj, os.W_OK):
logger.warning(f"Library path not writable: {library_path}, falling back to ingest")
status_callback("resolving", f"Library path not writable: {library_path}")
if not os.access(destination, os.W_OK):
logger.warning(f"Destination not writable: {destination}")
status_callback("resolving", f"Destination not writable: {destination}")
return None
# Determine if we should use hardlinking (torrents only)
# Determine if we should use hardlinking
use_hardlink = False
hardlink_source = None
if task.original_download_path:
# Torrent with download client path available
torrent_hardlink_enabled = config.get("TORRENT_HARDLINK", True)
if torrent_hardlink_enabled:
hardlink_source = Path(task.original_download_path)
if hardlink_source.exists():
# Check same filesystem (required for hardlinks)
if same_filesystem(hardlink_source, library_path):
use_hardlink = True
else:
logger.warning(
f"Cannot hardlink: {hardlink_source} and {library_path} are on different filesystems. "
"Falling back to copy. To fix: ensure torrent client downloads to same filesystem as library."
)
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
if _should_hardlink(task):
hardlink_source = Path(task.original_download_path)
if hardlink_source.exists():
# Check same filesystem (required for hardlinks)
if same_filesystem(hardlink_source, destination):
use_hardlink = True
else:
logger.warning(
f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. "
"Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination."
)
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
# Build metadata dict for template
metadata = {
"Author": task.author,
"Title": task.title,
"Subtitle": task.subtitle,
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
}
metadata = _build_metadata_dict(task)
try:
if use_hardlink:
status_callback("resolving", "Creating library hardlinks")
status_callback("resolving", "Creating hardlinks")
else:
status_callback("resolving", "Organizing in library")
status_callback("resolving", "Organizing files")
# Use torrent client path for hardlinks, staging path for moves
source = hardlink_source if use_hardlink else temp_file
if source.is_dir():
return _transfer_directory_to_library(
source, library_path, template, metadata, task, temp_file, status_callback, use_hardlink
source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink
)
else:
return _transfer_file_to_library(
source, library_path, template, metadata, task, temp_file, status_callback, use_hardlink
source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink
)
except PermissionError as e:
logger.error(f"Permission denied in library mode: {e}")
logger.error(f"Permission denied: {e}")
status_callback("error", f"Permission denied: {e}")
return None
except Exception as e:
logger.error_trace(f"Library mode failed: {e}")
status_callback("error", f"Library mode failed: {e}")
logger.error_trace(f"Organization failed: {e}")
status_callback("error", f"Organization failed: {e}")
return None
@@ -975,9 +1112,13 @@ def _post_process_download(
cancel_flag: Event,
status_callback,
) -> Optional[str]:
"""Post-process a downloaded file: handle archives and move to ingest.
"""Post-process a downloaded file based on file organization settings.
This runs uniformly for all download sources, ensuring consistent behavior.
Handles three organization modes:
- "none": Keep original filename, move to destination
- "rename": Apply template to filename, move to destination
- "organize": Apply template with folders, move to destination
Args:
temp_file: Path to downloaded file in temp directory
@@ -986,7 +1127,7 @@ def _post_process_download(
status_callback: Callback for status updates
Returns:
Final path in ingest directory, or None on failure
Final path in destination directory, or None on failure
"""
content_type = task.content_type.lower() if task.content_type else ""
is_audiobook = "audiobook" in content_type
@@ -997,40 +1138,26 @@ def _post_process_download(
elif task.search_mode not in (SearchMode.DIRECT, SearchMode.UNIVERSAL):
logger.warning(f"Task {task.task_id} has invalid search_mode '{task.search_mode}', defaulting to Direct mode behavior")
# Library mode and audiobook-specific directories only apply to Universal mode
is_universal = task.search_mode == SearchMode.UNIVERSAL
# Get file organization mode and destination
organization_mode = _get_file_organization(is_audiobook)
destination = _get_final_destination(task)
if is_universal:
# Determine processing mode based on content type
if is_audiobook:
processing_mode = config.get("PROCESSING_MODE_AUDIOBOOK", "ingest")
else:
processing_mode = config.get("PROCESSING_MODE", "ingest")
logger.debug(f"File organization: mode={organization_mode}, destination={destination}")
if processing_mode == "library":
result = _process_library_mode(temp_file, task, status_callback)
if result is not None:
return result
# If library mode fails, fall through to normal processing
# "Organize" mode with folders uses specialized handler
if organization_mode == "organize":
result = _process_organize_mode(temp_file, task, status_callback)
if result is not None:
return result
# If organize mode fails, fall through to flat mode
logger.warning("Organize mode failed, falling back to flat destination")
# Determine ingest directory
default_ingest_dir = get_ingest_dir()
# Ensure destination exists
os.makedirs(destination, exist_ok=True)
if is_universal and is_audiobook:
# Universal audiobooks use dedicated setting, falling back to main ingest dir
audiobook_ingest = config.get("INGEST_DIR_AUDIOBOOK", "")
ingest_dir = Path(audiobook_ingest) if audiobook_ingest else default_ingest_dir
else:
# Direct mode and non-audiobook content use content-type routing
ingest_dir = get_ingest_dir(content_type)
if ingest_dir != default_ingest_dir:
logger.debug(f"Routing '{content_type or 'default'}' to {ingest_dir}")
os.makedirs(ingest_dir, exist_ok=True)
# For torrents going to ingest mode, stage first to preserve seeding
# For torrents with hardlinking disabled, stage first to preserve seeding
# (Torrent handler returns original path, not staged copy)
if _is_torrent_source(temp_file, task):
if _is_torrent_source(temp_file, task) and not _should_hardlink(task):
status_callback("resolving", "Staging torrent files")
staging_dir = get_staging_dir()
@@ -1053,15 +1180,15 @@ def _post_process_download(
temp_file = staged_path
# Handle archive extraction (RAR/ZIP)
if is_archive(temp_file):
# Handle archive extraction (RAR/ZIP) - only if not hardlinking
if is_archive(temp_file) and _should_extract_archives(task):
logger.info(f"Archive detected, extracting: {temp_file.name}")
status_callback("resolving", "Extracting archive")
result = process_archive(
archive_path=temp_file,
temp_dir=TMP_DIR,
ingest_dir=ingest_dir,
ingest_dir=destination,
archive_id=task.task_id,
task=task,
)
@@ -1080,7 +1207,7 @@ def _post_process_download(
final_paths, error = process_directory(
directory=temp_file,
ingest_dir=ingest_dir,
ingest_dir=destination,
task=task,
)
@@ -1099,7 +1226,7 @@ def _post_process_download(
status_callback("error", "No book files found")
return None
# Non-archive: run custom script if configured, then move to ingest
# Non-archive: run custom script if configured, then move to destination
if config.CUSTOM_SCRIPT:
logger.info(f"Running custom script: {config.CUSTOM_SCRIPT}")
try:
@@ -1132,24 +1259,34 @@ def _post_process_download(
# Check cancellation before final move
if cancel_flag.is_set():
logger.info(f"Download cancelled before ingest: {task.task_id}")
logger.info(f"Download cancelled before final move: {task.task_id}")
temp_file.unlink(missing_ok=True)
return None
# Generate filename: use formatted name if USE_BOOK_TITLE, else preserve original
if config.USE_BOOK_TITLE:
filename = task.get_filename()
if not filename:
filename = temp_file.name
else:
# Determine filename based on organization mode
if organization_mode == "none":
# Keep original filename
filename = temp_file.name
else:
# "rename" mode - apply template to filename
template = _get_template(is_audiobook, "rename")
metadata = _build_metadata_dict(task)
extension = temp_file.suffix.lstrip('.') or task.format or ""
dest_path = ingest_dir / filename
# Parse template to generate filename
filename = parse_naming_template(template, metadata)
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
elif not filename:
# Template produced empty result, fall back to original
filename = temp_file.name
dest_path = destination / filename
try:
final_path = _atomic_move(temp_file, dest_path)
except Exception as e:
logger.error(f"Failed to move file to ingest: {e}")
logger.error(f"Failed to move file to destination: {e}")
status_callback("error", f"Failed to move file: {e}")
return None
+34 -7
View File
@@ -943,7 +943,7 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password FROM user WHERE name = ?", (username,))
cur.execute("SELECT password, role FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
@@ -951,11 +951,16 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
if not row or not row[0] or not check_password_hash(row[0], password):
return _failed_login_response(username, ip_address)
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
user_role = row[1] if row[1] is not None else 0
is_admin = (user_role & 1) == 1
# Successful authentication - create session and clear failed attempts
session['user_id'] = username
session['is_admin'] = is_admin
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, remember_me={remember_me})")
logger.info(f"Login successful for user '{username}' from IP {ip_address} (CWA auth, is_admin={is_admin}, remember_me={remember_me})")
return jsonify({"success": True})
except Exception as e:
@@ -998,32 +1003,54 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
Returns:
flask.Response: JSON with authentication status, whether auth is required,
and which auth mode is active.
which auth mode is active, and whether user has admin privileges.
"""
from cwa_book_downloader.core.settings_registry import load_config_file
try:
auth_mode = get_auth_mode()
# If no authentication is configured, access is allowed
# If no authentication is configured, access is allowed (full admin)
if auth_mode == "none":
return jsonify({
"authenticated": True,
"auth_required": False,
"auth_mode": "none"
"auth_mode": "none",
"is_admin": True
})
# Check if user has a valid session
is_authenticated = 'user_id' in session
# Determine admin status for settings access
# - Built-in auth: single user is always admin
# - CWA auth: check RESTRICT_SETTINGS_TO_ADMIN setting
if auth_mode == "builtin":
is_admin = True
elif auth_mode == "cwa":
security_config = load_config_file("security")
restrict_to_admin = security_config.get("RESTRICT_SETTINGS_TO_ADMIN", False)
if restrict_to_admin:
is_admin = session.get('is_admin', False)
else:
# All authenticated CWA users can access settings
is_admin = True
else:
is_admin = False
return jsonify({
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False
})
except Exception as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify({
"authenticated": False,
"auth_required": True,
"auth_mode": "unknown"
"auth_mode": "unknown",
"is_admin": False
})
@@ -582,6 +582,9 @@ class HardcoverProvider(MetadataProvider):
# No contribution_types or length mismatch - use all names as fallback
authors = author_names
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
cover_url = _extract_cover_url(item, "image")
publish_year = _extract_publish_year(item)
source_url = _build_source_url(item.get("slug", ""))
@@ -662,6 +665,9 @@ class HardcoverProvider(MetadataProvider):
elif isinstance(contrib, str):
authors.append(contrib)
# Normalize whitespace in author names (some API data has multiple spaces)
authors = [" ".join(name.split()) for name in authors]
cover_url = _extract_cover_url(book, "cached_image", "image")
publish_year = _extract_publish_year(book)
@@ -92,6 +92,8 @@ class ColumnSchema:
color_hint: Optional[ColumnColorHint] = None # For BADGE render type
fallback: str = "-" # Value to show when data is missing
uppercase: bool = False # Force uppercase display
sortable: bool = False # Show in sort dropdown (opt-in)
sort_key: Optional[str] = None # Field to sort by (defaults to `key` if None)
class LeadingCellType(str, Enum):
@@ -138,6 +140,8 @@ def serialize_column_config(config: ReleaseColumnConfig) -> Dict[str, Any]:
} if col.color_hint else None,
"fallback": col.fallback,
"uppercase": col.uppercase,
"sortable": col.sortable,
"sort_key": col.sort_key,
}
for col in config.columns
],
@@ -1169,9 +1169,13 @@ class DirectDownloadHandler(DownloadHandler):
try:
logger.info(f"Starting download: {book_info.title}")
# Prepare paths
full_name = book_info.get_filename()
book_name = full_name if config.USE_BOOK_TITLE else f"{book_info.id}.{book_info.format or 'bin'}"
# Prepare paths - use descriptive staging filename, orchestrator will rename
# based on FILE_ORGANIZATION setting
file_org = config.get("FILE_ORGANIZATION", "rename")
if file_org == "none":
book_name = f"{book_info.id}.{book_info.format or 'bin'}"
else:
book_name = book_info.get_filename()
book_path = TMP_DIR / book_name
# Check cancellation before download
@@ -90,6 +90,7 @@ class IRCReleaseSource(ReleaseSource):
label="Server",
render_type=ColumnRenderType.TEXT,
width="100px",
sortable=True,
),
ColumnSchema(
key="format",
@@ -98,12 +99,15 @@ class IRCReleaseSource(ReleaseSource):
color_hint=ColumnColorHint(type="map", value="format"),
width="70px",
uppercase=True,
sortable=True,
),
ColumnSchema(
key="size",
label="Size",
render_type=ColumnRenderType.TEXT,
width="70px",
sortable=True,
sort_key="size_bytes",
),
],
grid_template="minmax(0,2fr) 100px 70px 70px",
@@ -283,20 +287,27 @@ class IRCReleaseSource(ReleaseSource):
@staticmethod
def _parse_size(size_str: str) -> Optional[int]:
"""Parse human-readable size to bytes."""
"""Parse human-readable size to bytes.
Handles formats like: 1.2MB, 1.2M, 500KB, 500K, 1GB, 1G, etc.
"""
if not size_str:
return None
size_str = size_str.strip().upper()
multipliers = {
'B': 1,
'KB': 1024,
'MB': 1024 * 1024,
'GB': 1024 * 1024 * 1024,
}
# Map suffixes to multipliers (check longer suffixes first)
multipliers = [
('GB', 1024 * 1024 * 1024),
('MB', 1024 * 1024),
('KB', 1024),
('G', 1024 * 1024 * 1024),
('M', 1024 * 1024),
('K', 1024),
('B', 1),
]
for suffix, mult in multipliers.items():
for suffix, mult in multipliers:
if size_str.endswith(suffix):
try:
num = float(size_str[:-len(suffix)].strip())
@@ -304,4 +315,8 @@ class IRCReleaseSource(ReleaseSource):
except ValueError:
return None
return None
# Try parsing as plain number (bytes)
try:
return int(float(size_str))
except ValueError:
return None
@@ -33,6 +33,33 @@ POLL_INTERVAL = 2
class ProwlarrHandler(DownloadHandler):
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
def _get_category_for_task(self, client, task: DownloadTask) -> Optional[str]:
"""Get the appropriate category based on content type.
Returns the audiobook-specific category if configured and the task is an audiobook,
otherwise returns None to let the client use its default category.
"""
is_audiobook = task.content_type and "audiobook" in task.content_type.lower()
if not is_audiobook:
return None
# Client-specific audiobook category config keys
audiobook_key = {
"qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK",
"transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK",
"deluge": "DELUGE_CATEGORY_AUDIOBOOK",
"nzbget": "NZBGET_CATEGORY_AUDIOBOOK",
"sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK",
}.get(client.name)
if audiobook_key:
audiobook_cat = config.get(audiobook_key, "")
if audiobook_cat:
return audiobook_cat
return None # Let client use its default
def download(
self,
task: DownloadTask,
@@ -119,9 +146,11 @@ class ProwlarrHandler(DownloadHandler):
status_callback("resolving", f"Sending to {client.name}")
try:
release_name = prowlarr_result.get("title") or task.title or "Unknown"
category = self._get_category_for_task(client, task)
download_id = client.add_download(
url=download_url,
name=release_name,
category=category,
)
except Exception as e:
logger.error(f"Failed to add to {client.name}: {e}")
@@ -394,12 +394,20 @@ def prowlarr_clients_settings():
),
TextField(
key="QBITTORRENT_CATEGORY",
label="Category",
description="Category to assign to downloads in qBittorrent",
label="Book Category",
description="Category to assign to book downloads in qBittorrent",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TextField(
key="QBITTORRENT_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
# --- Transmission Settings ---
TextField(
@@ -431,12 +439,20 @@ def prowlarr_clients_settings():
),
TextField(
key="TRANSMISSION_CATEGORY",
label="Label",
description="Label to assign to downloads in Transmission",
label="Book Label",
description="Label to assign to book downloads in Transmission",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
TextField(
key="TRANSMISSION_CATEGORY_AUDIOBOOK",
label="Audiobook Label",
description="Label for audiobook downloads. Leave empty to use the book label.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
),
# --- Deluge Settings ---
TextField(
@@ -477,12 +493,20 @@ def prowlarr_clients_settings():
),
TextField(
key="DELUGE_CATEGORY",
label="Label",
description="Label to assign to downloads in Deluge",
label="Book Label",
description="Label to assign to book downloads in Deluge",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_CATEGORY_AUDIOBOOK",
label="Audiobook Label",
description="Label for audiobook downloads. Leave empty to use the book label.",
placeholder="",
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
# Note: Torrent client download path must be mounted identically in both containers.
# Torrents are always copied (not moved) to preserve seeding capability.
@@ -536,12 +560,20 @@ def prowlarr_clients_settings():
),
TextField(
key="NZBGET_CATEGORY",
label="Category",
description="Category to assign to downloads in NZBGet",
label="Book Category",
description="Category to assign to book downloads in NZBGet",
placeholder="Books",
default="Books",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
TextField(
key="NZBGET_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "nzbget"},
),
# --- SABnzbd Settings ---
TextField(
@@ -567,12 +599,20 @@ def prowlarr_clients_settings():
),
TextField(
key="SABNZBD_CATEGORY",
label="Category",
description="Category to assign to downloads in SABnzbd",
label="Book Category",
description="Category to assign to book downloads in SABnzbd",
placeholder="cwabd",
default="cwabd",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
TextField(
key="SABNZBD_CATEGORY_AUDIOBOOK",
label="Audiobook Category",
description="Category for audiobook downloads. Leave empty to use the book category.",
placeholder="",
default="",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
# Note: Usenet client download path must be mounted identically in both containers.
SelectField(
@@ -280,6 +280,7 @@ class ProwlarrSource(ReleaseSource):
align=ColumnAlign.LEFT,
width="minmax(80px, 1fr)",
hide_mobile=True,
sortable=True,
),
ColumnSchema(
key="protocol",
@@ -299,6 +300,8 @@ class ProwlarrSource(ReleaseSource):
width="70px",
hide_mobile=True,
fallback="-",
sortable=True,
sort_key="seeders",
),
ColumnSchema(
key="content_type",
@@ -318,6 +321,8 @@ class ProwlarrSource(ReleaseSource):
align=ColumnAlign.CENTER,
width="80px",
hide_mobile=False,
sortable=True,
sort_key="size_bytes",
),
],
grid_template="minmax(0,2fr) minmax(80px,1fr) 60px 70px 90px 80px",
+3 -2
View File
@@ -60,6 +60,7 @@ function App() {
isAuthenticated,
authRequired,
authChecked,
isAdmin,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -530,13 +531,13 @@ function App() {
searchInput={searchInput}
onSearchChange={setSearchInput}
onDownloadsClick={() => setDownloadsSidebarOpen(true)}
onSettingsClick={() => {
onSettingsClick={isAdmin ? () => {
if (config?.settings_enabled) {
setSettingsOpen(true);
} else {
setConfigBannerOpen(true);
}
}}
} : undefined}
statusCounts={statusCounts}
onLogoClick={() => handleResetSearch(config)}
authRequired={authRequired}
+247 -10
View File
@@ -55,6 +55,85 @@ function setCachedReleases(provider: string, providerId: string, source: string,
cacheTimestamps.set(key, Date.now());
}
// LocalStorage helpers for persisting sort preferences per source
const SORT_STORAGE_PREFIX = 'cwa-bd-release-sort-';
interface SortState {
key: string;
direction: 'asc' | 'desc';
}
function getSavedSort(sourceName: string): SortState | null {
try {
const saved = localStorage.getItem(`${SORT_STORAGE_PREFIX}${sourceName}`);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.key && parsed.direction) {
return parsed as SortState;
}
}
return null;
} catch {
return null;
}
}
function saveSort(sourceName: string, sortState: SortState): void {
try {
localStorage.setItem(`${SORT_STORAGE_PREFIX}${sourceName}`, JSON.stringify(sortState));
} catch {
// localStorage may be unavailable in private browsing
}
}
// Get nested value from an object using dot notation path
function getNestedSortValue(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce<unknown>((current, key) => {
if (current && typeof current === 'object' && key in (current as Record<string, unknown>)) {
return (current as Record<string, unknown>)[key];
}
return undefined;
}, obj);
}
// Infer default sort direction from column render type
function inferDefaultDirection(renderType: string): 'asc' | 'desc' {
// Numeric types sort descending by default (bigger is usually better)
if (renderType === 'size' || renderType === 'number' || renderType === 'peers') {
return 'desc';
}
// Text/badge types sort ascending (alphabetical)
return 'asc';
}
// Sort releases by a column
function sortReleases(
releases: Release[],
sortKey: string,
direction: 'asc' | 'desc'
): Release[] {
return [...releases].sort((a, b) => {
const aVal = getNestedSortValue(a as unknown as Record<string, unknown>, sortKey);
const bVal = getNestedSortValue(b as unknown as Record<string, unknown>, sortKey);
// Handle null/undefined - sort them to the end
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Numeric comparison
if (typeof aVal === 'number' && typeof bVal === 'number') {
return direction === 'asc' ? aVal - bVal : bVal - aVal;
}
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
const cmp = aStr.localeCompare(bStr);
return direction === 'asc' ? cmp : -cmp;
});
}
// Default column configuration (fallback when backend doesn't provide one)
const DEFAULT_COLUMN_CONFIG: ReleaseColumnConfig = {
columns: [
@@ -575,6 +654,10 @@ export const ReleaseModal = ({
const [formatFilter, setFormatFilter] = useState<string>('');
const [languageFilter, setLanguageFilter] = useState<string[]>([LANGUAGE_OPTION_DEFAULT]);
// Sort state - keyed by source name, persisted to localStorage
// null means "Default" (backend order), undefined means "not set yet"
const [sortBySource, setSortBySource] = useState<Record<string, SortState | null>>({});
// Description expansion
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [descriptionOverflows, setDescriptionOverflows] = useState(false);
@@ -943,12 +1026,85 @@ export const ReleaseModal = ({
return getLanguageFilterValues(languageFilter, bookLanguages, defaultLanguages);
}, [languageFilter, bookLanguages, defaultLanguages]);
// Filter releases based on settings and user selection
// Get column config from response or use default (moved before filteredReleases for sorting)
const columnConfig = useMemo((): ReleaseColumnConfig => {
const response = releasesBySource[activeTab];
if (response?.column_config) {
return response.column_config;
}
return DEFAULT_COLUMN_CONFIG;
}, [releasesBySource, activeTab]);
// Get sortable columns from column config
const sortableColumns = useMemo(() => {
return columnConfig.columns.filter(col => col.sortable) || [];
}, [columnConfig]);
// Get current sort state for active tab (from state, localStorage, or default to null = backend order)
const currentSort = useMemo((): SortState | null => {
// Check state first - explicit null means "Default" was selected
if (activeTab in sortBySource) {
return sortBySource[activeTab];
}
// Check localStorage
const saved = getSavedSort(activeTab);
if (saved) {
// Verify the saved sort is still valid for this source
const isValid = sortableColumns.some(col => (col.sort_key || col.key) === saved.key);
if (isValid) {
return saved;
}
}
// Default to null (backend order / no client-side sorting)
return null;
}, [activeTab, sortBySource, sortableColumns]);
// Handle sort change - null means "Default" (backend order), otherwise toggle direction or set new column
const handleSortChange = useCallback((sortKey: string | null, column: ColumnSchema | null) => {
if (sortKey === null) {
// "Default" selected - clear client-side sorting
setSortBySource(prev => {
const next = { ...prev };
delete next[activeTab];
return next;
});
// Clear from localStorage
try {
localStorage.removeItem(`${SORT_STORAGE_PREFIX}${activeTab}`);
} catch {
// Ignore localStorage errors
}
return;
}
const currentState = sortBySource[activeTab] ?? currentSort;
let newState: SortState;
if (currentState && currentState.key === sortKey) {
// Same column - toggle direction
newState = {
key: sortKey,
direction: currentState.direction === 'asc' ? 'desc' : 'asc',
};
} else {
// New column - use default direction for this column type
newState = {
key: sortKey,
direction: inferDefaultDirection(column!.render_type),
};
}
setSortBySource(prev => ({ ...prev, [activeTab]: newState }));
saveSort(activeTab, newState);
}, [activeTab, sortBySource, currentSort]);
// Filter and sort releases based on settings and user selection
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
return releases.filter((r) => {
// First, filter
let filtered = releases.filter((r) => {
// Format filtering
const fmt = r.format?.toLowerCase();
@@ -969,16 +1125,14 @@ export const ReleaseModal = ({
return true;
});
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages]);
// Get column config from response or use default
const columnConfig = useMemo((): ReleaseColumnConfig => {
const response = releasesBySource[activeTab];
if (response?.column_config) {
return response.column_config;
// Then, sort if we have a current sort and sortable columns
if (currentSort && sortableColumns.length > 0) {
filtered = sortReleases(filtered, currentSort.key, currentSort.direction);
}
return DEFAULT_COLUMN_CONFIG;
}, [releasesBySource, activeTab]);
return filtered;
}, [releasesBySource, activeTab, formatFilter, resolvedLanguageCodes, effectiveFormats, defaultLanguages, currentSort, sortableColumns]);
// Pre-compute display field lookups to avoid repeated .find() calls in JSX
const displayFields = useMemo(() => {
@@ -1281,6 +1435,89 @@ export const ReleaseModal = ({
</div>
</div>
{/* Sort dropdown - only show if source has sortable columns */}
{sortableColumns.length > 0 && (
<Dropdown
align="right"
widthClassName="w-auto flex-shrink-0"
panelClassName="w-48"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`relative p-2 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
isOpen ? 'bg-[var(--hover-surface)]' : ''
}`}
aria-label="Sort releases"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7.5 7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5" />
</svg>
{currentSort && (
<span className="absolute top-1 right-1 w-2 h-2 bg-emerald-500 rounded-full" />
)}
</button>
)}
>
{({ close }) => (
<div className="py-1">
{/* Default option - no client-side sorting */}
<button
type="button"
onClick={() => {
handleSortChange(null, null);
close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
!currentSort
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<span>Default</span>
{!currentSort && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
)}
</button>
{sortableColumns.map((col) => {
const sortKey = col.sort_key || col.key;
const isSelected = currentSort?.key === sortKey;
const direction = isSelected ? currentSort?.direction : null;
return (
<button
key={sortKey}
type="button"
onClick={() => {
handleSortChange(sortKey, col);
// Don't close - allow toggling direction
if (!isSelected) close();
}}
className={`w-full px-3 py-2 text-left text-sm flex items-center justify-between hover-surface rounded ${
isSelected
? 'text-emerald-600 dark:text-emerald-400 font-medium'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<span>{col.label}</span>
{isSelected && direction && (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
)}
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
{/* Filter funnel button - stays fixed */}
{/* Only show filter button if source supports at least one filter type */}
{((columnConfig.supported_filters?.includes('format') && availableFormats.length > 0) ||
@@ -19,6 +19,7 @@ export const SelectField = ({ field, value, onChange, disabled }: SelectFieldPro
const dropdownOptions = field.options.map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
}));
const handleChange = (newValue: string | string[]) => {
+9
View File
@@ -12,6 +12,7 @@ interface UseAuthReturn {
isAuthenticated: boolean;
authRequired: boolean;
authChecked: boolean;
isAdmin: boolean;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -26,6 +27,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
@@ -36,13 +38,16 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const response = await checkAuth();
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
const admin = response.is_admin || false;
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
setIsAdmin(admin);
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
setIsAuthenticated(false);
setIsAdmin(false);
} finally {
setAuthChecked(true);
}
@@ -56,7 +61,10 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
try {
const response = await login(credentials);
if (response.success) {
// Re-check auth to get updated admin status from session
const authResponse = await checkAuth();
setIsAuthenticated(true);
setIsAdmin(authResponse.is_admin || false);
setLoginError(null);
navigate('/', { replace: true });
} else {
@@ -89,6 +97,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
isAuthenticated,
authRequired,
authChecked,
isAdmin,
loginError,
isLoggingIn,
setIsAuthenticated,
+3
View File
@@ -177,6 +177,7 @@ export interface AuthResponse {
success?: boolean;
authenticated?: boolean;
auth_required?: boolean;
is_admin?: boolean;
error?: string;
}
@@ -216,6 +217,8 @@ export interface ColumnSchema {
color_hint?: ColumnColorHint | null;
fallback: string; // Value when data is missing
uppercase: boolean; // Force uppercase display
sortable?: boolean; // Show in sort dropdown (opt-in)
sort_key?: string; // Field to sort by (defaults to key if not specified)
}
// Leading cell config - what to show in the left-most position of each row
+1
View File
@@ -14,6 +14,7 @@ export type FieldType =
export interface SelectOption {
value: string;
label: string;
description?: string; // Optional description shown below the label in dropdowns
childOf?: string; // Parent value - when parent is selected, this option is auto-selected and disabled
}