Base url additions and bug fixes (#519)

- Base URL option in settings for reverse proxy setups
- Fix NZB downloads not deleting on completion
- Fix handling for audiobook files over 100+ parts
- Fix prowlarr search timeout 
- Fix prowlarr categorisation for expanded searches
This commit is contained in:
Alex
2026-01-23 13:03:02 +00:00
committed by GitHub
parent 03c364e375
commit 3be99effe4
32 changed files with 557 additions and 163 deletions
+98 -26
View File
@@ -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)
</details>
@@ -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)
<details>
<summary>Detailed descriptions</summary>
#### `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))
</details>
@@ -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)
</details>
@@ -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)
</details>
@@ -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` |
<details>
<summary>Detailed descriptions</summary>
@@ -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)
</details>
+35
View File
@@ -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.
+2
View File
@@ -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
+19 -2
View File
@@ -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]:
+49 -44
View File
@@ -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",
+31
View File
@@ -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"]
+23
View File
@@ -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())
+35 -5
View File
@@ -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 = '<base href="/" data-shelfmark-base />'
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'<base href="{_base_href()}" data-shelfmark-base />', 1)
return Response(html, mimetype='text/html')
# Serve frontend static files
@app.route('/assets/<path:filename>')
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')
+1 -1
View File
@@ -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
+16 -9
View File
@@ -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 []
+1 -1
View File
@@ -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
@@ -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
@@ -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.
+69 -3
View File
@@ -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}"
+37 -7
View File
@@ -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 []
+3 -2
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="Shelfmark - Book search and download" />
<base href="/" data-shelfmark-base />
<!-- Theme color with media queries for light/dark mode -->
<meta name="theme-color" content="#f8f8f8" media="(prefers-color-scheme: light)" />
@@ -15,8 +16,8 @@
<meta name="apple-mobile-web-app-title" content="Shelfmark" />
<!-- App Icons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="icon" type="image/x-icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="logo.png" />
<title>Shelfmark</title>
<script>
// Apply theme immediately before first paint to prevent flash
+2 -22
View File
@@ -29,6 +29,7 @@ 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 { withBasePath } from './utils/basePath';
import { SearchModeProvider } from './contexts/SearchModeContext';
import './styles.css';
@@ -128,15 +129,6 @@ function App() {
};
}, []);
const [featureNoticeDismissed, setFeatureNoticeDismissed] = useState(() => {
return localStorage.getItem('cwa-bd-prowlarr-irc-notice-dismissed') === 'true';
});
const handleDismissFeatureNotice = useCallback(() => {
localStorage.setItem('cwa-bd-prowlarr-irc-notice-dismissed', 'true');
setFeatureNoticeDismissed(true);
}, []);
// URL-based search: parse URL params for automatic search on page load
const urlSearchEnabled = isAuthenticated && config !== null;
const { parsedParams, wasProcessed } = useUrlSearch({ enabled: urlSearchEnabled });
@@ -210,7 +202,7 @@ function App() {
// Auto-download to browser if enabled
if (config?.download_to_browser && book.download_path) {
const link = document.createElement('a');
link.href = `/api/localdownload?id=${encodeURIComponent(bookId)}`;
link.href = withBasePath(`/api/localdownload?id=${encodeURIComponent(bookId)}`);
link.download = '';
document.body.appendChild(link);
link.click();
@@ -626,18 +618,6 @@ function App() {
onContentTypeChange={setContentType}
/>
{isInitialState && !featureNoticeDismissed && (
<div className="absolute bottom-4 left-0 right-0 px-4 text-center text-sm opacity-40">
<span>We've renamed to Shelfmark. New: Torrent, Usenet, IRC and Audiobook support.</span>
<button
onClick={handleDismissFeatureNotice}
className="ml-2 text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
Dismiss
</button>
</div>
)}
<ResultsSection
books={books}
visible={hasResults}
@@ -1,5 +1,6 @@
import { useEffect } from 'react';
import { StatusData, Book } from '../types';
import { withBasePath } from '../utils/basePath';
interface DownloadsSidebarProps {
isOpen: boolean;
@@ -211,7 +212,7 @@ export const DownloadsSidebar = ({
<h3 className="font-semibold text-sm truncate" title={book.title}>
{isCompleted && book.download_path ? (
<a
href={`/api/localdownload?id=${encodeURIComponent(book.id)}`}
href={withBasePath(`/api/localdownload?id=${encodeURIComponent(book.id)}`)}
className="text-sky-600 hover:underline"
>
{book.title || 'Unknown Title'}
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import { SearchBar, SearchBarHandle } from './SearchBar';
import { ContentType } from '../types';
import { withBasePath } from '../utils/basePath';
export interface HeaderHandle {
submitSearch: () => void;
@@ -345,7 +346,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
// Show persistent toast while gathering logs
const loadingToastId = onShowToast?.('Gathering debug logs... This may take a minute.', 'info', true);
try {
const response = await fetch('/api/debug', {
const response = await fetch(withBasePath('/api/debug'), {
method: 'GET',
credentials: 'include',
});
@@ -394,7 +395,7 @@ export const Header = forwardRef<HeaderHandle, HeaderProps>(({
</svg>
<span>Debug</span>
</button>
<form action="/api/restart" method="get" className="w-full">
<form action={withBasePath('/api/restart')} method="get" className="w-full">
<button
className="w-full text-left px-4 py-2 hover-surface transition-colors flex items-center gap-3 text-orange-600 dark:text-orange-400"
type="submit"
+2 -3
View File
@@ -1,5 +1,6 @@
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { LoginCredentials } from '../types';
import { withBasePath } from '../utils/basePath';
interface LoginFormProps {
onSubmit: (credentials: LoginCredentials) => void;
@@ -97,7 +98,7 @@ export const LoginForm = ({
)}
<form
method="post"
action="/api/login"
action={withBasePath('/api/login')}
autoComplete="on"
id="login-form"
name="login"
@@ -227,5 +228,3 @@ export const LoginForm = ({
</div>
);
};
@@ -75,13 +75,14 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
}
// Use minmax(0, ...) so the grid can shrink inside the settings modal.
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_auto]';
// Use fixed width for delete button column to ensure header/data alignment.
const gridTemplate = 'sm:grid-cols-[minmax(0,180px)_minmax(0,1fr)_minmax(0,1fr)_2rem]';
return (
<div className="space-y-3 min-w-0">
<div className={`hidden sm:grid ${gridTemplate} gap-2 px-1 text-xs font-medium opacity-70`}>
<div className={`hidden sm:grid ${gridTemplate} gap-3 items-start min-w-0 text-xs font-medium opacity-70`}>
{columns.map((col) => (
<div key={col.key} className="truncate">
<div key={col.key} className="min-w-0 truncate">
{col.label}
</div>
))}
@@ -165,12 +166,12 @@ export const TableField = ({ field, value, onChange, disabled }: TableFieldProps
);
})}
<div className="flex items-center justify-center">
<div className="flex items-start pt-1.5">
<button
type="button"
onClick={() => removeRow(rowIndex)}
disabled={isDisabled}
className="p-2 rounded hover:bg-[var(--hover-surface)]
className="p-1.5 rounded-full hover:bg-[var(--hover-surface)]
disabled:opacity-60 disabled:cursor-not-allowed"
aria-label="Remove row"
>
@@ -66,8 +66,9 @@ export const FieldWrapper = ({
disabledOverride,
disabledReasonOverride,
}: FieldWrapperProps) => {
// Action buttons and headings handle their own layout
if (field.type === 'ActionButton' || field.type === 'HeadingField') {
// Action buttons, headings, and table fields handle their own layout
// Table fields have column headers, so they don't need a separate label
if (field.type === 'ActionButton' || field.type === 'HeadingField' || field.type === 'TableField') {
return <>{children}</>;
}
+3 -1
View File
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState, ReactNode } from 'react';
import { io, Socket } from 'socket.io-client';
import { withBasePath } from '../utils/basePath';
interface SocketContextValue {
socket: Socket | null;
@@ -23,11 +24,12 @@ export const SocketProvider = ({ children }: SocketProviderProps) => {
const wsUrl = window.location.port === '5173'
? 'http://localhost:8084'
: window.location.origin;
const socketPath = withBasePath('/socket.io');
console.log('SocketProvider: Connecting to', wsUrl);
const socket = io(wsUrl, {
path: '/socket.io',
path: socketPath,
transports: ['polling', 'websocket'],
withCredentials: false,
});
+5 -1
View File
@@ -3,13 +3,17 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { SocketProvider } from './contexts/SocketContext';
import App from './App';
import { getBasePath } from './utils/basePath';
const root = document.getElementById('root');
if (!root) throw new Error('Root element not found');
const basePath = getBasePath();
const routerBase = basePath === '/' ? undefined : basePath;
createRoot(root).render(
<StrictMode>
<BrowserRouter>
<BrowserRouter basename={routerBase}>
<SocketProvider>
<App />
</SocketProvider>
+21 -8
View File
@@ -1,8 +1,9 @@
import { Book, StatusData, AppConfig, LoginCredentials, AuthResponse, ReleaseSource, ReleasesResponse } from '../types';
import { SettingsResponse, ActionResult, UpdateResult } from '../types/settings';
import { MetadataBookData, transformMetadataToBook } from '../utils/bookTransformers';
import { getApiBase } from '../utils/basePath';
const API_BASE = '/api';
const API_BASE = getApiBase();
// API endpoints
const API = {
@@ -39,12 +40,17 @@ export class TimeoutError extends Error {
// Default request timeout in milliseconds (30 seconds)
const DEFAULT_TIMEOUT_MS = 30000;
const EXPANDED_RELEASES_TIMEOUT_MS = 60000;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
async function fetchJSON<T>(
url: string,
opts: RequestInit = {},
timeoutMs: number | null = DEFAULT_TIMEOUT_MS
): Promise<T> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const timeoutId = timeoutMs && timeoutMs > 0
? setTimeout(() => controller.abort(), timeoutMs)
: null;
try {
const res = await fetch(url, {
@@ -60,13 +66,16 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
if (!res.ok) {
// Try to parse error message from response body
let errorMessage = `${res.status} ${res.statusText}`;
let hasServerMessage = false;
try {
const errorData = await res.json();
// Prefer user-friendly 'message' field, fall back to 'error'
if (errorData.message) {
errorMessage = errorData.message;
hasServerMessage = true;
} else if (errorData.error) {
errorMessage = errorData.error;
hasServerMessage = true;
}
} catch (e) {
// Log parse failure for debugging - server may have returned non-JSON (e.g., HTML error page)
@@ -75,7 +84,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
// 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.`;
if (!hasServerMessage) {
errorMessage = `Server unavailable (${res.status}). If using a reverse proxy, check its configuration.`;
}
}
// Throw appropriate error based on status code
@@ -94,7 +105,9 @@ async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: numb
}
throw error;
} finally {
clearTimeout(timeoutId);
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
@@ -353,6 +366,6 @@ export const getReleases = async (
if (manualQuery) {
params.set('manual_query', manualQuery);
}
const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
// Let the backend control timeouts for release searches (can be long-running).
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
};
+45
View File
@@ -0,0 +1,45 @@
const normalizeBasePath = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return '/';
}
let path = trimmed;
if (!path.startsWith('/')) {
path = `/${path}`;
}
if (path.length > 1 && path.endsWith('/')) {
path = path.slice(0, -1);
}
return path;
};
const resolveBasePath = (): string => {
if (typeof document === 'undefined') {
return '/';
}
const baseHref = document.querySelector('base')?.getAttribute('href') || '/';
try {
return new URL(baseHref, window.location.origin).pathname;
} catch {
return baseHref;
}
};
const BASE_PATH = normalizeBasePath(resolveBasePath());
export const getBasePath = (): string => BASE_PATH;
export const withBasePath = (path: string): string => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
if (BASE_PATH === '/') {
return normalizedPath;
}
return `${BASE_PATH}${normalizedPath}`;
};
export const getApiBase = (): string => withBasePath('/api');
+3 -2
View File
@@ -2,7 +2,8 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
export default defineConfig(({ command }) => ({
base: command === 'build' ? './' : '/',
plugins: [react()],
resolve: {
alias: {
@@ -29,4 +30,4 @@ export default defineConfig({
outDir: 'dist',
sourcemap: true,
},
});
}));
@@ -1,10 +1,10 @@
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.search_plan import build_release_search_plan
from shelfmark.core.search_plan import build_release_search_plan
class TestReleaseSearchPlanManualQuery:
def test_manual_query_overrides_plan(self, monkeypatch):
import shelfmark.release_sources.search_plan as sp
import shelfmark.core.search_plan as sp
monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False)
@@ -1,11 +1,11 @@
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.search_plan import build_release_search_plan
from shelfmark.core.search_plan import build_release_search_plan
class TestReleaseSearchPlan:
def test_uses_default_languages_when_none(self, monkeypatch):
# config.BOOK_LANGUAGE is a Config attribute; patch the instance.
import shelfmark.release_sources.search_plan as sp
import shelfmark.core.search_plan as sp
monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en", "hu"], raising=False)
@@ -44,7 +44,7 @@ class TestReleaseSearchPlan:
]
def test_all_language_disables_grouping(self, monkeypatch):
import shelfmark.release_sources.search_plan as sp
import shelfmark.core.search_plan as sp
monkeypatch.setattr(sp.config, "BOOK_LANGUAGE", ["en"], raising=False)
+1 -1
View File
@@ -1,6 +1,6 @@
from shelfmark.metadata_providers import BookMetadata
from shelfmark.release_sources.direct_download import DirectDownloadSource
from shelfmark.release_sources.search_plan import build_release_search_plan
from shelfmark.core.search_plan import build_release_search_plan
class TestDirectDownloadSearchQueries:
+25 -4
View File
@@ -12,6 +12,7 @@ from shelfmark.release_sources.prowlarr.source import (
_parse_size,
_extract_format,
_extract_language,
_detect_content_type_from_categories,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
from shelfmark.metadata_providers import BookMetadata
@@ -247,6 +248,26 @@ class TestExtractLanguage:
assert _extract_language("Book [German]") == "de"
class TestDetectContentType:
"""Tests for the _detect_content_type_from_categories function."""
def test_fallback_without_categories(self):
assert _detect_content_type_from_categories([], "ebook") == "book"
assert _detect_content_type_from_categories([], "audiobook") == "audiobook"
def test_audiobook_categories(self):
assert _detect_content_type_from_categories([{"id": 3030}], "ebook") == "audiobook"
assert _detect_content_type_from_categories([3000], "ebook") == "audiobook"
def test_book_category_range(self):
assert _detect_content_type_from_categories([{"id": 7000}], "ebook") == "book"
assert _detect_content_type_from_categories([7020], "audiobook") == "book"
assert _detect_content_type_from_categories([7030], "ebook") == "book"
def test_non_book_categories_return_other(self):
assert _detect_content_type_from_categories([{"id": 2000}], "ebook") == "other"
class TestProwlarrLocalizedQueries:
def test_manual_query_still_applies_content_type_categories(self, monkeypatch):
class FakeClient:
@@ -279,7 +300,7 @@ class TestProwlarrLocalizedQueries:
authors=["Someone"],
)
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=["en"], manual_query="my custom")
source.search(book, plan, content_type="audiobook")
@@ -317,7 +338,7 @@ class TestProwlarrLocalizedQueries:
authors=["Someone"],
)
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=["en"], manual_query="my custom")
source.search(book, plan, expand_search=True, content_type="audiobook")
@@ -356,7 +377,7 @@ class TestProwlarrLocalizedQueries:
titles_by_language={"hu": "A villámtolvaj"},
)
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=["en", "hu"])
source.search(book, plan, content_type="ebook")
@@ -402,7 +423,7 @@ class TestProwlarrLocalizedQueries:
},
)
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=["en", "hu"])
source.search(book, plan, content_type="ebook")