Compare commits

..
10 Commits
Author SHA1 Message Date
Alex 475ae420e5 Fix: Improve hardlink robustness (#449) 2026-01-13 20:59:18 +00:00
Alex c48d7a0cb0 Fix: Run onboarding once for all users (#446) 2026-01-13 20:13:14 +00:00
Alex 66dca96182 Fix: IRC connection threading + fs logging (#445) 2026-01-13 19:37:25 +00:00
Alex 8b801c104e Fix: Add SABnzbd cleanup, file directory notice, audiobook button (#444)
- Add SABnzbd archive cleanup upon download completion
- Added frontend audiobook library URL button
- Clearer errors for misconfigured destination paths
- Test clients.yml added
2026-01-13 19:06:17 +00:00
Alex be5382cd1e Fix: Config initialization and category fallback (#442)
- Added more robust config directory initialisation and file creation
- Fixed category fallback not triggering correctly for one content type
if another is cached
2026-01-13 18:26:51 +00:00
Alex fbc3dd2552 Fix: SABnzbd status polling (#439) 2026-01-13 16:29:12 +00:00
Alex bd1ad3495c Proxy, prowlarr, ingest dir fixes (#437) 2026-01-13 14:18:23 +00:00
Alex a0079c5a7f Added onboarding + release fixes (#433) 2026-01-13 11:25:12 +00:00
CaliBrain 92b8323a8b make os mv and cp commands non interactive (#430)
- fix(fs): handle NFS permission errors with robust fallback - Catch
PermissionError/OSError(EPERM) in atomic_move and atomic_copy -
Implement layered fallback: shutil.copyfile (content only) -> system
mv/cp - Add _perform_nfs_fallback and _system_op helpers to reduce
duplication - Set fallback logging to DEBUG to reduce spam on NFS mounts
- make os mv and cp commands non interactive (-f)
2026-01-13 00:08:49 -05:00
CaliBrain 1ca80e8b6f fix(fs): handle NFS permission errors with robust fallback (#429)
Fix for #423

- Catch PermissionError/OSError(EPERM) in atomic_move and atomic_copy
- Implement layered fallback: shutil.copyfile (content only) -> system
mv/cp
- Add _perform_nfs_fallback and _system_op helpers to reduce duplication
- Set fallback logging to DEBUG to reduce spam on NFS mounts
2026-01-13 00:02:26 -05:00
41 changed files with 2274 additions and 212 deletions
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

+161
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 -4
View File
@@ -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 │
├─────────────────────────────────────────────────────────────┤
+2 -2
View File
@@ -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
+35 -20
View File
@@ -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 ===
+429
View File
@@ -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)}
+81
View File
@@ -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 = {}
+80 -2
View File
@@ -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}")
+3 -3
View File
@@ -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
)
+56 -4
View File
@@ -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
+33 -24
View File
@@ -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
View File
@@ -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(
+1
View File
@@ -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)
+30 -2
View File
@@ -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,
+5 -5
View File
@@ -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):
+88 -30
View File
@@ -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()
+16 -10
View File
@@ -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",
+16 -11
View File
@@ -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
+59 -7
View File
@@ -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
+16 -9
View File
@@ -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"},
+21 -12
View File
@@ -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 = []
+25
View File
@@ -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>
);
+1 -1
View File
@@ -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"
+26 -4
View File
@@ -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 -12
View File
@@ -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';
+2 -2
View File
@@ -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)' },
+2 -2
View File
@@ -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 {
+102 -31
View File
@@ -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
+3 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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;
+51
View File
@@ -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 = {