mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 09:10:23 +01:00
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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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,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,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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+45
-27
@@ -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,17 +213,19 @@ def get_zlib_cookie_domains() -> set:
|
||||
|
||||
# Add all default domains
|
||||
for url in DEFAULT_ZLIB_MIRRORS:
|
||||
domain = url.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
normalized = _normalize_mirror_url(url)
|
||||
if normalized:
|
||||
domain = normalized.replace("https://", "").replace("http://", "").split("/")[0]
|
||||
domains.add(domain)
|
||||
|
||||
# Add custom domains
|
||||
config = _get_config()
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 + "/"):
|
||||
remainder = remote_normalized[len(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]:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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(
|
||||
mappings=mappings,
|
||||
host=host,
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
host = get_client_host_identifier(client) or ""
|
||||
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
|
||||
mappings = parse_remote_path_mappings(mapping_value)
|
||||
|
||||
if remapped != source_path_obj and remapped.exists():
|
||||
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,
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -418,14 +502,45 @@ class ProwlarrHandler(DownloadHandler):
|
||||
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: {source_path}. "
|
||||
f"Client: {client.name}, ID: {download_id}. {hint}"
|
||||
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}'.",
|
||||
)
|
||||
status_callback("error", hint)
|
||||
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=source_path_obj,
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
"/books",
|
||||
"{Author}/{Title}",
|
||||
{"Author": "../../../etc", "Title": "passwd"},
|
||||
extension="txt"
|
||||
)
|
||||
path = build_library_path(
|
||||
"/books",
|
||||
"{Author}/{Title}",
|
||||
{"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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user