URL normalization and path mapping tweaks (#489)

- URL normalization (WIP) for external clients / prowlarr / booklore
URLs used.
- More robust handling of Windows path directories in mapping 
- UI tweaks
- Compose clean-ups
This commit is contained in:
Alex
2026-01-18 17:36:16 +00:00
committed by GitHub
parent 4e00cf42f6
commit 8470095534
41 changed files with 849 additions and 221 deletions
@@ -2,15 +2,14 @@ services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:latest
environment:
# TZ: America/New_York
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
@@ -4,10 +4,9 @@ services:
image: ghcr.io/calibrain/shelfmark:latest
environment:
FLASK_PORT: 8084
# TZ: America/New_York
USING_TOR: true
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
@@ -15,7 +14,7 @@ services:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
@@ -3,14 +3,13 @@ services:
image: ghcr.io/calibrain/shelfmark:latest
container_name: shelfmark
environment:
# TZ: America/New_York
# PUID: 1000
# PGID: 1000
PUID: 1000
PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
-20
View File
@@ -1,20 +0,0 @@
# Uses external Cloudflare bypasser (FlareSolverr/ByParr) instead of built-in Selenium
services:
shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:dev
environment:
# TZ: America/New_York
EXT_BYPASSER_URL: http://flaresolverr:8191
# PUID: 1000
# PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
flaresolverr:
image: ghcr.io/flaresolverr/flaresolverr:latest
-21
View File
@@ -1,21 +0,0 @@
# Routes all traffic through Tor - requires NET_ADMIN capability
services:
shelfmark-tor:
image: ghcr.io/calibrain/shelfmark:dev
environment:
FLASK_PORT: 8084
# TZ: America/New_York
USING_TOR: true
# PUID: 1000
# PGID: 1000
cap_add:
- NET_ADMIN
- NET_RAW
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
-16
View File
@@ -1,16 +0,0 @@
services:
shelfmark:
image: ghcr.io/calibrain/shelfmark:dev
container_name: shelfmark
environment:
# TZ: America/New_York
# PUID: 1000
# PGID: 1000
ports:
- 8084:8084
restart: unless-stopped
volumes:
- /path/to/books:/books # Book destination directory
- /path/to/config:/config # App configuration
# Download client mount - path must match your torrent/usenet client's volume exactly
# - /path/to/downloads:/path/to/downloads
+2 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-lite-dev:
extends:
file: ./compose/edge/docker-compose.extbp.yml
file: ./compose/docker-compose.lite.yml
service: shelfmark-lite
build:
context: .
@@ -18,7 +18,7 @@ services:
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
flaresolverr:
+2 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-tor-dev:
extends:
file: ./compose/edge/docker-compose.tor.yml
file: ./compose/docker-compose.tor.yml
service: shelfmark-tor
build:
context: .
@@ -16,5 +16,5 @@ services:
- ./.local/books:/books
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+2 -2
View File
@@ -2,7 +2,7 @@
services:
shelfmark-dev:
extends:
file: ./compose/edge/docker-compose.yml
file: ./compose/docker-compose.yml
service: shelfmark
build:
context: .
@@ -18,5 +18,5 @@ services:
- ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro
# Download client mount - path must match your torrent/usenet client's volume exactly
# Required for torrent / usenet - path must match your download client's volume exactly
# - /path/to/downloads:/path/to/downloads
+4 -6
View File
@@ -41,9 +41,9 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
### Installation
1. Download the docker-compose file:
1. Download the [docker-compose file](compose/docker-compose.yml):
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml
```
2. Start the service:
@@ -51,8 +51,6 @@ Shelfmark is a unified web interface for searching and aggregating books and aud
docker compose up -d
```
> **Edge users**: If you're tracking the main branch (`:dev` tag), use compose files from `compose/edge/` instead.
3. Open `http://localhost:8084`
That's it! Configure settings through the web interface as needed.
@@ -120,7 +118,7 @@ The full-featured image with built-in Cloudflare bypass.
#### Enable Tor Routing
Routes all traffic through Tor for enhanced privacy:
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.tor.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml
docker compose -f docker-compose.tor.yml up -d
```
@@ -138,7 +136,7 @@ A smaller image without the built-in Cloudflare bypasser. Ideal for:
- **Audiobooks** - Using Shelfmark exclusively for audiobooks
```bash
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.lite.yml
curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml
docker compose -f docker-compose.lite.yml up -d
```
+3 -1
View File
@@ -10,6 +10,7 @@ import requests
from shelfmark.bypass import BypassCancelledException
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
if TYPE_CHECKING:
from shelfmark.download import network
@@ -29,10 +30,11 @@ BACKOFF_CAP = 10.0
def _fetch_via_bypasser(target_url: str) -> Optional[str]:
"""Make a single request to the external bypasser service. Returns HTML or None."""
bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191")
bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1")
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
bypasser_url = normalize_http_url(raw_bypasser_url)
if not bypasser_url or not bypasser_path:
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
return None
+22 -4
View File
@@ -8,6 +8,9 @@ from typing import Any, Dict
def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
"""Validate advanced settings before persisting."""
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS")
if mappings is None:
@@ -20,9 +23,12 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
"values": values,
}
logger.debug("Processing %d remote path mapping entries", len(mappings))
cleaned = []
for entry in mappings:
for i, entry in enumerate(mappings):
if not isinstance(entry, dict):
logger.debug("Skipping entry %d: not a dict", i)
continue
host = str(entry.get("host", "") or "").strip().lower()
@@ -30,17 +36,29 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
local_path = str(entry.get("localPath", "") or "").strip()
if not host or not remote_path or not local_path:
logger.debug(
"Skipping entry %d: missing field(s) - host=%r, remotePath=%r, localPath=%r",
i,
host,
remote_path,
local_path,
)
continue
if not local_path.startswith("/"):
return {
"error": True,
"message": "Local Path must be an absolute path",
"message": f"Local Path must be an absolute path (got: {local_path})",
"values": values,
}
cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path})
logger.info("Saved %d remote path mapping(s)", len(cleaned))
if cleaned:
for m in cleaned:
logger.debug(" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"])
values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned
return {"error": False, "values": values}
@@ -646,7 +664,7 @@ def download_settings():
},
{
"value": "rename",
"label": "Rename only",
"label": "Rename Only",
"description": "Rename files using a template"
},
{
@@ -771,7 +789,7 @@ def download_settings():
description="Choose how downloaded audiobook files are named and organized.",
options=[
{"value": "none", "label": "None", "description": "Keep original filename from source"},
{"value": "rename", "label": "Rename only", "description": "Rename files using a template"},
{"value": "rename", "label": "Rename Only", "description": "Rename files using a template"},
{"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."},
],
default="rename",
+44 -26
View File
@@ -2,6 +2,8 @@
from typing import List
from shelfmark.core.utils import normalize_http_url
# Lazy import to avoid circular imports
_config_module = None
@@ -44,6 +46,10 @@ DEFAULT_WELIB_MIRRORS = [
]
def _normalize_mirror_url(url: str) -> str:
return normalize_http_url(url, default_scheme="https")
def get_aa_mirrors() -> List[str]:
"""
Get Anna's Archive mirrors from config + defaults.
@@ -51,15 +57,16 @@ def get_aa_mirrors() -> List[str]:
Returns:
List of AA mirror URLs, starting with defaults then custom additions.
"""
mirrors = list(DEFAULT_AA_MIRRORS)
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_AA_MIRRORS]
mirrors = [url for url in mirrors if url]
config = _get_config()
additional = config.get("AA_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -71,15 +78,16 @@ def get_libgen_mirrors() -> List[str]:
Returns:
List of LibGen mirror URLs (defaults first, then custom additions).
"""
mirrors = list(DEFAULT_LIBGEN_MIRRORS)
mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS]
mirrors = [url for url in mirrors if url]
config = _get_config()
additional = config.get("LIBGEN_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -93,21 +101,24 @@ def get_zlib_mirrors() -> List[str]:
"""
config = _get_config()
primary = config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
if not primary:
primary = _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_ZLIB_MIRRORS:
if url != primary:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -120,7 +131,8 @@ def get_zlib_primary_url() -> str:
Primary Z-Library mirror URL.
"""
config = _get_config()
return config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]))
return primary or _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0])
def get_zlib_url_template() -> str:
@@ -143,21 +155,24 @@ def get_welib_mirrors() -> List[str]:
"""
config = _get_config()
primary = config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
if not primary:
primary = _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
mirrors = [primary]
# Add other defaults (excluding primary)
for url in DEFAULT_WELIB_MIRRORS:
if url != primary:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors
additional = config.get("WELIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url and url not in mirrors:
mirrors.append(url)
normalized = _normalize_mirror_url(url)
if normalized and normalized not in mirrors:
mirrors.append(normalized)
return mirrors
@@ -170,7 +185,8 @@ def get_welib_primary_url() -> str:
Primary Welib mirror URL.
"""
config = _get_config()
return config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])
primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]))
return primary or _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0])
def get_welib_url_template() -> str:
@@ -197,7 +213,9 @@ def get_zlib_cookie_domains() -> set:
# Add all default domains
for url in DEFAULT_ZLIB_MIRRORS:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
normalized = _normalize_mirror_url(url)
if normalized:
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain)
# Add custom domains
@@ -205,9 +223,9 @@ def get_zlib_cookie_domains() -> set:
additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional:
for url in additional.split(","):
url = url.strip()
if url:
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
normalized = _normalize_mirror_url(url)
if normalized:
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain)
return domains
+12 -8
View File
@@ -3,7 +3,7 @@
import os
import re
from pathlib import Path
from typing import Dict, Optional, Union
from typing import Dict, Optional, Union, Mapping
from shelfmark.core.logger import setup_logger
@@ -17,10 +17,10 @@ TOKEN_PATTERN = re.compile(
)
# Characters that are invalid in filenames on various filesystems
INVALID_CHARS = re.compile(r'[\\:*?"<>|]')
INVALID_CHARS = re.compile(r'[\\/:*?"<>|]')
def _sanitize(name: str, max_length: int = 245) -> str:
def _sanitize(name: Optional[str], max_length: int = 245) -> str:
"""Sanitize a string for filesystem use."""
if not name:
return ""
@@ -31,7 +31,7 @@ def _sanitize(name: str, max_length: int = 245) -> str:
return sanitized[:max_length]
def sanitize_filename(name: str, max_length: int = 245) -> str:
def sanitize_filename(name: Optional[str], max_length: int = 245) -> str:
"""Sanitize a string for use as a filename or path component."""
return _sanitize(name, max_length)
@@ -40,7 +40,7 @@ def sanitize_filename(name: str, max_length: int = 245) -> str:
sanitize_path_component = sanitize_filename
def format_series_position(position: Optional[Union[int, float]]) -> str:
def format_series_position(position: Optional[Union[str, int, float]]) -> str:
if position is None:
return ""
@@ -78,7 +78,9 @@ def assign_part_numbers(
def parse_naming_template(
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
metadata: Mapping[str, Optional[Union[str, int, float]]],
*,
allow_path_separators: bool = True,
) -> str:
if not template:
return ""
@@ -108,6 +110,8 @@ def parse_naming_template(
if not value:
return ""
if not allow_path_separators:
value = value.replace("/", "_")
# Sanitize the value
value = sanitize_filename(value)
@@ -140,10 +144,10 @@ def parse_naming_template(
def build_library_path(
base_path: str,
template: str,
metadata: Dict[str, Optional[Union[str, int, float]]],
metadata: Mapping[str, Optional[Union[str, int, float]]],
extension: Optional[str] = None,
) -> Path:
relative = parse_naming_template(template, metadata)
relative = parse_naming_template(template, metadata, allow_path_separators=True)
if not relative:
# Fallback to title if template produces empty result
+39 -5
View File
@@ -34,6 +34,11 @@ def _normalize_prefix(path: str) -> str:
return normalized
def _is_windows_path(path: str) -> bool:
"""Check if a path looks like a Windows path (has a drive letter like C:/)."""
return len(path) >= 2 and path[1] == ":" and path[0].isalpha()
def _normalize_host(host: str) -> str:
return str(host or "").strip().lower()
@@ -61,12 +66,21 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
return mappings
def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path:
def remap_remote_to_local_with_match(
*,
mappings: Iterable[RemotePathMapping],
host: str,
remote_path: str | Path,
) -> tuple[Path, bool]:
host_normalized = _normalize_host(host)
remote_normalized = _normalize_prefix(str(remote_path))
if not remote_normalized:
return Path(str(remote_path))
return Path(str(remote_path)), False
# Windows paths are case-insensitive, so we need case-insensitive matching
# for paths that look like Windows paths (e.g., D:/Torrents)
is_windows = _is_windows_path(remote_normalized)
for mapping in mappings:
if _normalize_host(mapping.host) != host_normalized:
@@ -76,16 +90,36 @@ def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, r
if not remote_prefix:
continue
if remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/"):
# For Windows paths, do case-insensitive prefix matching
if is_windows:
remote_lower = remote_normalized.lower()
prefix_lower = remote_prefix.lower()
matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/")
else:
matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/")
if matches:
# Use the length of the original prefix to extract remainder
# This preserves the original case in folder names
remainder = remote_normalized[len(remote_prefix):]
local_prefix = _normalize_prefix(mapping.local_path)
if remainder.startswith("/"):
remainder = remainder[1:]
return Path(local_prefix) / remainder if remainder else Path(local_prefix)
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
return remapped, True
return Path(remote_normalized)
return Path(remote_normalized), False
def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path:
remapped, _ = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=remote_path,
)
return remapped
def get_client_host_identifier(client: Any) -> Optional[str]:
+46
View File
@@ -5,6 +5,52 @@ from pathlib import Path
from typing import Optional
def normalize_http_url(
url: Optional[str],
*,
default_scheme: str = "http",
strip_trailing_slash: bool = True,
allow_special: tuple[str, ...] = (),
) -> str:
"""Normalize a configured HTTP URL for requests and links."""
if not isinstance(url, str):
return ""
normalized = url.strip()
if not normalized:
return ""
if (normalized.startswith("\"") and normalized.endswith("\"")) or (
normalized.startswith("'") and normalized.endswith("'")
):
normalized = normalized[1:-1].strip()
if not normalized:
return ""
if allow_special:
special_map = {
value.lower(): value
for value in allow_special
if isinstance(value, str)
}
special_match = special_map.get(normalized.lower())
if special_match is not None:
return special_match
if normalized.startswith(("/", "./", "../")):
return normalized
if "://" not in normalized:
scheme = default_scheme.strip().rstrip(":/")
if scheme:
normalized = f"{scheme}://{normalized}"
if strip_trailing_slash:
normalized = normalized.rstrip("/")
return normalized
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())
+16 -2
View File
@@ -12,6 +12,7 @@ import ipaddress
from shelfmark.core.logger import setup_logger
from shelfmark.core.config import config as app_config
from shelfmark.core.utils import normalize_http_url
from datetime import datetime, timedelta
@@ -729,7 +730,14 @@ def rotate_dns_and_reset_aa() -> bool:
return False
# Reset AA URL to first available auto option if using auto AA
global _aa_base_url, _current_aa_url_index
configured_url = app_config.get("AA_BASE_URL", "auto")
configured_url = normalize_http_url(
app_config.get("AA_BASE_URL", "auto"),
default_scheme="https",
allow_special=("auto",),
)
if not configured_url:
configured_url = "auto"
if configured_url == "auto" or _aa_base_url in _aa_urls:
_current_aa_url_index = 0
_aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.se"
@@ -900,7 +908,13 @@ def _initialize_aa_state() -> None:
_aa_urls = _build_aa_urls()
# Get configured base URL from config
configured_url = app_config.get("AA_BASE_URL", "auto")
configured_url = normalize_http_url(
app_config.get("AA_BASE_URL", "auto"),
default_scheme="https",
allow_special=("auto",),
)
if not configured_url:
configured_url = "auto"
if configured_url == "auto":
if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls:
+1 -1
View File
@@ -194,7 +194,7 @@ def transfer_book_files(
metadata = build_metadata_dict(task)
extension = book_file.suffix.lstrip(".") or task.format or ""
filename = parse_naming_template(template, metadata)
filename = parse_naming_template(template, metadata, allow_path_separators=False)
filename = Path(filename).name if filename else ""
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
+17 -1
View File
@@ -1430,10 +1430,26 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
try:
source = get_source(source_name)
source_instances[source_name] = source
logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search}, content_type={content_type})")
from shelfmark.release_sources.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=languages, manual_query=manual_query)
if plan.manual_query:
planned_query = plan.manual_query
planned_query_type = "manual"
elif not expand_search and plan.isbn_candidates:
planned_query = plan.isbn_candidates[0]
planned_query_type = "isbn"
else:
planned_query = plan.primary_query
planned_query_type = "title_author"
logger.debug(
f"Searching {source_name}: {planned_query_type}='{planned_query}' "
f"(title='{book.title}', authors={book.authors}, expand={expand_search}, content_type={content_type})"
)
releases = source.search(book, plan, expand_search=expand_search, content_type=content_type)
all_releases.extend(releases)
except ValueError:
+21 -1
View File
@@ -208,7 +208,27 @@ def _compute_search_title(
# Prefer subtitle when it looks like the real title.
if normalized_subtitle and not _is_probably_series_position(normalized_subtitle):
# If title contains the subtitle, this is likely "Series: Subtitle".
match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title)
if match:
prefix = match.group(1).strip()
suffix = _strip_parenthetical_suffix(match.group(2).strip())
prefix_words = len(prefix.split()) if prefix else 0
subtitle_words = len(normalized_subtitle.split())
series_normalized = " ".join(series_name.split()).strip() if series_name else ""
if series_normalized and prefix.lower() == series_normalized.lower():
return normalized_subtitle
# If the subtitle is much longer than the prefix, treat it as a descriptive subtitle.
if prefix and subtitle_words >= (prefix_words + 4):
return prefix
# Otherwise assume "Series: Book Title" and prefer the subtitle.
if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower():
return normalized_subtitle
# Fallback: if title contains the subtitle, this is likely "Series: Subtitle".
if normalized_subtitle.lower() in normalized_title.lower():
return normalized_subtitle
+2 -2
View File
@@ -1162,7 +1162,7 @@ class DirectDownloadSource(ReleaseSource):
if not expand_search:
isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None
if isbn:
logger.debug(f"Searching by ISBN: {isbn}")
logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}")
filters = SearchFilters(isbn=[isbn])
if lang_filter:
filters.lang = lang_filter
@@ -1191,7 +1191,7 @@ class DirectDownloadSource(ReleaseSource):
if not query:
continue
logger.debug(f"Searching: query='{query}', langs={langs}")
logger.debug(f"Searching direct_download: title_author='{query}', langs={langs}")
filters = SearchFilters(lang=langs) if langs else SearchFilters()
try:
for bi in search_books(query, filters):
+2 -1
View File
@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Tuple
import requests
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
logger = setup_logger(__name__)
@@ -13,7 +14,7 @@ class ProwlarrClient:
"""Client for interacting with the Prowlarr API."""
def __init__(self, url: str, api_key: str, timeout: int = 30):
self.base_url = url.rstrip("/")
self.base_url = normalize_http_url(url)
self.api_key = api_key
self.timeout = timeout
self._session = requests.Session()
@@ -19,6 +19,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -65,6 +66,10 @@ class DelugeClient(DownloadClient):
# Allow DELUGE_HOST to be either a hostname OR a full URL
# (useful when Deluge is behind a reverse proxy path).
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
if not raw_host:
raise ValueError("DELUGE_HOST is invalid")
host = raw_host
port = int(raw_port)
@@ -11,6 +11,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -30,11 +31,13 @@ class NZBGetClient(DownloadClient):
def __init__(self):
"""Initialize NZBGet client with settings from config."""
url = config.get("NZBGET_URL", "")
if not url:
raw_url = config.get("NZBGET_URL", "")
if not raw_url:
raise ValueError("NZBGET_URL is required")
self.url = url.rstrip("/")
self.url = normalize_http_url(raw_url)
if not self.url:
raise ValueError("NZBGET_URL is invalid")
self.username = config.get("NZBGET_USERNAME", "nzbget")
self.password = config.get("NZBGET_PASSWORD", "")
self._category = config.get("NZBGET_CATEGORY", "Books")
@@ -43,7 +46,7 @@ class NZBGetClient(DownloadClient):
def is_configured() -> bool:
"""Check if NZBGet is configured and selected as the usenet client."""
client = config.get("PROWLARR_USENET_CLIENT", "")
url = config.get("NZBGET_URL", "")
url = normalize_http_url(config.get("NZBGET_URL", ""))
return client == "nzbget" and bool(url)
@with_retry()
@@ -6,6 +6,7 @@ from typing import Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -91,13 +92,19 @@ class QBittorrentClient(DownloadClient):
# Lazy import to avoid dependency issues if not using torrents
from qbittorrentapi import Client
url = config.get("QBITTORRENT_URL", "")
if not url:
raw_url = config.get("QBITTORRENT_URL", "")
if not raw_url:
raise ValueError("QBITTORRENT_URL is required")
self._base_url = url.rstrip("/")
# We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL.
self._base_url = normalize_http_url(raw_url)
if not self._base_url:
raise ValueError("QBITTORRENT_URL is invalid")
# qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL
# for consistency.
self._client = Client(
host=url,
host=self._base_url,
username=config.get("QBITTORRENT_USERNAME", ""),
password=config.get("QBITTORRENT_PASSWORD", ""),
)
@@ -197,13 +204,20 @@ class QBittorrentClient(DownloadClient):
return [], f"qBittorrent request timed out at {self._base_url}"
except Exception as e:
logger.debug(f"Failed to get torrents info: {e}")
# requests raises InvalidSchema when the base URL doesn't include http(s)
if type(e).__name__ == "InvalidSchema":
return (
[],
"qBittorrent URL is invalid (missing http:// or https://). "
f"Configured: {self._base_url}",
)
return [], f"qBittorrent API error: {type(e).__name__}: {e}"
@staticmethod
def is_configured() -> bool:
"""Check if qBittorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = config.get("QBITTORRENT_URL", "")
url = normalize_http_url(config.get("QBITTORRENT_URL", ""))
return client == "qbittorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
@@ -9,6 +9,7 @@ from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -32,11 +33,13 @@ class RTorrentClient(DownloadClient):
"""Initialize rTorrent client with settings from config."""
from xmlrpc.client import ServerProxy
url = config.get("RTORRENT_URL", "")
if not url:
raw_url = config.get("RTORRENT_URL", "")
if not raw_url:
raise ValueError("RTORRENT_URL is required")
self._base_url = url.rstrip("/")
self._base_url = normalize_http_url(raw_url)
if not self._base_url:
raise ValueError("RTORRENT_URL is invalid")
username = config.get("RTORRENT_USERNAME", "")
password = config.get("RTORRENT_PASSWORD", "")
@@ -55,7 +58,7 @@ class RTorrentClient(DownloadClient):
def is_configured() -> bool:
"""Check if rTorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = config.get("RTORRENT_URL", "")
url = normalize_http_url(config.get("RTORRENT_URL", ""))
return client == "rtorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
@@ -10,6 +10,7 @@ import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -99,15 +100,17 @@ class SABnzbdClient(DownloadClient):
def __init__(self):
"""Initialize SABnzbd client with settings from config."""
url = config.get("SABNZBD_URL", "")
if not url:
raw_url = config.get("SABNZBD_URL", "")
if not raw_url:
raise ValueError("SABNZBD_URL is required")
api_key = config.get("SABNZBD_API_KEY", "")
if not api_key:
raise ValueError("SABNZBD_API_KEY is required")
self.url = url.rstrip("/")
self.url = normalize_http_url(raw_url)
if not self.url:
raise ValueError("SABNZBD_URL is invalid")
self.api_key = api_key
self._category = config.get("SABNZBD_CATEGORY", "books")
@@ -115,7 +118,7 @@ class SABnzbdClient(DownloadClient):
def is_configured() -> bool:
"""Check if SABnzbd is configured and selected as the usenet client."""
client = config.get("PROWLARR_USENET_CLIENT", "")
url = config.get("SABNZBD_URL", "")
url = normalize_http_url(config.get("SABNZBD_URL", ""))
api_key = config.get("SABNZBD_API_KEY", "")
return client == "sabnzbd" and bool(url) and bool(api_key)
@@ -9,6 +9,7 @@ from typing import Optional, Tuple
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
@@ -33,10 +34,14 @@ class TransmissionClient(DownloadClient):
"""Initialize Transmission client with settings from config."""
from transmission_rpc import Client
url = config.get("TRANSMISSION_URL", "")
if not url:
raw_url = config.get("TRANSMISSION_URL", "")
if not raw_url:
raise ValueError("TRANSMISSION_URL is required")
url = normalize_http_url(raw_url)
if not url:
raise ValueError("TRANSMISSION_URL is invalid")
username = config.get("TRANSMISSION_USERNAME", "")
password = config.get("TRANSMISSION_PASSWORD", "")
@@ -56,7 +61,7 @@ class TransmissionClient(DownloadClient):
def is_configured() -> bool:
"""Check if Transmission is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
url = config.get("TRANSMISSION_URL", "")
url = normalize_http_url(config.get("TRANSMISSION_URL", ""))
return client == "transmission" and bool(url)
def test_connection(self) -> Tuple[bool, str]:
+121 -6
View File
@@ -196,7 +196,7 @@ class ProwlarrHandler(DownloadHandler):
# If already complete, skip straight to file handling
if existing_status.complete:
logger.info(f"Existing download is complete, copying file directly")
logger.info("Existing download is complete, copying file directly")
status_callback("resolving", "Found existing download, copying to library")
source_path = client.get_download_path(download_id)
@@ -213,8 +213,74 @@ class ProwlarrHandler(DownloadHandler):
)
return None
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(source_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,
)
if matched_mapping:
if remapped.exists():
logger.info(
"Remapped existing download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
source_path_obj = remapped
else:
logger.error(
f"Download path does not exist after remapping: {source_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}. "
f"Check that the local path in your mapping is mounted correctly."
)
status_callback(
"error",
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.",
)
return None
elif mappings:
if source_path_obj.exists():
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
else:
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {source_path}. {hint}"
)
status_callback(
"error",
f"{hint} No remote path mapping matched for client '{client.name}'.",
)
return None
elif not source_path_obj.exists():
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist: {source_path}. "
f"Client: {client.name}, ID: {download_id}. {hint}"
)
status_callback("error", hint)
return None
result = self._handle_completed_file(
source_path=Path(source_path),
source_path=source_path_obj,
protocol=protocol,
task=task,
status_callback=status_callback,
@@ -395,21 +461,39 @@ class ProwlarrHandler(DownloadHandler):
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local,
remap_remote_to_local_with_match,
)
source_path_obj = Path(source_path)
if not source_path_obj.exists():
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
remapped = remap_remote_to_local(
logger.debug(
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
client.name,
host,
source_path_obj,
[(m.host, m.remote_path, m.local_path) for m in mappings],
)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if remapped != source_path_obj and remapped.exists():
logger.debug(
"Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
remapped.exists(),
remapped != source_path_obj,
matched_mapping,
)
if matched_mapping:
if remapped.exists():
logger.info(
"Remapped download path for %s (%s): %s -> %s",
client.name,
@@ -419,6 +503,37 @@ class ProwlarrHandler(DownloadHandler):
)
source_path_obj = remapped
else:
logger.error(
f"Download path does not exist after remapping: {source_path} -> {remapped}. "
f"Client: {client.name}, ID: {download_id}. "
f"Check that the local path in your mapping is mounted correctly."
)
status_callback(
"error",
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.",
)
return None
elif mappings:
if source_path_obj.exists():
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
else:
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist and no remote path mapping matched for {client.name} "
f"({download_id}): {source_path}. {hint}"
)
status_callback(
"error",
f"{hint} No remote path mapping matched for client '{client.name}'.",
)
return None
elif not source_path_obj.exists():
hint = _diagnose_path_issue(source_path)
logger.error(
f"Download path does not exist: {source_path}. "
+58 -25
View File
@@ -6,7 +6,7 @@ Registers Prowlarr settings as a group with multiple tabs:
- Download Clients: Torrent and usenet client settings
"""
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from shelfmark.core.settings_registry import (
register_group,
@@ -19,6 +19,7 @@ from shelfmark.core.settings_registry import (
SelectField,
MultiSelectField,
)
from shelfmark.core.utils import normalize_http_url
# ==================== Dynamic Options Loaders ====================
@@ -34,10 +35,14 @@ def _get_indexer_options() -> List[Dict[str, str]]:
logger = setup_logger(__name__)
url = config.get("PROWLARR_URL", "")
raw_url = config.get("PROWLARR_URL", "")
api_key = config.get("PROWLARR_API_KEY", "")
if not url or not api_key:
if not raw_url or not api_key:
return []
url = normalize_http_url(raw_url)
if not url:
return []
try:
@@ -72,7 +77,7 @@ def _get_indexer_options() -> List[Dict[str, str]]:
# ==================== Test Connection Callbacks ====================
def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the Prowlarr connection using current form values."""
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -81,11 +86,15 @@ def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str
logger = setup_logger(__name__)
current_values = current_values or {}
url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "")
raw_url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "")
api_key = current_values.get("PROWLARR_API_KEY") or config.get("PROWLARR_API_KEY", "")
if not url:
if not raw_url:
return {"success": False, "message": "Prowlarr URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "Prowlarr URL is invalid"}
if not api_key:
return {"success": False, "message": "API key is required"}
@@ -97,22 +106,26 @@ def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_qbittorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the qBittorrent connection using current form values."""
from shelfmark.core.config import config
current_values = current_values or {}
url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "")
raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "")
username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "")
password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "")
if not url:
if not raw_url:
return {"success": False, "message": "qBittorrent URL is required"}
try:
from qbittorrentapi import Client
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "qBittorrent URL is invalid"}
client = Client(host=url, username=username, password=password)
client.auth_log_in()
api_version = client.app.web_api_version
@@ -123,7 +136,7 @@ def _test_qbittorrent_connection(current_values: Dict[str, Any] = None) -> Dict[
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the Transmission connection using current form values."""
from shelfmark.core.config import config
from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
@@ -132,13 +145,17 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict
current_values = current_values or {}
url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "")
raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "")
username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "")
password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "")
if not url:
if not raw_url:
return {"success": False, "message": "Transmission URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "Transmission URL is invalid"}
try:
from transmission_rpc import Client
@@ -161,7 +178,7 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test Deluge Web UI JSON-RPC connection using current form values."""
from urllib.parse import urlparse
@@ -180,6 +197,10 @@ def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str,
return {"success": False, "message": "Deluge password is required"}
raw_host = str(raw_host)
raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else ""
if not raw_host:
return {"success": False, "message": "Deluge host is invalid"}
raw_port = str(raw_port or "8112")
scheme = "http"
@@ -256,7 +277,7 @@ def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str,
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the rTorrent connection using current form values."""
from shelfmark.core.config import config
from urllib.parse import urlparse
@@ -264,13 +285,17 @@ def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str
current_values = current_values or {}
url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "")
username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "")
password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "")
if not url:
if not raw_url:
return {"success": False, "message": "rTorrent URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "rTorrent URL is invalid"}
try:
# Add HTTP auth to URL if credentials provided
if username and password:
@@ -284,20 +309,24 @@ def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the NZBGet connection using current form values."""
import requests
from shelfmark.core.config import config
current_values = current_values or {}
url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "")
raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "")
username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget")
password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "")
if not url:
if not raw_url:
return {"success": False, "message": "NZBGet URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "NZBGet URL is invalid"}
try:
rpc_url = f"{url.rstrip('/')}/jsonrpc"
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1}
@@ -316,18 +345,22 @@ def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str,
return {"success": False, "message": f"Connection failed: {str(e)}"}
def _test_sabnzbd_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""Test the SABnzbd connection using current form values."""
import requests
from shelfmark.core.config import config
current_values = current_values or {}
url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "")
raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "")
api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "")
if not url:
if not raw_url:
return {"success": False, "message": "SABnzbd URL is required"}
url = normalize_http_url(raw_url)
if not url:
return {"success": False, "message": "SABnzbd URL is invalid"}
if not api_key:
return {"success": False, "message": "API key is required"}
@@ -745,10 +778,10 @@ def prowlarr_clients_settings():
SelectField(
key="PROWLARR_USENET_ACTION",
label="NZB Completion Action",
description="Copy files into your ingest folder, optionally cleaning up the usenet client",
description="Move deletes the job from your usenet client after import; Copy keeps it in the client",
options=[
{"value": "move", "label": "Copy and remove from client"},
{"value": "copy", "label": "Copy (keep in client)"},
{"value": "move", "label": "Move"},
{"value": "copy", "label": "Copy"},
],
default="move",
show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True},
+22 -6
View File
@@ -23,6 +23,7 @@ from shelfmark.release_sources import (
LeadingCellType,
)
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol
@@ -268,10 +269,14 @@ class ProwlarrSource(ReleaseSource):
def _get_client(self) -> Optional[ProwlarrClient]:
"""Get a configured Prowlarr client or None if not configured."""
url = config.get("PROWLARR_URL", "")
raw_url = config.get("PROWLARR_URL", "")
api_key = config.get("PROWLARR_API_KEY", "")
if not url or not api_key:
if not raw_url or not api_key:
return None
url = normalize_http_url(raw_url)
if not url:
return None
return ProwlarrClient(url, api_key)
@@ -313,7 +318,7 @@ class ProwlarrSource(ReleaseSource):
logger.warning("Prowlarr not configured - skipping search")
return []
queries = [v.query for v in plan.title_variants if v.query]
queries = [v.title for v in plan.title_variants if v.title]
queries = [q for q in queries if q]
if not queries and plan.isbn_candidates:
@@ -340,11 +345,22 @@ class ProwlarrSource(ReleaseSource):
categories = None if expand_search else search_categories
self.last_search_type = "expanded" if expand_search else "categories"
if plan.manual_query:
query_type = "manual"
elif not plan.title_variants and plan.isbn_candidates:
query_type = "isbn"
else:
query_type = "title"
indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
if len(queries) == 1:
logger.debug(f"Searching Prowlarr: query='{queries[0]}', {indexer_desc}, categories={categories}")
logger.debug(
f"Searching Prowlarr: {query_type}='{queries[0]}', {indexer_desc}, categories={categories}"
)
else:
logger.debug(f"Searching Prowlarr: {len(queries)} queries, {indexer_desc}, categories={categories}")
logger.debug(
f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}"
)
def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
"""Search indexers with given categories, collecting results."""
@@ -420,6 +436,6 @@ class ProwlarrSource(ReleaseSource):
"""Check if Prowlarr is enabled and configured."""
if not config.get("PROWLARR_ENABLED", False):
return False
url = config.get("PROWLARR_URL", "")
url = normalize_http_url(config.get("PROWLARR_URL", ""))
api_key = config.get("PROWLARR_API_KEY", "")
return bool(url and api_key)
+1 -1
View File
@@ -189,7 +189,7 @@ export const Dropdown = ({
panelDirection === 'down'
? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border shadow-lg z-20 ${panelClassName || widthClassName}`}
} border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{
background: 'var(--bg)',
borderColor: 'var(--border-muted)',
+14 -1
View File
@@ -673,6 +673,8 @@ export const ReleaseModal = ({
setExpandedBySource({});
setFormatFilter('');
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setManualQuery('');
setShowManualQuery(false);
setSearchStatus(null);
lastStatusTimeRef.current = 0;
pendingStatusRef.current = null;
@@ -1409,7 +1411,18 @@ export const ReleaseModal = ({
{/* Manual query button */}
<button
type="button"
onClick={() => setShowManualQuery((prev) => !prev)}
onClick={() => {
setShowManualQuery((prev) => {
const next = !prev;
if (next && !manualQuery.trim()) {
const baseTitle = book?.search_title || book?.title || '';
const baseAuthor = book?.search_author || book?.author || '';
const defaultQuery = `${baseTitle} ${baseAuthor}`.trim();
setManualQuery(defaultQuery);
}
return next;
});
}}
className={`p-2.5 rounded-full transition-colors hover-surface text-gray-500 dark:text-gray-400 ${
manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`}
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-2">
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-black/10 [&:not(:first-child)]:dark:border-white/10">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">
+4 -3
View File
@@ -251,13 +251,13 @@ class TestBuildLibraryPath:
def test_path_traversal_prevented(self):
"""Test that path traversal is prevented."""
with pytest.raises(ValueError, match="traversal"):
build_library_path(
path = build_library_path(
"/books",
"{Author}/{Title}",
{"Author": "../../../etc", "Title": "passwd"},
{"Author": "../etc", "Title": "passwd"},
extension="txt"
)
assert path == Path("/books/etc/passwd.txt")
def test_fallback_to_title(self):
"""Test fallback when template produces empty result."""
@@ -291,6 +291,7 @@ class TestSanitizeFilename:
('file"with"quotes', "file_with_quotes"),
("file<with>angles", "file_with_angles"),
("file|with|pipes", "file_with_pipes"),
("file/with/slash", "file_with_slash"),
])
def test_invalid_chars_replaced(self, input_name, expected):
"""Test that invalid characters are replaced."""
@@ -10,6 +10,15 @@ class TestHardcoverComputeSearchTitle:
== "The Final Empire"
)
def test_prefers_main_title_when_subtitle_is_descriptive(self):
assert (
_compute_search_title(
"The Cuckoo's Egg: Tracking a Spy Through the Maze of Computer Espionage",
"Tracking a Spy Through the Maze of Computer Espionage",
)
== "The Cuckoo's Egg"
)
def test_does_not_use_subtitle_when_it_looks_like_series_position(self):
assert _compute_search_title("The Stormlight Archive: Book 1", "Book 1") is None
assert _compute_search_title("Some Series: Volume II", "Volume II") is None
+1 -1
View File
@@ -146,7 +146,7 @@ class TestQBittorrentClientTestConnection:
def test_test_connection_failure(self, monkeypatch):
"""Test failed connection."""
config_values = {
"QBITTORRENT_URL": "http://localhost:8080",
"QBITTORRENT_URL": "localhost:8080",
"QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "wrong",
"QBITTORRENT_CATEGORY": "test",
+287
View File
@@ -89,3 +89,290 @@ def test_remaps_completed_path_when_remote_path_missing():
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_remap_prefers_mapping_when_original_exists():
with tempfile.TemporaryDirectory() as tmp_dir:
remote_dir = Path(tmp_dir) / "remote" / "downloads"
remote_dir.mkdir(parents=True)
remote_file = remote_dir / "book.epub"
remote_file.write_text("remote content")
local_dir = Path(tmp_dir) / "local" / "downloads"
local_dir.mkdir(parents=True)
local_file = local_dir / "book.epub"
local_file.write_text("local content")
remote_path = str(remote_file)
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=remote_path,
)
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="poll-mapping-prefer", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_remap_fails_when_mapping_exists_but_path_missing():
with tempfile.TemporaryDirectory() as tmp_dir:
remote_dir = Path(tmp_dir) / "remote" / "downloads"
remote_dir.mkdir(parents=True)
remote_file = remote_dir / "book.epub"
remote_file.write_text("remote content")
local_dir = Path(tmp_dir) / "local" / "downloads"
local_dir.mkdir(parents=True)
local_file = local_dir / "book.epub"
remote_path = str(remote_file)
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=remote_path,
)
mock_client.get_download_path.return_value = remote_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
"remotePath": str(remote_dir),
"localPath": str(local_dir),
}
]
return default
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="poll-mapping-missing", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert any(status == "error" for status, _ in recorder.status_updates)
assert not local_file.exists()
def test_remaps_windows_path_to_linux():
"""Test that Windows paths from external download clients are correctly remapped."""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a local file that represents the mounted path
local_file = Path(tmp_dir) / "downloads" / "Le Fay" / "book.epub"
local_file.parent.mkdir(parents=True)
local_file.write_text("test content")
# Windows path as reported by qBittorrent running on Windows
windows_path = r"D:\Torrents\Le Fay\book.epub"
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=windows_path,
)
mock_client.get_download_path.return_value = windows_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
# User enters Windows path in settings (with backslashes)
"remotePath": r"D:\Torrents",
"localPath": str(Path(tmp_dir) / "downloads"),
}
]
return default
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="windows-path-test", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
def test_windows_path_case_insensitive_matching():
"""Test that Windows path matching is case-insensitive.
Users may enter paths in different case than what the download client reports.
For example, user enters 'd:\\torrents' but qBittorrent reports 'D:\\Torrents'.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
# Create a local file that represents the mounted path
local_file = Path(tmp_dir) / "downloads" / "Le Fay" / "book.epub"
local_file.parent.mkdir(parents=True)
local_file.write_text("test content")
# qBittorrent reports path with different case than user's setting
windows_path = r"D:\Torrents\Le Fay\book.epub" # Mixed case
mock_client = MagicMock()
mock_client.name = "qbittorrent"
mock_client.find_existing.return_value = None
mock_client.add_download.return_value = "download_id"
mock_client.get_status.return_value = DownloadStatus(
progress=100,
state=DownloadState.COMPLETE,
message="Complete",
complete=True,
file_path=windows_path,
)
mock_client.get_download_path.return_value = windows_path
def config_get(key: str, default=""):
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
return [
{
"host": "qbittorrent",
# User enters lowercase (as shown in UI screenshot)
"remotePath": r"d:\torrents",
"localPath": str(Path(tmp_dir) / "downloads"),
}
]
return default
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value={
"protocol": "torrent",
"magnetUrl": "magnet:?xt=urn:btih:abc123",
},
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.release_sources.prowlarr.handler.config.get",
side_effect=config_get,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
handler = ProwlarrHandler()
task = DownloadTask(task_id="case-insensitive-test", source="prowlarr", title="Test Book")
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result == str(local_file)
assert task.original_download_path == str(local_file)
+5 -5
View File
@@ -338,8 +338,8 @@ class TestProwlarrLocalizedQueries:
plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook")
assert "The Lightning Thief Rick Riordan" in fake_client.queries
assert "A villámtolvaj Rick Riordan" in fake_client.queries
assert "The Lightning Thief" in fake_client.queries
assert "A villámtolvaj" in fake_client.queries
assert len(fake_client.queries) == 2
def test_search_does_not_override_search_title_for_english(self, monkeypatch):
@@ -384,6 +384,6 @@ class TestProwlarrLocalizedQueries:
plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook")
assert "The Final Empire Brandon Sanderson" in fake_client.queries
assert "A végső birodalom Brandon Sanderson" in fake_client.queries
assert "Mistborn: The Final Empire Brandon Sanderson" not in fake_client.queries
assert "The Final Empire" in fake_client.queries
assert "A végső birodalom" in fake_client.queries
assert "Mistborn: The Final Empire" not in fake_client.queries
@@ -25,4 +25,5 @@ class TestReleaseSearchPlanManualQuery:
assert plan.isbn_candidates == []
assert plan.languages == ["en", "hu"]
assert [v.query for v in plan.title_variants] == ["some custom query"]
assert [v.title for v in plan.title_variants] == ["some custom query"]
assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [("some custom query", None)]
@@ -32,6 +32,12 @@ class TestReleaseSearchPlan:
"A végső birodalom Brandon Sanderson",
]
# Title-only variants are used by some sources (e.g. Prowlarr).
assert [v.title for v in plan.title_variants] == [
"The Final Empire",
"A végső birodalom",
]
assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [
("The Final Empire", ["en"]),
("A végső birodalom", ["hu"]),
@@ -56,6 +62,9 @@ class TestReleaseSearchPlan:
assert [v.query for v in plan.title_variants] == [
"The Lightning Thief Rick Riordan",
]
assert [v.title for v in plan.title_variants] == [
"The Lightning Thief",
]
assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [
("The Lightning Thief", None),
]