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