From b0206f76f84113d761863aab3e2ba8dd9037e080 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 3 Jan 2026 10:44:34 +0000 Subject: [PATCH] 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 - --- cwa_book_downloader/config/security.py | 10 + cwa_book_downloader/config/settings.py | 281 ++++++------- cwa_book_downloader/core/settings_registry.py | 102 +++++ cwa_book_downloader/core/utils.py | 102 ++++- cwa_book_downloader/download/archive.py | 86 +++- cwa_book_downloader/download/orchestrator.py | 369 ++++++++++++------ cwa_book_downloader/main.py | 41 +- .../metadata_providers/hardcover.py | 6 + .../release_sources/__init__.py | 4 + .../release_sources/direct_download.py | 10 +- .../release_sources/irc/source.py | 33 +- .../release_sources/prowlarr/handler.py | 29 ++ .../release_sources/prowlarr/settings.py | 60 ++- .../release_sources/prowlarr/source.py | 5 + src/frontend/src/App.tsx | 5 +- src/frontend/src/components/ReleaseModal.tsx | 257 +++++++++++- .../settings/fields/SelectField.tsx | 1 + src/frontend/src/hooks/useAuth.ts | 9 + src/frontend/src/types/index.ts | 3 + src/frontend/src/types/settings.ts | 1 + 20 files changed, 1106 insertions(+), 308 deletions(-) diff --git a/cwa_book_downloader/config/security.py b/cwa_book_downloader/config/security.py index 045f5712..6f288e54 100644 --- a/cwa_book_downloader/config/security.py +++ b/cwa_book_downloader/config/security.py @@ -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 diff --git a/cwa_book_downloader/config/settings.py b/cwa_book_downloader/config/settings.py index 6c79c1b5..0ffe4cab 100644 --- a/cwa_book_downloader/config/settings.py +++ b/cwa_book_downloader/config/settings.py @@ -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}, + ), ] diff --git a/cwa_book_downloader/core/settings_registry.py b/cwa_book_downloader/core/settings_registry.py index 1785f3b0..969f2b7c 100644 --- a/cwa_book_downloader/core/settings_registry.py +++ b/cwa_book_downloader/core/settings_registry.py @@ -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)): diff --git a/cwa_book_downloader/core/utils.py b/cwa_book_downloader/core/utils.py index 2670fad6..7ae4a363 100644 --- a/cwa_book_downloader/core/utils.py +++ b/cwa_book_downloader/core/utils.py @@ -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 diff --git a/cwa_book_downloader/download/archive.py b/cwa_book_downloader/download/archive.py index eb65cb8b..6b90f7a7 100644 --- a/cwa_book_downloader/download/archive.py +++ b/cwa_book_downloader/download/archive.py @@ -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 diff --git a/cwa_book_downloader/download/orchestrator.py b/cwa_book_downloader/download/orchestrator.py index 2271d179..c3717475 100644 --- a/cwa_book_downloader/download/orchestrator.py +++ b/cwa_book_downloader/download/orchestrator.py @@ -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 diff --git a/cwa_book_downloader/main.py b/cwa_book_downloader/main.py index 208196e4..dc95e420 100644 --- a/cwa_book_downloader/main.py +++ b/cwa_book_downloader/main.py @@ -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 }) diff --git a/cwa_book_downloader/metadata_providers/hardcover.py b/cwa_book_downloader/metadata_providers/hardcover.py index 05b218fb..e6542147 100644 --- a/cwa_book_downloader/metadata_providers/hardcover.py +++ b/cwa_book_downloader/metadata_providers/hardcover.py @@ -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) diff --git a/cwa_book_downloader/release_sources/__init__.py b/cwa_book_downloader/release_sources/__init__.py index d1794dab..81510a75 100644 --- a/cwa_book_downloader/release_sources/__init__.py +++ b/cwa_book_downloader/release_sources/__init__.py @@ -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 ], diff --git a/cwa_book_downloader/release_sources/direct_download.py b/cwa_book_downloader/release_sources/direct_download.py index 51704d5b..dffe7c2d 100644 --- a/cwa_book_downloader/release_sources/direct_download.py +++ b/cwa_book_downloader/release_sources/direct_download.py @@ -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 diff --git a/cwa_book_downloader/release_sources/irc/source.py b/cwa_book_downloader/release_sources/irc/source.py index f315a8fc..07f6491b 100644 --- a/cwa_book_downloader/release_sources/irc/source.py +++ b/cwa_book_downloader/release_sources/irc/source.py @@ -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 diff --git a/cwa_book_downloader/release_sources/prowlarr/handler.py b/cwa_book_downloader/release_sources/prowlarr/handler.py index e076c856..3e9a7452 100644 --- a/cwa_book_downloader/release_sources/prowlarr/handler.py +++ b/cwa_book_downloader/release_sources/prowlarr/handler.py @@ -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}") diff --git a/cwa_book_downloader/release_sources/prowlarr/settings.py b/cwa_book_downloader/release_sources/prowlarr/settings.py index 24f61ee7..44e4c894 100644 --- a/cwa_book_downloader/release_sources/prowlarr/settings.py +++ b/cwa_book_downloader/release_sources/prowlarr/settings.py @@ -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( diff --git a/cwa_book_downloader/release_sources/prowlarr/source.py b/cwa_book_downloader/release_sources/prowlarr/source.py index 869645cd..d0e33e81 100644 --- a/cwa_book_downloader/release_sources/prowlarr/source.py +++ b/cwa_book_downloader/release_sources/prowlarr/source.py @@ -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", diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 1e6a458f..af34b764 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -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} diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index fd132c34..2d5376cc 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -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, path: string): unknown { + return path.split('.').reduce((current, key) => { + if (current && typeof current === 'object' && key in (current as Record)) { + return (current as Record)[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, sortKey); + const bVal = getNestedSortValue(b as unknown as Record, 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(''); const [languageFilter, setLanguageFilter] = useState([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>({}); + // 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 = ({ + {/* Sort dropdown - only show if source has sortable columns */} + {sortableColumns.length > 0 && ( + ( + + )} + > + {({ close }) => ( +
+ {/* Default option - no client-side sorting */} + + {sortableColumns.map((col) => { + const sortKey = col.sort_key || col.key; + const isSelected = currentSort?.key === sortKey; + const direction = isSelected ? currentSort?.direction : null; + return ( + + ); + })} +
+ )} +
+ )} + {/* 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) || diff --git a/src/frontend/src/components/settings/fields/SelectField.tsx b/src/frontend/src/components/settings/fields/SelectField.tsx index 6cad0a20..4f4b05af 100644 --- a/src/frontend/src/components/settings/fields/SelectField.tsx +++ b/src/frontend/src/components/settings/fields/SelectField.tsx @@ -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[]) => { diff --git a/src/frontend/src/hooks/useAuth.ts b/src/frontend/src/hooks/useAuth.ts index 514bcb1d..08dfddfb 100644 --- a/src/frontend/src/hooks/useAuth.ts +++ b/src/frontend/src/hooks/useAuth.ts @@ -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(false); const [authRequired, setAuthRequired] = useState(true); const [authChecked, setAuthChecked] = useState(false); + const [isAdmin, setIsAdmin] = useState(false); const [loginError, setLoginError] = useState(null); const [isLoggingIn, setIsLoggingIn] = useState(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, diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 5e2ff72e..964543d6 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -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 diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index 2ed24bb2..4c0f1607 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -14,6 +14,7 @@ export type FieldType = export interface SelectOption { value: string; label: string; + 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 }