mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-25 15:00:24 +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:
|
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
|
||||||
@@ -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:
|
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,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,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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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}. "
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user