mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 09:51:14 +01:00
Added onboarding + release fixes (#433)
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 854 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ Formerly *Calibre Web Automated Book Downloader (CWABD)*
|
||||
|
||||
<img src="src/frontend/public/logo.png" alt="Shelfmark" width="200">
|
||||
|
||||
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 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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)}
|
||||
+90
-2
@@ -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('/<path:path>')
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
<OnboardingModal
|
||||
isOpen={onboardingOpen}
|
||||
onClose={() => setOnboardingOpen(false)}
|
||||
onComplete={() => loadConfig('settings-saved')}
|
||||
onShowToast={showToast}
|
||||
/>
|
||||
|
||||
</SearchModeProvider>
|
||||
);
|
||||
|
||||
|
||||
@@ -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"}
|
||||
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
getOnboarding,
|
||||
saveOnboarding,
|
||||
skipOnboarding,
|
||||
executeSettingsAction,
|
||||
OnboardingStep,
|
||||
} from '../services/api';
|
||||
import {
|
||||
SettingsField,
|
||||
TextFieldConfig,
|
||||
PasswordFieldConfig,
|
||||
CheckboxFieldConfig,
|
||||
SelectFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
HeadingFieldConfig,
|
||||
ActionButtonConfig,
|
||||
ActionResult,
|
||||
} from '../types/settings';
|
||||
import { FieldWrapper } from './settings/shared';
|
||||
import {
|
||||
TextField,
|
||||
PasswordField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
} from './settings/fields';
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => 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<string, unknown>
|
||||
): 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<string, unknown>
|
||||
): 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<ActionResult>,
|
||||
isDisabled: boolean
|
||||
) => {
|
||||
switch (field.type) {
|
||||
case 'TextField':
|
||||
return (
|
||||
<TextField
|
||||
field={field as TextFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'PasswordField':
|
||||
return (
|
||||
<PasswordField
|
||||
field={field as PasswordFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'CheckboxField':
|
||||
return (
|
||||
<CheckboxField
|
||||
field={field as CheckboxFieldConfig}
|
||||
value={(value as boolean) ?? false}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'SelectField':
|
||||
return (
|
||||
<SelectField
|
||||
field={field as SelectFieldConfig}
|
||||
value={(value as string) ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'MultiSelectField':
|
||||
return (
|
||||
<MultiSelectField
|
||||
field={field as MultiSelectFieldConfig}
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={onChange}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'ActionButton':
|
||||
return (
|
||||
<ActionButton
|
||||
field={field as ActionButtonConfig}
|
||||
onAction={onAction}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'HeadingField':
|
||||
return <HeadingField field={field as HeadingFieldConfig} />;
|
||||
default:
|
||||
return <div>Unknown field type</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export const OnboardingModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onComplete,
|
||||
onShowToast,
|
||||
}: OnboardingModalProps) => {
|
||||
const [steps, setSteps] = useState<OnboardingStep[]>([]);
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<ActionResult> => {
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative rounded-xl p-8 shadow-2xl"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Loading setup wizard...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={handleClose} />
|
||||
<div
|
||||
className="relative rounded-xl p-8 shadow-2xl max-w-md"
|
||||
style={{ background: 'var(--bg)' }}
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-red-500">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-sm">{error}</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFirstStep = currentStepIndex === 0;
|
||||
const isLastStep = currentStepIndex === visibleSteps.length - 1;
|
||||
const progress = ((currentStepIndex + 1) / visibleSteps.length) * 100;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150
|
||||
${isClosing ? 'opacity-0' : 'opacity-100'}`}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className={`relative w-full max-w-xl rounded-xl
|
||||
border border-[var(--border-muted)] shadow-2xl
|
||||
${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
|
||||
style={{ background: 'var(--bg)' }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Setup Wizard"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-muted)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-sky-500/20 text-sky-500 text-sm font-medium">
|
||||
{currentStepIndex + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{currentStep?.title || 'Setup'}</h2>
|
||||
<p className="text-xs opacity-60">
|
||||
Step {currentStepIndex + 1} of {visibleSteps.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[var(--hover-surface)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-[var(--bg-soft)]">
|
||||
<div
|
||||
className="h-full bg-sky-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5 space-y-5 min-h-[280px]">
|
||||
{visibleFields.map((field) => {
|
||||
const isDisabled = 'fromEnv' in field ? (field.fromEnv ?? false) : false;
|
||||
return (
|
||||
<FieldWrapper key={field.key} field={field}>
|
||||
{renderField(
|
||||
field,
|
||||
values[field.key],
|
||||
(v) => handleChange(field.key, v),
|
||||
() => handleAction(field.key),
|
||||
isDisabled
|
||||
)}
|
||||
</FieldWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-[var(--border-muted)] flex items-center justify-between h-[68px]">
|
||||
<div>
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={isSaving || !isFirstStep}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium
|
||||
${isFirstStep ? 'opacity-60 hover:opacity-100 transition-opacity' : 'invisible'}`}
|
||||
>
|
||||
Skip setup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!isFirstStep && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium
|
||||
bg-[var(--bg-soft)] border border-[var(--border-muted)]
|
||||
hover:bg-[var(--hover-surface)] transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLastStep ? (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium
|
||||
bg-sky-600 text-white
|
||||
hover:bg-sky-700 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Finish Setup'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={isSaving}
|
||||
className="px-5 py-2 rounded-lg text-sm font-medium
|
||||
bg-sky-600 text-white
|
||||
hover:bg-sky-700 transition-colors
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
flex items-center gap-1"
|
||||
>
|
||||
Next
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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']
|
||||
}]);
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,42 +29,72 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function for JSON fetch with credentials
|
||||
async function fetchJSON<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||
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<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
|
||||
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<string, unknown>;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
export const getOnboarding = async (): Promise<OnboardingConfig> => {
|
||||
return fetchJSON<OnboardingConfig>(`${API_BASE}/onboarding`);
|
||||
};
|
||||
|
||||
export const saveOnboarding = async (
|
||||
values: Record<string, unknown>
|
||||
): 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user