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: shelfmark-lite:
image: ghcr.io/calibrain/shelfmark-lite:latest image: ghcr.io/calibrain/shelfmark-lite:latest
environment: environment:
# TZ: America/New_York
# EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr # EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr
# PUID: 1000 PUID: 1000
# PGID: 1000 PGID: 1000
ports: ports:
- 8084:8084 - 8084:8084
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /path/to/books:/books # Book destination directory - /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration - /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 # - /path/to/downloads:/path/to/downloads
@@ -4,10 +4,9 @@ services:
image: ghcr.io/calibrain/shelfmark:latest image: ghcr.io/calibrain/shelfmark:latest
environment: environment:
FLASK_PORT: 8084 FLASK_PORT: 8084
# TZ: America/New_York
USING_TOR: true USING_TOR: true
# PUID: 1000 PUID: 1000
# PGID: 1000 PGID: 1000
cap_add: cap_add:
- NET_ADMIN - NET_ADMIN
- NET_RAW - NET_RAW
@@ -15,7 +14,7 @@ services:
- 8084:8084 - 8084:8084
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /path/to/books:/books # Book destination directory - /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration - /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 # - /path/to/downloads:/path/to/downloads
@@ -3,14 +3,13 @@ services:
image: ghcr.io/calibrain/shelfmark:latest image: ghcr.io/calibrain/shelfmark:latest
container_name: shelfmark container_name: shelfmark
environment: environment:
# TZ: America/New_York PUID: 1000
# PUID: 1000 PGID: 1000
# PGID: 1000
ports: ports:
- 8084:8084 - 8084:8084
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- /path/to/books:/books # Book destination directory - /path/to/books:/books # Default destination for book downloads
- /path/to/config:/config # App configuration - /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 # - /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: services:
shelfmark-lite-dev: shelfmark-lite-dev:
extends: extends:
file: ./compose/edge/docker-compose.extbp.yml file: ./compose/docker-compose.lite.yml
service: shelfmark-lite service: shelfmark-lite
build: build:
context: . context: .
@@ -18,7 +18,7 @@ services:
- ./.local/books:/books - ./.local/books:/books
- ./.local/log:/var/log/shelfmark - ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/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 # - /path/to/downloads:/path/to/downloads
flaresolverr: flaresolverr:
+2 -2
View File
@@ -2,7 +2,7 @@
services: services:
shelfmark-tor-dev: shelfmark-tor-dev:
extends: extends:
file: ./compose/edge/docker-compose.tor.yml file: ./compose/docker-compose.tor.yml
service: shelfmark-tor service: shelfmark-tor
build: build:
context: . context: .
@@ -16,5 +16,5 @@ services:
- ./.local/books:/books - ./.local/books:/books
- ./.local/log:/var/log/shelfmark - ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/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 # - /path/to/downloads:/path/to/downloads
+2 -2
View File
@@ -2,7 +2,7 @@
services: services:
shelfmark-dev: shelfmark-dev:
extends: extends:
file: ./compose/edge/docker-compose.yml file: ./compose/docker-compose.yml
service: shelfmark service: shelfmark
build: build:
context: . context: .
@@ -18,5 +18,5 @@ services:
- ./.local/log:/var/log/shelfmark - ./.local/log:/var/log/shelfmark
- ./.local/tmp:/tmp/shelfmark - ./.local/tmp:/tmp/shelfmark
- ./shelfmark:/app/shelfmark:ro - ./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 # - /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 ### Installation
1. Download the docker-compose file: 1. Download the [docker-compose file](compose/docker-compose.yml):
```bash ```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: 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 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` 3. Open `http://localhost:8084`
That's it! Configure settings through the web interface as needed. 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 #### Enable Tor Routing
Routes all traffic through Tor for enhanced privacy: Routes all traffic through Tor for enhanced privacy:
```bash ```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 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 - **Audiobooks** - Using Shelfmark exclusively for audiobooks
```bash ```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 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.bypass import BypassCancelledException
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
if TYPE_CHECKING: if TYPE_CHECKING:
from shelfmark.download import network from shelfmark.download import network
@@ -29,10 +30,11 @@ BACKOFF_CAP = 10.0
def _fetch_via_bypasser(target_url: str) -> Optional[str]: def _fetch_via_bypasser(target_url: str) -> Optional[str]:
"""Make a single request to the external bypasser service. Returns HTML or None.""" """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_path = config.get("EXT_BYPASSER_PATH", "/v1")
bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000) bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000)
bypasser_url = normalize_http_url(raw_bypasser_url)
if not bypasser_url or not bypasser_path: if not bypasser_url or not bypasser_path:
logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.") logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.")
return None 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]: def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
"""Validate advanced settings before persisting.""" """Validate advanced settings before persisting."""
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS") mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS")
if mappings is None: if mappings is None:
@@ -20,9 +23,12 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]:
"values": values, "values": values,
} }
logger.debug("Processing %d remote path mapping entries", len(mappings))
cleaned = [] cleaned = []
for entry in mappings: for i, entry in enumerate(mappings):
if not isinstance(entry, dict): if not isinstance(entry, dict):
logger.debug("Skipping entry %d: not a dict", i)
continue continue
host = str(entry.get("host", "") or "").strip().lower() 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() local_path = str(entry.get("localPath", "") or "").strip()
if not host or not remote_path or not local_path: 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 continue
if not local_path.startswith("/"): if not local_path.startswith("/"):
return { return {
"error": True, "error": True,
"message": "Local Path must be an absolute path", "message": f"Local Path must be an absolute path (got: {local_path})",
"values": values, "values": values,
} }
cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path}) 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 values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned
return {"error": False, "values": values} return {"error": False, "values": values}
@@ -646,7 +664,7 @@ def download_settings():
}, },
{ {
"value": "rename", "value": "rename",
"label": "Rename only", "label": "Rename Only",
"description": "Rename files using a template" "description": "Rename files using a template"
}, },
{ {
@@ -771,7 +789,7 @@ def download_settings():
description="Choose how downloaded audiobook files are named and organized.", description="Choose how downloaded audiobook files are named and organized.",
options=[ options=[
{"value": "none", "label": "None", "description": "Keep original filename from source"}, {"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."}, {"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", default="rename",
+44 -26
View File
@@ -2,6 +2,8 @@
from typing import List from typing import List
from shelfmark.core.utils import normalize_http_url
# Lazy import to avoid circular imports # Lazy import to avoid circular imports
_config_module = None _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]: def get_aa_mirrors() -> List[str]:
""" """
Get Anna's Archive mirrors from config + defaults. Get Anna's Archive mirrors from config + defaults.
@@ -51,15 +57,16 @@ def get_aa_mirrors() -> List[str]:
Returns: Returns:
List of AA mirror URLs, starting with defaults then custom additions. 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() config = _get_config()
additional = config.get("AA_ADDITIONAL_URLS", "") additional = config.get("AA_ADDITIONAL_URLS", "")
if additional: if additional:
for url in additional.split(","): for url in additional.split(","):
url = url.strip() normalized = _normalize_mirror_url(url)
if url and url not in mirrors: if normalized and normalized not in mirrors:
mirrors.append(url) mirrors.append(normalized)
return mirrors return mirrors
@@ -71,15 +78,16 @@ def get_libgen_mirrors() -> List[str]:
Returns: Returns:
List of LibGen mirror URLs (defaults first, then custom additions). 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() config = _get_config()
additional = config.get("LIBGEN_ADDITIONAL_URLS", "") additional = config.get("LIBGEN_ADDITIONAL_URLS", "")
if additional: if additional:
for url in additional.split(","): for url in additional.split(","):
url = url.strip() normalized = _normalize_mirror_url(url)
if url and url not in mirrors: if normalized and normalized not in mirrors:
mirrors.append(url) mirrors.append(normalized)
return mirrors return mirrors
@@ -93,21 +101,24 @@ def get_zlib_mirrors() -> List[str]:
""" """
config = _get_config() 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] mirrors = [primary]
# Add other defaults (excluding primary) # Add other defaults (excluding primary)
for url in DEFAULT_ZLIB_MIRRORS: for url in DEFAULT_ZLIB_MIRRORS:
if url != primary: normalized = _normalize_mirror_url(url)
mirrors.append(url) if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors # Add custom mirrors
additional = config.get("ZLIB_ADDITIONAL_URLS", "") additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional: if additional:
for url in additional.split(","): for url in additional.split(","):
url = url.strip() normalized = _normalize_mirror_url(url)
if url and url not in mirrors: if normalized and normalized not in mirrors:
mirrors.append(url) mirrors.append(normalized)
return mirrors return mirrors
@@ -120,7 +131,8 @@ def get_zlib_primary_url() -> str:
Primary Z-Library mirror URL. Primary Z-Library mirror URL.
""" """
config = _get_config() 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: def get_zlib_url_template() -> str:
@@ -143,21 +155,24 @@ def get_welib_mirrors() -> List[str]:
""" """
config = _get_config() 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] mirrors = [primary]
# Add other defaults (excluding primary) # Add other defaults (excluding primary)
for url in DEFAULT_WELIB_MIRRORS: for url in DEFAULT_WELIB_MIRRORS:
if url != primary: normalized = _normalize_mirror_url(url)
mirrors.append(url) if normalized and normalized != primary:
mirrors.append(normalized)
# Add custom mirrors # Add custom mirrors
additional = config.get("WELIB_ADDITIONAL_URLS", "") additional = config.get("WELIB_ADDITIONAL_URLS", "")
if additional: if additional:
for url in additional.split(","): for url in additional.split(","):
url = url.strip() normalized = _normalize_mirror_url(url)
if url and url not in mirrors: if normalized and normalized not in mirrors:
mirrors.append(url) mirrors.append(normalized)
return mirrors return mirrors
@@ -170,7 +185,8 @@ def get_welib_primary_url() -> str:
Primary Welib mirror URL. Primary Welib mirror URL.
""" """
config = _get_config() 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: def get_welib_url_template() -> str:
@@ -197,7 +213,9 @@ def get_zlib_cookie_domains() -> set:
# Add all default domains # Add all default domains
for url in DEFAULT_ZLIB_MIRRORS: 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) domains.add(domain)
# Add custom domains # Add custom domains
@@ -205,9 +223,9 @@ def get_zlib_cookie_domains() -> set:
additional = config.get("ZLIB_ADDITIONAL_URLS", "") additional = config.get("ZLIB_ADDITIONAL_URLS", "")
if additional: if additional:
for url in additional.split(","): for url in additional.split(","):
url = url.strip() normalized = _normalize_mirror_url(url)
if url: if normalized:
domain = url.replace("https://", "").replace("http://", "").split("/")[0] domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
domains.add(domain) domains.add(domain)
return domains return domains
+12 -8
View File
@@ -3,7 +3,7 @@
import os import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Union from typing import Dict, Optional, Union, Mapping
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
@@ -17,10 +17,10 @@ TOKEN_PATTERN = re.compile(
) )
# Characters that are invalid in filenames on various filesystems # 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.""" """Sanitize a string for filesystem use."""
if not name: if not name:
return "" return ""
@@ -31,7 +31,7 @@ def _sanitize(name: str, max_length: int = 245) -> str:
return sanitized[:max_length] 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.""" """Sanitize a string for use as a filename or path component."""
return _sanitize(name, max_length) return _sanitize(name, max_length)
@@ -40,7 +40,7 @@ def sanitize_filename(name: str, max_length: int = 245) -> str:
sanitize_path_component = sanitize_filename 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: if position is None:
return "" return ""
@@ -78,7 +78,9 @@ def assign_part_numbers(
def parse_naming_template( def parse_naming_template(
template: str, template: str,
metadata: Dict[str, Optional[Union[str, int, float]]], metadata: Mapping[str, Optional[Union[str, int, float]]],
*,
allow_path_separators: bool = True,
) -> str: ) -> str:
if not template: if not template:
return "" return ""
@@ -108,6 +110,8 @@ def parse_naming_template(
if not value: if not value:
return "" return ""
if not allow_path_separators:
value = value.replace("/", "_")
# Sanitize the value # Sanitize the value
value = sanitize_filename(value) value = sanitize_filename(value)
@@ -140,10 +144,10 @@ def parse_naming_template(
def build_library_path( def build_library_path(
base_path: str, base_path: str,
template: str, template: str,
metadata: Dict[str, Optional[Union[str, int, float]]], metadata: Mapping[str, Optional[Union[str, int, float]]],
extension: Optional[str] = None, extension: Optional[str] = None,
) -> Path: ) -> Path:
relative = parse_naming_template(template, metadata) relative = parse_naming_template(template, metadata, allow_path_separators=True)
if not relative: if not relative:
# Fallback to title if template produces empty result # Fallback to title if template produces empty result
+40 -6
View File
@@ -34,6 +34,11 @@ def _normalize_prefix(path: str) -> str:
return normalized 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: def _normalize_host(host: str) -> str:
return str(host or "").strip().lower() return str(host or "").strip().lower()
@@ -61,12 +66,21 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]:
return mappings 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) host_normalized = _normalize_host(host)
remote_normalized = _normalize_prefix(str(remote_path)) remote_normalized = _normalize_prefix(str(remote_path))
if not remote_normalized: 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: for mapping in mappings:
if _normalize_host(mapping.host) != host_normalized: 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: if not remote_prefix:
continue continue
if remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/"): # For Windows paths, do case-insensitive prefix matching
remainder = remote_normalized[len(remote_prefix) :] 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) local_prefix = _normalize_prefix(mapping.local_path)
if remainder.startswith("/"): if remainder.startswith("/"):
remainder = remainder[1:] 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]: def get_client_host_identifier(client: Any) -> Optional[str]:
+46
View File
@@ -5,6 +5,52 @@ from pathlib import Path
from typing import Optional 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: def is_audiobook(content_type: Optional[str]) -> bool:
"""Check if content type indicates an audiobook.""" """Check if content type indicates an audiobook."""
return bool(content_type and "audiobook" in content_type.lower()) 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.logger import setup_logger
from shelfmark.core.config import config as app_config from shelfmark.core.config import config as app_config
from shelfmark.core.utils import normalize_http_url
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -729,7 +730,14 @@ def rotate_dns_and_reset_aa() -> bool:
return False return False
# Reset AA URL to first available auto option if using auto AA # Reset AA URL to first available auto option if using auto AA
global _aa_base_url, _current_aa_url_index 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: if configured_url == "auto" or _aa_base_url in _aa_urls:
_current_aa_url_index = 0 _current_aa_url_index = 0
_aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.se" _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() _aa_urls = _build_aa_urls()
# Get configured base URL from config # 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 configured_url == "auto":
if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls: 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) metadata = build_metadata_dict(task)
extension = book_file.suffix.lstrip(".") or task.format or "" 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 "" filename = Path(filename).name if filename else ""
if filename and extension: if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}" filename = f"{sanitize_filename(filename)}.{extension}"
+17 -1
View File
@@ -1430,10 +1430,26 @@ def api_releases() -> Union[Response, Tuple[Response, int]]:
try: try:
source = get_source(source_name) source = get_source(source_name)
source_instances[source_name] = source 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 from shelfmark.release_sources.search_plan import build_release_search_plan
plan = build_release_search_plan(book, languages=languages, manual_query=manual_query) 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) releases = source.search(book, plan, expand_search=expand_search, content_type=content_type)
all_releases.extend(releases) all_releases.extend(releases)
except ValueError: except ValueError:
+21 -1
View File
@@ -208,7 +208,27 @@ def _compute_search_title(
# Prefer subtitle when it looks like the real title. # Prefer subtitle when it looks like the real title.
if normalized_subtitle and not _is_probably_series_position(normalized_subtitle): 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(): if normalized_subtitle.lower() in normalized_title.lower():
return normalized_subtitle return normalized_subtitle
+2 -2
View File
@@ -1162,7 +1162,7 @@ class DirectDownloadSource(ReleaseSource):
if not expand_search: if not expand_search:
isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None
if isbn: if isbn:
logger.debug(f"Searching by ISBN: {isbn}") logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}")
filters = SearchFilters(isbn=[isbn]) filters = SearchFilters(isbn=[isbn])
if lang_filter: if lang_filter:
filters.lang = lang_filter filters.lang = lang_filter
@@ -1191,7 +1191,7 @@ class DirectDownloadSource(ReleaseSource):
if not query: if not query:
continue 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() filters = SearchFilters(lang=langs) if langs else SearchFilters()
try: try:
for bi in search_books(query, filters): 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 import requests
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
logger = setup_logger(__name__) logger = setup_logger(__name__)
@@ -13,7 +14,7 @@ class ProwlarrClient:
"""Client for interacting with the Prowlarr API.""" """Client for interacting with the Prowlarr API."""
def __init__(self, url: str, api_key: str, timeout: int = 30): 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.api_key = api_key
self.timeout = timeout self.timeout = timeout
self._session = requests.Session() self._session = requests.Session()
@@ -19,6 +19,7 @@ import requests
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -65,6 +66,10 @@ class DelugeClient(DownloadClient):
# Allow DELUGE_HOST to be either a hostname OR a full URL # Allow DELUGE_HOST to be either a hostname OR a full URL
# (useful when Deluge is behind a reverse proxy path). # (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 host = raw_host
port = int(raw_port) port = int(raw_port)
@@ -11,6 +11,7 @@ import requests
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -30,11 +31,13 @@ class NZBGetClient(DownloadClient):
def __init__(self): def __init__(self):
"""Initialize NZBGet client with settings from config.""" """Initialize NZBGet client with settings from config."""
url = config.get("NZBGET_URL", "") raw_url = config.get("NZBGET_URL", "")
if not url: if not raw_url:
raise ValueError("NZBGET_URL is required") 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.username = config.get("NZBGET_USERNAME", "nzbget")
self.password = config.get("NZBGET_PASSWORD", "") self.password = config.get("NZBGET_PASSWORD", "")
self._category = config.get("NZBGET_CATEGORY", "Books") self._category = config.get("NZBGET_CATEGORY", "Books")
@@ -43,7 +46,7 @@ class NZBGetClient(DownloadClient):
def is_configured() -> bool: def is_configured() -> bool:
"""Check if NZBGet is configured and selected as the usenet client.""" """Check if NZBGet is configured and selected as the usenet client."""
client = config.get("PROWLARR_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) return client == "nzbget" and bool(url)
@with_retry() @with_retry()
@@ -6,6 +6,7 @@ from typing import Optional, Tuple
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -91,13 +92,19 @@ class QBittorrentClient(DownloadClient):
# Lazy import to avoid dependency issues if not using torrents # Lazy import to avoid dependency issues if not using torrents
from qbittorrentapi import Client from qbittorrentapi import Client
url = config.get("QBITTORRENT_URL", "") raw_url = config.get("QBITTORRENT_URL", "")
if not url: if not raw_url:
raise ValueError("QBITTORRENT_URL is required") 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( self._client = Client(
host=url, host=self._base_url,
username=config.get("QBITTORRENT_USERNAME", ""), username=config.get("QBITTORRENT_USERNAME", ""),
password=config.get("QBITTORRENT_PASSWORD", ""), password=config.get("QBITTORRENT_PASSWORD", ""),
) )
@@ -197,13 +204,20 @@ class QBittorrentClient(DownloadClient):
return [], f"qBittorrent request timed out at {self._base_url}" return [], f"qBittorrent request timed out at {self._base_url}"
except Exception as e: except Exception as e:
logger.debug(f"Failed to get torrents info: {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}" return [], f"qBittorrent API error: {type(e).__name__}: {e}"
@staticmethod @staticmethod
def is_configured() -> bool: def is_configured() -> bool:
"""Check if qBittorrent is configured and selected as the torrent client.""" """Check if qBittorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_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) return client == "qbittorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]: 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.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -32,11 +33,13 @@ class RTorrentClient(DownloadClient):
"""Initialize rTorrent client with settings from config.""" """Initialize rTorrent client with settings from config."""
from xmlrpc.client import ServerProxy from xmlrpc.client import ServerProxy
url = config.get("RTORRENT_URL", "") raw_url = config.get("RTORRENT_URL", "")
if not url: if not raw_url:
raise ValueError("RTORRENT_URL is required") 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", "") username = config.get("RTORRENT_USERNAME", "")
password = config.get("RTORRENT_PASSWORD", "") password = config.get("RTORRENT_PASSWORD", "")
@@ -55,7 +58,7 @@ class RTorrentClient(DownloadClient):
def is_configured() -> bool: def is_configured() -> bool:
"""Check if rTorrent is configured and selected as the torrent client.""" """Check if rTorrent is configured and selected as the torrent client."""
client = config.get("PROWLARR_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) return client == "rtorrent" and bool(url)
def test_connection(self) -> Tuple[bool, str]: def test_connection(self) -> Tuple[bool, str]:
@@ -10,6 +10,7 @@ import requests
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -99,15 +100,17 @@ class SABnzbdClient(DownloadClient):
def __init__(self): def __init__(self):
"""Initialize SABnzbd client with settings from config.""" """Initialize SABnzbd client with settings from config."""
url = config.get("SABNZBD_URL", "") raw_url = config.get("SABNZBD_URL", "")
if not url: if not raw_url:
raise ValueError("SABNZBD_URL is required") raise ValueError("SABNZBD_URL is required")
api_key = config.get("SABNZBD_API_KEY", "") api_key = config.get("SABNZBD_API_KEY", "")
if not api_key: if not api_key:
raise ValueError("SABNZBD_API_KEY is required") 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.api_key = api_key
self._category = config.get("SABNZBD_CATEGORY", "books") self._category = config.get("SABNZBD_CATEGORY", "books")
@@ -115,7 +118,7 @@ class SABnzbdClient(DownloadClient):
def is_configured() -> bool: def is_configured() -> bool:
"""Check if SABnzbd is configured and selected as the usenet client.""" """Check if SABnzbd is configured and selected as the usenet client."""
client = config.get("PROWLARR_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", "") api_key = config.get("SABNZBD_API_KEY", "")
return client == "sabnzbd" and bool(url) and bool(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.config import config
from shelfmark.core.logger import setup_logger from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.release_sources.prowlarr.clients import ( from shelfmark.release_sources.prowlarr.clients import (
DownloadClient, DownloadClient,
DownloadStatus, DownloadStatus,
@@ -33,10 +34,14 @@ class TransmissionClient(DownloadClient):
"""Initialize Transmission client with settings from config.""" """Initialize Transmission client with settings from config."""
from transmission_rpc import Client from transmission_rpc import Client
url = config.get("TRANSMISSION_URL", "") raw_url = config.get("TRANSMISSION_URL", "")
if not url: if not raw_url:
raise ValueError("TRANSMISSION_URL is required") 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", "") username = config.get("TRANSMISSION_USERNAME", "")
password = config.get("TRANSMISSION_PASSWORD", "") password = config.get("TRANSMISSION_PASSWORD", "")
@@ -56,7 +61,7 @@ class TransmissionClient(DownloadClient):
def is_configured() -> bool: def is_configured() -> bool:
"""Check if Transmission is configured and selected as the torrent client.""" """Check if Transmission is configured and selected as the torrent client."""
client = config.get("PROWLARR_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) return client == "transmission" and bool(url)
def test_connection(self) -> Tuple[bool, str]: 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 already complete, skip straight to file handling
if existing_status.complete: 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") status_callback("resolving", "Found existing download, copying to library")
source_path = client.get_download_path(download_id) source_path = client.get_download_path(download_id)
@@ -213,8 +213,74 @@ class ProwlarrHandler(DownloadHandler):
) )
return None 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( result = self._handle_completed_file(
source_path=Path(source_path), source_path=source_path_obj,
protocol=protocol, protocol=protocol,
task=task, task=task,
status_callback=status_callback, status_callback=status_callback,
@@ -395,21 +461,39 @@ class ProwlarrHandler(DownloadHandler):
from shelfmark.core.path_mappings import ( from shelfmark.core.path_mappings import (
get_client_host_identifier, get_client_host_identifier,
parse_remote_path_mappings, parse_remote_path_mappings,
remap_remote_to_local, remap_remote_to_local_with_match,
) )
source_path_obj = Path(source_path) source_path_obj = Path(source_path)
if not source_path_obj.exists():
host = get_client_host_identifier(client) or "" host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value) 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, mappings=mappings,
host=host, host=host,
remote_path=source_path_obj, 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( logger.info(
"Remapped download path for %s (%s): %s -> %s", "Remapped download path for %s (%s): %s -> %s",
client.name, client.name,
@@ -419,6 +503,37 @@ class ProwlarrHandler(DownloadHandler):
) )
source_path_obj = remapped source_path_obj = remapped
else: 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) hint = _diagnose_path_issue(source_path)
logger.error( logger.error(
f"Download path does not exist: {source_path}. " 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 - 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 ( from shelfmark.core.settings_registry import (
register_group, register_group,
@@ -19,6 +19,7 @@ from shelfmark.core.settings_registry import (
SelectField, SelectField,
MultiSelectField, MultiSelectField,
) )
from shelfmark.core.utils import normalize_http_url
# ==================== Dynamic Options Loaders ==================== # ==================== Dynamic Options Loaders ====================
@@ -34,10 +35,14 @@ def _get_indexer_options() -> List[Dict[str, str]]:
logger = setup_logger(__name__) logger = setup_logger(__name__)
url = config.get("PROWLARR_URL", "") raw_url = config.get("PROWLARR_URL", "")
api_key = config.get("PROWLARR_API_KEY", "") 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 [] return []
try: try:
@@ -72,7 +77,7 @@ def _get_indexer_options() -> List[Dict[str, str]]:
# ==================== Test Connection Callbacks ==================== # ==================== 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.""" """Test the Prowlarr connection using current form values."""
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger 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__) logger = setup_logger(__name__)
current_values = current_values or {} 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", "") 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"} 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: if not api_key:
return {"success": False, "message": "API key is required"} 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)}"} 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.""" """Test the qBittorrent connection using current form values."""
from shelfmark.core.config import config from shelfmark.core.config import config
current_values = current_values or {} 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", "") username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "")
password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "") 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"} return {"success": False, "message": "qBittorrent URL is required"}
try: try:
from qbittorrentapi import Client 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 = Client(host=url, username=username, password=password)
client.auth_log_in() client.auth_log_in()
api_version = client.app.web_api_version 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)}"} 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.""" """Test the Transmission connection using current form values."""
from shelfmark.core.config import config from shelfmark.core.config import config
from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( 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 {} 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", "") username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "")
password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") 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"} 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: try:
from transmission_rpc import Client 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)}"} 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.""" """Test Deluge Web UI JSON-RPC connection using current form values."""
from urllib.parse import urlparse 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"} return {"success": False, "message": "Deluge password is required"}
raw_host = str(raw_host) 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") raw_port = str(raw_port or "8112")
scheme = "http" 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)}"} 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.""" """Test the rTorrent connection using current form values."""
from shelfmark.core.config import config from shelfmark.core.config import config
from urllib.parse import urlparse 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 {} 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", "") username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "")
password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "") 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"} 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: try:
# Add HTTP auth to URL if credentials provided # Add HTTP auth to URL if credentials provided
if username and password: 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)}"} 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.""" """Test the NZBGet connection using current form values."""
import requests import requests
from shelfmark.core.config import config from shelfmark.core.config import config
current_values = current_values or {} 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") username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget")
password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "") 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"} 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: try:
rpc_url = f"{url.rstrip('/')}/jsonrpc" rpc_url = f"{url.rstrip('/')}/jsonrpc"
payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} 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)}"} 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.""" """Test the SABnzbd connection using current form values."""
import requests import requests
from shelfmark.core.config import config from shelfmark.core.config import config
current_values = current_values or {} 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", "") 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"} 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: if not api_key:
return {"success": False, "message": "API key is required"} return {"success": False, "message": "API key is required"}
@@ -745,10 +778,10 @@ def prowlarr_clients_settings():
SelectField( SelectField(
key="PROWLARR_USENET_ACTION", key="PROWLARR_USENET_ACTION",
label="NZB Completion 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=[ options=[
{"value": "move", "label": "Copy and remove from client"}, {"value": "move", "label": "Move"},
{"value": "copy", "label": "Copy (keep in client)"}, {"value": "copy", "label": "Copy"},
], ],
default="move", default="move",
show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True},
+22 -6
View File
@@ -23,6 +23,7 @@ from shelfmark.release_sources import (
LeadingCellType, LeadingCellType,
) )
from shelfmark.release_sources.prowlarr.api import ProwlarrClient 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.cache import cache_release
from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol 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]: def _get_client(self) -> Optional[ProwlarrClient]:
"""Get a configured Prowlarr client or None if not configured.""" """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", "") 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 None
return ProwlarrClient(url, api_key) return ProwlarrClient(url, api_key)
@@ -313,7 +318,7 @@ class ProwlarrSource(ReleaseSource):
logger.warning("Prowlarr not configured - skipping search") logger.warning("Prowlarr not configured - skipping search")
return [] 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] queries = [q for q in queries if q]
if not queries and plan.isbn_candidates: if not queries and plan.isbn_candidates:
@@ -340,11 +345,22 @@ class ProwlarrSource(ReleaseSource):
categories = None if expand_search else search_categories categories = None if expand_search else search_categories
self.last_search_type = "expanded" if expand_search else "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" indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
if len(queries) == 1: 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: 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]: def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
"""Search indexers with given categories, collecting results.""" """Search indexers with given categories, collecting results."""
@@ -420,6 +436,6 @@ class ProwlarrSource(ReleaseSource):
"""Check if Prowlarr is enabled and configured.""" """Check if Prowlarr is enabled and configured."""
if not config.get("PROWLARR_ENABLED", False): if not config.get("PROWLARR_ENABLED", False):
return False return False
url = config.get("PROWLARR_URL", "") url = normalize_http_url(config.get("PROWLARR_URL", ""))
api_key = config.get("PROWLARR_API_KEY", "") api_key = config.get("PROWLARR_API_KEY", "")
return bool(url and api_key) return bool(url and api_key)
+1 -1
View File
@@ -189,7 +189,7 @@ export const Dropdown = ({
panelDirection === 'down' panelDirection === 'down'
? renderTrigger ? 'mt-2' : '' ? renderTrigger ? 'mt-2' : ''
: renderTrigger ? 'bottom-full mb-2' : 'bottom-full' : renderTrigger ? 'bottom-full mb-2' : 'bottom-full'
} border shadow-lg z-20 ${panelClassName || widthClassName}`} } border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`}
style={{ style={{
background: 'var(--bg)', background: 'var(--bg)',
borderColor: 'var(--border-muted)', borderColor: 'var(--border-muted)',
+14 -1
View File
@@ -673,6 +673,8 @@ export const ReleaseModal = ({
setExpandedBySource({}); setExpandedBySource({});
setFormatFilter(''); setFormatFilter('');
setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); setLanguageFilter([LANGUAGE_OPTION_DEFAULT]);
setManualQuery('');
setShowManualQuery(false);
setSearchStatus(null); setSearchStatus(null);
lastStatusTimeRef.current = 0; lastStatusTimeRef.current = 0;
pendingStatusRef.current = null; pendingStatusRef.current = null;
@@ -1409,7 +1411,18 @@ export const ReleaseModal = ({
{/* Manual query button */} {/* Manual query button */}
<button <button
type="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 ${ 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' : '' manualQuery.trim() ? 'text-emerald-600 dark:text-emerald-400' : ''
}`} }`}
@@ -5,7 +5,7 @@ interface HeadingFieldProps {
} }
export const HeadingField = ({ field }: 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> <h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && ( {field.description && (
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
+4 -3
View File
@@ -251,13 +251,13 @@ class TestBuildLibraryPath:
def test_path_traversal_prevented(self): def test_path_traversal_prevented(self):
"""Test that path traversal is prevented.""" """Test that path traversal is prevented."""
with pytest.raises(ValueError, match="traversal"): path = build_library_path(
build_library_path(
"/books", "/books",
"{Author}/{Title}", "{Author}/{Title}",
{"Author": "../../../etc", "Title": "passwd"}, {"Author": "../etc", "Title": "passwd"},
extension="txt" extension="txt"
) )
assert path == Path("/books/etc/passwd.txt")
def test_fallback_to_title(self): def test_fallback_to_title(self):
"""Test fallback when template produces empty result.""" """Test fallback when template produces empty result."""
@@ -291,6 +291,7 @@ class TestSanitizeFilename:
('file"with"quotes', "file_with_quotes"), ('file"with"quotes', "file_with_quotes"),
("file<with>angles", "file_with_angles"), ("file<with>angles", "file_with_angles"),
("file|with|pipes", "file_with_pipes"), ("file|with|pipes", "file_with_pipes"),
("file/with/slash", "file_with_slash"),
]) ])
def test_invalid_chars_replaced(self, input_name, expected): def test_invalid_chars_replaced(self, input_name, expected):
"""Test that invalid characters are replaced.""" """Test that invalid characters are replaced."""
@@ -10,6 +10,15 @@ class TestHardcoverComputeSearchTitle:
== "The Final Empire" == "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): 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("The Stormlight Archive: Book 1", "Book 1") is None
assert _compute_search_title("Some Series: Volume II", "Volume II") 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): def test_test_connection_failure(self, monkeypatch):
"""Test failed connection.""" """Test failed connection."""
config_values = { config_values = {
"QBITTORRENT_URL": "http://localhost:8080", "QBITTORRENT_URL": "localhost:8080",
"QBITTORRENT_USERNAME": "admin", "QBITTORRENT_USERNAME": "admin",
"QBITTORRENT_PASSWORD": "wrong", "QBITTORRENT_PASSWORD": "wrong",
"QBITTORRENT_CATEGORY": "test", "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 result == str(local_file)
assert task.original_download_path == 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"]) plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook") source.search(book, plan, content_type="ebook")
assert "The Lightning Thief Rick Riordan" in fake_client.queries assert "The Lightning Thief" in fake_client.queries
assert "A villámtolvaj Rick Riordan" in fake_client.queries assert "A villámtolvaj" in fake_client.queries
assert len(fake_client.queries) == 2 assert len(fake_client.queries) == 2
def test_search_does_not_override_search_title_for_english(self, monkeypatch): 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"]) plan = build_release_search_plan(book, languages=["en", "hu"])
source.search(book, plan, content_type="ebook") source.search(book, plan, content_type="ebook")
assert "The Final Empire Brandon Sanderson" in fake_client.queries assert "The Final Empire" in fake_client.queries
assert "A végső birodalom Brandon Sanderson" in fake_client.queries assert "A végső birodalom" in fake_client.queries
assert "Mistborn: The Final Empire Brandon Sanderson" not 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.isbn_candidates == []
assert plan.languages == ["en", "hu"] assert plan.languages == ["en", "hu"]
assert [v.query for v in plan.title_variants] == ["some custom query"] 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)] 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", "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] == [ assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [
("The Final Empire", ["en"]), ("The Final Empire", ["en"]),
("A végső birodalom", ["hu"]), ("A végső birodalom", ["hu"]),
@@ -56,6 +62,9 @@ class TestReleaseSearchPlan:
assert [v.query for v in plan.title_variants] == [ assert [v.query for v in plan.title_variants] == [
"The Lightning Thief Rick Riordan", "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] == [ assert [(v.title, v.languages) for v in plan.grouped_title_variants] == [
("The Lightning Thief", None), ("The Lightning Thief", None),
] ]