mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-03 22:07:04 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user