diff --git a/compose/stable/docker-compose.lite.yml b/compose/docker-compose.lite.yml similarity index 60% rename from compose/stable/docker-compose.lite.yml rename to compose/docker-compose.lite.yml index e146fe91..e0a1bddb 100644 --- a/compose/stable/docker-compose.lite.yml +++ b/compose/docker-compose.lite.yml @@ -2,15 +2,14 @@ services: shelfmark-lite: image: ghcr.io/calibrain/shelfmark-lite:latest environment: - # TZ: America/New_York # EXT_BYPASSER_URL: http://flaresolverr:8191 #If using Flaresolverr - # PUID: 1000 - # PGID: 1000 + PUID: 1000 + PGID: 1000 ports: - 8084:8084 restart: unless-stopped volumes: - - /path/to/books:/books # Book destination directory + - /path/to/books:/books # Default destination for book downloads - /path/to/config:/config # App configuration - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads \ No newline at end of file diff --git a/compose/stable/docker-compose.tor.yml b/compose/docker-compose.tor.yml similarity index 65% rename from compose/stable/docker-compose.tor.yml rename to compose/docker-compose.tor.yml index 4fef788f..95de8125 100644 --- a/compose/stable/docker-compose.tor.yml +++ b/compose/docker-compose.tor.yml @@ -4,10 +4,9 @@ services: image: ghcr.io/calibrain/shelfmark:latest environment: FLASK_PORT: 8084 - # TZ: America/New_York USING_TOR: true - # PUID: 1000 - # PGID: 1000 + PUID: 1000 + PGID: 1000 cap_add: - NET_ADMIN - NET_RAW @@ -15,7 +14,7 @@ services: - 8084:8084 restart: unless-stopped volumes: - - /path/to/books:/books # Book destination directory + - /path/to/books:/books # Default destination for book downloads - /path/to/config:/config # App configuration - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads diff --git a/compose/stable/docker-compose.yml b/compose/docker-compose.yml similarity index 56% rename from compose/stable/docker-compose.yml rename to compose/docker-compose.yml index f363738c..d771ff77 100644 --- a/compose/stable/docker-compose.yml +++ b/compose/docker-compose.yml @@ -3,14 +3,13 @@ services: image: ghcr.io/calibrain/shelfmark:latest container_name: shelfmark environment: - # TZ: America/New_York - # PUID: 1000 - # PGID: 1000 + PUID: 1000 + PGID: 1000 ports: - 8084:8084 restart: unless-stopped volumes: - - /path/to/books:/books # Book destination directory + - /path/to/books:/books # Default destination for book downloads - /path/to/config:/config # App configuration - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads diff --git a/compose/edge/docker-compose.lite.yml b/compose/edge/docker-compose.lite.yml deleted file mode 100644 index 580ef110..00000000 --- a/compose/edge/docker-compose.lite.yml +++ /dev/null @@ -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 diff --git a/compose/edge/docker-compose.tor.yml b/compose/edge/docker-compose.tor.yml deleted file mode 100644 index 7613e339..00000000 --- a/compose/edge/docker-compose.tor.yml +++ /dev/null @@ -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 diff --git a/compose/edge/docker-compose.yml b/compose/edge/docker-compose.yml deleted file mode 100644 index dc919057..00000000 --- a/compose/edge/docker-compose.yml +++ /dev/null @@ -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 diff --git a/docker-compose.dev.extbp.yml b/docker-compose.dev.extbp.yml index 12abd648..29ec3547 100644 --- a/docker-compose.dev.extbp.yml +++ b/docker-compose.dev.extbp.yml @@ -2,7 +2,7 @@ services: shelfmark-lite-dev: extends: - file: ./compose/edge/docker-compose.extbp.yml + file: ./compose/docker-compose.lite.yml service: shelfmark-lite build: context: . @@ -18,7 +18,7 @@ services: - ./.local/books:/books - ./.local/log:/var/log/shelfmark - ./.local/tmp:/tmp/shelfmark - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads flaresolverr: diff --git a/docker-compose.dev.tor.yml b/docker-compose.dev.tor.yml index 9035beb8..5d6c23a4 100644 --- a/docker-compose.dev.tor.yml +++ b/docker-compose.dev.tor.yml @@ -2,7 +2,7 @@ services: shelfmark-tor-dev: extends: - file: ./compose/edge/docker-compose.tor.yml + file: ./compose/docker-compose.tor.yml service: shelfmark-tor build: context: . @@ -16,5 +16,5 @@ services: - ./.local/books:/books - ./.local/log:/var/log/shelfmark - ./.local/tmp:/tmp/shelfmark - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9a78b460..06fcc68d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,7 +2,7 @@ services: shelfmark-dev: extends: - file: ./compose/edge/docker-compose.yml + file: ./compose/docker-compose.yml service: shelfmark build: context: . @@ -18,5 +18,5 @@ services: - ./.local/log:/var/log/shelfmark - ./.local/tmp:/tmp/shelfmark - ./shelfmark:/app/shelfmark:ro - # Download client mount - path must match your torrent/usenet client's volume exactly + # Required for torrent / usenet - path must match your download client's volume exactly # - /path/to/downloads:/path/to/downloads diff --git a/readme.md b/readme.md index afaaff0b..46872bfc 100644 --- a/readme.md +++ b/readme.md @@ -41,9 +41,9 @@ Shelfmark is a unified web interface for searching and aggregating books and aud ### Installation -1. Download the docker-compose file: +1. Download the [docker-compose file](compose/docker-compose.yml): ```bash - curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.yml + curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.yml ``` 2. Start the service: @@ -51,8 +51,6 @@ Shelfmark is a unified web interface for searching and aggregating books and aud docker compose up -d ``` -> **Edge users**: If you're tracking the main branch (`:dev` tag), use compose files from `compose/edge/` instead. - 3. Open `http://localhost:8084` That's it! Configure settings through the web interface as needed. @@ -120,7 +118,7 @@ The full-featured image with built-in Cloudflare bypass. #### Enable Tor Routing Routes all traffic through Tor for enhanced privacy: ```bash -curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.tor.yml +curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.tor.yml docker compose -f docker-compose.tor.yml up -d ``` @@ -138,7 +136,7 @@ A smaller image without the built-in Cloudflare bypasser. Ideal for: - **Audiobooks** - Using Shelfmark exclusively for audiobooks ```bash -curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/stable/docker-compose.lite.yml +curl -O https://raw.githubusercontent.com/calibrain/shelfmark/main/compose/docker-compose.lite.yml docker compose -f docker-compose.lite.yml up -d ``` diff --git a/shelfmark/bypass/external_bypasser.py b/shelfmark/bypass/external_bypasser.py index 1a2ff4b6..cb0b0f11 100644 --- a/shelfmark/bypass/external_bypasser.py +++ b/shelfmark/bypass/external_bypasser.py @@ -10,6 +10,7 @@ import requests from shelfmark.bypass import BypassCancelledException from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url if TYPE_CHECKING: from shelfmark.download import network @@ -29,10 +30,11 @@ BACKOFF_CAP = 10.0 def _fetch_via_bypasser(target_url: str) -> Optional[str]: """Make a single request to the external bypasser service. Returns HTML or None.""" - bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191") + raw_bypasser_url = config.get("EXT_BYPASSER_URL", "http://flaresolverr:8191") bypasser_path = config.get("EXT_BYPASSER_PATH", "/v1") bypasser_timeout = config.get("EXT_BYPASSER_TIMEOUT", 60000) + bypasser_url = normalize_http_url(raw_bypasser_url) if not bypasser_url or not bypasser_path: logger.error("External bypasser not configured. Check EXT_BYPASSER_URL and EXT_BYPASSER_PATH.") return None diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 3cdeba60..8dcae714 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -8,6 +8,9 @@ from typing import Any, Dict def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: """Validate advanced settings before persisting.""" + from shelfmark.core.logger import setup_logger + + logger = setup_logger(__name__) mappings = values.get("PROWLARR_REMOTE_PATH_MAPPINGS") if mappings is None: @@ -20,9 +23,12 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: "values": values, } + logger.debug("Processing %d remote path mapping entries", len(mappings)) + cleaned = [] - for entry in mappings: + for i, entry in enumerate(mappings): if not isinstance(entry, dict): + logger.debug("Skipping entry %d: not a dict", i) continue host = str(entry.get("host", "") or "").strip().lower() @@ -30,17 +36,29 @@ def _on_save_advanced(values: Dict[str, Any]) -> Dict[str, Any]: local_path = str(entry.get("localPath", "") or "").strip() if not host or not remote_path or not local_path: + logger.debug( + "Skipping entry %d: missing field(s) - host=%r, remotePath=%r, localPath=%r", + i, + host, + remote_path, + local_path, + ) continue if not local_path.startswith("/"): return { "error": True, - "message": "Local Path must be an absolute path", + "message": f"Local Path must be an absolute path (got: {local_path})", "values": values, } cleaned.append({"host": host, "remotePath": remote_path, "localPath": local_path}) + logger.info("Saved %d remote path mapping(s)", len(cleaned)) + if cleaned: + for m in cleaned: + logger.debug(" Mapping: %s -> %s (client: %s)", m["remotePath"], m["localPath"], m["host"]) + values["PROWLARR_REMOTE_PATH_MAPPINGS"] = cleaned return {"error": False, "values": values} @@ -646,7 +664,7 @@ def download_settings(): }, { "value": "rename", - "label": "Rename only", + "label": "Rename Only", "description": "Rename files using a template" }, { @@ -771,7 +789,7 @@ def download_settings(): description="Choose how downloaded audiobook files are named and organized.", options=[ {"value": "none", "label": "None", "description": "Keep original filename from source"}, - {"value": "rename", "label": "Rename only", "description": "Rename files using a template"}, + {"value": "rename", "label": "Rename Only", "description": "Rename files using a template"}, {"value": "organize", "label": "Rename and Organize", "description": "Create folders and rename files using a template. Recommended for Audiobookshelf. Do not use with ingest folders."}, ], default="rename", diff --git a/shelfmark/core/mirrors.py b/shelfmark/core/mirrors.py index 628bb3ad..ac98822c 100644 --- a/shelfmark/core/mirrors.py +++ b/shelfmark/core/mirrors.py @@ -2,6 +2,8 @@ from typing import List +from shelfmark.core.utils import normalize_http_url + # Lazy import to avoid circular imports _config_module = None @@ -44,6 +46,10 @@ DEFAULT_WELIB_MIRRORS = [ ] +def _normalize_mirror_url(url: str) -> str: + return normalize_http_url(url, default_scheme="https") + + def get_aa_mirrors() -> List[str]: """ Get Anna's Archive mirrors from config + defaults. @@ -51,15 +57,16 @@ def get_aa_mirrors() -> List[str]: Returns: List of AA mirror URLs, starting with defaults then custom additions. """ - mirrors = list(DEFAULT_AA_MIRRORS) + mirrors = [_normalize_mirror_url(url) for url in DEFAULT_AA_MIRRORS] + mirrors = [url for url in mirrors if url] config = _get_config() additional = config.get("AA_ADDITIONAL_URLS", "") if additional: for url in additional.split(","): - url = url.strip() - if url and url not in mirrors: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized not in mirrors: + mirrors.append(normalized) return mirrors @@ -71,15 +78,16 @@ def get_libgen_mirrors() -> List[str]: Returns: List of LibGen mirror URLs (defaults first, then custom additions). """ - mirrors = list(DEFAULT_LIBGEN_MIRRORS) + mirrors = [_normalize_mirror_url(url) for url in DEFAULT_LIBGEN_MIRRORS] + mirrors = [url for url in mirrors if url] config = _get_config() additional = config.get("LIBGEN_ADDITIONAL_URLS", "") if additional: for url in additional.split(","): - url = url.strip() - if url and url not in mirrors: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized not in mirrors: + mirrors.append(normalized) return mirrors @@ -93,21 +101,24 @@ def get_zlib_mirrors() -> List[str]: """ config = _get_config() - primary = config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]) + primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])) + if not primary: + primary = _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0]) mirrors = [primary] # Add other defaults (excluding primary) for url in DEFAULT_ZLIB_MIRRORS: - if url != primary: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized != primary: + mirrors.append(normalized) # Add custom mirrors additional = config.get("ZLIB_ADDITIONAL_URLS", "") if additional: for url in additional.split(","): - url = url.strip() - if url and url not in mirrors: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized not in mirrors: + mirrors.append(normalized) return mirrors @@ -120,7 +131,8 @@ def get_zlib_primary_url() -> str: Primary Z-Library mirror URL. """ config = _get_config() - return config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0]) + primary = _normalize_mirror_url(config.get("ZLIB_PRIMARY_URL", DEFAULT_ZLIB_MIRRORS[0])) + return primary or _normalize_mirror_url(DEFAULT_ZLIB_MIRRORS[0]) def get_zlib_url_template() -> str: @@ -143,21 +155,24 @@ def get_welib_mirrors() -> List[str]: """ config = _get_config() - primary = config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]) + primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])) + if not primary: + primary = _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0]) mirrors = [primary] # Add other defaults (excluding primary) for url in DEFAULT_WELIB_MIRRORS: - if url != primary: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized != primary: + mirrors.append(normalized) # Add custom mirrors additional = config.get("WELIB_ADDITIONAL_URLS", "") if additional: for url in additional.split(","): - url = url.strip() - if url and url not in mirrors: - mirrors.append(url) + normalized = _normalize_mirror_url(url) + if normalized and normalized not in mirrors: + mirrors.append(normalized) return mirrors @@ -170,7 +185,8 @@ def get_welib_primary_url() -> str: Primary Welib mirror URL. """ config = _get_config() - return config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0]) + primary = _normalize_mirror_url(config.get("WELIB_PRIMARY_URL", DEFAULT_WELIB_MIRRORS[0])) + return primary or _normalize_mirror_url(DEFAULT_WELIB_MIRRORS[0]) def get_welib_url_template() -> str: @@ -197,17 +213,19 @@ def get_zlib_cookie_domains() -> set: # Add all default domains for url in DEFAULT_ZLIB_MIRRORS: - domain = url.replace("https://", "").replace("http://", "").split("/")[0] - domains.add(domain) + normalized = _normalize_mirror_url(url) + if normalized: + domain = normalized.replace("https://", "").replace("http://", "").split("/")[0] + domains.add(domain) # Add custom domains config = _get_config() additional = config.get("ZLIB_ADDITIONAL_URLS", "") if additional: for url in additional.split(","): - url = url.strip() - if url: - domain = url.replace("https://", "").replace("http://", "").split("/")[0] + normalized = _normalize_mirror_url(url) + if normalized: + domain = normalized.replace("https://", "").replace("http://", "").split("/")[0] domains.add(domain) return domains diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index bf0fac85..7c5baa74 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -3,7 +3,7 @@ import os import re from pathlib import Path -from typing import Dict, Optional, Union +from typing import Dict, Optional, Union, Mapping from shelfmark.core.logger import setup_logger @@ -17,10 +17,10 @@ TOKEN_PATTERN = re.compile( ) # Characters that are invalid in filenames on various filesystems -INVALID_CHARS = re.compile(r'[\\:*?"<>|]') +INVALID_CHARS = re.compile(r'[\\/:*?"<>|]') -def _sanitize(name: str, max_length: int = 245) -> str: +def _sanitize(name: Optional[str], max_length: int = 245) -> str: """Sanitize a string for filesystem use.""" if not name: return "" @@ -31,7 +31,7 @@ def _sanitize(name: str, max_length: int = 245) -> str: return sanitized[:max_length] -def sanitize_filename(name: str, max_length: int = 245) -> str: +def sanitize_filename(name: Optional[str], max_length: int = 245) -> str: """Sanitize a string for use as a filename or path component.""" return _sanitize(name, max_length) @@ -40,7 +40,7 @@ def sanitize_filename(name: str, max_length: int = 245) -> str: sanitize_path_component = sanitize_filename -def format_series_position(position: Optional[Union[int, float]]) -> str: +def format_series_position(position: Optional[Union[str, int, float]]) -> str: if position is None: return "" @@ -78,7 +78,9 @@ def assign_part_numbers( def parse_naming_template( template: str, - metadata: Dict[str, Optional[Union[str, int, float]]], + metadata: Mapping[str, Optional[Union[str, int, float]]], + *, + allow_path_separators: bool = True, ) -> str: if not template: return "" @@ -108,6 +110,8 @@ def parse_naming_template( if not value: return "" + if not allow_path_separators: + value = value.replace("/", "_") # Sanitize the value value = sanitize_filename(value) @@ -140,10 +144,10 @@ def parse_naming_template( def build_library_path( base_path: str, template: str, - metadata: Dict[str, Optional[Union[str, int, float]]], + metadata: Mapping[str, Optional[Union[str, int, float]]], extension: Optional[str] = None, ) -> Path: - relative = parse_naming_template(template, metadata) + relative = parse_naming_template(template, metadata, allow_path_separators=True) if not relative: # Fallback to title if template produces empty result diff --git a/shelfmark/core/path_mappings.py b/shelfmark/core/path_mappings.py index 29a7b8d0..2b9fb22a 100644 --- a/shelfmark/core/path_mappings.py +++ b/shelfmark/core/path_mappings.py @@ -34,6 +34,11 @@ def _normalize_prefix(path: str) -> str: return normalized +def _is_windows_path(path: str) -> bool: + """Check if a path looks like a Windows path (has a drive letter like C:/).""" + return len(path) >= 2 and path[1] == ":" and path[0].isalpha() + + def _normalize_host(host: str) -> str: return str(host or "").strip().lower() @@ -61,12 +66,21 @@ def parse_remote_path_mappings(value: Any) -> list[RemotePathMapping]: return mappings -def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path: +def remap_remote_to_local_with_match( + *, + mappings: Iterable[RemotePathMapping], + host: str, + remote_path: str | Path, +) -> tuple[Path, bool]: host_normalized = _normalize_host(host) remote_normalized = _normalize_prefix(str(remote_path)) if not remote_normalized: - return Path(str(remote_path)) + return Path(str(remote_path)), False + + # Windows paths are case-insensitive, so we need case-insensitive matching + # for paths that look like Windows paths (e.g., D:/Torrents) + is_windows = _is_windows_path(remote_normalized) for mapping in mappings: if _normalize_host(mapping.host) != host_normalized: @@ -76,16 +90,36 @@ def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, r if not remote_prefix: continue - if remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/"): - remainder = remote_normalized[len(remote_prefix) :] + # For Windows paths, do case-insensitive prefix matching + if is_windows: + remote_lower = remote_normalized.lower() + prefix_lower = remote_prefix.lower() + matches = remote_lower == prefix_lower or remote_lower.startswith(prefix_lower + "/") + else: + matches = remote_normalized == remote_prefix or remote_normalized.startswith(remote_prefix + "/") + + if matches: + # Use the length of the original prefix to extract remainder + # This preserves the original case in folder names + remainder = remote_normalized[len(remote_prefix):] local_prefix = _normalize_prefix(mapping.local_path) if remainder.startswith("/"): remainder = remainder[1:] - return Path(local_prefix) / remainder if remainder else Path(local_prefix) + remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix) + return remapped, True - return Path(remote_normalized) + return Path(remote_normalized), False + + +def remap_remote_to_local(*, mappings: Iterable[RemotePathMapping], host: str, remote_path: str | Path) -> Path: + remapped, _ = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=remote_path, + ) + return remapped def get_client_host_identifier(client: Any) -> Optional[str]: diff --git a/shelfmark/core/utils.py b/shelfmark/core/utils.py index 93762979..28f09ca6 100644 --- a/shelfmark/core/utils.py +++ b/shelfmark/core/utils.py @@ -5,6 +5,52 @@ from pathlib import Path from typing import Optional +def normalize_http_url( + url: Optional[str], + *, + default_scheme: str = "http", + strip_trailing_slash: bool = True, + allow_special: tuple[str, ...] = (), +) -> str: + """Normalize a configured HTTP URL for requests and links.""" + if not isinstance(url, str): + return "" + + normalized = url.strip() + if not normalized: + return "" + + if (normalized.startswith("\"") and normalized.endswith("\"")) or ( + normalized.startswith("'") and normalized.endswith("'") + ): + normalized = normalized[1:-1].strip() + if not normalized: + return "" + + if allow_special: + special_map = { + value.lower(): value + for value in allow_special + if isinstance(value, str) + } + special_match = special_map.get(normalized.lower()) + if special_match is not None: + return special_match + + if normalized.startswith(("/", "./", "../")): + return normalized + + if "://" not in normalized: + scheme = default_scheme.strip().rstrip(":/") + if scheme: + normalized = f"{scheme}://{normalized}" + + if strip_trailing_slash: + normalized = normalized.rstrip("/") + + return normalized + + def is_audiobook(content_type: Optional[str]) -> bool: """Check if content type indicates an audiobook.""" return bool(content_type and "audiobook" in content_type.lower()) diff --git a/shelfmark/download/network.py b/shelfmark/download/network.py index a27842a6..9022be7d 100644 --- a/shelfmark/download/network.py +++ b/shelfmark/download/network.py @@ -12,6 +12,7 @@ import ipaddress from shelfmark.core.logger import setup_logger from shelfmark.core.config import config as app_config +from shelfmark.core.utils import normalize_http_url from datetime import datetime, timedelta @@ -729,7 +730,14 @@ def rotate_dns_and_reset_aa() -> bool: return False # Reset AA URL to first available auto option if using auto AA global _aa_base_url, _current_aa_url_index - configured_url = app_config.get("AA_BASE_URL", "auto") + configured_url = normalize_http_url( + app_config.get("AA_BASE_URL", "auto"), + default_scheme="https", + allow_special=("auto",), + ) + if not configured_url: + configured_url = "auto" + if configured_url == "auto" or _aa_base_url in _aa_urls: _current_aa_url_index = 0 _aa_base_url = _aa_urls[0] if _aa_urls else "https://annas-archive.se" @@ -900,7 +908,13 @@ def _initialize_aa_state() -> None: _aa_urls = _build_aa_urls() # Get configured base URL from config - configured_url = app_config.get("AA_BASE_URL", "auto") + configured_url = normalize_http_url( + app_config.get("AA_BASE_URL", "auto"), + default_scheme="https", + allow_special=("auto",), + ) + if not configured_url: + configured_url = "auto" if configured_url == "auto": if state.get('aa_base_url') and state['aa_base_url'] in _aa_urls: diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index 55fa2a00..7f6af1ec 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -194,7 +194,7 @@ def transfer_book_files( metadata = build_metadata_dict(task) extension = book_file.suffix.lstrip(".") or task.format or "" - filename = parse_naming_template(template, metadata) + filename = parse_naming_template(template, metadata, allow_path_separators=False) filename = Path(filename).name if filename else "" if filename and extension: filename = f"{sanitize_filename(filename)}.{extension}" diff --git a/shelfmark/main.py b/shelfmark/main.py index 8458d7e2..78809e66 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1430,10 +1430,26 @@ def api_releases() -> Union[Response, Tuple[Response, int]]: try: source = get_source(source_name) source_instances[source_name] = source - logger.debug(f"Searching {source_name} for '{book.title}' by {book.authors} (expand={expand_search}, content_type={content_type})") + from shelfmark.release_sources.search_plan import build_release_search_plan plan = build_release_search_plan(book, languages=languages, manual_query=manual_query) + + if plan.manual_query: + planned_query = plan.manual_query + planned_query_type = "manual" + elif not expand_search and plan.isbn_candidates: + planned_query = plan.isbn_candidates[0] + planned_query_type = "isbn" + else: + planned_query = plan.primary_query + planned_query_type = "title_author" + + logger.debug( + f"Searching {source_name}: {planned_query_type}='{planned_query}' " + f"(title='{book.title}', authors={book.authors}, expand={expand_search}, content_type={content_type})" + ) + releases = source.search(book, plan, expand_search=expand_search, content_type=content_type) all_releases.extend(releases) except ValueError: diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 9d77a02f..cce004f8 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -208,7 +208,27 @@ def _compute_search_title( # Prefer subtitle when it looks like the real title. if normalized_subtitle and not _is_probably_series_position(normalized_subtitle): - # If title contains the subtitle, this is likely "Series: Subtitle". + match = re.match(r"^(.+?)\s*:\s*(.+)$", normalized_title) + if match: + prefix = match.group(1).strip() + suffix = _strip_parenthetical_suffix(match.group(2).strip()) + + prefix_words = len(prefix.split()) if prefix else 0 + subtitle_words = len(normalized_subtitle.split()) + + series_normalized = " ".join(series_name.split()).strip() if series_name else "" + if series_normalized and prefix.lower() == series_normalized.lower(): + return normalized_subtitle + + # If the subtitle is much longer than the prefix, treat it as a descriptive subtitle. + if prefix and subtitle_words >= (prefix_words + 4): + return prefix + + # Otherwise assume "Series: Book Title" and prefer the subtitle. + if normalized_subtitle.lower() == suffix.lower() or normalized_subtitle.lower() in suffix.lower(): + return normalized_subtitle + + # Fallback: if title contains the subtitle, this is likely "Series: Subtitle". if normalized_subtitle.lower() in normalized_title.lower(): return normalized_subtitle diff --git a/shelfmark/release_sources/direct_download.py b/shelfmark/release_sources/direct_download.py index ba7cdea0..aa838745 100644 --- a/shelfmark/release_sources/direct_download.py +++ b/shelfmark/release_sources/direct_download.py @@ -1162,7 +1162,7 @@ class DirectDownloadSource(ReleaseSource): if not expand_search: isbn = plan.isbn_candidates[0] if plan.isbn_candidates else None if isbn: - logger.debug(f"Searching by ISBN: {isbn}") + logger.debug(f"Searching direct_download: isbn='{isbn}', langs={lang_filter}") filters = SearchFilters(isbn=[isbn]) if lang_filter: filters.lang = lang_filter @@ -1191,7 +1191,7 @@ class DirectDownloadSource(ReleaseSource): if not query: continue - logger.debug(f"Searching: query='{query}', langs={langs}") + logger.debug(f"Searching direct_download: title_author='{query}', langs={langs}") filters = SearchFilters(lang=langs) if langs else SearchFilters() try: for bi in search_books(query, filters): diff --git a/shelfmark/release_sources/prowlarr/api.py b/shelfmark/release_sources/prowlarr/api.py index 8af842cd..0cbb285d 100644 --- a/shelfmark/release_sources/prowlarr/api.py +++ b/shelfmark/release_sources/prowlarr/api.py @@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional, Tuple import requests from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url logger = setup_logger(__name__) @@ -13,7 +14,7 @@ class ProwlarrClient: """Client for interacting with the Prowlarr API.""" def __init__(self, url: str, api_key: str, timeout: int = 30): - self.base_url = url.rstrip("/") + self.base_url = normalize_http_url(url) self.api_key = api_key self.timeout = timeout self._session = requests.Session() diff --git a/shelfmark/release_sources/prowlarr/clients/deluge.py b/shelfmark/release_sources/prowlarr/clients/deluge.py index 5c6ef017..a678b503 100644 --- a/shelfmark/release_sources/prowlarr/clients/deluge.py +++ b/shelfmark/release_sources/prowlarr/clients/deluge.py @@ -19,6 +19,7 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -65,6 +66,10 @@ class DelugeClient(DownloadClient): # Allow DELUGE_HOST to be either a hostname OR a full URL # (useful when Deluge is behind a reverse proxy path). + raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else "" + if not raw_host: + raise ValueError("DELUGE_HOST is invalid") + host = raw_host port = int(raw_port) diff --git a/shelfmark/release_sources/prowlarr/clients/nzbget.py b/shelfmark/release_sources/prowlarr/clients/nzbget.py index 74a464d5..4aec3fe7 100644 --- a/shelfmark/release_sources/prowlarr/clients/nzbget.py +++ b/shelfmark/release_sources/prowlarr/clients/nzbget.py @@ -11,6 +11,7 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -30,11 +31,13 @@ class NZBGetClient(DownloadClient): def __init__(self): """Initialize NZBGet client with settings from config.""" - url = config.get("NZBGET_URL", "") - if not url: + raw_url = config.get("NZBGET_URL", "") + if not raw_url: raise ValueError("NZBGET_URL is required") - self.url = url.rstrip("/") + self.url = normalize_http_url(raw_url) + if not self.url: + raise ValueError("NZBGET_URL is invalid") self.username = config.get("NZBGET_USERNAME", "nzbget") self.password = config.get("NZBGET_PASSWORD", "") self._category = config.get("NZBGET_CATEGORY", "Books") @@ -43,7 +46,7 @@ class NZBGetClient(DownloadClient): def is_configured() -> bool: """Check if NZBGet is configured and selected as the usenet client.""" client = config.get("PROWLARR_USENET_CLIENT", "") - url = config.get("NZBGET_URL", "") + url = normalize_http_url(config.get("NZBGET_URL", "")) return client == "nzbget" and bool(url) @with_retry() diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py index 0e936c33..aac64760 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py @@ -6,6 +6,7 @@ from typing import Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -91,13 +92,19 @@ class QBittorrentClient(DownloadClient): # Lazy import to avoid dependency issues if not using torrents from qbittorrentapi import Client - url = config.get("QBITTORRENT_URL", "") - if not url: + raw_url = config.get("QBITTORRENT_URL", "") + if not raw_url: raise ValueError("QBITTORRENT_URL is required") - self._base_url = url.rstrip("/") + # We use `_base_url` for direct HTTP calls, so it must be a fully-qualified URL. + self._base_url = normalize_http_url(raw_url) + if not self._base_url: + raise ValueError("QBITTORRENT_URL is invalid") + + # qbittorrent-api accepts either a full URL or host:port; prefer the normalized URL + # for consistency. self._client = Client( - host=url, + host=self._base_url, username=config.get("QBITTORRENT_USERNAME", ""), password=config.get("QBITTORRENT_PASSWORD", ""), ) @@ -197,13 +204,20 @@ class QBittorrentClient(DownloadClient): return [], f"qBittorrent request timed out at {self._base_url}" except Exception as e: logger.debug(f"Failed to get torrents info: {e}") + # requests raises InvalidSchema when the base URL doesn't include http(s) + if type(e).__name__ == "InvalidSchema": + return ( + [], + "qBittorrent URL is invalid (missing http:// or https://). " + f"Configured: {self._base_url}", + ) return [], f"qBittorrent API error: {type(e).__name__}: {e}" @staticmethod def is_configured() -> bool: """Check if qBittorrent is configured and selected as the torrent client.""" client = config.get("PROWLARR_TORRENT_CLIENT", "") - url = config.get("QBITTORRENT_URL", "") + url = normalize_http_url(config.get("QBITTORRENT_URL", "")) return client == "qbittorrent" and bool(url) def test_connection(self) -> Tuple[bool, str]: diff --git a/shelfmark/release_sources/prowlarr/clients/rtorrent.py b/shelfmark/release_sources/prowlarr/clients/rtorrent.py index 66b363e5..f1231fcf 100644 --- a/shelfmark/release_sources/prowlarr/clients/rtorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/rtorrent.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -32,11 +33,13 @@ class RTorrentClient(DownloadClient): """Initialize rTorrent client with settings from config.""" from xmlrpc.client import ServerProxy - url = config.get("RTORRENT_URL", "") - if not url: + raw_url = config.get("RTORRENT_URL", "") + if not raw_url: raise ValueError("RTORRENT_URL is required") - self._base_url = url.rstrip("/") + self._base_url = normalize_http_url(raw_url) + if not self._base_url: + raise ValueError("RTORRENT_URL is invalid") username = config.get("RTORRENT_USERNAME", "") password = config.get("RTORRENT_PASSWORD", "") @@ -55,7 +58,7 @@ class RTorrentClient(DownloadClient): def is_configured() -> bool: """Check if rTorrent is configured and selected as the torrent client.""" client = config.get("PROWLARR_TORRENT_CLIENT", "") - url = config.get("RTORRENT_URL", "") + url = normalize_http_url(config.get("RTORRENT_URL", "")) return client == "rtorrent" and bool(url) def test_connection(self) -> Tuple[bool, str]: diff --git a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py index dabd58fc..03c90c51 100644 --- a/shelfmark/release_sources/prowlarr/clients/sabnzbd.py +++ b/shelfmark/release_sources/prowlarr/clients/sabnzbd.py @@ -10,6 +10,7 @@ import requests from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -99,15 +100,17 @@ class SABnzbdClient(DownloadClient): def __init__(self): """Initialize SABnzbd client with settings from config.""" - url = config.get("SABNZBD_URL", "") - if not url: + raw_url = config.get("SABNZBD_URL", "") + if not raw_url: raise ValueError("SABNZBD_URL is required") api_key = config.get("SABNZBD_API_KEY", "") if not api_key: raise ValueError("SABNZBD_API_KEY is required") - self.url = url.rstrip("/") + self.url = normalize_http_url(raw_url) + if not self.url: + raise ValueError("SABNZBD_URL is invalid") self.api_key = api_key self._category = config.get("SABNZBD_CATEGORY", "books") @@ -115,7 +118,7 @@ class SABnzbdClient(DownloadClient): def is_configured() -> bool: """Check if SABnzbd is configured and selected as the usenet client.""" client = config.get("PROWLARR_USENET_CLIENT", "") - url = config.get("SABNZBD_URL", "") + url = normalize_http_url(config.get("SABNZBD_URL", "")) api_key = config.get("SABNZBD_API_KEY", "") return client == "sabnzbd" and bool(url) and bool(api_key) diff --git a/shelfmark/release_sources/prowlarr/clients/transmission.py b/shelfmark/release_sources/prowlarr/clients/transmission.py index dda426d6..f115f140 100644 --- a/shelfmark/release_sources/prowlarr/clients/transmission.py +++ b/shelfmark/release_sources/prowlarr/clients/transmission.py @@ -9,6 +9,7 @@ from typing import Optional, Tuple from shelfmark.core.config import config from shelfmark.core.logger import setup_logger +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.clients import ( DownloadClient, DownloadStatus, @@ -33,10 +34,14 @@ class TransmissionClient(DownloadClient): """Initialize Transmission client with settings from config.""" from transmission_rpc import Client - url = config.get("TRANSMISSION_URL", "") - if not url: + raw_url = config.get("TRANSMISSION_URL", "") + if not raw_url: raise ValueError("TRANSMISSION_URL is required") + url = normalize_http_url(raw_url) + if not url: + raise ValueError("TRANSMISSION_URL is invalid") + username = config.get("TRANSMISSION_USERNAME", "") password = config.get("TRANSMISSION_PASSWORD", "") @@ -56,7 +61,7 @@ class TransmissionClient(DownloadClient): def is_configured() -> bool: """Check if Transmission is configured and selected as the torrent client.""" client = config.get("PROWLARR_TORRENT_CLIENT", "") - url = config.get("TRANSMISSION_URL", "") + url = normalize_http_url(config.get("TRANSMISSION_URL", "")) return client == "transmission" and bool(url) def test_connection(self) -> Tuple[bool, str]: diff --git a/shelfmark/release_sources/prowlarr/handler.py b/shelfmark/release_sources/prowlarr/handler.py index d4aa942f..2679cd1f 100644 --- a/shelfmark/release_sources/prowlarr/handler.py +++ b/shelfmark/release_sources/prowlarr/handler.py @@ -196,7 +196,7 @@ class ProwlarrHandler(DownloadHandler): # If already complete, skip straight to file handling if existing_status.complete: - logger.info(f"Existing download is complete, copying file directly") + logger.info("Existing download is complete, copying file directly") status_callback("resolving", "Found existing download, copying to library") source_path = client.get_download_path(download_id) @@ -213,8 +213,74 @@ class ProwlarrHandler(DownloadHandler): ) return None + from shelfmark.core.path_mappings import ( + get_client_host_identifier, + parse_remote_path_mappings, + remap_remote_to_local_with_match, + ) + + source_path_obj = Path(source_path) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + if matched_mapping: + if remapped.exists(): + logger.info( + "Remapped existing download path for %s (%s): %s -> %s", + client.name, + download_id, + source_path_obj, + remapped, + ) + source_path_obj = remapped + else: + logger.error( + f"Download path does not exist after remapping: {source_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}. " + f"Check that the local path in your mapping is mounted correctly." + ) + status_callback( + "error", + f"Remapped path '{remapped}' does not exist. " + f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.", + ) + return None + elif mappings: + if source_path_obj.exists(): + logger.info( + "No remote path mapping matched for %s (%s); using client path: %s", + client.name, + download_id, + source_path_obj, + ) + else: + hint = _diagnose_path_issue(source_path) + logger.error( + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {source_path}. {hint}" + ) + status_callback( + "error", + f"{hint} No remote path mapping matched for client '{client.name}'.", + ) + return None + elif not source_path_obj.exists(): + hint = _diagnose_path_issue(source_path) + logger.error( + f"Download path does not exist: {source_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + status_callback("error", hint) + return None + result = self._handle_completed_file( - source_path=Path(source_path), + source_path=source_path_obj, protocol=protocol, task=task, status_callback=status_callback, @@ -395,21 +461,39 @@ class ProwlarrHandler(DownloadHandler): from shelfmark.core.path_mappings import ( get_client_host_identifier, parse_remote_path_mappings, - remap_remote_to_local, + remap_remote_to_local_with_match, ) source_path_obj = Path(source_path) - if not source_path_obj.exists(): - host = get_client_host_identifier(client) or "" - mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) - mappings = parse_remote_path_mappings(mapping_value) - remapped = remap_remote_to_local( - mappings=mappings, - host=host, - remote_path=source_path_obj, - ) + host = get_client_host_identifier(client) or "" + mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", []) + mappings = parse_remote_path_mappings(mapping_value) - if remapped != source_path_obj and remapped.exists(): + logger.debug( + "Attempting path remap: client=%s, host=%s, path=%s, mappings=%s", + client.name, + host, + source_path_obj, + [(m.host, m.remote_path, m.local_path) for m in mappings], + ) + + remapped, matched_mapping = remap_remote_to_local_with_match( + mappings=mappings, + host=host, + remote_path=source_path_obj, + ) + + logger.debug( + "Remap result: %s -> %s (exists=%s, changed=%s, matched=%s)", + source_path_obj, + remapped, + remapped.exists(), + remapped != source_path_obj, + matched_mapping, + ) + + if matched_mapping: + if remapped.exists(): logger.info( "Remapped download path for %s (%s): %s -> %s", client.name, @@ -418,14 +502,45 @@ class ProwlarrHandler(DownloadHandler): remapped, ) source_path_obj = remapped + else: + logger.error( + f"Download path does not exist after remapping: {source_path} -> {remapped}. " + f"Client: {client.name}, ID: {download_id}. " + f"Check that the local path in your mapping is mounted correctly." + ) + status_callback( + "error", + f"Remapped path '{remapped}' does not exist. " + f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings.", + ) + return None + elif mappings: + if source_path_obj.exists(): + logger.info( + "No remote path mapping matched for %s (%s); using client path: %s", + client.name, + download_id, + source_path_obj, + ) else: hint = _diagnose_path_issue(source_path) logger.error( - f"Download path does not exist: {source_path}. " - f"Client: {client.name}, ID: {download_id}. {hint}" + f"Download path does not exist and no remote path mapping matched for {client.name} " + f"({download_id}): {source_path}. {hint}" + ) + status_callback( + "error", + f"{hint} No remote path mapping matched for client '{client.name}'.", ) - status_callback("error", hint) return None + elif not source_path_obj.exists(): + hint = _diagnose_path_issue(source_path) + logger.error( + f"Download path does not exist: {source_path}. " + f"Client: {client.name}, ID: {download_id}. {hint}" + ) + status_callback("error", hint) + return None result = self._handle_completed_file( source_path=source_path_obj, diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 977bc3e0..f1c512c7 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -6,7 +6,7 @@ Registers Prowlarr settings as a group with multiple tabs: - Download Clients: Torrent and usenet client settings """ -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional from shelfmark.core.settings_registry import ( register_group, @@ -19,6 +19,7 @@ from shelfmark.core.settings_registry import ( SelectField, MultiSelectField, ) +from shelfmark.core.utils import normalize_http_url # ==================== Dynamic Options Loaders ==================== @@ -34,10 +35,14 @@ def _get_indexer_options() -> List[Dict[str, str]]: logger = setup_logger(__name__) - url = config.get("PROWLARR_URL", "") + raw_url = config.get("PROWLARR_URL", "") api_key = config.get("PROWLARR_API_KEY", "") - if not url or not api_key: + if not raw_url or not api_key: + return [] + + url = normalize_http_url(raw_url) + if not url: return [] try: @@ -72,7 +77,7 @@ def _get_indexer_options() -> List[Dict[str, str]]: # ==================== Test Connection Callbacks ==================== -def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_prowlarr_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the Prowlarr connection using current form values.""" from shelfmark.core.config import config from shelfmark.core.logger import setup_logger @@ -81,11 +86,15 @@ def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str logger = setup_logger(__name__) current_values = current_values or {} - url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "") + raw_url = current_values.get("PROWLARR_URL") or config.get("PROWLARR_URL", "") api_key = current_values.get("PROWLARR_API_KEY") or config.get("PROWLARR_API_KEY", "") - if not url: + if not raw_url: return {"success": False, "message": "Prowlarr URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "Prowlarr URL is invalid"} if not api_key: return {"success": False, "message": "API key is required"} @@ -97,22 +106,26 @@ def _test_prowlarr_connection(current_values: Dict[str, Any] = None) -> Dict[str return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_qbittorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_qbittorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the qBittorrent connection using current form values.""" from shelfmark.core.config import config current_values = current_values or {} - url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "") + raw_url = current_values.get("QBITTORRENT_URL") or config.get("QBITTORRENT_URL", "") username = current_values.get("QBITTORRENT_USERNAME") or config.get("QBITTORRENT_USERNAME", "") password = current_values.get("QBITTORRENT_PASSWORD") or config.get("QBITTORRENT_PASSWORD", "") - if not url: + if not raw_url: return {"success": False, "message": "qBittorrent URL is required"} try: from qbittorrentapi import Client + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "qBittorrent URL is invalid"} + client = Client(host=url, username=username, password=password) client.auth_log_in() api_version = client.app.web_api_version @@ -123,7 +136,7 @@ def _test_qbittorrent_connection(current_values: Dict[str, Any] = None) -> Dict[ return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_transmission_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the Transmission connection using current form values.""" from shelfmark.core.config import config from shelfmark.release_sources.prowlarr.clients.torrent_utils import ( @@ -132,13 +145,17 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict current_values = current_values or {} - url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") + raw_url = current_values.get("TRANSMISSION_URL") or config.get("TRANSMISSION_URL", "") username = current_values.get("TRANSMISSION_USERNAME") or config.get("TRANSMISSION_USERNAME", "") password = current_values.get("TRANSMISSION_PASSWORD") or config.get("TRANSMISSION_PASSWORD", "") - if not url: + if not raw_url: return {"success": False, "message": "Transmission URL is required"} + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "Transmission URL is invalid"} + try: from transmission_rpc import Client @@ -161,7 +178,7 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_deluge_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test Deluge Web UI JSON-RPC connection using current form values.""" from urllib.parse import urlparse @@ -180,6 +197,10 @@ def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, return {"success": False, "message": "Deluge password is required"} raw_host = str(raw_host) + raw_host = normalize_http_url(raw_host, strip_trailing_slash=False) if raw_host else "" + if not raw_host: + return {"success": False, "message": "Deluge host is invalid"} + raw_port = str(raw_port or "8112") scheme = "http" @@ -256,7 +277,7 @@ def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the rTorrent connection using current form values.""" from shelfmark.core.config import config from urllib.parse import urlparse @@ -264,13 +285,17 @@ def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str current_values = current_values or {} - url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "") + raw_url = current_values.get("RTORRENT_URL") or config.get("RTORRENT_URL", "") username = current_values.get("RTORRENT_USERNAME") or config.get("RTORRENT_USERNAME", "") password = current_values.get("RTORRENT_PASSWORD") or config.get("RTORRENT_PASSWORD", "") - if not url: + if not raw_url: return {"success": False, "message": "rTorrent URL is required"} + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "rTorrent URL is invalid"} + try: # Add HTTP auth to URL if credentials provided if username and password: @@ -284,20 +309,24 @@ def _test_rtorrent_connection(current_values: Dict[str, Any] = None) -> Dict[str return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_nzbget_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the NZBGet connection using current form values.""" import requests from shelfmark.core.config import config current_values = current_values or {} - url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "") + raw_url = current_values.get("NZBGET_URL") or config.get("NZBGET_URL", "") username = current_values.get("NZBGET_USERNAME") or config.get("NZBGET_USERNAME", "nzbget") password = current_values.get("NZBGET_PASSWORD") or config.get("NZBGET_PASSWORD", "") - if not url: + if not raw_url: return {"success": False, "message": "NZBGet URL is required"} + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "NZBGet URL is invalid"} + try: rpc_url = f"{url.rstrip('/')}/jsonrpc" payload = {"jsonrpc": "2.0", "method": "status", "params": [], "id": 1} @@ -316,18 +345,22 @@ def _test_nzbget_connection(current_values: Dict[str, Any] = None) -> Dict[str, return {"success": False, "message": f"Connection failed: {str(e)}"} -def _test_sabnzbd_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]: +def _test_sabnzbd_connection(current_values: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """Test the SABnzbd connection using current form values.""" import requests from shelfmark.core.config import config current_values = current_values or {} - url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "") + raw_url = current_values.get("SABNZBD_URL") or config.get("SABNZBD_URL", "") api_key = current_values.get("SABNZBD_API_KEY") or config.get("SABNZBD_API_KEY", "") - if not url: + if not raw_url: return {"success": False, "message": "SABnzbd URL is required"} + + url = normalize_http_url(raw_url) + if not url: + return {"success": False, "message": "SABnzbd URL is invalid"} if not api_key: return {"success": False, "message": "API key is required"} @@ -745,10 +778,10 @@ def prowlarr_clients_settings(): SelectField( key="PROWLARR_USENET_ACTION", label="NZB Completion Action", - description="Copy files into your ingest folder, optionally cleaning up the usenet client", + description="Move deletes the job from your usenet client after import; Copy keeps it in the client", options=[ - {"value": "move", "label": "Copy and remove from client"}, - {"value": "copy", "label": "Copy (keep in client)"}, + {"value": "move", "label": "Move"}, + {"value": "copy", "label": "Copy"}, ], default="move", show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True}, diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 605c5143..a44c5d61 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -23,6 +23,7 @@ from shelfmark.release_sources import ( LeadingCellType, ) from shelfmark.release_sources.prowlarr.api import ProwlarrClient +from shelfmark.core.utils import normalize_http_url from shelfmark.release_sources.prowlarr.cache import cache_release from shelfmark.release_sources.prowlarr.utils import get_preferred_download_url, get_protocol @@ -268,10 +269,14 @@ class ProwlarrSource(ReleaseSource): def _get_client(self) -> Optional[ProwlarrClient]: """Get a configured Prowlarr client or None if not configured.""" - url = config.get("PROWLARR_URL", "") + raw_url = config.get("PROWLARR_URL", "") api_key = config.get("PROWLARR_API_KEY", "") - if not url or not api_key: + if not raw_url or not api_key: + return None + + url = normalize_http_url(raw_url) + if not url: return None return ProwlarrClient(url, api_key) @@ -313,7 +318,7 @@ class ProwlarrSource(ReleaseSource): logger.warning("Prowlarr not configured - skipping search") return [] - queries = [v.query for v in plan.title_variants if v.query] + queries = [v.title for v in plan.title_variants if v.title] queries = [q for q in queries if q] if not queries and plan.isbn_candidates: @@ -340,11 +345,22 @@ class ProwlarrSource(ReleaseSource): categories = None if expand_search else search_categories self.last_search_type = "expanded" if expand_search else "categories" + if plan.manual_query: + query_type = "manual" + elif not plan.title_variants and plan.isbn_candidates: + query_type = "isbn" + else: + query_type = "title" + indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers" if len(queries) == 1: - logger.debug(f"Searching Prowlarr: query='{queries[0]}', {indexer_desc}, categories={categories}") + logger.debug( + f"Searching Prowlarr: {query_type}='{queries[0]}', {indexer_desc}, categories={categories}" + ) else: - logger.debug(f"Searching Prowlarr: {len(queries)} queries, {indexer_desc}, categories={categories}") + logger.debug( + f"Searching Prowlarr: {query_type} ({len(queries)} variants), {indexer_desc}, categories={categories}" + ) def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]: """Search indexers with given categories, collecting results.""" @@ -420,6 +436,6 @@ class ProwlarrSource(ReleaseSource): """Check if Prowlarr is enabled and configured.""" if not config.get("PROWLARR_ENABLED", False): return False - url = config.get("PROWLARR_URL", "") + url = normalize_http_url(config.get("PROWLARR_URL", "")) api_key = config.get("PROWLARR_API_KEY", "") return bool(url and api_key) diff --git a/src/frontend/src/components/Dropdown.tsx b/src/frontend/src/components/Dropdown.tsx index b1b09cd6..846c4368 100644 --- a/src/frontend/src/components/Dropdown.tsx +++ b/src/frontend/src/components/Dropdown.tsx @@ -189,7 +189,7 @@ export const Dropdown = ({ panelDirection === 'down' ? renderTrigger ? 'mt-2' : '' : renderTrigger ? 'bottom-full mb-2' : 'bottom-full' - } border shadow-lg z-20 ${panelClassName || widthClassName}`} + } border z-20 ${panelDirection === 'down' ? 'shadow-lg' : ''} ${panelClassName || widthClassName}`} style={{ background: 'var(--bg)', borderColor: 'var(--border-muted)', diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index eb1a5898..ac98c4a5 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -673,6 +673,8 @@ export const ReleaseModal = ({ setExpandedBySource({}); setFormatFilter(''); setLanguageFilter([LANGUAGE_OPTION_DEFAULT]); + setManualQuery(''); + setShowManualQuery(false); setSearchStatus(null); lastStatusTimeRef.current = 0; pendingStatusRef.current = null; @@ -1409,7 +1411,18 @@ export const ReleaseModal = ({ {/* Manual query button */}