diff --git a/README_images/downloads.png b/README_images/downloads.png index 67993b9a..6be3e2e2 100644 Binary files a/README_images/downloads.png and b/README_images/downloads.png differ diff --git a/README_images/multi-source.png b/README_images/multi-source.png index ceb65890..3d39eaf8 100644 Binary files a/README_images/multi-source.png and b/README_images/multi-source.png differ diff --git a/README_images/search-results.png b/README_images/search-results.png index 2501ee66..b96043c4 100644 Binary files a/README_images/search-results.png and b/README_images/search-results.png differ diff --git a/docs/release-sources-plugin-guide.md b/docs/release-sources-plugin-guide.md index 55cc7bce..10039aa3 100644 --- a/docs/release-sources-plugin-guide.md +++ b/docs/release-sources-plugin-guide.md @@ -43,7 +43,7 @@ The release sources system is built around two core interfaces: - **DownloadHandler**: Executes the actual download with progress reporting This separation allows: -- Different search sources (Anna's Archive, Prowlarr, IRC, etc.) +- Different search sources (Direct Download, Prowlarr, IRC, etc.) - Different download protocols (HTTP, torrent, usenet, etc.) - Shared queue and progress infrastructure @@ -371,7 +371,7 @@ class Release: info_url: Optional[str] = None # Link to tracker/info page protocol: Optional[str] = None # "http", "torrent", "usenet" - indexer: Optional[str] = None # Display name: "Anna's Archive", "MyAnonamouse" + indexer: Optional[str] = None # Display name: "Direct Download", "My Indexer" seeders: Optional[int] = None # For torrents extra: Dict = field(default_factory=dict) # Source-specific metadata diff --git a/docs/url-search-parameters.md b/docs/url-search-parameters.md index aa5b17fe..56fde306 100644 --- a/docs/url-search-parameters.md +++ b/docs/url-search-parameters.md @@ -61,7 +61,7 @@ Some parameters support multiple values by repeating the parameter: ### Direct Download Mode (default) -All parameters are used to filter results from Anna's Archive. +All parameters are used to filter results from the direct download source. ### Universal Mode diff --git a/readme.md b/readme.md index 5c8e54c9..60d300f7 100644 --- a/readme.md +++ b/readme.md @@ -4,7 +4,7 @@ Formerly *Calibre Web Automated Book Downloader (CWABD)* Shelfmark -Shelfmark is a unified web interface for searching and downloading books and audiobooks from multiple sources - all in one place. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to create a single hub for building your digital library. +Shelfmark is a unified web interface for searching and aggregating books and audiobook downloads from multiple sources - all in one place. Works out of the box with popular web sources, no configuration required. Add metadata providers, additional release sources, and download clients to create a single hub for building your digital library. **Fully standalone** - no external dependencies required. Works great alongside library tools like [Calibre-Web-Automated](https://github.com/crocodilestick/Calibre-Web-Automated), [Booklore](https://github.com/booklore-app/booklore) or [Audiobookshelf](https://github.com/advplyr/audiobookshelf) for automatic import. @@ -15,7 +15,7 @@ Shelfmark is a unified web interface for searching and downloading books and aud - **Audiobook support** - Full audiobook search and download with dedicated processing - **Real-Time Progress** - Unified download queue with live status updates across all sources - **Two Search Modes**: - - **Direct** - Search and download books from popular web sources + - **Direct** - Search popular web sources - **Universal** - Search metadata providers (Hardcover, Open Library) for richer book and audiobook discovery, with multi-source downloads - **Cloudflare Bypass** - Built-in bypasser for reliable access to protected sources @@ -99,7 +99,7 @@ Environment variables work for initial setup and Docker deployments. They serve | `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` | Some of the additional options available in Settings: -- **AA Donator Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads +- **Fast Download Key** - Use your paid account to skip Cloudflare challenges entirely and use faster, direct downloads - **Prowlarr** - Configure indexers and download clients to download books and audiobooks - **IRC** - Add details for IRC book sources and download directly from the UI - **Library Link** - Add a link to your Calibre-Web or Booklore instance in the UI header @@ -212,7 +212,7 @@ The frontend dev server proxies to the backend on port 8084. ├───────────────────┴─────────────────────┴───────────────────┤ │ Release Sources │ ├─────────────────────────────────────────────────────────────┤ -│ • Direct Download (Anna's Archive → Libgen → Welib) │ +│ • Direct Download (Web Sources → Mirrors → Fallbacks) │ ├─────────────────────────────────────────────────────────────┤ │ Network Layer │ ├─────────────────────────────────────────────────────────────┤ diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 8e4cbc65..849e5027 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -67,7 +67,7 @@ from shelfmark.core.settings_registry import ( register_group( "direct_download", - "Anna's Archive", + "Direct Download", icon="download", order=20 ) @@ -80,7 +80,7 @@ register_group( ) -# Anna's Archive sort options (for Direct mode) +# Direct mode sort options _AA_SORT_OPTIONS = [ {"value": "relevance", "label": "Most relevant"}, {"value": "newest", "label": "Newest (publication year)"}, @@ -308,7 +308,7 @@ def search_mode_settings(): HeadingField( key="search_mode_heading", title="Search Mode", - description="Direct mode searches Anna's Archive and downloads immediately. Universal mode searches book metadata first, letting you choose from multiple release sources including Anna's Archive and Prowlarr.", + description="Direct mode searches web sources and downloads immediately. Universal mode supports Prowlarr, IRC and audiobooks with metadata-based searching.", ), SelectField( key="SEARCH_MODE", @@ -317,8 +317,8 @@ def search_mode_settings(): options=[ { "value": "direct", - "label": "Direct (Anna's Archive)", - "description": "Search Anna's Archive and download directly. Works out of the box.", + "label": "Direct", + "description": "Search web sources for books and download directly. Works out of the box.", }, { "value": "universal", @@ -331,7 +331,7 @@ def search_mode_settings(): SelectField( key="AA_DEFAULT_SORT", label="Default Sort Order", - description="Default sort order for Anna's Archive search results.", + description="Default sort order for search results.", options=_AA_SORT_OPTIONS, default="relevance", env_supported=False, # UI-only setting @@ -667,11 +667,11 @@ def _get_fast_source_options(): return [ { "id": "aa-fast", - "label": "Anna's Archive (Fast)", + "label": "AA Fast Downloads", "description": "Fast downloads for donators", "isPinned": True, "isLocked": not has_donator_key, - "disabledReason": "Requires AA Donator Key" if not has_donator_key else None, + "disabledReason": "Requires Donator Key" if not has_donator_key else None, }, { "id": "libgen", @@ -701,14 +701,14 @@ def _get_slow_source_options(): return [ { "id": "aa-slow-nowait", - "label": "Anna's Archive (Slowest, No Waitlist)", + "label": "AA Slow Downloads (No Waitlist)", "description": "Partner servers", "isLocked": locked, "disabledReason": disabled_reason, }, { "id": "aa-slow-wait", - "label": "Anna's Archive (Slow with Waitlist)", + "label": "AA Slow Downloads (Waitlist)", "description": "Partner servers with countdown timer", "isLocked": locked, "disabledReason": disabled_reason, @@ -722,7 +722,7 @@ def _get_slow_source_options(): }, { "id": "zlib", - "label": "Z-Library", + "label": "Zlib", "description": "Alternative mirror", "isLocked": locked, "disabledReason": disabled_reason, @@ -748,8 +748,8 @@ def download_source_settings(): return [ PasswordField( key="AA_DONATOR_KEY", - label="Anna's Archive Donator Key", - description="Enables fast downloads from Anna's Archive.", + label="Account Donator Key", + description="Enables fast download access on AA. Get this from your donator account page.", ), HeadingField( key="source_priority_heading", @@ -790,12 +790,12 @@ def download_source_settings(): 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.", + description="Route downloads to different folders based on content type. Only applies to Direct download source.", ), CheckboxField( key="AA_CONTENT_TYPE_ROUTING", label="Enable Content-Type Routing", - description="Override destination based on Anna's Archive content type metadata.", + description="Override destination based on content type metadata.", default=False, ), TextField( @@ -904,10 +904,10 @@ def mirror_settings(): from shelfmark.core.mirrors import DEFAULT_ZLIB_MIRRORS, DEFAULT_WELIB_MIRRORS return [ - # === ANNA'S ARCHIVE === + # === PRIMARY SOURCE === HeadingField( key="aa_mirrors_heading", - title="Anna's Archive", + title="Primary Source", description="Primary mirror with auto-probe on startup. Additional mirrors used as fallback.", ), SelectField( @@ -920,7 +920,7 @@ def mirror_settings(): TextField( key="AA_ADDITIONAL_URLS", label="Additional Mirrors", - description="Comma-separated list of custom Anna's Archive mirror URLs.", + description="Comma-separated list of custom mirror URLs.", ), # === LIBGEN === diff --git a/shelfmark/core/onboarding.py b/shelfmark/core/onboarding.py new file mode 100644 index 00000000..b2e7d018 --- /dev/null +++ b/shelfmark/core/onboarding.py @@ -0,0 +1,448 @@ +""" +Onboarding wizard configuration. + +Defines the steps and fields for the first-run onboarding experience. +Reuses field definitions from the settings registry where possible. +""" + +import json +from dataclasses import replace +from pathlib import Path +from typing import Any, Dict, List, Optional + +from shelfmark.core.logger import setup_logger +from shelfmark.core.settings_registry import ( + HeadingField, + SettingsField, + get_settings_tab, + serialize_field, + save_config_file, + get_setting_value, +) + +logger = setup_logger(__name__) + + +ONBOARDING_STORAGE_KEY = "onboarding_complete" + + +def _get_config_dir() -> Path: + """Get the config directory path.""" + from shelfmark.config.env import CONFIG_DIR + return Path(CONFIG_DIR) + + +def is_onboarding_complete() -> bool: + """ + Check if onboarding has been completed. + + For existing users updating from a version without onboarding, + we check if they have existing settings configured - if so, + we consider onboarding complete and mark it as such. + """ + config_file = _get_config_dir() / "settings.json" + if not config_file.exists(): + return False + + try: + with open(config_file, 'r') as f: + config = json.load(f) + + # Explicit flag set - they've completed onboarding + if config.get(ONBOARDING_STORAGE_KEY, False): + return True + + # Check if this is an existing user (has other settings configured) + # If settings.json has any keys besides onboarding_complete, they're existing + other_keys = [k for k in config.keys() if k != ONBOARDING_STORAGE_KEY] + if other_keys: + # Existing user - auto-mark onboarding complete + logger.info(f"Existing user detected (has settings: {other_keys}), marking onboarding complete") + mark_onboarding_complete() + return True + + return False + except (json.JSONDecodeError, OSError): + return False + + +def mark_onboarding_complete() -> bool: + """Mark onboarding as complete.""" + try: + return save_config_file("general", {ONBOARDING_STORAGE_KEY: True}) + except Exception as e: + logger.error(f"Failed to mark onboarding complete: {e}") + return False + + +def _get_field_from_tab(tab_name: str, field_key: str) -> Optional[SettingsField]: + """ + Extract a specific field from a registered settings tab. + + Args: + tab_name: Name of the settings tab (e.g., 'search_mode', 'hardcover') + field_key: Key of the field to extract (e.g., 'SEARCH_MODE', 'HARDCOVER_API_KEY') + + Returns: + The field if found, None otherwise + """ + tab = get_settings_tab(tab_name) + if not tab: + logger.warning(f"Settings tab not found: {tab_name}") + return None + + for field in tab.fields: + if hasattr(field, 'key') and field.key == field_key: + return field + + logger.warning(f"Field {field_key} not found in tab {tab_name}") + return None + + +def _clone_field_with_overrides(field: SettingsField, **overrides) -> SettingsField: + """ + Clone a field with optional attribute overrides. + + Useful for customizing labels, descriptions, or defaults for onboarding context. + """ + return replace(field, **overrides) + + +# ============================================================================= +# Step Definitions +# ============================================================================= + + +def get_search_mode_fields() -> List[SettingsField]: + """Step 1: Choose search mode - uses actual SEARCH_MODE field from settings.""" + fields: List[SettingsField] = [ + HeadingField( + key="welcome_heading", + title="Welcome to Shelfmark", + description="Let's configure how you want to search for and download books.", + ), + ] + + # Get the actual SEARCH_MODE field from settings + search_mode_field = _get_field_from_tab("search_mode", "SEARCH_MODE") + if search_mode_field: + # Clone with onboarding-specific description + fields.append(_clone_field_with_overrides( + search_mode_field, + description="Choose how you want to find books.", + )) + + return fields + + +def get_metadata_provider_fields() -> List[SettingsField]: + """Step 2: Choose metadata provider - uses actual METADATA_PROVIDER field.""" + fields: List[SettingsField] = [ + HeadingField( + key="metadata_heading", + title="Metadata Provider", + description="Choose where to search for book information. You can enable more providers in Settings later.", + ), + ] + + # Get the actual METADATA_PROVIDER field from settings + provider_field = _get_field_from_tab("search_mode", "METADATA_PROVIDER") + if provider_field: + # Custom options with Hardcover marked as recommended + onboarding_options = [ + { + "value": "hardcover", + "label": "Hardcover (Recommended)", + "description": "Modern book tracking platform with excellent metadata, ratings, and series information. Requires free API key.", + }, + { + "value": "openlibrary", + "label": "Open Library", + "description": "Free, open-source library catalog from the Internet Archive. No API key required.", + }, + { + "value": "googlebooks", + "label": "Google Books", + "description": "Google's book database with good coverage. Requires free API key.", + }, + ] + + # Clone with onboarding-specific options and default + fields.append(_clone_field_with_overrides( + provider_field, + default="hardcover", + options=onboarding_options, + )) + + return fields + + +def get_hardcover_setup_fields() -> List[SettingsField]: + """Step 3a: Configure Hardcover - uses actual API key and test connection fields.""" + fields: List[SettingsField] = [ + HeadingField( + key="hardcover_setup_heading", + title="Hardcover Setup", + description="Get your free API key from hardcover.app/account/api", + link_url="https://hardcover.app/account/api", + link_text="Get API Key", + ), + ] + + # Get the actual HARDCOVER_API_KEY field + api_key_field = _get_field_from_tab("hardcover", "HARDCOVER_API_KEY") + if api_key_field: + fields.append(api_key_field) + + # Get the test connection button + test_button = _get_field_from_tab("hardcover", "test_connection") + if test_button: + fields.append(test_button) + + return fields + + +def get_googlebooks_setup_fields() -> List[SettingsField]: + """Step 3b: Configure Google Books - uses actual API key and test connection fields.""" + fields: List[SettingsField] = [ + HeadingField( + key="googlebooks_setup_heading", + title="Google Books Setup", + description="Get your free API key from Google Cloud Console (APIs & Services > Credentials).", + link_url="https://console.cloud.google.com/apis/library/books.googleapis.com", + link_text="Get API Key", + ), + ] + + # Get the actual GOOGLEBOOKS_API_KEY field + api_key_field = _get_field_from_tab("googlebooks", "GOOGLEBOOKS_API_KEY") + if api_key_field: + fields.append(api_key_field) + + # Get the test connection button + test_button = _get_field_from_tab("googlebooks", "test_connection") + if test_button: + fields.append(test_button) + + return fields + + +def get_prowlarr_fields() -> List[SettingsField]: + """Step 4: Configure Prowlarr connection - uses actual Prowlarr fields.""" + fields: List[SettingsField] = [ + HeadingField( + key="prowlarr_heading", + title="Prowlarr Integration (Optional)", + description="Connect to Prowlarr to search your indexers for torrents and NZBs. Skip this step if you only want to use Direct Download.", + ), + ] + + # Get actual Prowlarr connection fields + prowlarr_fields = ["PROWLARR_ENABLED", "PROWLARR_URL", "PROWLARR_API_KEY", "test_prowlarr"] + for field_key in prowlarr_fields: + field = _get_field_from_tab("prowlarr_config", field_key) + if field: + fields.append(field) + + return fields + + +def get_prowlarr_indexers_fields() -> List[SettingsField]: + """Step 5: Select Prowlarr indexers to search.""" + fields: List[SettingsField] = [ + HeadingField( + key="prowlarr_indexers_heading", + title="Select Indexers", + description="Choose which indexers to search for books. Leave empty to search all available indexers.", + ), + ] + + # Get the indexers multi-select field + indexers_field = _get_field_from_tab("prowlarr_config", "PROWLARR_INDEXERS") + if indexers_field: + fields.append(indexers_field) + + return fields + + +# ============================================================================= +# Step Configuration +# ============================================================================= + + +ONBOARDING_STEPS = [ + { + "id": "search_mode", + "title": "Search Mode", + "tab": "search_mode", + "get_fields": get_search_mode_fields, + }, + { + "id": "metadata_provider", + "title": "Metadata Provider", + "tab": "search_mode", + "get_fields": get_metadata_provider_fields, + "show_when": [{"field": "SEARCH_MODE", "value": "universal"}], + }, + { + "id": "hardcover_setup", + "title": "Hardcover Setup", + "tab": "hardcover", + "get_fields": get_hardcover_setup_fields, + # Must be universal mode AND hardcover selected + "show_when": [ + {"field": "SEARCH_MODE", "value": "universal"}, + {"field": "METADATA_PROVIDER", "value": "hardcover"}, + ], + }, + { + "id": "googlebooks_setup", + "title": "Google Books Setup", + "tab": "googlebooks", + "get_fields": get_googlebooks_setup_fields, + # Must be universal mode AND googlebooks selected + "show_when": [ + {"field": "SEARCH_MODE", "value": "universal"}, + {"field": "METADATA_PROVIDER", "value": "googlebooks"}, + ], + }, + { + "id": "prowlarr", + "title": "Prowlarr", + "tab": "prowlarr_config", + "get_fields": get_prowlarr_fields, + "show_when": [{"field": "SEARCH_MODE", "value": "universal"}], + "optional": True, + }, + { + "id": "prowlarr_indexers", + "title": "Indexers", + "tab": "prowlarr_config", + "get_fields": get_prowlarr_indexers_fields, + # Only show when Prowlarr is enabled + "show_when": [ + {"field": "SEARCH_MODE", "value": "universal"}, + {"field": "PROWLARR_ENABLED", "value": True}, + ], + "optional": True, + }, +] + + +def get_onboarding_config() -> Dict[str, Any]: + """ + Get the full onboarding configuration including steps and current values. + """ + steps = [] + all_values = {} + + for step_config in ONBOARDING_STEPS: + fields = step_config["get_fields"]() + tab_name = step_config["tab"] + + # Serialize fields with current values + serialized_fields = [] + for field in fields: + serialized = serialize_field(field, tab_name, include_value=True) + serialized_fields.append(serialized) + + # Collect values (skip HeadingFields) + if hasattr(field, 'key') and field.key and not isinstance(field, HeadingField): + value = get_setting_value(field, tab_name) + all_values[field.key] = value if value is not None else getattr(field, 'default', '') + + step = { + "id": step_config["id"], + "title": step_config["title"], + "tab": tab_name, + "fields": serialized_fields, + } + + if "show_when" in step_config: + step["showWhen"] = step_config["show_when"] + if step_config.get("optional"): + step["optional"] = True + + steps.append(step) + + return { + "steps": steps, + "values": all_values, + "complete": is_onboarding_complete(), + } + + +def save_onboarding_settings(values: Dict[str, Any]) -> Dict[str, Any]: + """ + Save onboarding settings and mark as complete. + + Args: + values: Dict of field key -> value + + Returns: + Dict with success status and message + """ + try: + # Group values by their target tab + tab_values: Dict[str, Dict[str, Any]] = {} + + for step_config in ONBOARDING_STEPS: + tab_name = step_config["tab"] + fields = step_config["get_fields"]() + + for field in fields: + if isinstance(field, HeadingField): + continue + + key = field.key + if key in values: + if tab_name not in tab_values: + tab_values[tab_name] = {} + tab_values[tab_name][key] = values[key] + + # Save each tab's values + for tab_name, tab_data in tab_values.items(): + if tab_data: + save_config_file(tab_name, tab_data) + logger.info(f"Saved onboarding settings to {tab_name}: {list(tab_data.keys())}") + + # Enable the selected metadata provider + search_mode = values.get("SEARCH_MODE", "direct") + if search_mode == "universal": + provider = values.get("METADATA_PROVIDER", "hardcover") + if provider: + # Map provider name to its enabled key + enabled_key_map = { + "hardcover": "HARDCOVER_ENABLED", + "openlibrary": "OPENLIBRARY_ENABLED", + "googlebooks": "GOOGLEBOOKS_ENABLED", + } + enabled_key = enabled_key_map.get(provider, f"{provider.upper()}_ENABLED") + + # Get existing provider config and add enabled flag + provider_config = {enabled_key: True} + + # Include API key if provided for that provider + if provider == "hardcover" and values.get("HARDCOVER_API_KEY"): + provider_config["HARDCOVER_API_KEY"] = values["HARDCOVER_API_KEY"] + elif provider == "googlebooks" and values.get("GOOGLEBOOKS_API_KEY"): + provider_config["GOOGLEBOOKS_API_KEY"] = values["GOOGLEBOOKS_API_KEY"] + + save_config_file(provider, provider_config) + logger.info(f"Enabled metadata provider: {provider} with keys: {list(provider_config.keys())}") + + # Mark onboarding as complete + mark_onboarding_complete() + + # Refresh config + try: + from shelfmark.core.config import config + config.refresh() + except ImportError: + pass + + return {"success": True, "message": "Onboarding complete!"} + + except Exception as e: + logger.error(f"Failed to save onboarding settings: {e}") + return {"success": False, "message": str(e)} diff --git a/shelfmark/main.py b/shelfmark/main.py index 68a0ee3e..9603a8c7 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -20,8 +20,8 @@ from shelfmark.download import orchestrator as backend from shelfmark.release_sources.direct_download import SearchUnavailable from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE from shelfmark.config.env import ( - BUILD_VERSION, CWA_DB_PATH, DEBUG, FLASK_HOST, FLASK_PORT, - RELEASE_VERSION, + BUILD_VERSION, CONFIG_DIR, CWA_DB_PATH, DEBUG, FLASK_HOST, FLASK_PORT, + RELEASE_VERSION, _is_config_dir_writable, ) from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger @@ -492,6 +492,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: get_provider_default_sort, ) from shelfmark.config.env import _is_config_dir_writable + from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete config = { "calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""), @@ -509,6 +510,7 @@ def api_config() -> Union[Response, Tuple[Response, int]]: "auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True), "download_to_browser": app_config.get("DOWNLOAD_TO_BROWSER", False), "settings_enabled": _is_config_dir_writable(), + "onboarding_complete": _get_onboarding_complete(), # Default sort orders "default_sort": app_config.get("AA_DEFAULT_SORT", "relevance"), # For direct mode (Anna's Archive) "metadata_default_sort": get_provider_default_sort(), # For universal mode @@ -1536,6 +1538,85 @@ def api_settings_execute_action(tab_name: str, action_key: str) -> Union[Respons return jsonify({"error": str(e)}), 500 +# ============================================================================= +# Onboarding API +# ============================================================================= + + +@app.route('/api/onboarding', methods=['GET']) +@login_required +def api_onboarding_get() -> Union[Response, Tuple[Response, int]]: + """ + Get onboarding configuration including steps, fields, and current values. + + Returns: + flask.Response: JSON with onboarding steps and values. + """ + try: + from shelfmark.core.onboarding import get_onboarding_config + + # Ensure settings are registered + import shelfmark.config.settings # noqa: F401 + + config = get_onboarding_config() + return jsonify(config) + except Exception as e: + logger.error_trace(f"Onboarding get error: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/onboarding', methods=['POST']) +@login_required +def api_onboarding_save() -> Union[Response, Tuple[Response, int]]: + """ + Save onboarding settings and mark as complete. + + Request Body: + JSON object with all onboarding field values + + Returns: + flask.Response: JSON with success/error status. + """ + try: + from shelfmark.core.onboarding import save_onboarding_settings + + # Ensure settings are registered + import shelfmark.config.settings # noqa: F401 + + data = request.get_json() + if not data: + return jsonify({"success": False, "message": "No data provided"}), 400 + + result = save_onboarding_settings(data) + + if result["success"]: + return jsonify(result) + else: + return jsonify(result), 400 + except Exception as e: + logger.error_trace(f"Onboarding save error: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/onboarding/skip', methods=['POST']) +@login_required +def api_onboarding_skip() -> Union[Response, Tuple[Response, int]]: + """ + Skip onboarding and mark as complete without saving any settings. + + Returns: + flask.Response: JSON with success status. + """ + try: + from shelfmark.core.onboarding import mark_onboarding_complete + + mark_onboarding_complete() + return jsonify({"success": True, "message": "Onboarding skipped"}) + except Exception as e: + logger.error_trace(f"Onboarding skip error: {e}") + return jsonify({"error": str(e)}), 500 + + # Catch-all route for React Router (must be last) # This handles client-side routing by serving index.html for any unmatched routes @app.route('/') @@ -1587,6 +1668,13 @@ def handle_status_request(): logger.log_resource_usage() +# Warn if config directory is not writable (settings won't persist) +if not _is_config_dir_writable(): + logger.warning( + f"Config directory {CONFIG_DIR} is not writable. Settings will not persist. " + "Mount a config volume to enable settings persistence (see docs for details)." + ) + if __name__ == '__main__': logger.info(f"Starting Flask application with WebSocket support on {FLASK_HOST}:{FLASK_PORT} (debug={DEBUG})") socketio.run( diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index 708a4467..c88f12c8 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -191,7 +191,7 @@ def search_books(query: str, filters: SearchFilters) -> List[BookInfo]: html = downloader.html_get_page(url, selector=selector) if not html: # Network/mirror exhaustion path bubbles up so API can notify clients - raise SearchUnavailable("Unable to reach Anna's Archive. Network restricted or mirrors are blocked.") + raise SearchUnavailable("Unable to reach download source. Network restricted or mirrors are blocked.") if "No files found." in html: logger.info(f"No books found for query: {query}") @@ -1053,7 +1053,7 @@ def _book_info_to_release(book_info: BookInfo) -> Release: download_url=book_info.download_urls[0] if book_info.download_urls else None, info_url=f"{network.get_aa_base_url()}/md5/{book_info.id}", protocol=ReleaseProtocol.HTTP, - indexer="Anna's Archive", + indexer="Direct Download", content_type=book_info.content, # Preserve content type from source extra={ "author": book_info.author, @@ -1071,13 +1071,13 @@ def _book_info_to_release(book_info: BookInfo) -> Release: @register_source("direct_download") class DirectDownloadSource(ReleaseSource): """ - Direct download source - searches Anna's Archive, Libgen, etc. + Direct download source - searches web sources for books. This wraps the search_books() functionality to provide releases via the plugin interface. """ name = "direct_download" - display_name = "Anna's Archive" + display_name = "Direct Download" supported_content_types = ["ebook"] # Direct downloads only support ebooks def __init__(self): diff --git a/shelfmark/release_sources/irc/client.py b/shelfmark/release_sources/irc/client.py index dd1ff598..51f78b52 100644 --- a/shelfmark/release_sources/irc/client.py +++ b/shelfmark/release_sources/irc/client.py @@ -120,12 +120,50 @@ class IRCClient: self._send(f"USER {self.nick} 0 * :{self.nick}") self._send(f"NICK {self.nick}") - # Wait for server to process welcome messages - logger.debug(f"Waiting {POST_CONNECT_DELAY}s for server welcome") - time.sleep(POST_CONNECT_DELAY) + # Wait for 001 (RPL_WELCOME) which confirms registration is complete + # Server may take time for hostname lookup, ident check, etc. + logger.debug("Waiting for server welcome (001)...") + self._socket.settimeout(2.0) # Short timeout for polling - self._connected = True - logger.info(f"Connected as {self.nick}") + start = time.time() + timeout = 30.0 # Max wait for registration + + while time.time() - start < timeout: + try: + data = self._socket.recv(RECV_BUFFER) + if not data: + raise IRCConnectionError("Connection closed during registration") + self._buffer += data.decode('utf-8', errors='replace') + except socket.timeout: + continue + + # Process lines looking for 001 or errors + while '\r\n' in self._buffer: + line, self._buffer = self._buffer.split('\r\n', 1) + if not line: + continue + + # Handle PING during registration + if line.startswith("PING"): + pong = line.replace("PING", "PONG", 1) + self._send(pong) + logger.debug(f"PONG {pong.split(':')[-1] if ':' in pong else ''}") + continue + + # 001 = RPL_WELCOME - registration complete + if " 001 " in line: + self._socket.settimeout(SOCKET_TIMEOUT) # Restore timeout + self._connected = True + logger.info(f"Connected as {self.nick}") + return + + # Check for fatal errors + if " 433 " in line: # Nickname in use + raise IRCConnectionError("Nickname already in use") + if " 432 " in line: # Erroneous nickname + raise IRCConnectionError("Invalid nickname") + + raise IRCConnectionError("Timeout waiting for server welcome") def disconnect(self) -> None: """Gracefully disconnect from server.""" @@ -153,38 +191,58 @@ class IRCClient: self.online_servers.clear() if wait_for_join: - # Wait for end of NAMES list (366) which confirms we're in the channel - start = time.time() - timeout = 10.0 # 10 seconds should be plenty + # Use a short socket timeout during join so we can check elapsed time + original_timeout = self._socket.gettimeout() + self._socket.settimeout(2.0) # 2 second recv timeout - for line in self._recv_lines(): - if time.time() - start > timeout: - logger.warning(f"Timeout waiting for JOIN confirmation on #{channel}") - break + try: + start = time.time() + timeout = 15.0 # Total wait time for join - msg = self._parse_message(line) + while time.time() - start < timeout: + # Read data with short timeout + try: + data = self._socket.recv(RECV_BUFFER) + if not data: + break + self._buffer += data.decode('utf-8', errors='replace') + except socket.timeout: + continue # No data yet, check time and retry - # Handle PING during join wait - if msg.event == IRCEvent.PING: - self._handle_ping(msg) - continue + # Process any complete lines in buffer + while '\r\n' in self._buffer: + line, self._buffer = self._buffer.split('\r\n', 1) + if not line: + continue - # 353 = RPL_NAMREPLY - parse the names list - if msg.command == "353": - self._parse_names_list(msg.raw) - continue + msg = self._parse_message(line) + logger.debug(f"JOIN wait recv: {msg.command} - {line[:80]}") - # 366 = RPL_ENDOFNAMES - channel join is complete - if msg.command == "366": - logger.info(f"Joined #{channel} - {len(self.online_servers)} servers online") - return + # Handle PING during join wait + if msg.event == IRCEvent.PING: + self._handle_ping(msg) + continue - # Check for errors (e.g., banned, channel doesn't exist) - if msg.command in ("473", "474", "475", "403"): - logger.error(f"Cannot join #{channel}: {msg.trailing}") - return + # 353 = RPL_NAMREPLY - parse the names list + if msg.command == "353": + self._parse_names_list(msg.raw) + continue - logger.warning(f"Joined #{channel} (no confirmation received)") + # 366 = RPL_ENDOFNAMES - channel join is complete + if msg.command == "366": + logger.info(f"Joined #{channel} - {len(self.online_servers)} servers online") + return + + # Check for errors (e.g., banned, channel doesn't exist) + if msg.command in ("473", "474", "475", "403"): + logger.error(f"Cannot join #{channel}: {msg.trailing}") + return + + logger.warning(f"Timeout waiting for JOIN confirmation on #{channel}") + + finally: + # Restore original socket timeout + self._socket.settimeout(original_timeout) def send_message(self, target: str, message: str) -> None: """Send a PRIVMSG to a channel or user.""" diff --git a/shelfmark/release_sources/irc/connection_manager.py b/shelfmark/release_sources/irc/connection_manager.py new file mode 100644 index 00000000..674faad5 --- /dev/null +++ b/shelfmark/release_sources/irc/connection_manager.py @@ -0,0 +1,207 @@ +"""IRC connection manager. + +Maintains persistent IRC connections to avoid reconnecting between search and download. +""" + +import threading +import time +from typing import Optional + +from shelfmark.core.logger import setup_logger + +from .client import IRCClient + +logger = setup_logger(__name__) + +# How long to keep an idle connection before closing it +IDLE_TIMEOUT = 300.0 # 5 minutes + + +class IRCConnectionManager: + """Manages persistent IRC connections. + + Keeps connections alive between search and download operations to avoid + the overhead of reconnecting. Connections are automatically closed after + being idle for IDLE_TIMEOUT seconds. + """ + + _instance: Optional["IRCConnectionManager"] = None + _lock = threading.Lock() + + def __new__(cls) -> "IRCConnectionManager": + """Singleton pattern - only one connection manager.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + + self._connections: dict[str, IRCClient] = {} + self._last_used: dict[str, float] = {} + self._channels: dict[str, str] = {} # connection_key -> joined channel + self._conn_lock = threading.Lock() + self._cleanup_thread: Optional[threading.Thread] = None + self._running = True + self._initialized = True + + # Start background cleanup thread + self._start_cleanup_thread() + + def _connection_key(self, server: str, port: int, nick: str) -> str: + """Generate a unique key for a connection.""" + return f"{server}:{port}:{nick}" + + def _start_cleanup_thread(self) -> None: + """Start background thread to clean up idle connections.""" + def cleanup_loop(): + while self._running: + time.sleep(30) # Check every 30 seconds + self._cleanup_idle_connections() + + self._cleanup_thread = threading.Thread(target=cleanup_loop, daemon=True) + self._cleanup_thread.start() + + def _cleanup_idle_connections(self) -> None: + """Close connections that have been idle too long.""" + now = time.time() + to_remove = [] + + with self._conn_lock: + for key, last_used in list(self._last_used.items()): + if now - last_used > IDLE_TIMEOUT: + to_remove.append(key) + + for key in to_remove: + client = self._connections.pop(key, None) + self._last_used.pop(key, None) + self._channels.pop(key, None) + + if client: + logger.info(f"Closing idle IRC connection: {key}") + try: + client.disconnect() + except Exception as e: + logger.debug(f"Error closing idle connection: {e}") + + def get_connection( + self, + server: str, + port: int, + nick: str, + use_tls: bool, + channel: str, + ) -> IRCClient: + """Get or create an IRC connection. + + If an existing connection to the same server/port/nick exists and is + still connected, it will be reused. Otherwise, a new connection is created. + + Args: + server: IRC server hostname + port: IRC server port + nick: IRC nickname + use_tls: Whether to use TLS + channel: Channel to join (without # prefix) + + Returns: + Connected IRCClient instance that has joined the channel + """ + key = self._connection_key(server, port, nick) + + with self._conn_lock: + # Check for existing connection + existing = self._connections.get(key) + + if existing and existing.is_connected: + logger.info(f"Reusing existing IRC connection to {server}") + self._last_used[key] = time.time() + + # Check if we need to join a different channel + current_channel = self._channels.get(key) + if current_channel != channel: + logger.debug(f"Joining channel #{channel}") + existing.join_channel(channel) + self._channels[key] = channel + + return existing + + # Clean up dead connection if it exists + if existing: + logger.debug(f"Removing dead connection: {key}") + self._connections.pop(key, None) + self._last_used.pop(key, None) + self._channels.pop(key, None) + try: + existing.disconnect() + except Exception: + pass + + # Create new connection + logger.info(f"Creating new IRC connection to {server}:{port}") + client = IRCClient(nick, server, port, use_tls=use_tls) + client.connect() + client.join_channel(channel) + + # Store connection + self._connections[key] = client + self._last_used[key] = time.time() + self._channels[key] = channel + + return client + + def release_connection(self, client: IRCClient) -> None: + """Mark a connection as available for reuse. + + This updates the last-used timestamp to prevent premature cleanup. + The connection stays open for potential reuse. + """ + key = self._connection_key(client.server, client.port, client.nick) + + with self._conn_lock: + if key in self._connections: + self._last_used[key] = time.time() + logger.debug(f"Released IRC connection for reuse: {key}") + + def close_connection(self, client: IRCClient) -> None: + """Explicitly close a connection (e.g., on error). + + Use this when you want to force-close a connection rather than + releasing it for reuse. + """ + key = self._connection_key(client.server, client.port, client.nick) + + with self._conn_lock: + self._connections.pop(key, None) + self._last_used.pop(key, None) + self._channels.pop(key, None) + + try: + client.disconnect() + except Exception as e: + logger.debug(f"Error closing connection: {e}") + + logger.debug(f"Closed IRC connection: {key}") + + def close_all(self) -> None: + """Close all connections (for shutdown).""" + with self._conn_lock: + for key, client in list(self._connections.items()): + try: + client.disconnect() + except Exception as e: + logger.debug(f"Error closing connection {key}: {e}") + + self._connections.clear() + self._last_used.clear() + self._channels.clear() + + logger.info("Closed all IRC connections") + + +# Global singleton instance +connection_manager = IRCConnectionManager() diff --git a/shelfmark/release_sources/irc/handler.py b/shelfmark/release_sources/irc/handler.py index 4424db0d..83c38805 100644 --- a/shelfmark/release_sources/irc/handler.py +++ b/shelfmark/release_sources/irc/handler.py @@ -12,7 +12,7 @@ from shelfmark.core.logger import setup_logger from shelfmark.core.models import DownloadTask from shelfmark.release_sources import DownloadHandler, register_handler -from .client import IRCClient +from .connection_manager import connection_manager from .dcc import DCCError, download_dcc logger = setup_logger(__name__) @@ -36,6 +36,7 @@ class IRCDownloadHandler(DownloadHandler): # Get IRC settings server = config.get("IRC_SERVER", "") port = config.get("IRC_PORT", 6697) + use_tls = config.get("IRC_USE_TLS", True) channel = config.get("IRC_CHANNEL", "") nick = config.get("IRC_NICK", "") @@ -51,20 +52,24 @@ class IRCDownloadHandler(DownloadHandler): if not cancel_flag.is_set(): return False if client: - client.disconnect() + connection_manager.close_connection(client) status_callback("cancelled", "Cancelled") return True try: - # Phase 1: Connect to IRC + # Phase 1: Get or reuse IRC connection status_callback("resolving", f"Connecting to {server}") if check_cancelled(): return None - client = IRCClient(nick, server, port) - client.connect() - client.join_channel(channel) + client = connection_manager.get_connection( + server=server, + port=port, + nick=nick, + use_tls=use_tls, + channel=channel, + ) # Phase 2: Send download request status_callback("resolving", "Requesting file from bot") @@ -82,7 +87,7 @@ class IRCDownloadHandler(DownloadHandler): if not offer: status_callback("error", "No response from bot") - client.disconnect() + connection_manager.release_connection(client) return None if check_cancelled(): @@ -106,7 +111,8 @@ class IRCDownloadHandler(DownloadHandler): timeout=60.0, ) - client.disconnect() + # Release connection for reuse (don't close it) + connection_manager.release_connection(client) if cancel_flag.is_set(): # Clean up partial download @@ -121,14 +127,14 @@ class IRCDownloadHandler(DownloadHandler): logger.error(f"DCC error: {e}") status_callback("error", str(e)) if client: - client.disconnect() + connection_manager.close_connection(client) return None except Exception as e: logger.error(f"Download failed: {e}") status_callback("error", f"Download failed: {e}") if client: - client.disconnect() + connection_manager.close_connection(client) return None def cancel(self, task_id: str) -> bool: diff --git a/shelfmark/release_sources/irc/settings.py b/shelfmark/release_sources/irc/settings.py index f3fc7265..23d098c8 100644 --- a/shelfmark/release_sources/irc/settings.py +++ b/shelfmark/release_sources/irc/settings.py @@ -5,6 +5,7 @@ Registers IRC settings for the settings UI. from shelfmark.core.settings_registry import ( ActionButton, + CheckboxField, HeadingField, NumberField, SelectField, @@ -63,6 +64,14 @@ def irc_settings(): env_supported=True, ), + CheckboxField( + key="IRC_USE_TLS", + label="Use TLS", + default=True, + description="Enable TLS/SSL encryption for the IRC connection. Disable for servers that don't support TLS.", + env_supported=True, + ), + TextField( key="IRC_CHANNEL", label="Channel", diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index ed41a9a3..98f84bde 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -26,7 +26,7 @@ from shelfmark.release_sources import ( register_source, ) -from .client import IRCClient +from .connection_manager import connection_manager from .dcc import DCCError, download_dcc from .parser import SearchResult, extract_results_from_zip, parse_results_file @@ -159,19 +159,22 @@ class IRCReleaseSource(ReleaseSource): # Get IRC settings server = config.get("IRC_SERVER", "") port = config.get("IRC_PORT", 6697) + use_tls = config.get("IRC_USE_TLS", True) channel = config.get("IRC_CHANNEL", "") nick = config.get("IRC_NICK", "") search_bot = config.get("IRC_SEARCH_BOT", "") client = None try: - # Connect to IRC + # Get or reuse IRC connection _emit_status(f"Connecting to {server}...", phase='connecting') - client = IRCClient(nick, server, port) - client.connect() - - _emit_status(f"Joining #{channel}...", phase='connecting') - client.join_channel(channel) + client = connection_manager.get_connection( + server=server, + port=port, + nick=nick, + use_tls=use_tls, + channel=channel, + ) # Capture online servers (elevated users in channel) self._online_servers = client.online_servers @@ -186,7 +189,8 @@ class IRCReleaseSource(ReleaseSource): if not offer: logger.info("No search results received") _emit_status("No results found", phase='complete') - client.disconnect() + # Release connection for reuse (don't close it) + connection_manager.release_connection(client) # Cache empty result to avoid repeated failed searches cache_results( book.provider, @@ -209,7 +213,8 @@ class IRCReleaseSource(ReleaseSource): else: content = result_path.read_text(errors='replace') - client.disconnect() + # Release connection for reuse (don't close it) + connection_manager.release_connection(client) # Convert to Release objects results = parse_results_file(content) @@ -230,13 +235,13 @@ class IRCReleaseSource(ReleaseSource): logger.error(f"DCC error during search: {e}") _emit_status(f"DCC error: {e}", phase='error') if client: - client.disconnect() + connection_manager.close_connection(client) return [] except Exception as e: logger.error(f"IRC search failed: {e}") _emit_status(f"Search failed: {e}", phase='error') if client: - client.disconnect() + connection_manager.close_connection(client) return [] def _build_query(self, book: BookMetadata) -> str: diff --git a/shelfmark/release_sources/prowlarr/clients/deluge.py b/shelfmark/release_sources/prowlarr/clients/deluge.py index 2f5d918b..349e0df5 100644 --- a/shelfmark/release_sources/prowlarr/clients/deluge.py +++ b/shelfmark/release_sources/prowlarr/clients/deluge.py @@ -184,7 +184,8 @@ class DelugeClient(DownloadClient): deluge_state = _decode(status.get(b'state', b'Unknown')) state, message = state_map.get(deluge_state, ('unknown', deluge_state)) progress = status.get(b'progress', 0) - complete = progress >= 100 + # Don't mark complete while files are being moved + complete = progress >= 100 and deluge_state != 'Moving' if complete: message = "Complete" diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py index 16d81313..c315056b 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py @@ -212,7 +212,9 @@ class QBittorrentClient(DownloadClient): } state, message = state_info.get(torrent.state, ("unknown", torrent.state)) - complete = torrent.progress >= 1.0 + # Don't mark complete while files are being moved to final location + # (qBittorrent moves files from incomplete → complete folder) + complete = torrent.progress >= 1.0 and torrent.state != "moving" # For active downloads without a special message, leave message as None # so the handler can build the progress message diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 8d8920b0..665e5388 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -26,6 +26,7 @@ import { Footer } from './components/Footer'; import { LoginPage } from './pages/LoginPage'; import { SettingsModal } from './components/settings'; import { ConfigSetupBanner } from './components/ConfigSetupBanner'; +import { OnboardingModal } from './components/OnboardingModal'; import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages'; import { buildSearchQuery } from './utils/buildSearchQuery'; import { SearchModeProvider } from './contexts/SearchModeContext'; @@ -117,6 +118,16 @@ function App() { const [downloadsSidebarOpen, setDownloadsSidebarOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [configBannerOpen, setConfigBannerOpen] = useState(false); + const [onboardingOpen, setOnboardingOpen] = useState(false); + + // Expose debug function to trigger onboarding from browser console + useEffect(() => { + (window as unknown as { showOnboarding: () => void }).showOnboarding = () => setOnboardingOpen(true); + return () => { + delete (window as unknown as { showOnboarding?: () => void }).showOnboarding; + }; + }, []); + const [featureNoticeDismissed, setFeatureNoticeDismissed] = useState(() => { return localStorage.getItem('cwa-bd-prowlarr-irc-notice-dismissed') === 'true'; }); @@ -249,6 +260,11 @@ function App() { prevSearchModeRef.current = cfg.search_mode; setConfig(cfg); + // Show onboarding modal on first run (settings enabled but not completed yet) + if (mode === 'initial' && cfg.settings_enabled && !cfg.onboarding_complete) { + setOnboardingOpen(true); + } + // Determine the default sort based on search mode const defaultSort = cfg.search_mode === 'universal' ? (cfg.metadata_default_sort || 'relevance') @@ -702,6 +718,14 @@ function App() { }} /> + {/* Onboarding wizard shown on first run */} + setOnboardingOpen(false)} + onComplete={() => loadConfig('settings-saved')} + onShowToast={showToast} + /> + ); diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index 37e806ce..3d193fd0 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -320,7 +320,7 @@ export const DetailsModal = ({ book, onClose, onDownload, onFindDownloads, onSea rel="noopener noreferrer" className="inline-flex items-center gap-1.5 rounded-full border border-[var(--border-muted)] bg-[var(--bg)] px-3 py-2 text-xs font-medium text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-900 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-200" > - View on {isMetadata ? providerDisplay : "Anna's Archive"} + View on {isMetadata ? providerDisplay : "Source"} void; + onComplete: () => void; + onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void; +} + +// Check if a field should be visible based on showWhen condition +function isFieldVisible( + field: SettingsField, + values: Record +): boolean { + const showWhen = field.showWhen; + if (!showWhen) return true; + + const currentValue = values[showWhen.field]; + + // Handle notEmpty condition + if (showWhen.notEmpty) { + if (Array.isArray(currentValue)) { + return currentValue.length > 0; + } + return currentValue !== undefined && currentValue !== null && currentValue !== ''; + } + + // Handle array of allowed values or single value + return Array.isArray(showWhen.value) + ? showWhen.value.includes(currentValue as string) + : currentValue === showWhen.value; +} + +// Check if a step should be visible based on its showWhen conditions (all must be true) +function isStepVisible( + step: OnboardingStep, + values: Record +): boolean { + if (!step.showWhen || step.showWhen.length === 0) return true; + + // All conditions must be true (AND logic) + return step.showWhen.every((condition) => { + const currentValue = values[condition.field]; + return currentValue === condition.value; + }); +} + +// Render the appropriate field component based on type +const renderField = ( + field: SettingsField, + value: unknown, + onChange: (value: unknown) => void, + onAction: () => Promise, + isDisabled: boolean +) => { + switch (field.type) { + case 'TextField': + return ( + + ); + case 'PasswordField': + return ( + + ); + case 'CheckboxField': + return ( + + ); + case 'SelectField': + return ( + + ); + case 'MultiSelectField': + return ( + + ); + case 'ActionButton': + return ( + + ); + case 'HeadingField': + return ; + default: + return
Unknown field type
; + } +}; + +export const OnboardingModal = ({ + isOpen, + onClose, + onComplete, + onShowToast, +}: OnboardingModalProps) => { + const [steps, setSteps] = useState([]); + const [values, setValues] = useState>({}); + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [error, setError] = useState(null); + + // Fetch onboarding config on mount + useEffect(() => { + if (!isOpen) return; + + const fetchOnboarding = async () => { + try { + setIsLoading(true); + setError(null); + const config = await getOnboarding(); + setSteps(config.steps); + setValues(config.values); + } catch (err) { + console.error('Failed to fetch onboarding config:', err); + setError('Failed to load setup wizard'); + } finally { + setIsLoading(false); + } + }; + + fetchOnboarding(); + }, [isOpen]); + + // Get visible steps based on current values + const visibleSteps = useMemo(() => { + return steps.filter((step) => isStepVisible(step, values)); + }, [steps, values]); + + // Get current step + const currentStep = visibleSteps[currentStepIndex]; + + // Get visible fields for current step + const visibleFields = useMemo(() => { + if (!currentStep) return []; + return currentStep.fields.filter((field) => isFieldVisible(field, values)); + }, [currentStep, values]); + + // Handle field value changes + const handleChange = useCallback((key: string, value: unknown) => { + setValues((prev) => ({ ...prev, [key]: value })); + }, []); + + // Handle next step + const handleNext = useCallback(() => { + if (currentStepIndex < visibleSteps.length - 1) { + setCurrentStepIndex(currentStepIndex + 1); + } + }, [currentStepIndex, visibleSteps.length]); + + // Handle previous step + const handleBack = useCallback(() => { + if (currentStepIndex > 0) { + setCurrentStepIndex(currentStepIndex - 1); + } + }, [currentStepIndex]); + + // Handle close with animation + const handleClose = useCallback(() => { + setIsClosing(true); + setTimeout(() => { + setIsClosing(false); + onClose(); + }, 150); + }, [onClose]); + + // Handle skip + const handleSkip = useCallback(async () => { + try { + setIsSaving(true); + await skipOnboarding(); + onShowToast?.('Setup skipped - using defaults', 'info'); + handleClose(); + onComplete(); + } catch (err) { + console.error('Failed to skip onboarding:', err); + onShowToast?.('Failed to skip setup', 'error'); + } finally { + setIsSaving(false); + } + }, [handleClose, onComplete, onShowToast]); + + // Handle finish (save and complete) + const handleFinish = useCallback(async () => { + try { + setIsSaving(true); + const result = await saveOnboarding(values); + if (result.success) { + onShowToast?.('Setup complete!', 'success'); + handleClose(); + onComplete(); + } else { + onShowToast?.(result.message || 'Failed to save settings', 'error'); + } + } catch (err) { + console.error('Failed to save onboarding:', err); + onShowToast?.('Failed to save settings', 'error'); + } finally { + setIsSaving(false); + } + }, [values, handleClose, onComplete, onShowToast]); + + // Handle action button (e.g., test connection) + const handleAction = useCallback( + async (fieldKey: string): Promise => { + if (!currentStep) { + return { success: false, message: 'No current step' }; + } + try { + // Pass current values so actions can use them (e.g., API key for test connection) + return await executeSettingsAction(currentStep.tab, fieldKey, values); + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : 'Action failed', + }; + } + }, + [currentStep, values] + ); + + // Handle ESC key + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + handleClose(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, handleClose]); + + // Prevent body scroll when open + useEffect(() => { + if (isOpen) { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + } + }, [isOpen]); + + if (!isOpen && !isClosing) return null; + + // Loading state + if (isLoading) { + return ( +
+
+
+
+ + + + + Loading setup wizard... +
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
+
+
+ + + +
+

{error}

+ +
+
+
+ ); + } + + const isFirstStep = currentStepIndex === 0; + const isLastStep = currentStepIndex === visibleSteps.length - 1; + const progress = ((currentStepIndex + 1) / visibleSteps.length) * 100; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+
+
+ {currentStepIndex + 1} +
+
+

{currentStep?.title || 'Setup'}

+

+ Step {currentStepIndex + 1} of {visibleSteps.length} +

+
+
+ +
+ + {/* Progress bar */} +
+
+
+ + {/* Content */} +
+ {visibleFields.map((field) => { + const isDisabled = 'fromEnv' in field ? (field.fromEnv ?? false) : false; + return ( + + {renderField( + field, + values[field.key], + (v) => handleChange(field.key, v), + () => handleAction(field.key), + isDisabled + )} + + ); + })} +
+ + {/* Footer */} +
+
+ +
+ +
+ {!isFirstStep && ( + + )} + + {isLastStep ? ( + + ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index 0f3497e3..ed5d31f6 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -805,7 +805,7 @@ export const ReleaseModal = ({ // Fallback: assume direct_download is available (for ebooks) setAvailableSources([{ name: 'direct_download', - display_name: "Anna's Archive", + display_name: "Direct Download", enabled: true, supported_content_types: ['ebook'] }]); diff --git a/src/frontend/src/data/filterOptions.ts b/src/frontend/src/data/filterOptions.ts index 73f52ddb..803dde02 100644 --- a/src/frontend/src/data/filterOptions.ts +++ b/src/frontend/src/data/filterOptions.ts @@ -1,4 +1,4 @@ -// Direct download mode sort options (Anna's Archive) +// Direct download mode sort options export const SORT_OPTIONS = [ { value: '', label: 'Most relevant' }, { value: 'newest', label: 'Newest (publication year)' }, @@ -12,7 +12,7 @@ export const SORT_OPTIONS = [ // Note: Metadata mode sort options are now dynamic per provider // They come from the /api/config endpoint as metadata_sort_options -// Direct download mode content type options (Anna's Archive) +// Direct download mode content type options export const CONTENT_OPTIONS = [ { value: '', label: 'All' }, { value: 'book_nonfiction', label: 'Book (non-fiction)' }, diff --git a/src/frontend/src/hooks/useSearch.ts b/src/frontend/src/hooks/useSearch.ts index e539e726..da13b09c 100644 --- a/src/frontend/src/hooks/useSearch.ts +++ b/src/frontend/src/hooks/useSearch.ts @@ -193,9 +193,9 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn { } else { console.error('Search failed:', error); const message = error instanceof Error ? error.message : 'Search failed'; - const friendly = message.includes("Anna's Archive") || message.includes('Network restricted') + const friendly = message.includes('Network restricted') || message.includes('Unable to reach') ? message - : "Unable to reach Anna's Archive. Network may be restricted or mirrors blocked."; + : "Unable to reach download source. Network may be restricted or mirrors blocked."; showToast(friendly, 'error'); } } finally { diff --git a/src/frontend/src/services/api.ts b/src/frontend/src/services/api.ts index 9e3e08cc..f8428970 100644 --- a/src/frontend/src/services/api.ts +++ b/src/frontend/src/services/api.ts @@ -29,42 +29,72 @@ export class AuthenticationError extends Error { } } -// Utility function for JSON fetch with credentials -async function fetchJSON(url: string, opts: RequestInit = {}): Promise { - const res = await fetch(url, { - ...opts, - credentials: 'include', // Enable cookies for session - headers: { - 'Content-Type': 'application/json', - ...opts.headers, - }, - }); - - if (!res.ok) { - // Try to parse error message from response body - let errorMessage = `${res.status} ${res.statusText}`; - try { - const errorData = await res.json(); - // Prefer user-friendly 'message' field, fall back to 'error' - if (errorData.message) { - errorMessage = errorData.message; - } else if (errorData.error) { - errorMessage = errorData.error; +// Custom error class for request timeouts +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +// Default request timeout in milliseconds (30 seconds) +const DEFAULT_TIMEOUT_MS = 30000; + +// Utility function for JSON fetch with credentials and timeout +async function fetchJSON(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + ...opts, + credentials: 'include', // Enable cookies for session + signal: controller.signal, + headers: { + 'Content-Type': 'application/json', + ...opts.headers, + }, + }); + + if (!res.ok) { + // Try to parse error message from response body + let errorMessage = `${res.status} ${res.statusText}`; + try { + const errorData = await res.json(); + // Prefer user-friendly 'message' field, fall back to 'error' + if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.error) { + errorMessage = errorData.error; + } + } catch (e) { + // Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page) + console.warn(`Failed to parse error response from ${url}:`, e instanceof Error ? e.message : e); + } + + // Provide helpful message for gateway/proxy errors + if (res.status === 502 || res.status === 503 || res.status === 504) { + errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`; + } + + // Throw appropriate error based on status code + if (res.status === 401) { + throw new AuthenticationError(errorMessage); } - } catch (e) { - // Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page) - console.warn(`Failed to parse error response from ${url}:`, e instanceof Error ? e.message : e); + + throw new Error(errorMessage); } - // Throw appropriate error based on status code - if (res.status === 401) { - throw new AuthenticationError(errorMessage); + return res.json(); + } catch (error) { + // Handle abort/timeout errors + if (error instanceof Error && error.name === 'AbortError') { + throw new TimeoutError('Request timed out. Check your network connection or proxy configuration.'); } - - throw new Error(errorMessage); + throw error; + } finally { + clearTimeout(timeoutId); } - - return res.json(); } // API functions @@ -236,6 +266,47 @@ export const executeSettingsAction = async ( }); }; +// Onboarding API functions + +export interface OnboardingStepCondition { + field: string; + value: unknown; +} + +export interface OnboardingStep { + id: string; + title: string; + tab: string; + fields: import('../types/settings').SettingsField[]; + showWhen?: OnboardingStepCondition[]; // Array of conditions (all must be true) + optional?: boolean; +} + +export interface OnboardingConfig { + steps: OnboardingStep[]; + values: Record; + complete: boolean; +} + +export const getOnboarding = async (): Promise => { + return fetchJSON(`${API_BASE}/onboarding`); +}; + +export const saveOnboarding = async ( + values: Record +): Promise<{ success: boolean; message: string }> => { + return fetchJSON<{ success: boolean; message: string }>(`${API_BASE}/onboarding`, { + method: 'POST', + body: JSON.stringify(values), + }); +}; + +export const skipOnboarding = async (): Promise<{ success: boolean; message: string }> => { + return fetchJSON<{ success: boolean; message: string }>(`${API_BASE}/onboarding/skip`, { + method: 'POST', + }); +}; + // Release source API functions // Get available release sources from plugin registry diff --git a/src/frontend/src/types/index.ts b/src/frontend/src/types/index.ts index 35c124e0..40b569b5 100644 --- a/src/frontend/src/types/index.ts +++ b/src/frontend/src/types/index.ts @@ -162,7 +162,8 @@ export interface AppConfig { auto_open_downloads_sidebar: boolean; // Auto-open sidebar when download is queued download_to_browser: boolean; // Auto-download completed files to browser settings_enabled: boolean; // Whether config directory is mounted and writable - default_sort: string; // Default sort for direct mode (Anna's Archive) + onboarding_complete: boolean; // Whether the user has completed initial setup + default_sort: string; // Default sort for direct mode metadata_default_sort: string; // Default sort for universal mode (from metadata provider) } diff --git a/src/frontend/src/utils/buildSearchQuery.ts b/src/frontend/src/utils/buildSearchQuery.ts index 41ec40a1..6f075259 100644 --- a/src/frontend/src/utils/buildSearchQuery.ts +++ b/src/frontend/src/utils/buildSearchQuery.ts @@ -34,7 +34,7 @@ export const buildSearchQuery = ({ return queryParts.join('&'); } - // Direct mode: include all Anna's Archive filters + // Direct mode: include all filters if (showAdvanced) { const { isbn, author, title, content, formats, lang } = advancedFilters;