diff --git a/docs/environment-variables.md b/docs/environment-variables.md index e90048d..8dee363 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -192,7 +192,7 @@ How you want to search for and download books. - **Type:** string (choice) - **Default:** `direct` -- **Options:** Direct, Universal +- **Options:** `direct` (Direct), `universal` (Universal) #### `AA_DEFAULT_SORT` @@ -202,7 +202,7 @@ Default sort order for search results. - **Type:** string (choice) - **Default:** `relevance` -- **Options:** Most relevant, Newest (publication year), Oldest (publication year), Largest (filesize), Smallest (filesize), Newest (open sourced), Oldest (open sourced) +- **Options:** `relevance` (Most relevant), `newest` (Newest (publication year)), `oldest` (Oldest (publication year)), `largest` (Largest (filesize)), `smallest` (Smallest (filesize)), `newest_added` (Newest (open sourced)), `oldest_added` (Oldest (open sourced)) #### `METADATA_PROVIDER` @@ -212,7 +212,7 @@ Choose which metadata provider to use for book searches. - **Type:** string (choice) - **Default:** `openlibrary` -- **Options:** No providers enabled +- **Options:** `""` (No providers enabled) #### `METADATA_PROVIDER_AUDIOBOOK` @@ -222,7 +222,7 @@ Metadata provider for audiobook searches. Uses the book provider if not set. - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** Use book provider, No providers enabled +- **Options:** `""` (Use book provider), `""` (No providers enabled) #### `DEFAULT_RELEASE_SOURCE` @@ -232,7 +232,7 @@ The release source tab to open by default in the release modal. - **Type:** string (choice) - **Default:** `direct_download` -- **Options:** Direct Download, Prowlarr +- **Options:** `direct_download` (Direct Download), `prowlarr` (Prowlarr) @@ -272,7 +272,7 @@ Choose where completed book files are sent. - **Type:** string (choice) - **Default:** `folder` -- **Options:** Folder, Booklore (API) +- **Options:** `folder` (Folder), `booklore` (Booklore (API)) #### `INGEST_DIR` @@ -292,7 +292,7 @@ Choose how downloaded book files are named and organized. - **Type:** string (choice) - **Default:** `rename` -- **Options:** None, Rename, Organize +- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize) #### `TEMPLATE_RENAME` @@ -388,7 +388,7 @@ Choose how downloaded audiobook files are named and organized. - **Type:** string (choice) - **Default:** `rename` -- **Options:** None, Rename, Organize +- **Options:** `none` (None), `rename` (Rename Only), `organize` (Rename and Organize) #### `TEMPLATE_AUDIOBOOK_RENAME` @@ -483,7 +483,7 @@ DNS provider for domain resolution. 'Auto' rotates through providers on failure. - **Type:** string (choice) - **Default:** `auto` -- **Options:** Auto (Recommended), System, Google, Cloudflare, Quad9, OpenDNS, Manual +- **Options:** `auto` (Auto (Recommended)), `system` (System), `google` (Google), `cloudflare` (Cloudflare), `quad9` (Quad9), `opendns` (OpenDNS), `manual` (Manual) #### `CUSTOM_DNS_MANUAL` @@ -520,7 +520,7 @@ Choose proxy type. SOCKS5 handles all traffic through a single proxy. - **Type:** string (choice) - **Default:** `none` -- **Options:** None (Direct Connection), HTTP/HTTPS Proxy, SOCKS5 Proxy +- **Options:** `none` (None (Direct Connection)), `http` (HTTP/HTTPS Proxy), `socks5` (SOCKS5 Proxy) #### `HTTP_PROXY` @@ -564,10 +564,12 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local) | Variable | Description | Type | Default | |----------|-------------|------|---------| -| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ | +| `URL_BASE` | Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root. | string | _none_ | | `DEBUG` | Enable verbose logging to console and file. Not recommended for normal use. | boolean | `false` | | `MAIN_LOOP_SLEEP_TIME` | How often the download queue is checked for new items. | number | `5` | | `DOWNLOAD_PROGRESS_UPDATE_INTERVAL` | How often download progress is broadcast to the UI. | number | `1` | +| `CUSTOM_SCRIPT` | Path to a script to run after each successful download. Must be executable. | string | _none_ | +| `CUSTOM_SCRIPT_PATH_MODE` | Pass the path to the custom script as an absolute path or relative to the destination folder. | string (choice) | `absolute` | | `COVERS_CACHE_ENABLED` | Cache book covers on the server for faster loading. | boolean | `true` | | `COVERS_CACHE_TTL` | How long to keep cached covers. Set to 0 to keep forever (recommended for static artwork). | number | `0` | | `COVERS_CACHE_MAX_SIZE_MB` | Maximum disk space for cached covers. Oldest images are removed when limit is reached. | number | `500` | @@ -578,14 +580,15 @@ Comma-separated hosts to bypass proxy (e.g., localhost,127.0.0.1,10.*,*.local)
Detailed descriptions -#### `CUSTOM_SCRIPT` +#### `URL_BASE` -**Custom Script Path** +**Base Path** -Path to a script to run after each successful download. Must be executable. +Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root. - **Type:** string - **Default:** _none_ +- **Requires restart:** Yes #### `DEBUG` @@ -619,6 +622,25 @@ How often download progress is broadcast to the UI. - **Requires restart:** Yes - **Constraints:** min: 1, max: 10 +#### `CUSTOM_SCRIPT` + +**Custom Script Path** + +Path to a script to run after each successful download. Must be executable. + +- **Type:** string +- **Default:** _none_ + +#### `CUSTOM_SCRIPT_PATH_MODE` + +**Custom Script Path Mode** + +Pass the path to the custom script as an absolute path or relative to the destination folder. + +- **Type:** string (choice) +- **Default:** `absolute` +- **Options:** `absolute` (Absolute), `relative` (Relative) + #### `COVERS_CACHE_ENABLED` **Enable Cover Cache** @@ -759,7 +781,7 @@ How long to keep cached search results before they expire. - **Type:** string (choice) - **Default:** `2592000` -- **Options:** 30 days, Forever (until manually cleared) +- **Options:** `2592000` (30 days), `0` (Forever (until manually cleared))
@@ -805,7 +827,7 @@ Default sort order for Hardcover search results. - **Type:** string (choice) - **Default:** `relevance` -- **Options:** Most relevant, Most popular, Highest rated, Newest, Oldest +- **Options:** `relevance` (Most relevant), `popularity` (Most popular), `rating` (Highest rated), `newest` (Newest), `oldest` (Oldest) #### `HARDCOVER_EXCLUDE_COMPILATIONS` @@ -854,7 +876,7 @@ Default sort order for Open Library search results. - **Type:** string (choice) - **Default:** `relevance` -- **Options:** Most relevant, Newest, Oldest +- **Options:** `relevance` (Most relevant), `newest` (Newest), `oldest` (Oldest) @@ -896,7 +918,7 @@ Default sort order for Google Books search results. - **Type:** string (choice) - **Default:** `relevance` -- **Options:** Most relevant, Newest +- **Options:** `relevance` (Most relevant), `newest` (Newest) @@ -1127,7 +1149,7 @@ Select 'Auto' to probe mirrors on startup, or choose a specific mirror. - **Type:** string (choice) - **Default:** `auto` -- **Options:** Auto (Recommended), annas-archive.se, annas-archive.li, annas-archive.pm, annas-archive.in +- **Options:** `auto` (Auto (Recommended)), `https://annas-archive.se` (annas-archive.se), `https://annas-archive.li` (annas-archive.li), `https://annas-archive.pm` (annas-archive.pm), `https://annas-archive.in` (annas-archive.in) #### `AA_ADDITIONAL_URLS` @@ -1155,7 +1177,7 @@ Z-Library mirror to use for downloads. - **Type:** string (choice) - **Default:** `https://z-lib.fm` -- **Options:** z-lib.fm, z-lib.gs, z-lib.id, z-library.sk, zlibrary-global.se +- **Options:** `https://z-lib.fm` (z-lib.fm), `https://z-lib.gs` (z-lib.gs), `https://z-lib.id` (z-lib.id), `https://z-library.sk` (z-library.sk), `https://zlibrary-global.se` (zlibrary-global.se) #### `ZLIB_ADDITIONAL_URLS` @@ -1174,7 +1196,7 @@ Welib mirror to use for downloads. - **Type:** string (choice) - **Default:** `https://welib.org` -- **Options:** welib.org +- **Options:** `https://welib.org` (welib.org) #### `WELIB_ADDITIONAL_URLS` @@ -1271,6 +1293,11 @@ Automatically retry search without category filtering if no results are found | `DELUGE_PASSWORD` | Deluge Web UI password (default: deluge) | string (secret) | _none_ | | `DELUGE_CATEGORY` | Label to assign to book downloads in Deluge | string | `books` | | `DELUGE_CATEGORY_AUDIOBOOK` | Label for audiobook downloads. Leave empty to use the book label. | string | _empty string_ | +| `RTORRENT_URL` | XML-RPC URL of your rTorrent instance | string | _none_ | +| `RTORRENT_USERNAME` | HTTP Basic auth username (if authentication enabled) | string | _none_ | +| `RTORRENT_PASSWORD` | HTTP Basic auth password | string (secret) | _none_ | +| `RTORRENT_LABEL` | Label to assign to book downloads in rTorrent | string | `cwabd` | +| `RTORRENT_DOWNLOAD_DIR` | Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) | string | _none_ | | `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ | | `NZBGET_URL` | URL of your NZBGet instance | string | _none_ | | `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` | @@ -1281,7 +1308,7 @@ Automatically retry search without category filtering if no results are found | `SABNZBD_API_KEY` | Found in SABnzbd: Config > General > API Key | string (secret) | _none_ | | `SABNZBD_CATEGORY` | Category to assign to book downloads in SABnzbd | string | `books` | | `SABNZBD_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ | -| `PROWLARR_USENET_ACTION` | Copy files into your ingest folder, optionally cleaning up the usenet client | string (choice) | `move` | +| `PROWLARR_USENET_ACTION` | Move deletes the job from your usenet client after import; Copy keeps it in the client | string (choice) | `move` |
Detailed descriptions @@ -1294,7 +1321,7 @@ Choose which torrent client to use - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** None, qBittorrent, Transmission, Deluge +- **Options:** `""` (None), `qbittorrent` (qBittorrent), `transmission` (Transmission), `deluge` (Deluge), `rtorrent` (rTorrent) #### `QBITTORRENT_URL` @@ -1431,6 +1458,51 @@ Label for audiobook downloads. Leave empty to use the book label. - **Type:** string - **Default:** _empty string_ +#### `RTORRENT_URL` + +**rTorrent URL** + +XML-RPC URL of your rTorrent instance + +- **Type:** string +- **Default:** _none_ + +#### `RTORRENT_USERNAME` + +**Username** + +HTTP Basic auth username (if authentication enabled) + +- **Type:** string +- **Default:** _none_ + +#### `RTORRENT_PASSWORD` + +**Password** + +HTTP Basic auth password + +- **Type:** string (secret) +- **Default:** _none_ + +#### `RTORRENT_LABEL` + +**Book Label** + +Label to assign to book downloads in rTorrent + +- **Type:** string +- **Default:** `cwabd` + +#### `RTORRENT_DOWNLOAD_DIR` + +**Download Directory** + +Server-side directory where torrents are downloaded (optional, uses rTorrent default if not specified) + +- **Type:** string +- **Default:** _none_ + #### `PROWLARR_USENET_CLIENT` **Usenet Client** @@ -1439,7 +1511,7 @@ Choose which usenet client to use - **Type:** string (choice) - **Default:** _empty string_ -- **Options:** None, NZBGet, SABnzbd +- **Options:** `""` (None), `nzbget` (NZBGet), `sabnzbd` (SABnzbd) #### `NZBGET_URL` @@ -1526,10 +1598,10 @@ Category for audiobook downloads. Leave empty to use the book category. **NZB Completion Action** -Copy files into your ingest folder, optionally cleaning up the usenet client +Move deletes the job from your usenet client after import; Copy keeps it in the client - **Type:** string (choice) - **Default:** `move` -- **Options:** Copy and remove from client, Copy (keep in client) +- **Options:** `move` (Move), `copy` (Copy)
diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..b73dc8f --- /dev/null +++ b/docs/reverse-proxy.md @@ -0,0 +1,35 @@ +# Reverse Proxy & Subpath Hosting + +Shelfmark can run behind a reverse proxy at the root path (recommended) or +under a subpath like `/shelfmark`. + +## Subpath setup + +1) Set the base path in Shelfmark: +- UI: Settings → Advanced → Base Path +- Env var: `URL_BASE=/shelfmark` + +2) Configure your reverse proxy to forward the subpath to Shelfmark and +**strip the prefix** before sending to the backend. The proxy must also allow +WebSocket upgrades for Socket.IO. + +Example (Nginx-style): + +``` +location /shelfmark/ { + proxy_pass http://shelfmark:8084/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +} +``` + +Notes: +- Use a trailing slash on the `location` and `proxy_pass` to ensure the + `/shelfmark` prefix is removed. +- Health checks still work at `/api/health` without the subpath. + +## Root path setup + +If you can serve Shelfmark at the root path (`https://shelfmark.example.com/`), +leave `URL_BASE` empty. This is the simplest option. diff --git a/readme.md b/readme.md index 46872bf..de3f84f 100644 --- a/readme.md +++ b/readme.md @@ -96,6 +96,8 @@ Environment variables work for initial setup and Docker deployments. They serve | `SEARCH_MODE` | `direct` or `universal` | `direct` | | `USING_TOR` | Enable Tor routing (requires `NET_ADMIN` capability) | `false` | +See the full [Environment Variables Reference](docs/environment-variables.md) for all available options. + Some of the additional options available in Settings: - **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 diff --git a/scripts/generate_env_docs.py b/scripts/generate_env_docs.py index f8ea198..3428e9f 100755 --- a/scripts/generate_env_docs.py +++ b/scripts/generate_env_docs.py @@ -83,7 +83,11 @@ def format_default_value(field) -> str: def get_select_options(field) -> Optional[List[str]]: - """Get the available options for a SelectField.""" + """Get the available options for a SelectField. + + Returns options formatted as 'value (label)' or just 'value' if they match, + so users know the actual values to use in environment variables. + """ from shelfmark.core.settings_registry import SelectField if not isinstance(field, SelectField): @@ -99,7 +103,20 @@ def get_select_options(field) -> Optional[List[str]]: if not options: return None - return [opt.get("label", opt.get("value", "")) for opt in options] + result = [] + for opt in options: + value = opt.get("value", "") + label = opt.get("label", "") + + # Format as "value (label)" unless they're the same or value is empty + if value == "": + result.append(f'`""` ({label})') + elif value == label or not label: + result.append(f"`{value}`") + else: + result.append(f"`{value}` ({label})") + + return result def _generate_bootstrap_env_docs() -> List[str]: diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 852a81e..871077d 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -865,7 +865,7 @@ register_on_save("downloads", _on_save_downloads) def _get_fast_source_options(): - """Fast download sources - display only, not configurable.""" + """Fast download sources - configurable list shown in settings.""" from shelfmark.core.config import config has_donator_key = bool(config.get("AA_DONATOR_KEY", "")) @@ -1184,6 +1184,54 @@ def mirror_settings(): def advanced_settings(): """Advanced settings for power users.""" return [ + TextField( + key="URL_BASE", + label="Base Path", + description="Optional URL path prefix. Use a path like /shelfmark (no hostname). Leave blank for root.", + placeholder="/shelfmark", + requires_restart=True, + ), + CheckboxField( + key="DEBUG", + label="Debug Mode", + description="Enable verbose logging to console and file. Not recommended for normal use.", + default=False, + requires_restart=True, + ), + NumberField( + key="MAIN_LOOP_SLEEP_TIME", + label="Queue Check Interval (seconds)", + description="How often the download queue is checked for new items.", + default=5, + min_value=1, + max_value=60, + requires_restart=True, + ), + NumberField( + key="DOWNLOAD_PROGRESS_UPDATE_INTERVAL", + label="Progress Update Interval (seconds)", + description="How often download progress is broadcast to the UI.", + default=1, + min_value=1, + max_value=10, + requires_restart=True, + ), + TextField( + key="CUSTOM_SCRIPT", + label="Custom Script Path", + description="Path to a script to run after each successful download. Must be executable.", + placeholder="/path/to/script.sh", + ), + SelectField( + key="CUSTOM_SCRIPT_PATH_MODE", + label="Custom Script Path Mode", + description="Pass the path to the custom script as an absolute path or relative to the destination folder.", + options=[ + {"value": "absolute", "label": "Absolute", "description": "Pass the full destination path (default)."}, + {"value": "relative", "label": "Relative", "description": "Pass the path relative to the destination folder."}, + ], + default="absolute", + ), HeadingField( key="remote_path_mappings_heading", title="Remote Path Mappings", @@ -1211,13 +1259,11 @@ def advanced_settings(): "key": "remotePath", "label": "Remote Path", "type": "path", - "placeholder": "/downloads", }, { "key": "localPath", "label": "Local Path", "type": "path", - "placeholder": "/data/downloads", }, ], default=[], @@ -1225,47 +1271,6 @@ def advanced_settings(): empty_message="No mappings configured.", env_supported=False, ), - TextField( - key="CUSTOM_SCRIPT", - label="Custom Script Path", - description="Path to a script to run after each successful download. Must be executable.", - placeholder="/path/to/script.sh", - ), - SelectField( - key="CUSTOM_SCRIPT_PATH_MODE", - label="Custom Script Path Mode", - description="Pass the path to the custom script as an absolute path or relative to the destination folder.", - options=[ - {"value": "absolute", "label": "Absolute", "description": "Pass the full destination path (default)."}, - {"value": "relative", "label": "Relative", "description": "Pass the path relative to the destination folder."}, - ], - default="absolute", - ), - CheckboxField( - key="DEBUG", - label="Debug Mode", - description="Enable verbose logging to console and file. Not recommended for normal use.", - default=False, - requires_restart=True, - ), - NumberField( - key="MAIN_LOOP_SLEEP_TIME", - label="Queue Check Interval (seconds)", - description="How often the download queue is checked for new items.", - default=5, - min_value=1, - max_value=60, - requires_restart=True, - ), - NumberField( - key="DOWNLOAD_PROGRESS_UPDATE_INTERVAL", - label="Progress Update Interval (seconds)", - description="How often download progress is broadcast to the UI.", - default=1, - min_value=1, - max_value=10, - requires_restart=True, - ), HeadingField( key="covers_cache_heading", title="Cover Image Cache", diff --git a/shelfmark/core/prefix_middleware.py b/shelfmark/core/prefix_middleware.py new file mode 100644 index 0000000..80684a7 --- /dev/null +++ b/shelfmark/core/prefix_middleware.py @@ -0,0 +1,31 @@ +"""WSGI middleware for hosting Shelfmark under a URL prefix.""" + +from __future__ import annotations + +from typing import Iterable, Optional + + +class PrefixMiddleware: + """Strip a configured URL prefix from PATH_INFO before routing.""" + + def __init__(self, app, prefix: str, bypass_paths: Optional[Iterable[str]] = None) -> None: + self.app = app + self.prefix = prefix.rstrip("/") + self.bypass_paths = set(bypass_paths or []) + + def __call__(self, environ, start_response): + path = environ.get("PATH_INFO", "") or "" + + if path in self.bypass_paths: + return self.app(environ, start_response) + + if not self.prefix: + return self.app(environ, start_response) + + if path == self.prefix or path.startswith(self.prefix + "/"): + environ["SCRIPT_NAME"] = self.prefix + environ["PATH_INFO"] = path[len(self.prefix):] or "/" + return self.app(environ, start_response) + + start_response("404 Not Found", [("Content-Type", "text/plain")]) + return [b"Not Found"] diff --git a/shelfmark/release_sources/search_plan.py b/shelfmark/core/search_plan.py similarity index 100% rename from shelfmark/release_sources/search_plan.py rename to shelfmark/core/search_plan.py diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 28f09ca..0cd59df 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -3,6 +3,7 @@ import base64 from pathlib import Path from typing import Optional +from urllib.parse import urlparse def normalize_http_url( @@ -51,6 +52,28 @@ def normalize_http_url( return normalized +def normalize_base_path(value: Optional[str]) -> str: + """Normalize a URL base path for reverse proxy subpath deployments.""" + if not isinstance(value, str): + return "" + + path = value.strip() + if not path: + return "" + + if "://" in path: + parsed = urlparse(path) + path = parsed.path or "" + + if not path or path == "/": + return "" + + if not path.startswith("/"): + path = "/" + path + + return path.rstrip("/") + + def is_audiobook(content_type: Optional[str]) -> bool: """Check if content type indicates an audiobook.""" return bool(content_type and "audiobook" in content_type.lower()) diff --git a/shelfmark/main.py b/shelfmark/main.py index 78809e6..d5ce709 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -26,6 +26,8 @@ from shelfmark.config.env import ( from shelfmark.core.config import config as app_config from shelfmark.core.logger import setup_logger from shelfmark.core.models import SearchFilters +from shelfmark.core.prefix_middleware import PrefixMiddleware +from shelfmark.core.utils import normalize_base_path from shelfmark.api.websocket import ws_manager logger = setup_logger(__name__) @@ -34,10 +36,14 @@ logger = setup_logger(__name__) PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) FRONTEND_DIST = os.path.join(PROJECT_ROOT, 'frontend-dist') +BASE_PATH = normalize_base_path(app_config.get("URL_BASE", "")) + app = Flask(__name__) -app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore app.config['SEND_FILE_MAX_AGE_DEFAULT'] = 0 # Disable caching -app.config['APPLICATION_ROOT'] = '/' +app.config['APPLICATION_ROOT'] = BASE_PATH or '/' +app.wsgi_app = ProxyFix(app.wsgi_app) # type: ignore +if BASE_PATH: + app.wsgi_app = PrefixMiddleware(app.wsgi_app, BASE_PATH, bypass_paths={"/api/health"}) # Socket.IO async mode. # We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true), @@ -343,6 +349,30 @@ def login_required(f): return decorated_function +_BASE_TAG = '' + + +def _base_href() -> str: + if not BASE_PATH: + return "/" + return f"{BASE_PATH}/" + + +def _serve_index_html() -> Response: + """Serve index.html with an adjusted base tag for subpath deployments.""" + index_path = os.path.join(FRONTEND_DIST, 'index.html') + try: + with open(index_path, 'r', encoding='utf-8') as handle: + html = handle.read() + except OSError: + return send_from_directory(FRONTEND_DIST, 'index.html') + + if BASE_PATH and _BASE_TAG in html: + html = html.replace(_BASE_TAG, f'', 1) + + return Response(html, mimetype='text/html') + + # Serve frontend static files @app.route('/assets/') def serve_frontend_assets(filename: str) -> Response: @@ -357,7 +387,7 @@ def index() -> Response: Serve the React frontend application. Authentication is handled by the React app itself. """ - return send_from_directory(FRONTEND_DIST, 'index.html') + return _serve_index_html() @app.route('/logo.png') def logo() -> Response: @@ -1431,7 +1461,7 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: source = get_source(source_name) source_instances[source_name] = source - from shelfmark.release_sources.search_plan import build_release_search_plan + from shelfmark.core.search_plan import build_release_search_plan plan = build_release_search_plan(book, languages=languages, manual_query=manual_query) @@ -1763,7 +1793,7 @@ def catch_all(path: str) -> Response: if path.startswith('api/') or path.startswith('assets/'): return jsonify({"error": "Resource not found"}), 404 # Otherwise serve the React app - return send_from_directory(FRONTEND_DIST, 'index.html') + return _serve_index_html() # WebSocket event handlers @socketio.on('connect') diff --git a/shelfmark/release_sources/__init__.py b/shelfmark/release_sources/__init__.py index 843d749..34e3f9f 100644 --- a/shelfmark/release_sources/__init__.py +++ b/shelfmark/release_sources/__init__.py @@ -7,7 +7,7 @@ from threading import Event from typing import List, Optional, Dict, Type, Callable, Literal, Any, TYPE_CHECKING if TYPE_CHECKING: - from shelfmark.release_sources.search_plan import ReleaseSearchPlan + from shelfmark.core.search_plan import ReleaseSearchPlan from shelfmark.core.models import DownloadTask from shelfmark.metadata_providers import BookMetadata diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index aa83874..b0e2d5c 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -88,18 +88,25 @@ _LIBGEN_GET_PATTERNS = [ def _get_source_priority() -> List[Dict]: """Get the full source priority list. - Fast sources (AA Fast, LibGen) are hardcoded first. + Fast sources come from user config (FAST_SOURCES_DISPLAY). Slow sources come from user config. """ - # Fast sources - always first, hardcoded - fast_sources = [] + # Fast sources - always first, configurable via settings/env + fast_sources: List[Dict] = [] + configured_fast = config.get("FAST_SOURCES_DISPLAY") or [] + has_donator_key = bool(config.get("AA_DONATOR_KEY")) - # AA Fast only if donator key is set - if config.get("AA_DONATOR_KEY"): - fast_sources.append({"id": "aa-fast", "enabled": True}) - - # LibGen always available - fast_sources.append({"id": "libgen", "enabled": True}) + if isinstance(configured_fast, list): + for item in configured_fast: + if not isinstance(item, dict): + continue + source_id = item.get("id") + if source_id not in ("aa-fast", "libgen"): + continue + enabled = bool(item.get("enabled", True)) + if source_id == "aa-fast" and not has_donator_key: + enabled = False + fast_sources.append({"id": source_id, "enabled": enabled}) # User's configured slow sources (config won't contain fast sources) slow_sources = config.get("SOURCE_PRIORITY") or [] diff --git a/shelfmark/release_sources/irc/source.py b/shelfmark/release_sources/irc/source.py index 18014fe..9b04bc5 100644 --- a/shelfmark/release_sources/irc/source.py +++ b/shelfmark/release_sources/irc/source.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import List, Optional, TYPE_CHECKING if TYPE_CHECKING: - from shelfmark.release_sources.search_plan import ReleaseSearchPlan + from shelfmark.core.search_plan import ReleaseSearchPlan from shelfmark.api.websocket import ws_manager from shelfmark.core.config import config diff --git a/shelfmark/release_sources/prowlarr/clients/nzbget.py b/shelfmark/release_sources/prowlarr/clients/nzbget.py index 2b9a2bb..6d7706e 100644 --- a/shelfmark/release_sources/prowlarr/clients/nzbget.py +++ b/shelfmark/release_sources/prowlarr/clients/nzbget.py @@ -267,7 +267,7 @@ class NZBGetClient(DownloadClient): state="error", message=f"Download failed: {status}", complete=True, - file_path=None, + file_path=file_path, ) # Not found in queue or history diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py index f430cda..bcea35f 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py @@ -396,12 +396,14 @@ class SABnzbdClient(DownloadClient): ) elif status_text == "FAILED": fail_message = slot.get("fail_message", "Download failed") + title = slot.get("name") or slot.get("nzb_name") or "" + resolved_storage = self._resolve_completed_storage_path(storage, title) return DownloadStatus( progress=100, state="error", message=fail_message, complete=True, - file_path=None, + file_path=resolved_storage, ) else: # Post-processing states: Queued, QuickCheck, Verifying, @@ -433,8 +435,9 @@ class SABnzbdClient(DownloadClient): Returns: True if successful. """ + # First try to remove from queue. If it isn't there (common for completed jobs), + # fall back to history removal instead of failing fast on a SABnzbd error response. try: - # First try to remove from queue result = self._api_call( "queue", { @@ -447,8 +450,11 @@ class SABnzbdClient(DownloadClient): if result.get("status"): logger.info(f"Removed NZB from SABnzbd queue: {download_id}") return True + except Exception as e: + logger.debug(f"SABnzbd queue delete skipped for {download_id}: {e}") - # If not in queue, try to remove from history + # If not in queue (or queue delete failed), try to remove from history. + try: result = self._api_call( "history", { @@ -463,12 +469,12 @@ class SABnzbdClient(DownloadClient): action = "archived" if archive else "removed" logger.info(f"NZB {action} from SABnzbd history: {download_id}") return True - - return False except Exception as e: self._log_error("remove", e) return False + return False + def get_download_path(self, download_id: str) -> Optional[str]: """ Get the path where NZB files are located. diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index 5e2b230..66e7760 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -1,5 +1,6 @@ """Prowlarr download handler - executes downloads via torrent/usenet clients.""" +import shutil from pathlib import Path from threading import Event from typing import Callable, Optional @@ -101,10 +102,72 @@ class ProwlarrHandler(DownloadHandler): return try: - client.remove(download_id, delete_files=True) + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) except Exception as e: logger.warning(f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}") + def _remove_usenet_download( + self, + client: DownloadClient, + download_id: str, + *, + delete_files: bool, + archive: bool = True, + ) -> None: + """Remove a usenet download with SABnzbd-specific archive handling.""" + if getattr(client, "name", "") == "sabnzbd": + client.remove(download_id, delete_files=delete_files, archive=archive) + else: + client.remove(download_id, delete_files=delete_files) + + def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None: + """Best-effort local deletion of client download data.""" + try: + raw_path = client.get_download_path(download_id) + except Exception as e: + logger.debug(f"Failed to resolve download path for {client.name} {download_id}: {e}") + return + + if not raw_path: + logger.debug(f"No download path available for {client.name} {download_id}") + return + + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local_with_match, + ) + + source_path_obj = Path(raw_path) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + delete_path = remapped if matched_mapping else source_path_obj + + if str(delete_path) in ("", "/"): + logger.warning(f"Refusing to delete unsafe path for {client.name} {download_id}: {delete_path}") + return + + if not delete_path.exists(): + logger.debug(f"Local download path does not exist for cleanup: {delete_path}") + return + + try: + if delete_path.is_dir(): + shutil.rmtree(delete_path) + else: + delete_path.unlink() + logger.info(f"Deleted local download data for {client.name} {download_id}: {delete_path}") + except Exception as e: + logger.warning(f"Failed to delete local download data for {client.name} {download_id}: {e}") + def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None: """Best-effort removal of a failed/cancelled download from the client. @@ -124,7 +187,9 @@ class ProwlarrHandler(DownloadHandler): return try: - client.remove(download_id, delete_files=True) + # Permanent delete for failed usenet downloads (SABnzbd archive=0). + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=False) except Exception as e: logger.warning( f"Failed to remove download {download_id} from {client.name} after {reason}: {e}" @@ -433,7 +498,8 @@ class ProwlarrHandler(DownloadHandler): if protocol == "usenet": logger.info(f"Download cancelled, removing from {client.name}: {download_id}") try: - client.remove(download_id, delete_files=True) + self._delete_local_download_data(client, download_id) + self._remove_usenet_download(client, download_id, delete_files=True, archive=True) except Exception as e: logger.warning( f"Failed to remove download {download_id} from {client.name} after cancellation: {e}" diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index a44c5d6..19da899 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -1,10 +1,11 @@ """Prowlarr release source - searches indexers for book releases (torrents/usenet).""" import re +import time from typing import List, Optional, TYPE_CHECKING if TYPE_CHECKING: - from shelfmark.release_sources.search_plan import ReleaseSearchPlan + from shelfmark.core.search_plan import ReleaseSearchPlan from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -58,6 +59,9 @@ AUDIOBOOK_FORMATS = ["m4b", "mp3", "m4a", "flac", "ogg", "wma", "aac", "wav", "o # Combined list for format detection (audiobook formats first for priority) ALL_BOOK_FORMATS = AUDIOBOOK_FORMATS + EBOOK_FORMATS +# Backend safeguard: cap total Prowlarr search time per request. +PROWLARR_SEARCH_TIMEOUT_SECONDS = 120.0 + def _extract_format(title: str) -> Optional[str]: """Extract ebook/audiobook format from release title (extension, bracketed, or standalone).""" @@ -109,11 +113,11 @@ def _extract_language(title: str) -> Optional[str]: # Prowlarr category IDs for content type detection # See: https://wiki.servarr.com/prowlarr/cardigann-yml-definition#categories AUDIOBOOK_CATEGORY_IDS = {3000, 3030} # 3000 = Audio, 3030 = Audio/Audiobook -EBOOK_CATEGORY_IDS = {7000, 7020} # 7000 = Books, 7020 = Books/Ebook +BOOK_CATEGORY_RANGE = range(7000, 8000) # 7000-7999 = Books (all subcategories) def _detect_content_type_from_categories(categories: list, fallback: str = "book") -> str: - """Detect content type from Prowlarr category IDs. Returns 'audiobook' or 'book'.""" + """Detect content type from Prowlarr category IDs. Returns 'audiobook', 'book', or 'other'.""" # Normalize fallback - convert "ebook" to "book" for display consistency normalized_fallback = "book" if fallback == "ebook" else fallback @@ -127,13 +131,17 @@ def _detect_content_type_from_categories(categories: list, fallback: str = "book if (isinstance(cat, dict) and cat.get("id") is not None) or isinstance(cat, int) } - # Check for audiobook categories first (more specific), then ebook + if not cat_ids: + return normalized_fallback + + # Check for audiobook categories first (more specific), then any book range if cat_ids & AUDIOBOOK_CATEGORY_IDS: return "audiobook" - if cat_ids & EBOOK_CATEGORY_IDS: + if any(cat_id in BOOK_CATEGORY_RANGE for cat_id in cat_ids): return "book" - return normalized_fallback + # Categories are present but not book/audiobook + return "other" def _prowlarr_result_to_release(result: dict, search_content_type: str = "ebook") -> Release: @@ -366,7 +374,18 @@ class ProwlarrSource(ReleaseSource): """Search indexers with given categories, collecting results.""" results = [] if indexer_ids: - # Search specific indexers one at a time + # Prefer a single request for all selected indexers to reduce latency. + try: + raw = client.search(query=query, indexer_ids=indexer_ids, categories=cats) + if raw: + results.extend(raw) + return results + except Exception as e: + logger.warning( + f"Search failed for selected indexers {indexer_ids}: {e}. Falling back to per-indexer search." + ) + + # Fallback: 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) @@ -386,11 +405,18 @@ class ProwlarrSource(ReleaseSource): try: auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False) + deadline = time.monotonic() + PROWLARR_SEARCH_TIMEOUT_SECONDS + def _check_timeout() -> None: + if time.monotonic() > deadline: + raise TimeoutError( + f"Prowlarr search timed out after {int(PROWLARR_SEARCH_TIMEOUT_SECONDS)}s" + ) seen_keys: set[str] = set() all_results: List[dict] = [] for idx, query in enumerate(queries, start=1): + _check_timeout() if len(queries) > 1: logger.debug(f"Prowlarr query {idx}/{len(queries)}: '{query}'") @@ -398,6 +424,7 @@ class ProwlarrSource(ReleaseSource): # Auto-expand: if no results with categories and auto-expand enabled, retry without if not raw_results and categories and auto_expand_enabled: + _check_timeout() logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search") raw_results = search_indexers(query=query, cats=None) self.last_search_type = "expanded" @@ -428,6 +455,9 @@ class ProwlarrSource(ReleaseSource): return results + except TimeoutError as e: + logger.warning(f"Prowlarr search timed out: {e}") + raise except Exception as e: logger.error(f"Prowlarr search failed: {e}") return [] diff --git a/src/frontend/index.html b/src/frontend/index.html index 1d74dd8..402e17b 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -4,6 +4,7 @@ + @@ -15,8 +16,8 @@ - - + + Shelfmark