mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
475ae420e5 | ||
|
|
c48d7a0cb0 | ||
|
|
66dca96182 | ||
|
|
8b801c104e | ||
|
|
be5382cd1e | ||
|
|
fbc3dd2552 | ||
|
|
bd1ad3495c | ||
|
|
a0079c5a7f | ||
|
|
92b8323a8b | ||
|
|
1ca80e8b6f |
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 |
@@ -0,0 +1,161 @@
|
||||
# Test stack for download client development
|
||||
# Includes shelfmark + all download clients on same network with shared volumes
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.test-clients.yml up -d
|
||||
# # Access shelfmark at http://localhost:8084
|
||||
# # Configure clients in Settings > Prowlarr > Download Clients
|
||||
#
|
||||
# Web UIs:
|
||||
# - shelfmark: http://localhost:8084
|
||||
# - Prowlarr: http://localhost:9696 (no auth by default)
|
||||
# - qBittorrent: http://localhost:8080 (check container logs for temp password)
|
||||
# - Transmission: http://localhost:9091 (admin / admin)
|
||||
# - Deluge: http://localhost:8112 (admin / deluge)
|
||||
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
|
||||
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
|
||||
#
|
||||
|
||||
|
||||
services:
|
||||
shelfmark:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
target: shelfmark
|
||||
container_name: test-shelfmark
|
||||
cap_add:
|
||||
- SYS_PTRACE
|
||||
environment:
|
||||
TZ: UTC
|
||||
DEBUG: "true"
|
||||
# All client configuration is done via Settings UI
|
||||
# Use Docker service names for URLs:
|
||||
# - qBittorrent: http://qbittorrent:8080
|
||||
# - Transmission: http://transmission:9091
|
||||
# - Deluge host: deluge (port 58846)
|
||||
# - NZBGet: http://nzbget:6789
|
||||
# - SABnzbd: http://sabnzbd:8080
|
||||
ports:
|
||||
- "8084:8084"
|
||||
volumes:
|
||||
# Config and state
|
||||
- ./.local/test-clients/shelfmark/config:/config
|
||||
- ./.local/test-clients/shelfmark/log:/var/log/shelfmark
|
||||
# Book destination directory (where completed books go)
|
||||
- ./.local/test-clients/books:/books
|
||||
# Staging directory
|
||||
- ./.local/test-clients/tmp:/tmp/shelfmark
|
||||
# CRITICAL: Mount client download directories so shelfmark can access completed files
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
# Mount source code for hot-reload (no rebuild needed for Python changes)
|
||||
- ./shelfmark:/app/shelfmark:ro
|
||||
# Mount tests for running pytest in container
|
||||
- ./tests:/app/tests:ro
|
||||
- ./pyproject.toml:/app/pyproject.toml:ro
|
||||
# Mount client configs for integration tests to read credentials
|
||||
- ./.local/test-clients/qbittorrent/config:/qbittorrent-config:ro
|
||||
- ./.local/test-clients/sabnzbd/config:/sabnzbd-config:ro
|
||||
depends_on:
|
||||
- nzbget
|
||||
- sabnzbd
|
||||
- qbittorrent
|
||||
- transmission
|
||||
- deluge
|
||||
restart: unless-stopped
|
||||
|
||||
prowlarr:
|
||||
image: lscr.io/linuxserver/prowlarr:latest
|
||||
container_name: test-prowlarr
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./.local/test-clients/prowlarr/config:/config
|
||||
ports:
|
||||
- "9696:9696"
|
||||
restart: unless-stopped
|
||||
|
||||
nzbget:
|
||||
image: lscr.io/linuxserver/nzbget:latest
|
||||
container_name: test-nzbget
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./.local/test-clients/nzbget/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
- ./.local/test-clients/nzbget/custom-cont-init.d:/custom-cont-init.d:ro
|
||||
ports:
|
||||
- "6789:6789" # Web UI / JSON-RPC
|
||||
restart: unless-stopped
|
||||
|
||||
sabnzbd:
|
||||
image: lscr.io/linuxserver/sabnzbd:latest
|
||||
container_name: test-sabnzbd
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
volumes:
|
||||
- ./.local/test-clients/sabnzbd/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
ports:
|
||||
- "8085:8080" # Web UI (external:internal)
|
||||
restart: unless-stopped
|
||||
|
||||
qbittorrent:
|
||||
image: lscr.io/linuxserver/qbittorrent:latest
|
||||
container_name: test-qbittorrent
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- WEBUI_PORT=8080
|
||||
volumes:
|
||||
- ./.local/test-clients/qbittorrent/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
- ./.local/test-clients/qbittorrent/custom-cont-init.d:/custom-cont-init.d:ro
|
||||
ports:
|
||||
- "8080:8080" # Web UI / API
|
||||
- "6882:6881"
|
||||
- "6882:6881/udp"
|
||||
restart: unless-stopped
|
||||
|
||||
transmission:
|
||||
image: lscr.io/linuxserver/transmission:latest
|
||||
container_name: test-transmission
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- USER=admin
|
||||
- PASS=admin
|
||||
volumes:
|
||||
- ./.local/test-clients/transmission/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
ports:
|
||||
- "9091:9091" # Web UI / RPC
|
||||
- "51413:51413"
|
||||
- "51413:51413/udp"
|
||||
restart: unless-stopped
|
||||
|
||||
deluge:
|
||||
image: lscr.io/linuxserver/deluge:latest
|
||||
container_name: test-deluge
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
- DELUGE_LOGLEVEL=error
|
||||
volumes:
|
||||
- ./.local/test-clients/deluge/config:/config
|
||||
- ./.local/test-clients/downloads:/downloads
|
||||
ports:
|
||||
- "8112:8112" # Web UI
|
||||
- "58846:58846" # Daemon RPC
|
||||
- "6881:6881"
|
||||
- "6881:6881/udp"
|
||||
restart: unless-stopped
|
||||
@@ -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 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
|
||||
@@ -770,7 +770,7 @@ def get(url: str, retry: Optional[int] = None, cancel_flag: Optional[Event] = No
|
||||
cookies = get_cf_cookies_for_domain(urlparse(url).hostname or "")
|
||||
if cookies:
|
||||
try:
|
||||
response = requests.get(url, cookies=cookies, proxies=get_proxies(), timeout=(5, 10))
|
||||
response = requests.get(url, cookies=cookies, proxies=get_proxies(url), timeout=(5, 10))
|
||||
if response.status_code == 200:
|
||||
logger.debug("Cookies available after lock wait - skipped Chrome")
|
||||
return response.text
|
||||
@@ -1035,7 +1035,7 @@ def _try_with_cached_cookies(url: str, hostname: str) -> Optional[str]:
|
||||
headers['User-Agent'] = stored_ua
|
||||
|
||||
logger.debug(f"Trying request with cached cookies: {url}")
|
||||
response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(), timeout=(5, 10))
|
||||
response = requests.get(url, cookies=cookies, headers=headers, proxies=get_proxies(url), timeout=(5, 10))
|
||||
if response.status_code == 200:
|
||||
logger.debug("Cached cookies worked, skipped Chrome bypass")
|
||||
return response.text
|
||||
|
||||
@@ -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)"},
|
||||
@@ -268,10 +268,17 @@ def general_settings():
|
||||
return [
|
||||
TextField(
|
||||
key="CALIBRE_WEB_URL",
|
||||
label="Book Management App URL",
|
||||
description="Adds a navigation button to your book manager instance (Calibre-Web Automated, Booklore, etc).",
|
||||
label="Library URL",
|
||||
description="Adds a navigation button to your book library (Calibre-Web Automated, Booklore, etc).",
|
||||
placeholder="http://calibre-web:8083",
|
||||
),
|
||||
TextField(
|
||||
key="AUDIOBOOK_LIBRARY_URL",
|
||||
label="Audiobook Library URL",
|
||||
description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.",
|
||||
placeholder="http://audiobookshelf:8080",
|
||||
env_supported=False,
|
||||
),
|
||||
HeadingField(
|
||||
key="search_defaults_heading",
|
||||
title="Default Search Filters",
|
||||
@@ -308,7 +315,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 +324,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 +338,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
|
||||
@@ -493,6 +500,14 @@ def network_settings():
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
show_when={"field": "PROXY_MODE", "value": "socks5"},
|
||||
),
|
||||
TextField(
|
||||
key="NO_PROXY",
|
||||
label="No Proxy",
|
||||
description="Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)",
|
||||
disabled=tor_overrides_network,
|
||||
disabled_reason="Proxy settings are not used when Tor routing is enabled.",
|
||||
show_when={"field": "PROXY_MODE", "value": ["http", "socks5"]},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -667,11 +682,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 +716,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 +737,7 @@ def _get_slow_source_options():
|
||||
},
|
||||
{
|
||||
"id": "zlib",
|
||||
"label": "Z-Library",
|
||||
"label": "Zlib",
|
||||
"description": "Alternative mirror",
|
||||
"isLocked": locked,
|
||||
"disabledReason": disabled_reason,
|
||||
@@ -748,8 +763,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 +805,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 +919,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 +935,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,429 @@
|
||||
"""
|
||||
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."""
|
||||
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)
|
||||
return config.get(ONBOARDING_STORAGE_KEY, False)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning(f"Could not read onboarding status from settings.json: {e}")
|
||||
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 as e:
|
||||
logger.debug(f"Could not refresh config after onboarding: {e}")
|
||||
|
||||
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)}
|
||||
@@ -284,7 +284,78 @@ def save_config_file(tab_name: str, values: Dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def initialize_default_configs() -> bool:
|
||||
"""Initialize config files with default values on first startup.
|
||||
|
||||
Creates config files for all settings tabs that don't have one yet,
|
||||
populating them with field default values. This ensures config files
|
||||
exist from first startup rather than only being created on explicit save.
|
||||
|
||||
Returns:
|
||||
True if initialization succeeded or was skipped (already initialized),
|
||||
False if there was an error accessing the config directory.
|
||||
"""
|
||||
try:
|
||||
config_dir = _get_config_dir()
|
||||
|
||||
# Check if config directory exists and is writable
|
||||
if not config_dir.exists():
|
||||
logger.warning(f"Config directory does not exist: {config_dir}")
|
||||
return False
|
||||
|
||||
# Test writability
|
||||
test_file = config_dir / ".write_test"
|
||||
try:
|
||||
test_file.touch()
|
||||
test_file.unlink()
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Config directory is not writable: {config_dir} - {e}")
|
||||
return False
|
||||
|
||||
initialized_tabs = []
|
||||
|
||||
for tab in get_all_settings_tabs():
|
||||
config_path = _get_config_file_path(tab.name)
|
||||
|
||||
# Skip if config file already exists
|
||||
if config_path.exists():
|
||||
continue
|
||||
|
||||
# Collect default values for all fields
|
||||
defaults = {}
|
||||
for field in tab.fields:
|
||||
# Skip non-value fields
|
||||
if isinstance(field, (ActionButton, HeadingField)):
|
||||
continue
|
||||
|
||||
# Only include fields that have a non-None default
|
||||
if field.default is not None:
|
||||
defaults[field.key] = field.default
|
||||
|
||||
# Create config file with defaults if we have any
|
||||
if defaults:
|
||||
_ensure_config_dir(tab.name)
|
||||
try:
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(defaults, f, indent=2)
|
||||
initialized_tabs.append(tab.name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize config for {tab.name}: {e}")
|
||||
|
||||
if initialized_tabs:
|
||||
logger.info(f"Initialized default configs for: {initialized_tabs}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during config initialization: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def sync_env_to_config() -> None:
|
||||
# Initialize default configs first (for fresh installs)
|
||||
initialize_default_configs()
|
||||
|
||||
for tab in get_all_settings_tabs():
|
||||
values_to_sync = {}
|
||||
|
||||
@@ -333,6 +404,16 @@ def migrate_legacy_settings() -> None:
|
||||
if "FILE_ORGANIZATION" in downloads_config or "DESTINATION" in downloads_config:
|
||||
return
|
||||
|
||||
# Skip migration if no legacy settings exist (fresh install)
|
||||
legacy_keys = {
|
||||
"PROCESSING_MODE", "INGEST_DIR", "LIBRARY_PATH", "USE_BOOK_TITLE",
|
||||
"LIBRARY_TEMPLATE", "PROCESSING_MODE_AUDIOBOOK", "INGEST_DIR_AUDIOBOOK",
|
||||
"LIBRARY_PATH_AUDIOBOOK", "LIBRARY_TEMPLATE_AUDIOBOOK", "TORRENT_HARDLINK",
|
||||
"USE_CONTENT_TYPE_DIRECTORIES",
|
||||
}
|
||||
if not any(key in downloads_config for key in legacy_keys):
|
||||
return
|
||||
|
||||
migrated_downloads = {}
|
||||
migrated_sources = {}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ when multiple workers may try to write to the same path simultaneously.
|
||||
import errno
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -53,6 +54,53 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
|
||||
raise RuntimeError(f"Could not write file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
|
||||
def _is_permission_error(e: Exception) -> bool:
|
||||
"""Check if exception is a permission error (including NFS/SMB issues)."""
|
||||
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
|
||||
|
||||
|
||||
def _system_op(op: str, source: Path, dest: Path) -> None:
|
||||
"""Execute system command (mv or cp) as final fallback."""
|
||||
logger.info(f"Attempting system {op} as final fallback: {source} -> {dest}")
|
||||
subprocess.run(
|
||||
[op, "-f", str(source), str(dest)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
|
||||
def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
|
||||
"""Handle NFS permission errors by falling back to copyfile -> system op."""
|
||||
try:
|
||||
# Fallback 1: copy content only
|
||||
shutil.copyfile(str(source), str(dest))
|
||||
|
||||
if is_move:
|
||||
# Verify copy success before removing source
|
||||
if dest.exists() and dest.stat().st_size == source.stat().st_size:
|
||||
source.unlink()
|
||||
return
|
||||
else:
|
||||
raise IOError(f"Copy verification failed for {source} -> {dest}")
|
||||
|
||||
except Exception as copy_error:
|
||||
# Clean up failed copy attempt if it exists
|
||||
if dest.exists():
|
||||
dest.unlink(missing_ok=True)
|
||||
|
||||
logger.error(f"Fallback copyfile failed: {copy_error}")
|
||||
|
||||
# Fallback 2: system command
|
||||
op = "mv" if is_move else "cp"
|
||||
try:
|
||||
_system_op(op, source, dest)
|
||||
except subprocess.CalledProcessError as sys_error:
|
||||
logger.error(f"System {op} failed: {sys_error.stderr}")
|
||||
dest.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) -> Path:
|
||||
"""Move a file with collision detection.
|
||||
|
||||
@@ -107,10 +155,26 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
return try_path
|
||||
except Exception:
|
||||
try_path.unlink(missing_ok=True)
|
||||
# Clean up the placeholder if move failed
|
||||
if try_path.exists() and try_path.stat().st_size == 0:
|
||||
try_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except FileExistsError:
|
||||
continue
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors (e.g. inability to set metadata)
|
||||
if _is_permission_error(e):
|
||||
logger.debug(f"Permission error during move, falling back to copyfile: {e}")
|
||||
try:
|
||||
_perform_nfs_fallback(source_path, try_path, is_move=True)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved (fallback): {try_path.name}")
|
||||
return try_path
|
||||
except Exception as fallback_error:
|
||||
# Fallback failed, chain exceptions for better debugging
|
||||
logger.error(f"NFS fallback also failed: {fallback_error}")
|
||||
raise e from fallback_error
|
||||
raise
|
||||
|
||||
raise RuntimeError(f"Could not move file after {max_attempts} attempts: {dest_path}")
|
||||
|
||||
@@ -173,10 +237,24 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
# Atomically claim the destination by creating an exclusive file
|
||||
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
os.close(fd)
|
||||
|
||||
# Copy to temp file first, then replace to avoid partial files
|
||||
temp_path = try_path.parent / f".{try_path.name}.tmp"
|
||||
try:
|
||||
shutil.copy2(str(source_path), str(temp_path))
|
||||
try:
|
||||
shutil.copy2(str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors immediately here
|
||||
if _is_permission_error(e):
|
||||
logger.debug(f"Permission error during copy, falling back to copyfile: {e}")
|
||||
try:
|
||||
_perform_nfs_fallback(source_path, temp_path, is_move=False)
|
||||
except Exception as fallback_error:
|
||||
logger.error(f"NFS fallback also failed: {fallback_error}")
|
||||
raise e from fallback_error
|
||||
else:
|
||||
raise
|
||||
|
||||
temp_path.replace(try_path)
|
||||
if attempt > 0:
|
||||
logger.info(f"File collision resolved: {try_path.name}")
|
||||
|
||||
@@ -206,7 +206,7 @@ def html_get_page(
|
||||
# Try with CF cookies/UA if available (from previous bypass)
|
||||
headers = {}
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(current_url, proxies=get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response = requests.get(current_url, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response.raise_for_status()
|
||||
time.sleep(1)
|
||||
return response.text
|
||||
@@ -291,7 +291,7 @@ def download_url(
|
||||
logger.info(f"Downloading: {current_url} (attempt {attempt + 1}/{MAX_DOWNLOAD_RETRIES})")
|
||||
# Try with CF cookies/UA if available
|
||||
cookies = _apply_cf_bypass(current_url, headers)
|
||||
response = requests.get(current_url, stream=True, proxies=get_proxies(), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response = requests.get(current_url, stream=True, proxies=get_proxies(current_url), timeout=REQUEST_TIMEOUT, cookies=cookies, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
if status_callback:
|
||||
@@ -401,7 +401,7 @@ def _try_resume(
|
||||
resume_headers = {**(base_headers or DOWNLOAD_HEADERS), 'Range': f'bytes={start_byte}-'}
|
||||
cookies = _apply_cf_bypass(url, resume_headers)
|
||||
response = requests.get(
|
||||
url, stream=True, proxies=get_proxies(), timeout=REQUEST_TIMEOUT,
|
||||
url, stream=True, proxies=get_proxies(url), timeout=REQUEST_TIMEOUT,
|
||||
headers=resume_headers, cookies=cookies
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""DNS rotation, mirror selection, and network utilities."""
|
||||
|
||||
import fnmatch
|
||||
import requests
|
||||
import urllib.request
|
||||
from typing import Sequence, Tuple, Any, Union, cast, List, Optional, Callable
|
||||
@@ -14,8 +15,59 @@ from shelfmark.core.config import config as app_config
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
def get_proxies() -> dict:
|
||||
"""Get current proxy configuration from config singleton."""
|
||||
def _get_no_proxy_patterns() -> List[str]:
|
||||
"""Get list of NO_PROXY patterns from config."""
|
||||
no_proxy = app_config.get("NO_PROXY", "")
|
||||
if not no_proxy:
|
||||
return []
|
||||
return [p.strip().lower() for p in no_proxy.split(",") if p.strip()]
|
||||
|
||||
|
||||
def should_bypass_proxy(url: str) -> bool:
|
||||
"""Check if a URL should bypass the proxy based on NO_PROXY patterns.
|
||||
|
||||
Supports:
|
||||
- Exact hostname match: localhost, myhost.local
|
||||
- Wildcard prefix: *.local matches foo.local
|
||||
- Wildcard suffix: 10.* matches 10.1.2.3
|
||||
"""
|
||||
if not url:
|
||||
return False
|
||||
|
||||
patterns = _get_no_proxy_patterns()
|
||||
if not patterns:
|
||||
return False
|
||||
|
||||
# Extract hostname from URL
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to parse URL for proxy bypass check: {url} - {e}")
|
||||
return False
|
||||
|
||||
if not hostname:
|
||||
return False
|
||||
|
||||
for pattern in patterns:
|
||||
# Use fnmatch for wildcard matching (supports * and ?)
|
||||
if fnmatch.fnmatch(hostname, pattern):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_proxies(url: str = "") -> dict:
|
||||
"""Get current proxy configuration from config singleton.
|
||||
|
||||
Args:
|
||||
url: Optional URL to check against NO_PROXY patterns.
|
||||
If provided and matches a pattern, returns empty dict.
|
||||
"""
|
||||
# Check NO_PROXY bypass first
|
||||
if url and should_bypass_proxy(url):
|
||||
return {}
|
||||
|
||||
proxy_mode = app_config.get("PROXY_MODE", "none")
|
||||
|
||||
if proxy_mode == "socks5":
|
||||
@@ -340,7 +392,7 @@ class DoHResolver:
|
||||
response = self.session.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
proxies=get_proxies(),
|
||||
proxies=get_proxies(self.base_url),
|
||||
timeout=10 # Increased from 5s to handle slow network conditions
|
||||
)
|
||||
response.raise_for_status()
|
||||
@@ -834,7 +886,7 @@ def _initialize_aa_state() -> None:
|
||||
logger.debug(f"AA_BASE_URL: auto, checking available urls {_aa_urls}")
|
||||
for i, url in enumerate(_aa_urls):
|
||||
try:
|
||||
response = requests.get(url, proxies=get_proxies(), timeout=3)
|
||||
response = requests.get(url, proxies=get_proxies(url), timeout=3)
|
||||
if response.status_code == 200:
|
||||
_current_aa_url_index = i
|
||||
_aa_base_url = url
|
||||
|
||||
@@ -80,23 +80,18 @@ def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
|
||||
|
||||
|
||||
def _should_hardlink(task: DownloadTask) -> bool:
|
||||
"""Check if download should be hardlinked (Prowlarr torrents only)."""
|
||||
# Only Prowlarr downloads (torrents) can be hardlinked
|
||||
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
|
||||
if task.source != "prowlarr":
|
||||
return False
|
||||
|
||||
# Only applies if we have an original download path from torrent client
|
||||
if not task.original_download_path:
|
||||
return False
|
||||
|
||||
# Check per-content-type setting
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS"
|
||||
|
||||
# Check new setting first, then legacy TORRENT_HARDLINK
|
||||
hardlink_enabled = config.get(key)
|
||||
if hardlink_enabled is None:
|
||||
# Fall back to legacy setting (but only if explicitly set)
|
||||
hardlink_enabled = config.get("TORRENT_HARDLINK", False)
|
||||
|
||||
return bool(hardlink_enabled)
|
||||
@@ -231,10 +226,12 @@ def process_directory(
|
||||
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
|
||||
logger.debug(f"Also found {len(rejected_files)} file(s) with unsupported formats: {', '.join(rejected_exts)}")
|
||||
|
||||
# Move each book file to destination
|
||||
# Transfer each book file to destination
|
||||
final_paths = []
|
||||
is_audiobook = check_audiobook(task.content_type)
|
||||
organization_mode = _get_file_organization(is_audiobook)
|
||||
use_hardlink = _should_hardlink(task)
|
||||
is_torrent = _is_torrent_source(directory, task)
|
||||
|
||||
for book_file in book_files:
|
||||
# For multi-file downloads (book packs, series), always preserve original filenames
|
||||
@@ -260,17 +257,19 @@ def process_directory(
|
||||
filename = book_file.name
|
||||
|
||||
dest_path = ingest_dir / filename
|
||||
final_path = _atomic_move(book_file, dest_path)
|
||||
final_path, op = _transfer_single_file(book_file, dest_path, use_hardlink, is_torrent)
|
||||
final_paths.append(final_path)
|
||||
logger.debug(f"Moved to destination: {final_path.name}")
|
||||
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
|
||||
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
if not is_torrent:
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
|
||||
return final_paths, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing directory: {e}")
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
if not _is_torrent_source(directory, task):
|
||||
shutil.rmtree(directory, ignore_errors=True)
|
||||
return [], str(e)
|
||||
|
||||
|
||||
@@ -532,10 +531,11 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
# Check cancellation before post-processing
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before post-processing: {task_id}")
|
||||
if temp_file.is_dir():
|
||||
shutil.rmtree(temp_file, ignore_errors=True)
|
||||
else:
|
||||
temp_file.unlink(missing_ok=True)
|
||||
if not _is_torrent_source(temp_file, task):
|
||||
if temp_file.is_dir():
|
||||
shutil.rmtree(temp_file, ignore_errors=True)
|
||||
else:
|
||||
temp_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
# Post-processing: archive extraction or direct move to ingest
|
||||
@@ -552,7 +552,14 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
|
||||
task = book_queue.get_task(task_id)
|
||||
if task:
|
||||
book_queue.update_status(task_id, QueueStatus.ERROR)
|
||||
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}")
|
||||
# Check for known misconfiguration from earlier versions
|
||||
if isinstance(e, PermissionError) and "/cwa-book-ingest" in str(e):
|
||||
book_queue.update_status_message(
|
||||
task_id,
|
||||
"Destination misconfigured. Go to Settings → Downloads to update."
|
||||
)
|
||||
else:
|
||||
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -920,10 +927,13 @@ def _post_process_download(
|
||||
status_callback("error", f"Custom script failed: {stderr[:100]}")
|
||||
return None
|
||||
|
||||
# Check cancellation before final move
|
||||
use_hardlink = _should_hardlink(task)
|
||||
is_torrent = _is_torrent_source(temp_file, task)
|
||||
|
||||
if cancel_flag.is_set():
|
||||
logger.info(f"Download cancelled before final move: {task.task_id}")
|
||||
temp_file.unlink(missing_ok=True)
|
||||
logger.info(f"Download cancelled before final transfer: {task.task_id}")
|
||||
if not is_torrent:
|
||||
temp_file.unlink(missing_ok=True)
|
||||
return None
|
||||
|
||||
# Determine filename based on organization mode
|
||||
@@ -947,14 +957,13 @@ def _post_process_download(
|
||||
dest_path = destination / filename
|
||||
|
||||
try:
|
||||
final_path = _atomic_move(temp_file, dest_path)
|
||||
final_path, op = _transfer_single_file(temp_file, dest_path, use_hardlink, is_torrent)
|
||||
logger.info(f"Download completed ({op}): {final_path.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to move file to destination: {e}")
|
||||
status_callback("error", f"Failed to move file: {e}")
|
||||
logger.error(f"Failed to transfer file to destination: {e}")
|
||||
status_callback("error", f"Failed to transfer file: {e}")
|
||||
return None
|
||||
|
||||
logger.info(f"Download completed: {final_path.name}")
|
||||
|
||||
status_callback("complete", "Complete")
|
||||
|
||||
return str(final_path)
|
||||
|
||||
+91
-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,9 +492,11 @@ 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", ""),
|
||||
"audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""),
|
||||
"debug": app_config.get("DEBUG", False),
|
||||
"build_version": BUILD_VERSION,
|
||||
"release_version": RELEASE_VERSION,
|
||||
@@ -509,6 +511,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 +1539,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 +1669,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(
|
||||
|
||||
@@ -144,6 +144,7 @@ class BookMetadata:
|
||||
genres: List[str] = field(default_factory=list)
|
||||
source_url: Optional[str] = None # Link to book on provider's site
|
||||
subtitle: Optional[str] = None # Book subtitle, if any
|
||||
search_title: Optional[str] = None # Cleaner title for search queries (provider-specific)
|
||||
|
||||
# Provider-specific display fields for cards/lists
|
||||
display_fields: List[DisplayField] = field(default_factory=list)
|
||||
|
||||
@@ -95,6 +95,29 @@ def _build_source_url(slug: str) -> Optional[str]:
|
||||
return f"https://hardcover.app/books/{slug}" if slug else None
|
||||
|
||||
|
||||
def _compute_search_title(title: str, subtitle: Optional[str]) -> Optional[str]:
|
||||
"""Compute a cleaner search title from title and subtitle.
|
||||
|
||||
When Hardcover uses the "Series: Book Title" format, the subtitle contains
|
||||
the actual book title which is better for searching. For example:
|
||||
- title: "Mistborn: The Final Empire"
|
||||
- subtitle: "The Final Empire"
|
||||
- search_title: "The Final Empire" (better for Prowlarr/indexer searches)
|
||||
|
||||
Skips subtitles that start with series position indicators like "Book One",
|
||||
"Part 1", "Volume 2" as these are descriptors, not the actual title.
|
||||
"""
|
||||
if not subtitle or subtitle not in title:
|
||||
return None
|
||||
|
||||
# Skip if subtitle starts with series position indicators
|
||||
skip_prefixes = ('book ', 'part ', 'volume ')
|
||||
if subtitle.lower().startswith(skip_prefixes):
|
||||
return None
|
||||
|
||||
return subtitle
|
||||
|
||||
|
||||
@register_provider_kwargs("hardcover")
|
||||
def _hardcover_kwargs() -> Dict[str, Any]:
|
||||
"""Provide Hardcover-specific constructor kwargs."""
|
||||
@@ -564,6 +587,7 @@ class HardcoverProvider(MetadataProvider):
|
||||
provider_id=str(book_id),
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
search_title=_compute_search_title(title, subtitle),
|
||||
provider_display_name="Hardcover",
|
||||
authors=authors,
|
||||
cover_url=cover_url,
|
||||
@@ -681,11 +705,15 @@ class HardcoverProvider(MetadataProvider):
|
||||
if code3 and code3 not in titles_by_language:
|
||||
titles_by_language[code3] = edition_title
|
||||
|
||||
title = book["title"]
|
||||
subtitle = book.get("subtitle")
|
||||
|
||||
return BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id=str(book["id"]),
|
||||
title=book["title"],
|
||||
subtitle=book.get("subtitle"),
|
||||
title=title,
|
||||
subtitle=subtitle,
|
||||
search_title=_compute_search_title(title, subtitle),
|
||||
provider_display_name="Hardcover",
|
||||
authors=authors,
|
||||
isbn_10=isbn_10,
|
||||
|
||||
@@ -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}")
|
||||
@@ -696,7 +696,7 @@ def _extract_libgen_download_url(link: str, cancel_flag: Optional[Event] = None)
|
||||
headers=downloader.DOWNLOAD_HEADERS,
|
||||
timeout=(5, 10),
|
||||
allow_redirects=True,
|
||||
proxies=network.get_proxies(),
|
||||
proxies=network.get_proxies(link),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
@@ -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,237 @@
|
||||
"""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._connecting: dict[str, bool] = {} # Track keys currently being connected
|
||||
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)
|
||||
need_new_connection = False
|
||||
dead_client = None
|
||||
|
||||
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
|
||||
|
||||
# Check if another thread is already connecting
|
||||
if self._connecting.get(key):
|
||||
logger.debug(f"Another thread is connecting to {key}, waiting...")
|
||||
# Release lock and wait, then retry
|
||||
pass # Fall through to retry logic below
|
||||
else:
|
||||
# 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)
|
||||
dead_client = existing
|
||||
|
||||
# Mark that we're connecting (prevents duplicate attempts)
|
||||
self._connecting[key] = True
|
||||
need_new_connection = True
|
||||
|
||||
# Clean up dead client outside lock
|
||||
if dead_client:
|
||||
try:
|
||||
dead_client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If another thread is connecting, wait and retry
|
||||
if not need_new_connection:
|
||||
time.sleep(0.5)
|
||||
return self.get_connection(server, port, nick, use_tls, channel)
|
||||
|
||||
# Create new connection OUTSIDE the lock to avoid blocking other threads
|
||||
try:
|
||||
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 (re-acquire lock)
|
||||
with self._conn_lock:
|
||||
self._connections[key] = client
|
||||
self._last_used[key] = time.time()
|
||||
self._channels[key] = channel
|
||||
self._connecting.pop(key, None)
|
||||
|
||||
return client
|
||||
except Exception:
|
||||
# Clear connecting flag on failure
|
||||
with self._conn_lock:
|
||||
self._connecting.pop(key, None)
|
||||
raise
|
||||
|
||||
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
|
||||
|
||||
@@ -241,6 +241,7 @@ class SABnzbdClient(DownloadClient):
|
||||
if slot.get("nzo_id") == download_id:
|
||||
status_text = slot.get("status", "").upper()
|
||||
storage = slot.get("storage", "")
|
||||
logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'")
|
||||
|
||||
if status_text == "COMPLETED":
|
||||
return DownloadStatus(
|
||||
@@ -250,31 +251,43 @@ class SABnzbdClient(DownloadClient):
|
||||
complete=True,
|
||||
file_path=storage,
|
||||
)
|
||||
else:
|
||||
# Failed or other status
|
||||
fail_message = slot.get("fail_message", status_text)
|
||||
elif status_text == "FAILED":
|
||||
fail_message = slot.get("fail_message", "Download failed")
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="error",
|
||||
message=f"Download failed: {fail_message}",
|
||||
message=fail_message,
|
||||
complete=True,
|
||||
file_path=None,
|
||||
)
|
||||
else:
|
||||
# Post-processing states: Queued, QuickCheck, Verifying,
|
||||
# Repairing, Fetching, Extracting, Moving, Running
|
||||
# Keep polling - not yet complete
|
||||
return DownloadStatus(
|
||||
progress=100,
|
||||
state="processing",
|
||||
message=status_text.title(),
|
||||
complete=False,
|
||||
file_path=None,
|
||||
)
|
||||
|
||||
# Not found
|
||||
logger.warning(f"SABnzbd: download {download_id} not found in queue or history")
|
||||
return DownloadStatus.error("Download not found")
|
||||
except Exception as e:
|
||||
error_type = type(e).__name__
|
||||
logger.error(f"SABnzbd get_status failed ({error_type}): {e}")
|
||||
return DownloadStatus.error(f"{error_type}: {e}")
|
||||
|
||||
def remove(self, download_id: str, delete_files: bool = False) -> bool:
|
||||
def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool:
|
||||
"""
|
||||
Remove a download from SABnzbd.
|
||||
|
||||
Args:
|
||||
download_id: SABnzbd nzo_id
|
||||
delete_files: Whether to delete the files
|
||||
archive: If True, move to archive instead of permanent delete (history only)
|
||||
|
||||
Returns:
|
||||
True if successful.
|
||||
@@ -301,11 +314,13 @@ class SABnzbdClient(DownloadClient):
|
||||
"name": "delete",
|
||||
"value": download_id,
|
||||
"del_files": 1 if delete_files else 0,
|
||||
"archive": 1 if archive else 0,
|
||||
},
|
||||
)
|
||||
|
||||
if result.get("status"):
|
||||
logger.info(f"Removed NZB from SABnzbd history: {download_id}")
|
||||
action = "archived" if archive else "removed"
|
||||
logger.info(f"NZB {action} from SABnzbd history: {download_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -44,6 +44,14 @@ class ProwlarrHandler(DownloadHandler):
|
||||
audiobook_key = audiobook_keys.get(client.name)
|
||||
return config.get(audiobook_key, "") or None if audiobook_key else None
|
||||
|
||||
def _cleanup_client_history(self, client, download_id: str) -> None:
|
||||
"""Remove completed download from client history if configured."""
|
||||
if client.name == "sabnzbd" and config.get("SABNZBD_REMOVE_COMPLETED", True):
|
||||
try:
|
||||
client.remove(download_id, delete_files=True, archive=True)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove from SABnzbd history: {e}")
|
||||
|
||||
def _build_progress_message(self, status) -> str:
|
||||
"""Build a progress message from download status."""
|
||||
msg = f"{status.progress:.0f}%"
|
||||
@@ -127,6 +135,7 @@ class ProwlarrHandler(DownloadHandler):
|
||||
|
||||
if result:
|
||||
remove_release(task.task_id)
|
||||
self._cleanup_client_history(client, download_id)
|
||||
return result
|
||||
|
||||
# Existing but still downloading - join the progress polling
|
||||
@@ -178,6 +187,7 @@ class ProwlarrHandler(DownloadHandler):
|
||||
) -> Optional[str]:
|
||||
"""Poll the download client for progress and handle completion."""
|
||||
try:
|
||||
logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})")
|
||||
while not cancel_flag.is_set():
|
||||
status = client.get_status(download_id)
|
||||
progress_callback(status.progress)
|
||||
@@ -185,20 +195,27 @@ class ProwlarrHandler(DownloadHandler):
|
||||
# Check for completion
|
||||
if status.complete:
|
||||
if status.state == DownloadState.ERROR:
|
||||
logger.error(f"Download {download_id} completed with error: {status.message}")
|
||||
status_callback("error", status.message or "Download failed")
|
||||
return None
|
||||
# Download complete - break to handle file
|
||||
logger.debug(f"Download {download_id} complete, file_path={status.file_path}")
|
||||
break
|
||||
|
||||
# Check for error state
|
||||
if status.state == DownloadState.ERROR:
|
||||
logger.error(f"Download {download_id} error state: {status.message}")
|
||||
status_callback("error", status.message or "Download failed")
|
||||
client.remove(download_id, delete_files=True)
|
||||
return None
|
||||
|
||||
# Build status message - use client message if provided, else build progress
|
||||
msg = status.message or self._build_progress_message(status)
|
||||
status_callback("downloading", msg)
|
||||
if status.state == DownloadState.PROCESSING:
|
||||
# Post-processing (e.g., SABnzbd verifying/extracting)
|
||||
status_callback("resolving", msg)
|
||||
else:
|
||||
status_callback("downloading", msg)
|
||||
|
||||
# Wait for next poll (interruptible by cancel)
|
||||
if cancel_flag.wait(timeout=POLL_INTERVAL):
|
||||
@@ -214,19 +231,44 @@ class ProwlarrHandler(DownloadHandler):
|
||||
# Handle completed file
|
||||
source_path = client.get_download_path(download_id)
|
||||
if not source_path:
|
||||
status_callback("error", "Could not locate downloaded file")
|
||||
logger.error(
|
||||
f"Download client returned empty path for completed download. "
|
||||
f"Client: {client.name}, ID: {download_id}. "
|
||||
f"Check that the download client's completion folder is accessible to Shelfmark."
|
||||
)
|
||||
status_callback(
|
||||
"error",
|
||||
f"Download completed in {client.name} but path not returned. "
|
||||
f"Check volume mappings and category settings."
|
||||
)
|
||||
return None
|
||||
|
||||
# Verify the path actually exists in our filesystem
|
||||
source_path_obj = Path(source_path)
|
||||
if not source_path_obj.exists():
|
||||
logger.error(
|
||||
f"Download path does not exist: {source_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. "
|
||||
f"The download client's path may not be mounted in Shelfmark's container. "
|
||||
f"Ensure both containers use identical volume mappings for the download folder."
|
||||
)
|
||||
status_callback(
|
||||
"error",
|
||||
f"Path not accessible: {source_path}. Check volume mappings between {client.name} and Shelfmark."
|
||||
)
|
||||
return None
|
||||
|
||||
result = self._handle_completed_file(
|
||||
source_path=Path(source_path),
|
||||
source_path=source_path_obj,
|
||||
protocol=protocol,
|
||||
task=task,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
# Clean up cache on success
|
||||
# Clean up on success
|
||||
if result:
|
||||
remove_release(task.task_id)
|
||||
self._cleanup_client_history(client, download_id)
|
||||
|
||||
return result
|
||||
|
||||
@@ -279,12 +321,22 @@ class ProwlarrHandler(DownloadHandler):
|
||||
|
||||
return str(staged_path)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
logger.error(
|
||||
f"Source file not found during staging: {source_path}. "
|
||||
f"The file may have been moved or deleted by the download client. Error: {e}"
|
||||
)
|
||||
status_callback("error", f"File not found: {source_path}. It may have been moved or deleted.")
|
||||
return None
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied staging file: {e}")
|
||||
status_callback("error", f"Permission denied: {e}")
|
||||
logger.error(
|
||||
f"Permission denied staging file from {source_path}. "
|
||||
f"Check that Shelfmark has read access to the download folder. Error: {e}"
|
||||
)
|
||||
status_callback("error", f"Permission denied accessing {source_path}. Check folder permissions.")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Staging failed: {e}")
|
||||
logger.error(f"Staging failed for {source_path}: {e}")
|
||||
status_callback("error", f"Failed to stage file: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -396,8 +396,8 @@ def prowlarr_clients_settings():
|
||||
key="QBITTORRENT_CATEGORY",
|
||||
label="Book Category",
|
||||
description="Category to assign to book downloads in qBittorrent",
|
||||
placeholder="cwabd",
|
||||
default="cwabd",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
|
||||
),
|
||||
TextField(
|
||||
@@ -441,8 +441,8 @@ def prowlarr_clients_settings():
|
||||
key="TRANSMISSION_CATEGORY",
|
||||
label="Book Label",
|
||||
description="Label to assign to book downloads in Transmission",
|
||||
placeholder="cwabd",
|
||||
default="cwabd",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "transmission"},
|
||||
),
|
||||
TextField(
|
||||
@@ -495,8 +495,8 @@ def prowlarr_clients_settings():
|
||||
key="DELUGE_CATEGORY",
|
||||
label="Book Label",
|
||||
description="Label to assign to book downloads in Deluge",
|
||||
placeholder="cwabd",
|
||||
default="cwabd",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
|
||||
),
|
||||
TextField(
|
||||
@@ -601,8 +601,8 @@ def prowlarr_clients_settings():
|
||||
key="SABNZBD_CATEGORY",
|
||||
label="Book Category",
|
||||
description="Category to assign to book downloads in SABnzbd",
|
||||
placeholder="cwabd",
|
||||
default="cwabd",
|
||||
placeholder="books",
|
||||
default="books",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
TextField(
|
||||
@@ -613,11 +613,18 @@ def prowlarr_clients_settings():
|
||||
default="",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
CheckboxField(
|
||||
key="SABNZBD_REMOVE_COMPLETED",
|
||||
label="Remove completed downloads from history",
|
||||
default=True,
|
||||
description="Remove downloads from SABnzbd history after successful import (archives them)",
|
||||
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
|
||||
),
|
||||
|
||||
# Note: Usenet client download path must be mounted identically in both containers.
|
||||
SelectField(
|
||||
key="PROWLARR_USENET_ACTION",
|
||||
label="Completion Action",
|
||||
label="NZB Completion Action",
|
||||
description="What to do with usenet files after download completes",
|
||||
options=[
|
||||
{"value": "move", "label": "Move to ingest"},
|
||||
|
||||
@@ -306,8 +306,10 @@ class ProwlarrSource(ReleaseSource):
|
||||
|
||||
# Build search query
|
||||
query_parts = []
|
||||
if book.title:
|
||||
query_parts.append(book.title)
|
||||
# Prefer search_title if available (cleaner title for searches)
|
||||
search_title = book.search_title or book.title
|
||||
if search_title:
|
||||
query_parts.append(search_title)
|
||||
if book.authors:
|
||||
# Use first author only - authors may be a list or a single string
|
||||
# that contains multiple comma-separated names (from frontend)
|
||||
@@ -326,31 +328,38 @@ class ProwlarrSource(ReleaseSource):
|
||||
logger.warning("No search query available for book")
|
||||
return []
|
||||
|
||||
# Get selected indexer IDs from config
|
||||
# Get selected indexer IDs from config (None means search all)
|
||||
indexer_ids = self._get_selected_indexer_ids()
|
||||
|
||||
if not indexer_ids:
|
||||
logger.warning("No indexers selected - configure indexers in Prowlarr settings")
|
||||
return []
|
||||
|
||||
# Get search categories based on content type
|
||||
# Audiobooks use 3030 (Audio/Audiobook), ebooks use 7000 (Books)
|
||||
search_categories = [3030] if content_type == "audiobook" else [7000]
|
||||
categories = None if expand_search else search_categories
|
||||
self.last_search_type = "expanded" if expand_search else "categories"
|
||||
|
||||
logger.debug(f"Searching Prowlarr: query='{query}', indexers={indexer_ids}, categories={categories}")
|
||||
indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
|
||||
logger.debug(f"Searching Prowlarr: query='{query}', {indexer_desc}, categories={categories}")
|
||||
|
||||
def search_indexers(cats: Optional[List[int]]) -> List[dict]:
|
||||
"""Search all indexers with given categories, collecting results."""
|
||||
"""Search indexers with given categories, collecting results."""
|
||||
results = []
|
||||
for indexer_id in indexer_ids:
|
||||
if indexer_ids:
|
||||
# Search specific indexers one at a time
|
||||
for indexer_id in indexer_ids:
|
||||
try:
|
||||
raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed for indexer {indexer_id}: {e}")
|
||||
else:
|
||||
# Search all enabled indexers at once
|
||||
try:
|
||||
raw = client.search(query=query, indexer_ids=[indexer_id], categories=cats)
|
||||
raw = client.search(query=query, indexer_ids=None, categories=cats)
|
||||
if raw:
|
||||
results.extend(raw)
|
||||
except Exception as e:
|
||||
logger.warning(f"Search failed for indexer {indexer_id}: {e}")
|
||||
logger.warning(f"Search failed for all indexers: {e}")
|
||||
return results
|
||||
|
||||
all_results = []
|
||||
|
||||
@@ -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')
|
||||
@@ -525,6 +541,7 @@ function App() {
|
||||
<SearchModeProvider searchMode={searchMode}>
|
||||
<Header
|
||||
calibreWebUrl={config?.calibre_web_url || ''}
|
||||
audiobookLibraryUrl={config?.audiobook_library_url || ''}
|
||||
debug={config?.debug || false}
|
||||
logoUrl="/logo.png"
|
||||
showSearch={!isInitialState}
|
||||
@@ -702,6 +719,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"
|
||||
|
||||
@@ -14,6 +14,7 @@ interface StatusCounts {
|
||||
|
||||
interface HeaderProps {
|
||||
calibreWebUrl?: string;
|
||||
audiobookLibraryUrl?: string;
|
||||
debug?: boolean;
|
||||
logoUrl?: string;
|
||||
showSearch?: boolean;
|
||||
@@ -37,6 +38,7 @@ interface HeaderProps {
|
||||
|
||||
export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
calibreWebUrl,
|
||||
audiobookLibraryUrl,
|
||||
debug,
|
||||
logoUrl,
|
||||
showSearch = false,
|
||||
@@ -157,23 +159,43 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
|
||||
onSearchChange?.(value);
|
||||
};
|
||||
|
||||
// Determine if we should show icons only (both URLs configured)
|
||||
const showIconsOnly = Boolean(calibreWebUrl && audiobookLibraryUrl);
|
||||
|
||||
// Icon buttons component - reused for both states
|
||||
const IconButtons = () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Calibre-Web Button */}
|
||||
{/* Book Library Button */}
|
||||
{calibreWebUrl && (
|
||||
<a
|
||||
href={calibreWebUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
|
||||
aria-label="Open Calibre-Web"
|
||||
title="Go To Library"
|
||||
aria-label="Open book library"
|
||||
title={showIconsOnly ? "Book Library" : "Go To Library"}
|
||||
>
|
||||
<svg className="w-5 h-5" 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 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Go To Library</span>
|
||||
{!showIconsOnly && <span className="text-sm font-medium">Go To Library</span>}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* Audiobook Library Button */}
|
||||
{audiobookLibraryUrl && (
|
||||
<a
|
||||
href={audiobookLibraryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-full hover-action transition-all duration-200 text-gray-900 dark:text-gray-100"
|
||||
aria-label="Open audiobook library"
|
||||
title={showIconsOnly ? "Audiobook Library" : "Go To Library"}
|
||||
>
|
||||
<svg className="w-5 h-5" 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="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.009 9.009 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
|
||||
</svg>
|
||||
{!showIconsOnly && <span className="text-sm font-medium">Go To Library</span>}
|
||||
</a>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -12,20 +12,20 @@ import { LanguageMultiSelect } from './LanguageMultiSelect';
|
||||
import { LANGUAGE_OPTION_ALL, LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, releaseLanguageMatchesFilter } from '../utils/languageFilters';
|
||||
|
||||
// Module-level cache for release search results
|
||||
// Key format: `${provider}:${provider_id}:${source}`
|
||||
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
|
||||
// This persists across modal open/close cycles
|
||||
const releaseCache = new Map<string, ReleasesResponse>();
|
||||
|
||||
function getCacheKey(provider: string, providerId: string, source: string): string {
|
||||
return `${provider}:${providerId}:${source}`;
|
||||
function getCacheKey(provider: string, providerId: string, source: string, contentType: string): string {
|
||||
return `${provider}:${providerId}:${source}:${contentType}`;
|
||||
}
|
||||
|
||||
// Default cache TTL (5 minutes) - sources can override via column_config.cache_ttl_seconds
|
||||
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cacheTimestamps = new Map<string, number>();
|
||||
|
||||
function getCachedReleases(provider: string, providerId: string, source: string): ReleasesResponse | null {
|
||||
const key = getCacheKey(provider, providerId, source);
|
||||
function getCachedReleases(provider: string, providerId: string, source: string, contentType: string): ReleasesResponse | null {
|
||||
const key = getCacheKey(provider, providerId, source, contentType);
|
||||
const timestamp = cacheTimestamps.get(key);
|
||||
const cached = releaseCache.get(key);
|
||||
|
||||
@@ -49,8 +49,8 @@ function getCachedReleases(provider: string, providerId: string, source: string)
|
||||
return null;
|
||||
}
|
||||
|
||||
function setCachedReleases(provider: string, providerId: string, source: string, data: ReleasesResponse): void {
|
||||
const key = getCacheKey(provider, providerId, source);
|
||||
function setCachedReleases(provider: string, providerId: string, source: string, contentType: string, data: ReleasesResponse): void {
|
||||
const key = getCacheKey(provider, providerId, source, contentType);
|
||||
releaseCache.set(key, data);
|
||||
cacheTimestamps.set(key, Date.now());
|
||||
}
|
||||
@@ -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']
|
||||
}]);
|
||||
@@ -833,7 +833,7 @@ export const ReleaseModal = ({
|
||||
if (releasesBySource[activeTab] !== undefined || loadingBySource[activeTab] || errorBySource[activeTab]) return;
|
||||
|
||||
// Check module-level cache first
|
||||
const cached = getCachedReleases(provider, bookId, activeTab);
|
||||
const cached = getCachedReleases(provider, bookId, activeTab, contentType);
|
||||
if (cached) {
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: cached }));
|
||||
return;
|
||||
@@ -845,7 +845,7 @@ export const ReleaseModal = ({
|
||||
|
||||
try {
|
||||
const response = await getReleases(provider, bookId, activeTab, book.title, book.author, undefined, undefined, contentType);
|
||||
setCachedReleases(provider, bookId, activeTab, response);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, response);
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
|
||||
@@ -1550,7 +1550,7 @@ export const ReleaseModal = ({
|
||||
const bookId = book.provider_id;
|
||||
|
||||
// Clear cache and state
|
||||
const key = getCacheKey(provider, bookId, activeTab);
|
||||
const key = getCacheKey(provider, bookId, activeTab, contentType);
|
||||
releaseCache.delete(key);
|
||||
cacheTimestamps.delete(key);
|
||||
setExpandedBySource((prev) => {
|
||||
@@ -1577,7 +1577,7 @@ export const ReleaseModal = ({
|
||||
const response = await getReleases(
|
||||
provider, bookId, activeTab, book.title, book.author, false, languagesParam, contentType
|
||||
);
|
||||
setCachedReleases(provider, bookId, activeTab, response);
|
||||
setCachedReleases(provider, bookId, activeTab, contentType, response);
|
||||
setReleasesBySource((prev) => ({ ...prev, [activeTab]: response }));
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to fetch releases';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -148,6 +148,7 @@ export type ContentType = 'ebook' | 'audiobook';
|
||||
|
||||
export interface AppConfig {
|
||||
calibre_web_url: string;
|
||||
audiobook_library_url: string;
|
||||
debug: boolean;
|
||||
build_version: string;
|
||||
release_version: string;
|
||||
@@ -162,7 +163,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;
|
||||
|
||||
|
||||
@@ -247,6 +247,57 @@ class TestSABnzbdClientGetStatus:
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/complete/book"
|
||||
|
||||
def test_get_status_complete_empty_storage(self, monkeypatch):
|
||||
"""Test status for completed NZB with empty storage path.
|
||||
|
||||
This can happen if SABnzbd category is misconfigured or files are
|
||||
deleted after completion. The file_path should be empty string.
|
||||
"""
|
||||
config_values = {
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "cwabd",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.release_sources.prowlarr.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
def mock_api_call(mode, params=None):
|
||||
if mode == "queue":
|
||||
return {"queue": {"slots": []}}
|
||||
if mode == "history":
|
||||
return {
|
||||
"history": {
|
||||
"slots": [
|
||||
{
|
||||
"nzo_id": "SABnzbd_nzo_abc123",
|
||||
"status": "Completed",
|
||||
"storage": "", # Empty storage path
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
return {}
|
||||
|
||||
from shelfmark.release_sources.prowlarr.clients.sabnzbd import (
|
||||
SABnzbdClient,
|
||||
)
|
||||
|
||||
with patch.object(SABnzbdClient, "__init__", lambda x: None):
|
||||
client = SABnzbdClient()
|
||||
client.url = "http://localhost:8080"
|
||||
client.api_key = "abc123"
|
||||
client._category = "cwabd"
|
||||
client._api_call = mock_api_call
|
||||
|
||||
status = client.get_status("SABnzbd_nzo_abc123")
|
||||
|
||||
assert status.progress == 100.0
|
||||
assert status.state_value == "complete"
|
||||
assert status.complete is True
|
||||
assert status.file_path == "" # Empty, not None
|
||||
|
||||
def test_get_status_failed(self, monkeypatch):
|
||||
"""Test status for failed NZB."""
|
||||
config_values = {
|
||||
|
||||
Reference in New Issue
Block a user