mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 22:07:04 +01:00
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:
@@ -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
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user