File processing refactor and Booklore upload support (#474)

- Added new book output option **upload to Booklore**, available in
download settings
- Got annoyed at my messy processing code while implementing Booklore so
refactored the whole thing
- Full black box file processing testing with randomised configuration
- Deluge: Connect via WebUI auth for simplified setup
- Added env vars documentation, auto generated via script, and unlocked
most settings to be used as env vars
This commit is contained in:
Alex
2026-01-16 14:45:00 +00:00
committed by GitHub
parent ba906c45df
commit fd74021594
71 changed files with 7833 additions and 1959 deletions
+2 -2
View File
@@ -11,7 +11,7 @@
# - Prowlarr: http://localhost:9696 (no auth by default)
# - qBittorrent: http://localhost:8080 (check container logs for temp password)
# - Transmission: http://localhost:9091 (admin / admin)
# - Deluge: http://localhost:8112 (admin / deluge)
# - Deluge: http://localhost:8112 (password: deluge)
# - NZBGet: http://localhost:6789 (nzbget / tegbzn6789)
# - SABnzbd: http://localhost:8085 (complete setup wizard for API key)
# - rTorrent: http://localhost:8000 (admin / admin - if auth enabled)
@@ -33,7 +33,7 @@ services:
# Use Docker service names for URLs:
# - qBittorrent: http://qbittorrent:8080
# - Transmission: http://transmission:9091
# - Deluge host: deluge (port 58846)
# - Deluge Web UI: http://deluge:8112
# - NZBGet: http://nzbget:6789
# - SABnzbd: http://sabnzbd:8080
# - rTorrent: http://rtorrent:80 (XMLRPC via HTTP) or rtorrent (port 5000 for SCGI)
+3
View File
@@ -0,0 +1,3 @@
# Configuration
TODO
+3
View File
@@ -0,0 +1,3 @@
# Developer Documentation
TODO
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Shelfmark Documentation
TODO
+3
View File
@@ -0,0 +1,3 @@
# Installation
TODO
+3
View File
@@ -0,0 +1,3 @@
# Troubleshooting
TODO
-1
View File
@@ -14,4 +14,3 @@ emoji
rarfile
qbittorrent-api
transmission-rpc
deluge-client
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Generate markdown documentation for environment variables from the settings registry.
This script extracts all settings that support environment variable configuration
and generates a comprehensive markdown file documenting each option.
Usage:
python scripts/generate_env_docs.py [--output path/to/output.md]
The generated documentation includes:
- Environment variable name
- Description
- Type (string, number, boolean, etc.)
- Default value
- Organizational grouping by settings tab/group
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# Add project root to path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))
def get_field_type_name(field) -> str:
"""Get a human-readable type name for a field."""
from shelfmark.core.settings_registry import (
CheckboxField,
MultiSelectField,
NumberField,
OrderableListField,
PasswordField,
SelectField,
TextField,
)
if isinstance(field, CheckboxField):
return "boolean"
elif isinstance(field, NumberField):
return "number"
elif isinstance(field, SelectField):
return "string (choice)"
elif isinstance(field, MultiSelectField):
return "string (comma-separated)"
elif isinstance(field, OrderableListField):
return "JSON array"
elif isinstance(field, PasswordField):
return "string (secret)"
elif isinstance(field, TextField):
return "string"
else:
return "string"
def format_default_value(field) -> str:
"""Format the default value for display."""
default = field.default
if default is None:
return "_none_"
elif isinstance(default, bool):
return f"`{str(default).lower()}`"
elif isinstance(default, (int, float)):
return f"`{default}`"
elif isinstance(default, str):
if default == "":
return "_empty string_"
return f"`{default}`"
elif isinstance(default, list):
if not default:
return "_empty list_"
# For simple lists, show comma-separated values
if all(isinstance(item, str) for item in default):
return f"`{','.join(default)}`"
# For complex lists (e.g., OrderableListField defaults), summarize
return f"_see UI for defaults_"
else:
return f"`{default}`"
def get_select_options(field) -> Optional[List[str]]:
"""Get the available options for a SelectField."""
from shelfmark.core.settings_registry import SelectField
if not isinstance(field, SelectField):
return None
options = field.options
if callable(options):
try:
options = options()
except Exception:
return None
if not options:
return None
return [opt.get("label", opt.get("value", "")) for opt in options]
def _generate_bootstrap_env_docs() -> List[str]:
"""Generate documentation for bootstrap environment variables from env.py."""
# These are environment variables defined in env.py that are used before
# the settings registry is available
bootstrap_vars = [
{
"name": "CONFIG_DIR",
"description": "Directory for storing configuration files and plugin settings.",
"type": "string (path)",
"default": "/config",
},
{
"name": "LOG_ROOT",
"description": "Root directory for log files.",
"type": "string (path)",
"default": "/var/log/",
},
{
"name": "TMP_DIR",
"description": "Staging directory for downloads before moving to destination.",
"type": "string (path)",
"default": "/tmp/shelfmark",
},
{
"name": "ENABLE_LOGGING",
"description": "Enable file logging to LOG_ROOT/shelfmark/shelfmark.log.",
"type": "boolean",
"default": "true",
},
{
"name": "FLASK_HOST",
"description": "Host address for the Flask web server.",
"type": "string",
"default": "0.0.0.0",
},
{
"name": "FLASK_PORT",
"description": "Port number for the Flask web server.",
"type": "number",
"default": "8084",
},
{
"name": "SESSION_COOKIE_SECURE",
"description": "Enable secure cookies (requires HTTPS).",
"type": "boolean",
"default": "false",
},
{
"name": "CWA_DB_PATH",
"description": "Path to the Calibre-Web database for authentication integration.",
"type": "string (path)",
"default": "/auth/app.db",
},
{
"name": "DOCKERMODE",
"description": "Indicates the application is running inside a Docker container.",
"type": "boolean",
"default": "false",
},
]
lines = [
"## Bootstrap Configuration",
"",
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
"",
"| Variable | Description | Type | Default |",
"|----------|-------------|------|---------|",
]
for var in bootstrap_vars:
lines.append(f"| `{var['name']}` | {var['description']} | {var['type']} | `{var['default']}` |")
lines.append("")
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for var in bootstrap_vars:
lines.append(f"#### `{var['name']}`")
lines.append("")
lines.append(var["description"])
lines.append("")
lines.append(f"- **Type:** {var['type']}")
lines.append(f"- **Default:** `{var['default']}`")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def generate_env_docs() -> str:
"""Generate markdown documentation for all environment variables."""
# Import settings modules to ensure all settings are registered
import shelfmark.config.settings # noqa: F401
import shelfmark.release_sources.irc.settings # noqa: F401
import shelfmark.release_sources.prowlarr.settings # noqa: F401
import shelfmark.metadata_providers.hardcover # noqa: F401
import shelfmark.metadata_providers.openlibrary # noqa: F401
import shelfmark.metadata_providers.googlebooks # noqa: F401
from shelfmark.core.settings_registry import (
ActionButton,
HeadingField,
get_all_groups,
get_all_settings_tabs,
)
tabs = get_all_settings_tabs()
groups = {g.name: g for g in get_all_groups()}
# Organize tabs by group
grouped_tabs: Dict[Optional[str], List] = {None: []}
for group_name in groups:
grouped_tabs[group_name] = []
for tab in tabs:
group_name = tab.group
if group_name not in grouped_tabs:
grouped_tabs[group_name] = []
grouped_tabs[group_name].append(tab)
# Build markdown output
lines = [
"# Environment Variables",
"",
"This document lists all configuration options that can be set via environment variables.",
"",
"> **Auto-generated** - Do not edit manually. Run `python scripts/generate_env_docs.py` to regenerate.",
"",
"## Table of Contents",
"",
]
# Generate TOC
toc_entries = [
"- [Bootstrap Configuration](#bootstrap-configuration)",
]
# Ungrouped tabs first
for tab in grouped_tabs.get(None, []):
anchor = tab.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{tab.display_name}](#{anchor})")
# Then grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if group_tabs:
anchor = group.display_name.lower().replace(" ", "-")
toc_entries.append(f"- [{group.display_name}](#{anchor})")
for tab in group_tabs:
sub_anchor = f"{group.display_name}-{tab.display_name}".lower().replace(" ", "-")
toc_entries.append(f" - [{tab.display_name}](#{sub_anchor})")
lines.extend(toc_entries)
lines.append("")
lines.append("---")
lines.append("")
# Add bootstrap environment variables documentation
lines.extend(_generate_bootstrap_env_docs())
# Generate documentation for ungrouped tabs
for tab in grouped_tabs.get(None, []):
lines.extend(_generate_tab_docs(tab))
# Generate documentation for grouped tabs
for group_name, group in groups.items():
group_tabs = grouped_tabs.get(group_name, [])
if not group_tabs:
continue
lines.append(f"## {group.display_name}")
lines.append("")
for tab in group_tabs:
lines.extend(_generate_tab_docs(tab, group_prefix=group.display_name))
return "\n".join(lines)
def _generate_tab_docs(tab, group_prefix: Optional[str] = None) -> List[str]:
"""Generate documentation for a single settings tab."""
from shelfmark.core.settings_registry import ActionButton, HeadingField
lines = []
# Section header
if group_prefix:
lines.append(f"### {group_prefix}: {tab.display_name}")
anchor_id = f"{group_prefix}-{tab.display_name}".lower().replace(" ", "-")
else:
lines.append(f"## {tab.display_name}")
lines.append("")
# Collect env-supported fields
env_fields = []
for field in tab.fields:
# Skip non-value fields
if isinstance(field, (ActionButton, HeadingField)):
continue
# Skip fields that don't support ENV vars
if not getattr(field, "env_supported", True):
continue
env_fields.append(field)
if not env_fields:
lines.append("_No environment variables for this section._")
lines.append("")
return lines
# Generate table
lines.append("| Variable | Description | Type | Default |")
lines.append("|----------|-------------|------|---------|")
for field in env_fields:
env_var = field.get_env_var_name()
description = field.description or field.label
# Clean up description for table (remove newlines, escape pipes)
description = description.replace("\n", " ").replace("|", "\\|").strip()
field_type = get_field_type_name(field)
default = format_default_value(field)
lines.append(f"| `{env_var}` | {description} | {field_type} | {default} |")
lines.append("")
# Add detailed documentation for each field
lines.append("<details>")
lines.append("<summary>Detailed descriptions</summary>")
lines.append("")
for field in env_fields:
env_var = field.get_env_var_name()
lines.append(f"#### `{env_var}`")
lines.append("")
lines.append(f"**{field.label}**")
lines.append("")
if field.description:
lines.append(field.description)
lines.append("")
lines.append(f"- **Type:** {get_field_type_name(field)}")
lines.append(f"- **Default:** {format_default_value(field)}")
if getattr(field, "required", False):
lines.append("- **Required:** Yes")
if getattr(field, "requires_restart", False):
lines.append("- **Requires restart:** Yes")
# Show options for SelectField
options = get_select_options(field)
if options:
lines.append(f"- **Options:** {', '.join(options)}")
# Show constraints for NumberField
from shelfmark.core.settings_registry import NumberField
if isinstance(field, NumberField):
constraints = []
if field.min_value is not None:
constraints.append(f"min: {field.min_value}")
if field.max_value is not None:
constraints.append(f"max: {field.max_value}")
if constraints:
lines.append(f"- **Constraints:** {', '.join(constraints)}")
lines.append("")
lines.append("</details>")
lines.append("")
return lines
def main():
parser = argparse.ArgumentParser(
description="Generate markdown documentation for environment variables"
)
parser.add_argument(
"--output",
"-o",
type=Path,
default=project_root / "docs" / "environment-variables.md",
help="Output file path (default: docs/environment-variables.md)",
)
parser.add_argument(
"--stdout",
action="store_true",
help="Print to stdout instead of file",
)
args = parser.parse_args()
docs = generate_env_docs()
if args.stdout:
print(docs)
else:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(docs)
print(f"Generated: {args.output}")
if __name__ == "__main__":
main()
+67 -41
View File
@@ -26,7 +26,7 @@ Web UIs:
- rTorrent: http://localhost:8000 (web ui http://localhost:8089 via ruTorrent)
Prerequisites (for running this script locally):
pip install requests transmission-rpc deluge-client qbittorrent-api
pip install requests transmission-rpc qbittorrent-api
First-Time Setup:
qBittorrent:
@@ -38,10 +38,7 @@ First-Time Setup:
- No setup needed, credentials pre-configured (admin/admin)
Deluge:
1. Access Web UI at http://localhost:8112 (default password: deluge)
2. Add auth line to .local/test-clients/deluge/config/auth:
echo "admin:admin:10" >> .local/test-clients/deluge/config/auth
3. Restart: docker restart test-deluge
- Access Web UI at http://localhost:8112 (default password: deluge)
NZBGet:
- No setup needed, credentials pre-configured (admin/admin)
@@ -80,10 +77,8 @@ CONFIG = {
"password": "admin",
},
"deluge": {
"host": "localhost",
"port": 58846,
"username": "admin",
"password": "admin",
"url": "http://localhost:8112",
"password": "deluge",
},
"rtorrent": {
"url": "http://localhost:8000/RPC2",
@@ -330,46 +325,79 @@ def test_transmission():
def test_deluge():
"""Test Deluge connection."""
"""Test Deluge Web UI (JSON-RPC) connection."""
import requests
print("\n" + "=" * 50)
print("Testing Deluge")
print("=" * 50)
base_url = CONFIG["deluge"]["url"].rstrip("/")
password = CONFIG["deluge"]["password"]
rpc_url = f"{base_url}/json"
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params):
payload = {"id": rpc_id, "method": method, "params": list(params)}
resp = session.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
err = data["error"]
if isinstance(err, dict):
raise Exception(err.get("message") or str(err))
raise Exception(str(err))
return data.get("result")
try:
from deluge_client import DelugeRPCClient
session = requests.Session()
client = DelugeRPCClient(
host=CONFIG["deluge"]["host"],
port=CONFIG["deluge"]["port"],
username=CONFIG["deluge"]["username"],
password=CONFIG["deluge"]["password"],
)
# Authenticate to Deluge Web
if rpc_call(session, 1, "auth.login", password) is not True:
raise Exception("Authentication failed (check Deluge Web UI password)")
# Test connection
client.connect()
version = client.call("daemon.info")
# Ensure Deluge Web is connected to a daemon
if rpc_call(session, 2, "web.connected") is not True:
hosts = rpc_call(session, 3, "web.get_hosts") or []
if not hosts:
raise Exception(
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
"Add/connect a daemon in Deluge Web UI → Connection Manager."
)
host_id = hosts[0][0]
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
host_id = entry[0]
break
rpc_call(session, 4, "web.connect", host_id)
if rpc_call(session, 5, "web.connected") is not True:
raise Exception(
"Deluge Web UI couldn't connect to Deluge core. "
"Check Deluge Web UI → Connection Manager."
)
version = rpc_call(session, 6, "daemon.info")
print(f" Connected to Deluge {version}")
# Get torrent list
torrents = client.call("core.get_torrents_status", {}, ["name"])
torrents = rpc_call(session, 7, "core.get_torrents_status", {}, ["name"]) or {}
print(f" Active torrents: {len(torrents)}")
# Test adding a torrent (then remove it)
print(" Testing add/remove torrent...")
torrent_id = client.call("core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
torrent_id = rpc_call(session, 8, "core.add_torrent_magnet", TEST_MAGNET, {"add_paused": True})
if torrent_id:
torrent_id = str(torrent_id)
print(f" Added test torrent: {torrent_id[:20]}...")
# Get status
status = client.call("core.get_torrent_status", torrent_id, ["state", "progress"])
state = status.get(b"state", b"unknown")
if isinstance(state, bytes):
state = state.decode()
print(f" Status: {state}")
status = rpc_call(session, 9, "core.get_torrent_status", torrent_id, ["state", "progress"]) or {}
state = status.get("state", "unknown") if isinstance(status, dict) else "unknown"
progress = status.get("progress", 0) if isinstance(status, dict) else 0
print(f" Status: {state} ({progress:.1f}%)")
# Remove it
client.call("core.remove_torrent", torrent_id, True)
rpc_call(session, 10, "core.remove_torrent", torrent_id, True)
print(" Removed test torrent")
else:
print(" WARNING: Could not add test torrent")
@@ -377,19 +405,17 @@ def test_deluge():
print(" SUCCESS: Deluge is working!")
return True
except ImportError:
print(" ERROR: deluge-client not installed")
print(" Run: pip install deluge-client")
except requests.exceptions.ConnectionError:
print(" ERROR: Could not connect to Deluge Web UI")
print(" Is the container running? docker ps | grep deluge")
return False
except requests.exceptions.Timeout:
print(" ERROR: Deluge Web UI connection timed out")
return False
except Exception as e:
print(f" ERROR: {e}")
if "Connection refused" in str(e):
print(" Is the container running? docker ps | grep deluge")
elif "Bad login" in str(e) or "auth" in str(e).lower():
print("\n Deluge auth setup required:")
print(" 1. Add 'admin:admin:10' to .local/test-clients/deluge/config/auth")
print(" 2. Restart: docker restart test-deluge")
print(" 3. Or access Web UI at http://localhost:8112 (password: deluge)")
if "auth" in str(e).lower() or "login" in str(e).lower():
print(" Check Deluge Web UI password (default: deluge)")
return False
def test_rtorrent():
+197
View File
@@ -0,0 +1,197 @@
from __future__ import annotations
from typing import Any
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.download.outputs.booklore import (
BookloreConfig,
BookloreError,
booklore_list_libraries,
booklore_login,
)
logger = setup_logger(__name__)
_BOOKLORE_OPTIONS_CACHE: dict[str, Any] = {
"key": None,
"library_options": [],
"path_options": [],
}
def _get_booklore_cache_key(base_url: str, username: str, password: str) -> str:
return f"{base_url}|{username}|{hash(password)}"
def _get_booklore_select_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
# library_id/path_id are not used for login/library listing
booklore_config = BookloreConfig(
base_url=base_url.rstrip("/"),
username=username,
password=password,
library_id=1,
path_id=1,
verify_tls=True,
refresh_after_upload=True,
)
token = booklore_login(booklore_config)
libraries = booklore_list_libraries(booklore_config, token) or []
logger.debug("Booklore libraries response: %s", libraries)
library_options: list[dict[str, Any]] = []
path_options: list[dict[str, Any]] = []
for library in libraries:
if not isinstance(library, dict):
continue
library_id = library.get("id")
if library_id is None:
continue
library_name = str(library.get("name") or f"Library {library_id}")
library_id_str = str(library_id)
library_options.append({"value": library_id_str, "label": library_name})
paths = library.get("paths") or []
if not isinstance(paths, list):
continue
for path in paths:
if not isinstance(path, dict):
continue
path_id = path.get("id")
if path_id is None:
continue
path_label = str(path.get("path") or f"Path {path_id}")
path_options.append(
{
"value": str(path_id),
"label": f"{library_name}: {path_label}",
"childOf": library_id_str,
}
)
logger.debug(
"Booklore options built: libraries=%d paths=%d",
len(library_options),
len(path_options),
)
cache_key = _get_booklore_cache_key(base_url, username, password)
_BOOKLORE_OPTIONS_CACHE.update(
{
"key": cache_key,
"library_options": library_options,
"path_options": path_options,
}
)
return library_options, path_options
def _get_booklore_cached_options(
base_url: str,
username: str,
password: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
cache_key = _get_booklore_cache_key(base_url, username, password)
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return (
_BOOKLORE_OPTIONS_CACHE.get("library_options", []),
_BOOKLORE_OPTIONS_CACHE.get("path_options", []),
)
return _get_booklore_select_options(base_url, username, password)
def get_booklore_library_options() -> list[dict[str, Any]]:
"""Build Booklore library options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = config.get("BOOKLORE_PASSWORD", "") or ""
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
library_options, _ = _get_booklore_cached_options(base_url, username, password)
return library_options
except Exception as exc:
logger.error(f"Failed to fetch Booklore libraries: {exc}")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("library_options", [])
return []
def get_booklore_path_options() -> list[dict[str, Any]]:
"""Build Booklore path options dynamically from config."""
if config.get("BOOKS_OUTPUT_MODE", "folder") != "booklore":
return []
base_url = str(config.get("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(config.get("BOOKLORE_USERNAME", "") or "").strip()
password = config.get("BOOKLORE_PASSWORD", "") or ""
if not base_url or not username or not password:
return []
cache_key = _get_booklore_cache_key(base_url, username, password)
try:
_, path_options = _get_booklore_cached_options(base_url, username, password)
return path_options
except Exception as exc:
logger.error(f"Failed to fetch Booklore paths: {exc}")
if _BOOKLORE_OPTIONS_CACHE.get("key") == cache_key:
return _BOOKLORE_OPTIONS_CACHE.get("path_options", [])
return []
def test_booklore_connection(current_values: dict[str, Any] | None = None) -> dict[str, Any]:
"""Test the Booklore connection using current form values."""
current_values = current_values or {}
def _get_value(key: str, default: Any = None) -> Any:
value = current_values.get(key)
if value not in (None, ""):
return value
if default is None:
return config.get(key)
return config.get(key, default)
base_url = str(_get_value("BOOKLORE_HOST", "") or "").strip().rstrip("/")
username = str(_get_value("BOOKLORE_USERNAME", "") or "").strip()
password = _get_value("BOOKLORE_PASSWORD", "") or ""
if not base_url:
return {"success": False, "message": "Booklore URL is required"}
if not username:
return {"success": False, "message": "Booklore username is required"}
if not password:
return {"success": False, "message": "Booklore password is required"}
try:
library_options, _ = _get_booklore_select_options(base_url, username, password)
message = "Connected to Booklore"
if library_options:
message = f"Connected to Booklore ({len(library_options)} libraries)"
return {"success": True, "message": message}
except BookloreError as exc:
return {"success": False, "message": str(exc)}
+141 -10
View File
@@ -3,8 +3,14 @@
import os
from pathlib import Path
import json
from typing import Any
from shelfmark.config import env
from shelfmark.config.booklore_settings import (
get_booklore_library_options,
get_booklore_path_options,
test_booklore_connection,
)
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
@@ -53,6 +59,8 @@ def _log_external_bypasser_warning() -> None:
from shelfmark.core.settings_registry import (
register_settings,
register_group,
register_on_save,
load_config_file,
TextField,
PasswordField,
NumberField,
@@ -150,6 +158,8 @@ def _get_release_source_options():
if source.get("can_be_default", True)
]
_LANGUAGE_OPTIONS = [{"value": lang["code"], "label": lang["language"]} for lang in _SUPPORTED_BOOK_LANGUAGE]
def _get_aa_base_url_options():
@@ -277,7 +287,6 @@ def general_settings():
label="Audiobook Library URL",
description="Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text.",
placeholder="http://audiobookshelf:8080",
env_supported=False,
),
HeadingField(
key="search_defaults_heading",
@@ -341,7 +350,6 @@ def search_mode_settings():
description="Default sort order for search results.",
options=_AA_SORT_OPTIONS,
default="relevance",
env_supported=False, # UI-only setting
show_when={"field": "SEARCH_MODE", "value": "direct"},
),
HeadingField(
@@ -372,7 +380,6 @@ def search_mode_settings():
description="The release source tab to open by default in the release modal.",
options=_get_release_source_options, # Callable - evaluated lazily to avoid circular imports
default="direct_download",
env_supported=False, # UI-only setting, not configurable via ENV
show_when={"field": "SEARCH_MODE", "value": "universal"},
),
]
@@ -511,6 +518,40 @@ def network_settings():
]
def _contains_path_separators(value: Any) -> bool:
return isinstance(value, str) and ("/" in value or "\\" in value)
def _on_save_downloads(values: dict[str, Any]) -> dict[str, Any]:
"""Validate download settings before persisting."""
existing = load_config_file("downloads")
effective: dict[str, Any] = dict(existing)
effective.update(values)
# Books: only validate templates when saving to a folder.
books_output_mode = effective.get("BOOKS_OUTPUT_MODE", "folder")
if books_output_mode == "folder" and effective.get("FILE_ORGANIZATION", "rename") == "rename":
template = effective.get("TEMPLATE_RENAME", "")
if _contains_path_separators(template):
return {
"error": True,
"message": "Books Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
"values": values,
}
# Audiobooks are always folder output.
if effective.get("FILE_ORGANIZATION_AUDIOBOOK", "rename") == "rename":
template = effective.get("TEMPLATE_AUDIOBOOK_RENAME", "")
if _contains_path_separators(template):
return {
"error": True,
"message": "Audiobooks Naming Template cannot contain '/' or '\\' in Rename mode. Use Organize mode to create folders.",
"values": values,
}
return {"error": False, "values": values}
@register_settings("downloads", "Downloads", icon="folder", order=5)
def download_settings():
"""Configure download behavior and file locations."""
@@ -522,6 +563,24 @@ def download_settings():
title="Books",
description="Configure where ebooks, comics, and magazines are saved.",
),
SelectField(
key="BOOKS_OUTPUT_MODE",
label="Output Mode",
description="Choose where completed book files are sent.",
options=[
{
"value": "folder",
"label": "Folder",
"description": "Save files to the destination folder",
},
{
"value": "booklore",
"label": "Booklore (API)",
"description": "Upload files directly to Booklore",
},
],
default="folder",
),
TextField(
key="DESTINATION",
label="Destination",
@@ -529,6 +588,10 @@ def download_settings():
default="/books",
required=True,
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
show_when={
"field": "BOOKS_OUTPUT_MODE",
"value": "folder",
},
),
SelectField(
key="FILE_ORGANIZATION",
@@ -552,15 +615,22 @@ def download_settings():
},
],
default="rename",
show_when={
"field": "BOOKS_OUTPUT_MODE",
"value": "folder",
},
),
# Rename mode template - filename only
TextField(
key="TEMPLATE_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "rename"},
show_when=[
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
{"field": "FILE_ORGANIZATION", "value": "rename"},
],
),
# Organize mode template - folders allowed
TextField(
@@ -569,7 +639,10 @@ def download_settings():
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when={"field": "FILE_ORGANIZATION", "value": "organize"},
show_when=[
{"field": "BOOKS_OUTPUT_MODE", "value": "folder"},
{"field": "FILE_ORGANIZATION", "value": "organize"},
],
),
CheckboxField(
key="HARDLINK_TORRENTS",
@@ -577,6 +650,63 @@ def download_settings():
description="Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder.",
default=False,
universal_only=True,
show_when={
"field": "BOOKS_OUTPUT_MODE",
"value": "folder",
},
),
HeadingField(
key="booklore_heading",
title="Booklore",
description="Upload books directly to Booklore via API. Audiobooks always use folder mode.",
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
TextField(
key="BOOKLORE_HOST",
label="Booklore URL",
description="Base URL of your Booklore instance",
placeholder="http://booklore:6060",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
TextField(
key="BOOKLORE_USERNAME",
label="Username",
description="Booklore account username",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
PasswordField(
key="BOOKLORE_PASSWORD",
label="Password",
description="Booklore account password",
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
SelectField(
key="BOOKLORE_LIBRARY_ID",
label="Library",
description="Booklore library to upload into.",
options=get_booklore_library_options,
required=True,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
SelectField(
key="BOOKLORE_PATH_ID",
label="Path",
description="Booklore library path for uploads.",
options=get_booklore_path_options,
required=True,
filter_by_field="BOOKLORE_LIBRARY_ID",
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
ActionButton(
key="test_booklore",
label="Test Connection",
description="Verify your Booklore configuration",
style="primary",
callback=test_booklore_connection,
show_when={"field": "BOOKS_OUTPUT_MODE", "value": "booklore"},
),
# === AUDIOBOOKS SECTION ===
@@ -610,7 +740,7 @@ def download_settings():
TextField(
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
@@ -644,14 +774,12 @@ def download_settings():
label="Auto-Open Downloads Sidebar",
description="Automatically open the downloads sidebar when a new download is queued.",
default=False,
env_supported=False, # UI-only setting
),
CheckboxField(
key="DOWNLOAD_TO_BROWSER",
label="Download to Browser",
description="Automatically download completed files to your browser.",
default=False,
env_supported=False, # UI-only setting
),
NumberField(
key="MAX_CONCURRENT_DOWNLOADS",
@@ -673,6 +801,10 @@ def download_settings():
]
# Register the on_save handler for this tab
register_on_save("downloads", _on_save_downloads)
def _get_fast_source_options():
"""Fast download sources - display only, not configurable."""
from shelfmark.core.config import config
@@ -777,7 +909,6 @@ def download_source_settings():
description="Always tried first, no waiting or bypass required.",
options=_get_fast_source_options,
default=_get_fast_source_defaults(),
env_supported=False,
),
OrderableListField(
key="SOURCE_PRIORITY",
+38 -12
View File
@@ -24,7 +24,7 @@ class FieldBase:
env_supported: bool = True # Whether this setting can be set via ENV var (False = UI-only)
disabled: bool = False # Whether field is disabled/greyed out
disabled_reason: str = "" # Explanation shown when disabled
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
requires_restart: bool = False # Whether changing this setting requires a container restart
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
@@ -71,6 +71,7 @@ class SelectField(FieldBase):
"""Single-choice dropdown."""
# Options can be a list or a callable that returns a list (for lazy evaluation)
options: Any = field(default_factory=list) # [{value: "", label: ""}] or callable
filter_by_field: Optional[str] = None # Field key whose value filters options via childOf property
@dataclass
@@ -99,10 +100,10 @@ class ActionButton:
label: str # Button text
description: str = "" # Help text
style: str = "default" # "default", "primary", "danger"
callback: Optional[Callable[[], Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
callback: Optional[Callable[..., Dict[str, Any]]] = None # Returns {"success": bool, "message": str}
disabled: bool = False # Whether button is disabled/greyed out
disabled_reason: str = "" # Explanation shown when disabled
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
disabled_when: Optional[Dict[str, Any]] = None # Conditional disable: {"field": "key", "value": "expected", "reason": "..."}
def get_field_type(self) -> str:
@@ -122,7 +123,7 @@ class HeadingField:
description: str = "" # Description text (supports markdown-style links)
link_url: str = "" # Optional URL for a link
link_text: str = "" # Text for the link (defaults to URL if not provided)
show_when: Optional[Dict[str, Any]] = None # Conditional visibility: {"field": "key", "value": "expected"} or {"field": "key", "notEmpty": True}
show_when: Optional[Dict[str, Any] | List[Dict[str, Any]]] = None # Conditional visibility: {"field": "key", "value": "expected"} or list of conditions
universal_only: bool = False # Only show in Universal search mode (hide in Direct mode)
def get_field_type(self) -> str:
@@ -562,7 +563,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
"""
# HeadingField has a different structure - handle separately
if isinstance(field, HeadingField):
result = {
result: Dict[str, Any] = {
"key": field.key,
"type": field.get_field_type(),
"title": field.title,
@@ -577,7 +578,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["universalOnly"] = True
return result
result = {
result: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"type": field.get_field_type(),
@@ -613,6 +614,8 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["options"] = options
if field.default is not None:
result["default"] = field.default
if field.filter_by_field:
result["filterByField"] = field.filter_by_field
elif isinstance(field, MultiSelectField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
@@ -628,6 +631,23 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
if include_value and not isinstance(field, (ActionButton, HeadingField)):
value = get_setting_value(field, tab_name)
# Ensure select values are serialized as strings so the frontend can
# reliably match against string option values.
if isinstance(field, SelectField) and value is not None:
value = str(value)
elif isinstance(field, MultiSelectField):
if value is None:
value = []
elif isinstance(value, list):
value = [str(v) for v in value]
elif isinstance(value, str):
# Support legacy/manual configs where MultiSelect values were saved
# as comma-separated strings.
value = [v.strip() for v in value.split(",") if v.strip()]
else:
value = []
result["value"] = value if value is not None else ""
result["fromEnv"] = is_value_from_env(field)
@@ -696,7 +716,7 @@ def execute_action(tab_name: str, action_key: str, current_values: Optional[Dict
try:
# Check if callback accepts current_values parameter
sig = inspect.signature(field.callback)
if 'current_values' in sig.parameters:
if "current_values" in sig.parameters:
return field.callback(current_values=current_values or {})
else:
return field.callback()
@@ -816,16 +836,22 @@ def update_settings(tab_name: str, values: Dict[str, Any]) -> Dict[str, Any]:
# Save to config file
if save_config_file(tab_name, values_to_save):
# Refresh the config singleton so live settings take effect immediately
config_obj = None
try:
from shelfmark.core.config import config
config.refresh()
from shelfmark.core.config import config as config_obj
config_obj.refresh()
except ImportError:
pass # Config module not yet available during initial setup
config_obj = None # Config module not yet available during initial setup
# Apply DNS settings changes live (network tab)
dns_keys = {"CUSTOM_DNS", "CUSTOM_DNS_MANUAL", "USE_DOH"}
if tab_name == "network" and dns_keys.intersection(values_to_save.keys()):
_apply_dns_settings(config)
if (
config_obj is not None
and tab_name == "network"
and dns_keys.intersection(values_to_save.keys())
):
_apply_dns_settings(config_obj)
# Sync metadata provider selection when a provider's enabled state changes
tab = get_settings_tab(tab_name)
+22 -232
View File
@@ -3,107 +3,20 @@
import os
import shutil
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from shelfmark.core.logger import setup_logger
from shelfmark.core.config import config
from shelfmark.core.naming import parse_naming_template, sanitize_filename
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_write, atomic_move
from shelfmark.download.fs import atomic_write
logger = setup_logger(__name__)
def _get_supported_formats() -> List[str]:
"""Get current supported formats from config singleton."""
formats = config.get("SUPPORTED_FORMATS", ["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"])
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def _get_supported_audiobook_formats() -> List[str]:
"""Get current supported audiobook formats from config singleton."""
formats = config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def _get_file_organization(is_audiobook: bool) -> str:
"""Get the file organization mode for the content type."""
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
mode = config.get(key, "rename")
# Handle legacy settings migration
if mode not in ("none", "rename", "organize"):
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
legacy_mode = config.get(legacy_key, "ingest")
if legacy_mode == "library":
return "organize"
if config.get("USE_BOOK_TITLE", True):
return "rename"
return "none"
return mode
def _get_template(is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode."""
# Determine the correct key based on content type and organization mode
if is_audiobook:
if organization_mode == "organize":
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
else:
key = "TEMPLATE_AUDIOBOOK_RENAME"
else:
if organization_mode == "organize":
key = "TEMPLATE_ORGANIZE"
else:
key = "TEMPLATE_RENAME"
template = config.get(key, "")
# Fallback to legacy keys if new keys are empty
if not template:
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
template = config.get(legacy_key, "")
if not template:
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
template = config.get(legacy_key, "")
if not template:
if organization_mode == "organize":
return "{Author}/{Title} ({Year})"
return "{Author} - {Title} ({Year})"
return template
def _build_filename_from_task(task, extension: str, organization_mode: str) -> str:
"""Build a filename from task metadata using the configured template."""
is_audiobook = check_audiobook(task.content_type)
template = _get_template(is_audiobook, organization_mode)
metadata = {
"Author": task.author,
"Title": task.title,
"Subtitle": getattr(task, 'subtitle', None),
"Year": task.year,
"Series": getattr(task, 'series_name', None),
"SeriesPosition": getattr(task, 'series_position', None),
}
filename = parse_naming_template(template, metadata)
if filename:
return f"{sanitize_filename(filename)}.{extension}"
return ""
# Check for rarfile availability at module load
try:
import rarfile
@@ -142,9 +55,9 @@ def _is_supported_file(file_path: Path, content_type: Optional[str] = None) -> b
"""Check if file matches user's supported formats setting based on content type."""
ext = file_path.suffix.lower().lstrip(".")
if check_audiobook(content_type):
supported_formats = _get_supported_audiobook_formats()
supported_formats = get_supported_audiobook_formats()
else:
supported_formats = _get_supported_formats()
supported_formats = get_supported_formats()
return ext in supported_formats
@@ -225,6 +138,21 @@ def extract_archive(
return matched_files, warnings, rejected_files
def extract_archive_raw(
archive_path: Path,
output_dir: Path,
) -> Tuple[List[Path], List[str]]:
"""Extract archive without filtering (returns all extracted files)."""
suffix = archive_path.suffix.lower().lstrip(".")
if suffix == "zip":
return _extract_zip(archive_path, output_dir)
if suffix == "rar":
return _extract_rar(archive_path, output_dir)
raise ArchiveExtractionError(f"Unsupported archive format: {suffix}")
def _extract_files_from_archive(archive, output_dir: Path) -> List[Path]:
"""Extract files from ZipFile or RarFile to output_dir with security checks."""
extracted_files = []
@@ -309,141 +237,3 @@ def _extract_rar(archive_path: Path, output_dir: Path) -> Tuple[List[Path], List
raise ArchiveExtractionError(f"Permission denied: {e}")
@dataclass
class ArchiveResult:
"""Result of archive processing."""
success: bool
final_paths: List[Path]
message: str
error: Optional[str] = None
def process_archive(
archive_path: Path,
temp_dir: Path,
ingest_dir: Path,
archive_id: str,
task: Optional["DownloadTask"] = None,
) -> ArchiveResult:
"""Extract archive, filter to supported formats, move to ingest directory."""
extract_dir = temp_dir / f"extract_{archive_id}"
content_type = task.content_type if task else None
is_audiobook = check_audiobook(content_type)
file_type_label = "audiobook" if is_audiobook else "book"
try:
# Create temp extraction directory
os.makedirs(extract_dir, exist_ok=True)
os.makedirs(ingest_dir, exist_ok=True)
# Extract to temp directory (filters based on content type)
extracted_files, warnings, rejected_files = extract_archive(archive_path, extract_dir, content_type)
if not extracted_files:
# Clean up and return error
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
if rejected_files:
# Found files but they weren't in supported formats
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
supported_formats = _get_supported_audiobook_formats() if is_audiobook else _get_supported_formats()
logger.warning(
f"Found {len(rejected_files)} {file_type_label}(s) in archive but format not supported. "
f"Rejected: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}"
)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). Enable in Settings > Formats.",
)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"No {file_type_label} files found in archive",
)
for warning in warnings:
logger.debug(warning)
logger.info(f"Extracted {len(extracted_files)} {file_type_label} file(s) from archive")
# Move book files to ingest folder
final_paths = []
# Determine file organization mode
is_audiobook = check_audiobook(task.content_type) if task else False
organization_mode = _get_file_organization(is_audiobook) if task else "none"
for extracted_file in extracted_files:
# For multi-file archives (book packs, series), always preserve original filenames
# since metadata title only applies to the searched book, not the whole pack.
# For single files, respect FILE_ORGANIZATION setting.
if len(extracted_files) == 1 and organization_mode != "none" and task:
# Use the extracted file's actual extension, not the archive's extension
extracted_format = extracted_file.suffix.lower().lstrip('.')
filename = _build_filename_from_task(task, extracted_format, organization_mode)
if not filename:
filename = extracted_file.name
else:
filename = extracted_file.name
dest_path = ingest_dir / filename
final_path = atomic_move(extracted_file, dest_path)
final_paths.append(final_path)
logger.debug(f"Moved to ingest: {final_path.name}")
# Clean up temp extraction directory and archive
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
# Build success message with format info
formats = [p.suffix.lstrip(".").upper() for p in final_paths]
if len(formats) == 1:
message = f"Complete ({formats[0]})"
else:
message = f"Complete ({len(formats)} files)"
return ArchiveResult(
success=True,
final_paths=final_paths,
message=message,
)
except PasswordProtectedError:
logger.error(f"Password-protected archive: {archive_path.name}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error="Archive is password protected",
)
except CorruptedArchiveError as e:
logger.error(f"Corrupted archive: {e}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Corrupted archive: {e}",
)
except ArchiveExtractionError as e:
logger.error(f"Archive extraction failed: {e}")
shutil.rmtree(extract_dir, ignore_errors=True)
archive_path.unlink(missing_ok=True)
return ArchiveResult(
success=False,
final_paths=[],
message="",
error=f"Extraction failed: {e}",
)
+142 -29
View File
@@ -8,13 +8,47 @@ import errno
import os
import shutil
import subprocess
import time
from pathlib import Path
from shelfmark.core.logger import setup_logger
from shelfmark.download.permissions_debug import log_transfer_permission_context
logger = setup_logger(__name__)
_VERIFY_IO_WAIT_SECONDS = 3.0
def _verify_transfer_size(
dest: Path,
expected_size: int,
action: str,
) -> None:
"""Verify file transfer completed successfully.
Some filesystems (especially remote NAS/CIFS/NFS) can report stale sizes briefly
after large writes. Do a second stat after a short delay before declaring failure.
"""
actual_size = dest.stat().st_size
if actual_size == expected_size:
return
logger.debug(
f"File {action} size mismatch, waiting for filesystem sync: {dest} "
f"({actual_size} != {expected_size})"
)
time.sleep(_VERIFY_IO_WAIT_SECONDS)
actual_size = dest.stat().st_size
if actual_size != expected_size:
raise IOError(
f"File {action} incomplete, data loss may have occurred. "
f"'{dest}' was {actual_size} bytes instead of expected {expected_size}."
)
def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
"""Write data to a file with atomic collision detection.
@@ -40,7 +74,7 @@ def atomic_write(dest_path: Path, data: bytes, max_attempts: int = 100) -> Path:
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
try:
# O_CREAT | O_EXCL fails atomically if file exists
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666)
try:
os.write(fd, data)
finally:
@@ -61,7 +95,7 @@ def _is_permission_error(e: Exception) -> bool:
def _system_op(op: str, source: Path, dest: Path) -> None:
"""Execute system command (mv or cp) as final fallback."""
logger.info(f"Attempting system {op} as final fallback: {source} -> {dest}")
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
subprocess.run(
[op, "-f", str(source), str(dest)],
check=True,
@@ -71,32 +105,38 @@ def _system_op(op: str, source: Path, dest: Path) -> None:
def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
"""Handle NFS permission errors by falling back to copyfile -> system op."""
"""Handle NFS/SMB permission errors by falling back to copyfile -> system op."""
expected_size = source.stat().st_size
try:
# Fallback 1: copy content only
shutil.copyfile(str(source), str(dest))
_verify_transfer_size(dest, expected_size, "copy")
if is_move:
# Verify copy success before removing source
if dest.exists() and dest.stat().st_size == source.stat().st_size:
source.unlink()
return
else:
raise IOError(f"Copy verification failed for {source} -> {dest}")
source.unlink()
return
except Exception as copy_error:
# Clean up failed copy attempt if it exists
if dest.exists():
dest.unlink(missing_ok=True)
logger.error(f"Fallback copyfile failed: {copy_error}")
dest.unlink(missing_ok=True)
if _is_permission_error(copy_error):
log_transfer_permission_context("nfs_fallback_copyfile", source=source, dest=dest, error=copy_error)
logger.error("Fallback copyfile failed (%s -> %s): %s", source, dest, copy_error)
# Fallback 2: system command
op = "mv" if is_move else "cp"
try:
_system_op(op, source, dest)
# Best-effort verify after external command.
if dest.exists():
_verify_transfer_size(dest, expected_size, op)
if is_move:
source.unlink(missing_ok=True)
except subprocess.CalledProcessError as sys_error:
logger.error(f"System {op} failed: {sys_error.stderr}")
log_transfer_permission_context("nfs_fallback_system", source=source, dest=dest, error=sys_error)
logger.error("System %s failed (%s -> %s): %s", op, source, dest, sys_error.stderr)
dest.unlink(missing_ok=True)
raise
@@ -143,36 +183,75 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
# Race condition: file created between exists() check and rename()
continue
except OSError as e:
# Cross-filesystem - fall back to exclusive create + move
# Cross-filesystem - fall back to exclusive create + verified copy + delete.
if e.errno != errno.EXDEV:
raise
expected_size = source_path.stat().st_size
try:
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
# Claim destination path atomically.
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666)
os.close(fd)
# Copy to a temp file first, then replace to avoid partial files.
temp_path = try_path.parent / f".{try_path.name}.tmp"
try:
shutil.move(str(source_path), str(try_path))
try:
shutil.copy2(str(source_path), str(temp_path))
except (PermissionError, OSError) as copy_error:
if _is_permission_error(copy_error):
logger.debug(
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
copy_error,
)
_perform_nfs_fallback(source_path, temp_path, is_move=False)
else:
raise
temp_path.replace(try_path)
_verify_transfer_size(try_path, expected_size, "move")
source_path.unlink()
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
except Exception:
# Clean up the placeholder if move failed
if try_path.exists() and try_path.stat().st_size == 0:
try_path.unlink(missing_ok=True)
try_path.unlink(missing_ok=True)
temp_path.unlink(missing_ok=True)
raise
except FileExistsError:
continue
except (PermissionError, OSError) as e:
# Handle NFS permission errors (e.g. inability to set metadata)
if _is_permission_error(e):
logger.debug(f"Permission error during move, falling back to copyfile: {e}")
log_transfer_permission_context(
"atomic_move",
source=source_path,
dest=try_path,
error=e,
)
logger.debug(
"Permission error during move, falling back to copyfile (%s -> %s): %s",
source_path,
try_path,
e,
)
try:
_perform_nfs_fallback(source_path, try_path, is_move=True)
if attempt > 0:
logger.info(f"File collision resolved (fallback): {try_path.name}")
return try_path
except Exception as fallback_error:
# Fallback failed, chain exceptions for better debugging
logger.error(f"NFS fallback also failed: {fallback_error}")
logger.error(
"NFS fallback also failed (%s -> %s): %s",
source_path,
try_path,
fallback_error,
)
raise e from fallback_error
raise
@@ -206,6 +285,23 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
return try_path
except FileExistsError:
continue
except OSError as e:
if _is_permission_error(e) or e.errno in (errno.EXDEV, errno.EMLINK):
if _is_permission_error(e):
log_transfer_permission_context(
"atomic_hardlink",
source=source_path,
dest=try_path,
error=e,
)
logger.debug(
"Hardlink failed (%s), falling back to copy: %s -> %s",
e,
source_path,
dest_path,
)
return atomic_copy(source_path, dest_path, max_attempts=max_attempts)
raise
raise RuntimeError(f"Could not create hardlink after {max_attempts} attempts: {dest_path}")
@@ -235,7 +331,7 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
try_path = dest_path if attempt == 0 else parent / f"{base}_{attempt}{ext}"
try:
# Atomically claim the destination by creating an exclusive file
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
fd = os.open(str(try_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666)
os.close(fd)
# Copy to temp file first, then replace to avoid partial files
@@ -246,16 +342,33 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
except (PermissionError, OSError) as e:
# Handle NFS permission errors immediately here
if _is_permission_error(e):
logger.debug(f"Permission error during copy, falling back to copyfile: {e}")
log_transfer_permission_context(
"atomic_copy",
source=source_path,
dest=temp_path,
error=e,
)
logger.debug(
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
source_path,
temp_path,
e,
)
try:
_perform_nfs_fallback(source_path, temp_path, is_move=False)
except Exception as fallback_error:
logger.error(f"NFS fallback also failed: {fallback_error}")
logger.error(
"NFS fallback also failed (%s -> %s): %s",
source_path,
temp_path,
fallback_error,
)
raise e from fallback_error
else:
raise
temp_path.replace(try_path)
_verify_transfer_size(try_path, source_path.stat().st_size, "copy")
if attempt > 0:
logger.info(f"File collision resolved: {try_path.name}")
return try_path
+26 -2
View File
@@ -279,9 +279,33 @@ def _decode_port(port: Union[str, bytes, int, None]) -> int:
return int(port)
def _is_local_address(host_str: str) -> bool:
"""Check if an address is local or private and should bypass custom DNS."""
if host_str == 'localhost':
"""Check if an address is local/private and should bypass custom DNS.
Returns True for:
- 'localhost'
- Private/loopback/link-local IP addresses
- Simple hostnames without a dot (e.g., 'booklore', 'prowlarr') - likely Docker service names
- Hostnames ending in common internal TLDs (.local, .internal, .lan, .home, .docker)
"""
if not host_str:
return False
host_lower = host_str.lower()
# Check for localhost
if host_lower == 'localhost':
return True
# Check for simple hostnames (no dot = likely internal Docker/container name)
if '.' not in host_str:
return True
# Check for common internal TLDs
internal_tlds = ('.local', '.internal', '.lan', '.home', '.docker', '.localdomain')
if any(host_lower.endswith(tld) for tld in internal_tlds):
return True
# Check for private/loopback/link-local IP addresses
try:
addr = ipaddress.ip_address(host_str)
return addr.is_private or addr.is_loopback or addr.is_link_local
+48 -667
View File
@@ -4,11 +4,8 @@ Two-stage architecture: handlers stage to TMP_DIR, orchestrator moves to INGEST_
with archive extraction and custom script support.
"""
import hashlib
import os
import random
import shutil
import subprocess
import threading
import time
from concurrent.futures import Future, ThreadPoolExecutor
@@ -16,261 +13,25 @@ from pathlib import Path
from threading import Event, Lock
from typing import Any, Dict, List, Optional, Tuple
from shelfmark.release_sources import direct_download
from shelfmark.release_sources.direct_download import SearchUnavailable
from shelfmark.core.config import config
from shelfmark.config.env import TMP_DIR
from shelfmark.core.utils import get_ingest_dir, get_destination, get_aa_content_type_dir, is_audiobook as check_audiobook, transform_cover_url
from shelfmark.core.naming import build_library_path, same_filesystem, assign_part_numbers, parse_naming_template, sanitize_filename
from shelfmark.download.archive import (
is_archive,
process_archive,
_get_file_organization,
_get_template,
_get_supported_formats as _get_book_formats,
_get_supported_audiobook_formats,
)
from shelfmark.release_sources import get_handler, get_source_display_name
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import BookInfo, DownloadTask, QueueStatus, SearchFilters, SearchMode
from shelfmark.core.queue import book_queue
from shelfmark.core.utils import transform_cover_url
from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_cleanup_path
from shelfmark.download.postprocess.router import post_process_download
from shelfmark.release_sources import direct_download, get_handler, get_source_display_name
from shelfmark.release_sources.direct_download import SearchUnavailable
logger = setup_logger(__name__)
# =============================================================================
# Staging Directory Helpers
# Task Download and Processing
# =============================================================================
# Handlers should use these to get paths in the staging area.
# The orchestrator handles moving staged files to the ingest folder.
def get_staging_dir() -> Path:
"""Get the staging directory for downloads."""
TMP_DIR.mkdir(parents=True, exist_ok=True)
return TMP_DIR
def get_staging_path(task_id: str, extension: str) -> Path:
"""Get a staging path for a download."""
staging_dir = get_staging_dir()
# Hash task_id in case it contains invalid filename chars (e.g., Prowlarr URLs)
safe_id = hashlib.md5(task_id.encode()).hexdigest()[:16]
return staging_dir / f"{safe_id}.{extension.lstrip('.')}"
def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
"""Stage a file for ingest processing. Use copy=True for torrents to preserve seeding."""
staging_dir = get_staging_dir()
# Stage with original filename, add counter suffix if collision
staged_path = staging_dir / source_path.name
if staged_path.exists():
counter = 1
while staged_path.exists():
staged_path = staging_dir / f"{source_path.stem}_{counter}{source_path.suffix}"
counter += 1
if copy:
shutil.copy2(str(source_path), str(staged_path))
logger.debug(f"Copied to staging: {source_path} -> {staged_path}")
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Moved to staging: {source_path} -> {staged_path}")
return staged_path
def _should_hardlink(task: DownloadTask) -> bool:
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
if task.source != "prowlarr":
return False
if not task.original_download_path:
return False
is_audiobook = check_audiobook(task.content_type)
key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS"
hardlink_enabled = config.get(key)
if hardlink_enabled is None:
hardlink_enabled = config.get("TORRENT_HARDLINK", False)
return bool(hardlink_enabled)
def _should_extract_archives(task: DownloadTask) -> bool:
"""Check if archives should be extracted (disabled when hardlinking)."""
return not _should_hardlink(task)
def _get_final_destination(task: DownloadTask) -> Path:
"""Get final destination directory, with content-type routing support."""
is_audiobook = check_audiobook(task.content_type)
# For Anna's Archive (direct_download), check for content-type routing override
if task.source == "direct_download" and not is_audiobook:
override = get_aa_content_type_dir(task.content_type)
if override:
return override
return get_destination(is_audiobook)
def _build_metadata_dict(task: DownloadTask) -> dict:
"""Build metadata dictionary from task for template processing."""
return {
"Author": task.author,
"Title": task.title,
"Subtitle": task.subtitle,
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
}
def _get_supported_formats(content_type: str = None) -> List[str]:
"""Get current supported formats from config singleton based on content type."""
if check_audiobook(content_type):
return _get_supported_audiobook_formats()
return _get_book_formats()
def _find_book_files_in_directory(directory: Path, content_type: str = None) -> Tuple[List[Path], List[Path]]:
"""Find book files matching supported formats. Returns (matches, rejected)."""
book_files = []
rejected_files = []
supported_formats = _get_supported_formats(content_type)
supported_exts = {f".{fmt}" for fmt in supported_formats}
is_audiobook = check_audiobook(content_type)
if is_audiobook:
trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'}
else:
trackable_exts = {'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr', '.doc', '.docx', '.rtf', '.txt'}
for file_path in directory.rglob("*"):
if file_path.is_file():
if file_path.suffix.lower() in supported_exts:
book_files.append(file_path)
elif file_path.suffix.lower() in trackable_exts:
rejected_files.append(file_path)
return book_files, rejected_files
def process_directory(
directory: Path,
ingest_dir: Path,
task: DownloadTask,
) -> Tuple[List[Path], Optional[str]]:
"""Process staged directory: find book files, extract archives, move to ingest."""
try:
content_type = task.content_type
book_files, rejected_files = _find_book_files_in_directory(directory, content_type)
# Find archives in directory (ZIP/RAR)
archive_files = [f for f in directory.rglob("*") if f.is_file() and is_archive(f)]
if not book_files:
# No direct book files - check for archives to extract
if archive_files:
logger.info(f"No book files found, extracting {len(archive_files)} archive(s)")
all_final_paths = []
all_errors = []
for archive in archive_files:
result = process_archive(
archive_path=archive,
temp_dir=directory,
ingest_dir=ingest_dir,
archive_id=f"{task.task_id}_{archive.stem}",
task=task,
)
if result.success:
all_final_paths.extend(result.final_paths)
elif result.error:
all_errors.append(f"{archive.name}: {result.error}")
# Clean up directory after processing archives
shutil.rmtree(directory, ignore_errors=True)
if all_final_paths:
return all_final_paths, None
elif all_errors:
return [], "; ".join(all_errors)
else:
return [], "No book files found in archives"
# No book files and no archives
shutil.rmtree(directory, ignore_errors=True)
if rejected_files:
# Files were found but didn't match supported formats
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
supported_formats = _get_supported_formats(content_type)
logger.warning(
f"Found {len(rejected_files)} file(s) but none match supported formats. "
f"Rejected formats: {rejected_list}. Supported: {', '.join(sorted(supported_formats))}"
)
return [], f"Found {len(rejected_files)} file(s) but format not supported ({rejected_list}). Enable in Settings > Formats."
return [], "No book files found in download"
# We have book files - use them directly, skip any archives
if archive_files:
logger.debug(f"Ignoring {len(archive_files)} archive(s) - already have {len(book_files)} book file(s)")
logger.info(f"Found {len(book_files)} book file(s) in directory")
if rejected_files:
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
logger.debug(f"Also found {len(rejected_files)} file(s) with unsupported formats: {', '.join(rejected_exts)}")
# Transfer each book file to destination
final_paths = []
is_audiobook = check_audiobook(task.content_type)
organization_mode = _get_file_organization(is_audiobook)
use_hardlink = _should_hardlink(task)
is_torrent = _is_torrent_source(directory, task)
for book_file in book_files:
# For multi-file downloads (book packs, series), always preserve original filenames
# since metadata title only applies to the searched book, not the whole pack.
# For single files, respect FILE_ORGANIZATION setting.
if len(book_files) == 1 and organization_mode != "none":
# Update task format from actual file if not already set
# (Prowlarr releases may not know the format until download completes)
if not task.format:
task.format = book_file.suffix.lower().lstrip('.')
# Apply template to generate filename
template = _get_template(is_audiobook, "rename")
metadata = _build_metadata_dict(task)
extension = book_file.suffix.lstrip('.') or task.format or ""
filename = parse_naming_template(template, metadata)
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
else:
filename = book_file.name
else:
filename = book_file.name
dest_path = ingest_dir / filename
final_path, op = _transfer_single_file(book_file, dest_path, use_hardlink, is_torrent)
final_paths.append(final_path)
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
if not is_torrent:
shutil.rmtree(directory, ignore_errors=True)
return final_paths, None
except Exception as e:
logger.error(f"Error processing directory: {e}")
if not _is_torrent_source(directory, task):
shutil.rmtree(directory, ignore_errors=True)
return [], str(e)
#
# Post-download processing (staging, extraction, transfers, cleanup) lives in
# `shelfmark.download.postprocess`.
# WebSocket manager (initialized by app.py)
@@ -496,14 +257,22 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
try:
# Check for cancellation before starting
if cancel_flag.is_set():
logger.info(f"Download cancelled before starting: {task_id}")
logger.info("Task %s: cancelled before starting", task_id)
return None
task = book_queue.get_task(task_id)
if not task:
logger.error(f"Task not found in queue: {task_id}")
logger.error("Task not found in queue: %s", task_id)
return None
title_label = task.title or "Unknown title"
logger.info(
"Task %s: starting download (%s) - %s",
task_id,
get_source_display_name(task.source),
title_label,
)
def progress_callback(progress: float) -> None:
update_download_progress(task_id, progress)
@@ -530,24 +299,37 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
# Check cancellation before post-processing
if cancel_flag.is_set():
logger.info(f"Download cancelled before post-processing: {task_id}")
if not _is_torrent_source(temp_file, task):
if temp_file.is_dir():
shutil.rmtree(temp_file, ignore_errors=True)
else:
temp_file.unlink(missing_ok=True)
logger.info("Task %s: cancelled before post-processing", task_id)
if not is_torrent_source(temp_file, task):
safe_cleanup_path(temp_file, task)
return None
# Post-processing: archive extraction or direct move to ingest
return _post_process_download(
temp_file, task, cancel_flag, status_callback
)
logger.info("Task %s: download finished; starting post-processing", task_id)
logger.debug("Task %s: post-processing input path: %s", task_id, temp_file)
# Post-processing: output routing + file processing pipeline
result = post_process_download(temp_file, task, cancel_flag, status_callback)
if cancel_flag.is_set():
logger.info("Task %s: post-processing cancelled", task_id)
elif result:
logger.info("Task %s: post-processing complete", task_id)
logger.debug("Task %s: post-processing result: %s", task_id, result)
else:
logger.warning("Task %s: post-processing failed", task_id)
try:
handler.post_process_cleanup(task, success=bool(result))
except Exception as e:
logger.warning("Post-processing cleanup hook failed for %s: %s", task_id, e)
return result
except Exception as e:
if cancel_flag.is_set():
logger.info(f"Download cancelled during error handling: {task_id}")
logger.info("Task %s: cancelled during error handling", task_id)
else:
logger.error_trace(f"Error downloading: {e}")
logger.error_trace("Task %s: error downloading: %s", task_id, e)
# Update task status so user sees the failure
task = book_queue.get_task(task_id)
if task:
@@ -559,414 +341,13 @@ def _download_task(task_id: str, cancel_flag: Event) -> Optional[str]:
"Destination misconfigured. Go to Settings → Downloads to update."
)
else:
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}")
if isinstance(e, PermissionError):
book_queue.update_status_message(task_id, f"Permission denied: {e}")
else:
book_queue.update_status_message(task_id, f"Download failed: {type(e).__name__}")
return None
def _process_organize_mode(
temp_file: Path,
task: DownloadTask,
status_callback,
) -> Optional[str]:
"""Organize files into library folders using template. Supports hardlinking."""
is_audiobook = check_audiobook(task.content_type)
# Get destination and template
destination = _get_final_destination(task)
template = _get_template(is_audiobook, "organize")
# Validate destination path
if not destination.is_absolute():
logger.warning(f"Destination must be absolute: {destination}, falling back to flat mode")
status_callback("resolving", f"Destination must be absolute: {destination}")
return None
if not destination.exists():
try:
destination.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as e:
logger.warning(f"Cannot create destination: {e}")
status_callback("resolving", f"Cannot create destination: {e}")
return None
if not os.access(destination, os.W_OK):
logger.warning(f"Destination not writable: {destination}")
status_callback("resolving", f"Destination not writable: {destination}")
return None
# Determine if we should use hardlinking
use_hardlink = False
source = temp_file
if _should_hardlink(task):
hardlink_source = Path(task.original_download_path)
if hardlink_source.exists() and same_filesystem(hardlink_source, destination):
use_hardlink = True
source = hardlink_source
elif hardlink_source.exists():
logger.warning(
f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. "
"Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination."
)
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
# Build metadata dict for template
metadata = _build_metadata_dict(task)
try:
status_callback("resolving", "Creating hardlinks" if use_hardlink else "Organizing files")
if source.is_dir():
return _transfer_directory_to_library(
source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink
)
else:
return _transfer_file_to_library(
source, str(destination), template, metadata, task, temp_file, status_callback, use_hardlink
)
except PermissionError as e:
logger.error(f"Permission denied: {e}")
status_callback("error", f"Permission denied: {e}")
return None
except Exception as e:
logger.error_trace(f"Organization failed: {e}")
status_callback("error", f"Organization failed: {e}")
return None
def _is_torrent_source(source_path: Path, task: DownloadTask) -> bool:
"""Check if source is the torrent client path (needs copy to preserve seeding)."""
if not task.original_download_path:
return False
try:
return source_path.resolve() == Path(task.original_download_path).resolve()
except (OSError, ValueError):
return False
def _stage_torrent_path(source: Path) -> Path:
"""Copy torrent source to staging directory to preserve seeding."""
staging_dir = get_staging_dir()
staged_path = staging_dir / source.name
counter = 1
if source.is_dir():
while staged_path.exists():
staged_path = staging_dir / f"{source.name}_{counter}"
counter += 1
shutil.copytree(str(source), str(staged_path))
else:
while staged_path.exists():
staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}"
counter += 1
shutil.copy2(str(source), str(staged_path))
logger.debug(f"Staged torrent {'directory' if source.is_dir() else 'file'}: {staged_path.name}")
return staged_path
# Import atomic file operations from shared module
# Re-exported here for backwards compatibility with existing tests/imports
from shelfmark.download.fs import (
atomic_hardlink as _atomic_hardlink,
atomic_copy as _atomic_copy,
atomic_move as _atomic_move,
)
def _cleanup_staged_files(temp_file: Path, source_dir: Optional[Path] = None) -> None:
"""Remove staged files. Optionally removes source_dir if empty."""
try:
if temp_file.is_dir():
shutil.rmtree(temp_file)
elif temp_file.exists():
temp_file.unlink()
except (OSError, PermissionError) as e:
logger.debug(f"Cleanup failed for {temp_file}: {e}")
if source_dir and source_dir.is_dir():
try:
source_dir.rmdir()
except OSError:
pass # Directory not empty or permission issue
def _transfer_single_file(
source_path: Path,
dest_path: Path,
use_hardlink: bool,
is_torrent: bool,
) -> Tuple[Path, str]:
"""Transfer a file via hardlink, copy, or move. Returns (final_path, operation_name)."""
if use_hardlink:
return _atomic_hardlink(source_path, dest_path), "hardlink"
if is_torrent:
return _atomic_copy(source_path, dest_path), "copy"
return _atomic_move(source_path, dest_path), "move"
def _transfer_file_to_library(
source_path: Path,
library_base: str,
template: str,
metadata: dict,
task: DownloadTask,
temp_file: Optional[Path],
status_callback,
use_hardlink: bool,
) -> Optional[str]:
"""Transfer a single file to the library with template-based naming."""
extension = source_path.suffix.lstrip('.') or task.format
dest_path = build_library_path(library_base, template, metadata, extension)
dest_path.parent.mkdir(parents=True, exist_ok=True)
is_torrent = _is_torrent_source(source_path, task)
final_path, op = _transfer_single_file(source_path, dest_path, use_hardlink, is_torrent)
logger.info(f"Library {op}: {final_path}")
if use_hardlink:
_cleanup_staged_files(temp_file)
status_callback("complete", "Complete")
return str(final_path)
def _transfer_directory_to_library(
source_dir: Path,
library_base: str,
template: str,
metadata: dict,
task: DownloadTask,
temp_file: Optional[Path],
status_callback,
use_hardlink: bool,
) -> Optional[str]:
"""Transfer all files from a directory to the library with template-based naming."""
content_type = task.content_type.lower() if task.content_type else None
supported_formats = _get_supported_formats(content_type)
source_files = [
f for f in source_dir.rglob("*")
if f.is_file() and f.suffix.lower().lstrip('.') in supported_formats
]
if not source_files:
logger.warning(f"No supported files in {source_dir.name}")
status_callback("error", "No supported file formats found")
if temp_file:
_cleanup_staged_files(temp_file)
return None
base_library_path = build_library_path(library_base, template, metadata, extension=None)
base_library_path.parent.mkdir(parents=True, exist_ok=True)
# Check if this is a torrent source that needs copy instead of move
is_torrent = _is_torrent_source(source_dir, task)
transferred_paths = []
if len(source_files) == 1:
# Single file - no part numbering needed
source_file = source_files[0]
ext = source_file.suffix.lstrip('.')
dest_path = base_library_path.with_suffix(f'.{ext}')
final_path, op = _transfer_single_file(source_file, dest_path, use_hardlink, is_torrent)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
else:
# Multi-file: natural sort then sequential numbering
zero_pad_width = max(len(str(len(source_files))), 2)
files_with_parts = assign_part_numbers(source_files, zero_pad_width)
for source_file, part_number in files_with_parts:
ext = source_file.suffix.lstrip('.')
file_metadata = {**metadata, "PartNumber": part_number}
file_path = build_library_path(library_base, template, file_metadata, extension=ext)
file_path.parent.mkdir(parents=True, exist_ok=True)
final_path, op = _transfer_single_file(source_file, file_path, use_hardlink, is_torrent)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
# Get operation name for summary log
if use_hardlink:
operation = "hardlinks"
elif is_torrent:
operation = "copies"
else:
operation = "files"
logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}")
# Cleanup staging (not torrent source - that stays for seeding)
if use_hardlink:
_cleanup_staged_files(temp_file)
elif not is_torrent:
_cleanup_staged_files(temp_file, source_dir)
message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete"
status_callback("complete", message)
return str(transferred_paths[0])
def _post_process_download(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
"""Post-process download: extract archives, apply naming template, move to destination."""
is_audiobook = check_audiobook(task.content_type)
# Validate search_mode
if task.search_mode is None:
logger.warning(f"Task {task.task_id} has no search_mode set, defaulting to Direct mode behavior")
elif task.search_mode not in (SearchMode.DIRECT, SearchMode.UNIVERSAL):
logger.warning(f"Task {task.task_id} has invalid search_mode '{task.search_mode}', defaulting to Direct mode behavior")
# Get file organization mode and destination
organization_mode = _get_file_organization(is_audiobook)
destination = _get_final_destination(task)
logger.debug(f"File organization: mode={organization_mode}, destination={destination}")
# "Organize" mode with folders uses specialized handler
if organization_mode == "organize":
result = _process_organize_mode(temp_file, task, status_callback)
if result is not None:
return result
# If organize mode fails, fall through to flat mode
logger.warning(
f"Organize mode failed for '{task.title}', falling back to flat destination. "
"Check destination folder permissions and ensure the path is writable."
)
status_callback("resolving", "Organization failed, using flat destination")
# Ensure destination exists
os.makedirs(destination, exist_ok=True)
# For torrents with hardlinking disabled, stage first to preserve seeding
# (Torrent handler returns original path, not staged copy)
if _is_torrent_source(temp_file, task) and not _should_hardlink(task):
status_callback("resolving", "Staging torrent files")
temp_file = _stage_torrent_path(temp_file)
# Handle archive extraction (RAR/ZIP) - only if not hardlinking
if is_archive(temp_file) and _should_extract_archives(task):
logger.info(f"Archive detected, extracting: {temp_file.name}")
status_callback("resolving", "Extracting archive")
result = process_archive(
archive_path=temp_file,
temp_dir=TMP_DIR,
ingest_dir=destination,
archive_id=task.task_id,
task=task,
)
if result.success:
status_callback("complete", result.message)
return str(result.final_paths[0])
else:
status_callback("error", result.error)
return None
# Handle directory (multi-file torrent/usenet downloads)
if temp_file.is_dir():
logger.info(f"Directory detected, processing: {temp_file.name}")
status_callback("resolving", "Processing download folder")
final_paths, error = process_directory(
directory=temp_file,
ingest_dir=destination,
task=task,
)
if error:
status_callback("error", error)
return None
if not final_paths:
status_callback("error", "No book files found")
return None
message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)"
status_callback("complete", message)
return str(final_paths[0])
# Non-archive: run custom script if configured, then move to destination
if config.CUSTOM_SCRIPT:
logger.info(f"Running custom script: {config.CUSTOM_SCRIPT}")
try:
result = subprocess.run(
[config.CUSTOM_SCRIPT, str(temp_file)],
check=True,
timeout=300, # 5 minute timeout
capture_output=True,
text=True,
)
if result.stdout:
logger.debug(f"Custom script stdout: {result.stdout.strip()}")
except FileNotFoundError:
logger.error(f"Custom script not found: {config.CUSTOM_SCRIPT}")
status_callback("error", f"Custom script not found: {config.CUSTOM_SCRIPT}")
return None
except PermissionError:
logger.error(f"Custom script not executable: {config.CUSTOM_SCRIPT}")
status_callback("error", f"Custom script not executable: {config.CUSTOM_SCRIPT}")
return None
except subprocess.TimeoutExpired:
logger.error(f"Custom script timed out after 300s: {config.CUSTOM_SCRIPT}")
status_callback("error", "Custom script timed out")
return None
except subprocess.CalledProcessError as e:
stderr = e.stderr.strip() if e.stderr else "No error output"
logger.error(f"Custom script failed (exit code {e.returncode}): {stderr}")
status_callback("error", f"Custom script failed: {stderr[:100]}")
return None
use_hardlink = _should_hardlink(task)
is_torrent = _is_torrent_source(temp_file, task)
if cancel_flag.is_set():
logger.info(f"Download cancelled before final transfer: {task.task_id}")
if not is_torrent:
temp_file.unlink(missing_ok=True)
return None
# Determine filename based on organization mode
if organization_mode == "none":
# Keep original filename
filename = temp_file.name
else:
# "rename" mode - apply template to filename
template = _get_template(is_audiobook, "rename")
metadata = _build_metadata_dict(task)
extension = temp_file.suffix.lstrip('.') or task.format or ""
# Parse template to generate filename
filename = parse_naming_template(template, metadata)
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
elif not filename:
# Template produced empty result, fall back to original
filename = temp_file.name
dest_path = destination / filename
try:
final_path, op = _transfer_single_file(temp_file, dest_path, use_hardlink, is_torrent)
logger.info(f"Download completed ({op}): {final_path.name}")
except Exception as e:
logger.error(f"Failed to transfer file to destination: {e}")
status_callback("error", f"Failed to transfer file: {e}")
return None
status_callback("complete", "Complete")
return str(final_path)
def update_download_progress(book_id: str, progress: float) -> None:
"""Update download progress with throttled WebSocket broadcasts."""
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from threading import Event
from typing import Callable, Optional
from shelfmark.core.models import DownloadTask
StatusCallback = Callable[[str, Optional[str]], None]
OutputHandler = Callable[[Path, DownloadTask, Event, StatusCallback], Optional[str]]
@dataclass(frozen=True)
class OutputRegistration:
mode: str
supports_task: Callable[[DownloadTask], bool]
handler: OutputHandler
priority: int = 0
_OUTPUT_REGISTRY: list[OutputRegistration] = []
_OUTPUTS_LOADED = False
def register_output(
mode: str,
supports_task: Callable[[DownloadTask], bool],
priority: int = 0,
) -> Callable[[OutputHandler], OutputHandler]:
def decorator(handler: OutputHandler) -> OutputHandler:
_OUTPUT_REGISTRY.append(
OutputRegistration(
mode=mode,
supports_task=supports_task,
handler=handler,
priority=priority,
)
)
_OUTPUT_REGISTRY.sort(key=lambda entry: entry.priority, reverse=True)
return handler
return decorator
def load_output_handlers() -> None:
global _OUTPUTS_LOADED
if _OUTPUTS_LOADED:
return
from . import booklore # noqa: F401
from . import folder # noqa: F401
_OUTPUTS_LOADED = True
def resolve_output_handler(task: DownloadTask) -> Optional[OutputRegistration]:
load_output_handlers()
for entry in _OUTPUT_REGISTRY:
if entry.supports_task(task):
return entry
return None
+298
View File
@@ -0,0 +1,298 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from threading import Event
from typing import Any, Dict, List, Mapping, Optional
import requests
import shelfmark.core.config as core_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.outputs import register_output
from shelfmark.download.staging import STAGE_MOVE, STAGE_NONE, build_staging_dir
logger = setup_logger(__name__)
BOOKLORE_OUTPUT_MODE = "booklore"
BOOKLORE_SUPPORTED_EXTENSIONS = {".cb7", ".cbr", ".cbz", ".epub", ".fb2", ".pdf"}
BOOKLORE_SUPPORTED_FORMATS_LABEL = ", ".join(
ext.lstrip(".").upper() for ext in sorted(BOOKLORE_SUPPORTED_EXTENSIONS)
)
class BookloreError(Exception):
"""Raised when Booklore integration fails."""
@dataclass(frozen=True)
class BookloreConfig:
base_url: str
username: str
password: str
library_id: int
path_id: int
verify_tls: bool = True
refresh_after_upload: bool = False
def _parse_int(value: Any, label: str) -> int:
if value is None or value == "":
raise BookloreError(f"{label} is required")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise BookloreError(f"{label} must be a number") from exc
def build_booklore_config(values: Mapping[str, Any]) -> BookloreConfig:
base_url = str(values.get("BOOKLORE_HOST", "")).strip()
username = str(values.get("BOOKLORE_USERNAME", "")).strip()
password = values.get("BOOKLORE_PASSWORD", "") or ""
if not base_url:
raise BookloreError("Booklore URL is required")
if not username:
raise BookloreError("Booklore username is required")
if not password:
raise BookloreError("Booklore password is required")
library_id = _parse_int(values.get("BOOKLORE_LIBRARY_ID"), "Booklore library ID")
path_id = _parse_int(values.get("BOOKLORE_PATH_ID"), "Booklore path ID")
return BookloreConfig(
base_url=base_url.rstrip("/"),
username=username,
password=password,
library_id=library_id,
path_id=path_id,
verify_tls=True,
refresh_after_upload=True, # Always refresh library after upload
)
def booklore_login(booklore_config: BookloreConfig) -> str:
url = f"{booklore_config.base_url}/api/v1/auth/login"
payload = {"username": booklore_config.username, "password": booklore_config.password}
try:
response = requests.post(url, json=payload, timeout=30, verify=booklore_config.verify_tls)
except requests.exceptions.ConnectionError as exc:
raise BookloreError("Could not connect to Booklore") from exc
except requests.exceptions.Timeout as exc:
raise BookloreError("Booklore connection timed out") from exc
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore login failed: {exc}") from exc
if response.status_code in {401, 403}:
raise BookloreError("Booklore authentication failed")
try:
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
raise BookloreError(f"Booklore login failed ({response.status_code})") from exc
try:
data = response.json()
except ValueError as exc:
raise BookloreError("Invalid Booklore login response") from exc
token = data.get("accessToken")
if not token:
raise BookloreError("Booklore did not return an access token")
return token
def booklore_list_libraries(booklore_config: BookloreConfig, token: str) -> list[dict[str, Any]]:
url = f"{booklore_config.base_url}/api/v1/libraries"
headers = {"Authorization": f"Bearer {token}"}
try:
response = requests.get(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Failed to fetch Booklore libraries: {exc}") from exc
try:
return response.json()
except ValueError as exc:
raise BookloreError("Invalid Booklore libraries response") from exc
def booklore_upload_file(booklore_config: BookloreConfig, token: str, file_path: Path) -> None:
url = f"{booklore_config.base_url}/api/v1/files/upload"
headers = {"Authorization": f"Bearer {token}"}
params = {"libraryId": booklore_config.library_id, "pathId": booklore_config.path_id}
response = None
try:
with file_path.open("rb") as handle:
response = requests.post(
url,
headers=headers,
params=params,
files={"file": (file_path.name, handle)},
timeout=60,
verify=booklore_config.verify_tls,
)
response.raise_for_status()
except requests.exceptions.HTTPError as exc:
message = response.text.strip() if response is not None else ""
if message:
message = f": {message[:200]}"
status_code = response.status_code if response is not None else "unknown"
raise BookloreError(f"Booklore upload failed ({status_code}){message}") from exc
except requests.exceptions.ConnectionError as exc:
raise BookloreError("Could not connect to Booklore") from exc
except requests.exceptions.Timeout as exc:
raise BookloreError("Booklore upload timed out") from exc
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore upload failed: {exc}") from exc
def booklore_refresh_library(booklore_config: BookloreConfig, token: str) -> None:
url = f"{booklore_config.base_url}/api/v1/libraries/{booklore_config.library_id}/refresh"
headers = {"Authorization": f"Bearer {token}"}
try:
response = requests.put(url, headers=headers, timeout=30, verify=booklore_config.verify_tls)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
raise BookloreError(f"Booklore refresh failed: {exc}") from exc
def _supports_booklore(task: DownloadTask) -> bool:
if check_audiobook(task.content_type):
return False
return core_config.config.get("BOOKS_OUTPUT_MODE", "folder") == BOOKLORE_OUTPUT_MODE
def _get_booklore_settings() -> Dict[str, Any]:
return {
"BOOKLORE_HOST": core_config.config.get("BOOKLORE_HOST", ""),
"BOOKLORE_USERNAME": core_config.config.get("BOOKLORE_USERNAME", ""),
"BOOKLORE_PASSWORD": core_config.config.get("BOOKLORE_PASSWORD", ""),
"BOOKLORE_LIBRARY_ID": core_config.config.get("BOOKLORE_LIBRARY_ID"),
"BOOKLORE_PATH_ID": core_config.config.get("BOOKLORE_PATH_ID"),
}
def _booklore_format_error(rejected_files: List[Path]) -> str:
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
return (
f"Booklore does not support {rejected_list}. "
f"Supported formats: {BOOKLORE_SUPPORTED_FORMATS_LABEL}"
)
def _post_process_booklore(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
from shelfmark.download.postprocess.pipeline import (
OutputPlan,
cleanup_output_staging,
is_managed_workspace_path,
prepare_output_files,
)
if cancel_flag.is_set():
logger.info("Task %s: cancelled before Booklore upload", task.task_id)
return None
try:
booklore_config = build_booklore_config(_get_booklore_settings())
except BookloreError as e:
logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e)
status_callback("error", str(e))
return None
status_callback("resolving", "Preparing Booklore upload")
output_plan = OutputPlan(
mode=BOOKLORE_OUTPUT_MODE,
stage_action=STAGE_MOVE if is_managed_workspace_path(temp_file) else STAGE_NONE,
staging_dir=build_staging_dir("booklore", task.task_id),
allow_archive_extraction=True,
)
prepared = prepare_output_files(
temp_file,
task,
BOOKLORE_OUTPUT_MODE,
status_callback,
output_plan=output_plan,
)
if not prepared:
return None
logger.debug("Task %s: prepared %d file(s) for Booklore upload", task.task_id, len(prepared.files))
try:
unsupported_files = [
file_path
for file_path in prepared.files
if file_path.suffix.lower() not in BOOKLORE_SUPPORTED_EXTENSIONS
]
if unsupported_files:
error_message = _booklore_format_error(unsupported_files)
logger.warning("Task %s: %s", task.task_id, error_message)
status_callback("error", error_message)
return None
token = booklore_login(booklore_config)
logger.info("Task %s: uploading %d file(s) to Booklore", task.task_id, len(prepared.files))
for index, file_path in enumerate(prepared.files, start=1):
if cancel_flag.is_set():
logger.info("Task %s: cancelled during Booklore upload", task.task_id)
return None
status_callback("resolving", f"Uploading to Booklore ({index}/{len(prepared.files)})")
booklore_upload_file(booklore_config, token, file_path)
if booklore_config.refresh_after_upload:
try:
booklore_refresh_library(booklore_config, token)
except BookloreError as e:
logger.warning("Task %s: Booklore refresh failed: %s", task.task_id, e)
logger.info("Task %s: uploaded %d file(s) to Booklore", task.task_id, len(prepared.files))
message = "Uploaded to Booklore"
if len(prepared.files) > 1:
message = f"Uploaded to Booklore ({len(prepared.files)} files)"
status_callback("complete", message)
return f"booklore://{task.task_id}"
except BookloreError as e:
logger.warning("Task %s: Booklore upload failed: %s", task.task_id, e)
status_callback("error", str(e))
return None
except Exception as e:
logger.error_trace("Task %s: unexpected error uploading to Booklore: %s", task.task_id, e)
status_callback("error", f"Booklore upload failed: {e}")
return None
finally:
cleanup_output_staging(
prepared.output_plan,
prepared.working_path,
task,
prepared.cleanup_paths,
)
@register_output(BOOKLORE_OUTPUT_MODE, supports_task=_supports_booklore, priority=10)
def process_booklore_output(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
return _post_process_booklore(temp_file, task, cancel_flag, status_callback)
+263
View File
@@ -0,0 +1,263 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from pathlib import Path
from threading import Event
from typing import Any, Optional, List
import shelfmark.core.config as core_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.archive import is_archive
from shelfmark.download.outputs import register_output
from shelfmark.download.staging import StageAction, STAGE_NONE
logger = setup_logger(__name__)
FOLDER_OUTPUT_MODE = "folder"
@dataclass(frozen=True)
class _ProcessingPlan:
destination: Path
organization_mode: str
use_hardlink: bool
allow_archive_extraction: bool
stage_action: StageAction
staging_dir: Path
hardlink_source: Optional[Path]
output_mode: str = FOLDER_OUTPUT_MODE
def _supports_folder_output(task: DownloadTask) -> bool:
if check_audiobook(task.content_type):
return True
return core_config.config.get("BOOKS_OUTPUT_MODE", FOLDER_OUTPUT_MODE) == FOLDER_OUTPUT_MODE
def _build_processing_plan(
temp_file: Path,
task: DownloadTask,
status_callback,
) -> Optional[_ProcessingPlan]:
from shelfmark.download.postprocess.pipeline import (
build_output_plan,
get_final_destination,
validate_destination,
)
from shelfmark.download.postprocess.policy import get_file_organization
is_audiobook = check_audiobook(task.content_type)
organization_mode = get_file_organization(is_audiobook)
destination = get_final_destination(task)
if not validate_destination(destination, status_callback):
return None
output_plan = build_output_plan(
temp_file,
task,
output_mode=FOLDER_OUTPUT_MODE,
destination=destination,
status_callback=status_callback,
)
if not output_plan.transfer_plan:
return None
transfer_plan = output_plan.transfer_plan
hardlink_source = transfer_plan.source_path if transfer_plan.use_hardlink else None
return _ProcessingPlan(
destination=destination,
organization_mode=organization_mode,
use_hardlink=transfer_plan.use_hardlink,
allow_archive_extraction=transfer_plan.allow_archive_extraction,
stage_action=output_plan.stage_action,
staging_dir=output_plan.staging_dir,
hardlink_source=hardlink_source,
)
@register_output(FOLDER_OUTPUT_MODE, supports_task=_supports_folder_output, priority=0)
def process_folder_output(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
"""Post-process download to the configured folder destination."""
from shelfmark.download.postprocess.pipeline import (
cleanup_output_staging,
is_torrent_source,
log_plan_steps,
prepare_output_files,
record_step,
safe_cleanup_path,
transfer_book_files,
)
plan = _build_processing_plan(temp_file, task, status_callback)
if not plan:
return None
logger.debug(
"Processing plan for task %s: mode=%s destination=%s hardlink=%s stage_action=%s extract_archives=%s",
task.task_id,
plan.organization_mode,
plan.destination,
plan.use_hardlink,
plan.stage_action,
plan.allow_archive_extraction,
)
prepared = prepare_output_files(
temp_file,
task,
output_mode=plan.output_mode,
status_callback=status_callback,
destination=plan.destination,
)
if not prepared:
return None
steps: List[Any] = []
if prepared.output_plan.stage_action != STAGE_NONE:
step_name = f"stage_{prepared.output_plan.stage_action}"
record_step(steps, step_name, source=str(temp_file), dest=str(prepared.output_plan.staging_dir))
# Run custom script only for non-archive single files (matches legacy behavior)
if core_config.config.CUSTOM_SCRIPT and prepared.working_path.is_file() and not is_archive(prepared.working_path):
record_step(steps, "custom_script", script=str(core_config.config.CUSTOM_SCRIPT))
log_plan_steps(task.task_id, steps)
logger.info(
"Task %s: running custom script %s on %s",
task.task_id,
core_config.config.CUSTOM_SCRIPT,
prepared.working_path,
)
try:
result = subprocess.run(
[core_config.config.CUSTOM_SCRIPT, str(prepared.working_path)],
check=True,
timeout=300, # 5 minute timeout
capture_output=True,
text=True,
)
if result.stdout:
logger.debug("Task %s: custom script stdout: %s", task.task_id, result.stdout.strip())
except FileNotFoundError:
logger.error("Task %s: custom script not found: %s", task.task_id, core_config.config.CUSTOM_SCRIPT)
status_callback("error", f"Custom script not found: {core_config.config.CUSTOM_SCRIPT}")
return None
except PermissionError:
logger.error(
"Task %s: custom script not executable: %s",
task.task_id,
core_config.config.CUSTOM_SCRIPT,
)
status_callback("error", f"Custom script not executable: {core_config.config.CUSTOM_SCRIPT}")
return None
except subprocess.TimeoutExpired:
logger.error(
"Task %s: custom script timed out after 300s: %s",
task.task_id,
core_config.config.CUSTOM_SCRIPT,
)
status_callback("error", "Custom script timed out")
return None
except subprocess.CalledProcessError as e:
stderr = e.stderr.strip() if e.stderr else "No error output"
logger.error(
"Task %s: custom script failed (exit code %s): %s",
task.task_id,
e.returncode,
stderr,
)
status_callback("error", f"Custom script failed: {stderr[:100]}")
return None
# If we staged a copy into TMP_DIR (e.g. for custom script), transfer from the staged
# path and disable hardlinking for this transfer.
use_hardlink = plan.use_hardlink and prepared.output_plan.stage_action == STAGE_NONE
source_path = plan.hardlink_source if use_hardlink and plan.hardlink_source else prepared.working_path
is_torrent = is_torrent_source(source_path, task)
usenet_action = core_config.config.get("PROWLARR_USENET_ACTION", "move")
is_usenet = task.source == "prowlarr" and not task.original_download_path
# For external usenet downloads, always copy from the client path.
# "Move" is implemented as a client-side cleanup after import.
preserve_source = is_usenet
copy_for_label = is_torrent or preserve_source or prepared.output_plan.stage_action != STAGE_NONE
if cancel_flag.is_set():
logger.info("Task %s: cancelled before final transfer", task.task_id)
cleanup_output_staging(
prepared.output_plan,
prepared.working_path,
task,
prepared.cleanup_paths,
)
return None
if use_hardlink:
op_label = "Hardlinking"
elif is_usenet and usenet_action == "move" and prepared.output_plan.stage_action == STAGE_NONE:
# Presented as a move, but implemented as copy + client cleanup.
op_label = "Moving"
elif copy_for_label:
op_label = "Copying"
else:
op_label = "Moving"
status_callback("resolving", f"{op_label} file")
record_step(
steps,
"transfer",
op=op_label.lower(),
source=str(source_path),
dest=str(plan.destination),
hardlink=use_hardlink,
torrent=copy_for_label,
)
if prepared.output_plan.stage_action != STAGE_NONE:
record_step(steps, "cleanup_staging", path=str(prepared.working_path))
log_plan_steps(task.task_id, steps)
final_paths, error = transfer_book_files(
prepared.files,
destination=plan.destination,
task=task,
use_hardlink=use_hardlink,
is_torrent=is_torrent,
preserve_source=preserve_source,
organization_mode=plan.organization_mode,
)
if error:
logger.warning("Task %s: transfer failed: %s", task.task_id, error)
status_callback("error", error)
return None
logger.info(
"Task %s: transferred %d file(s) to %s (%s)",
task.task_id,
len(final_paths),
plan.destination,
op_label.lower(),
)
cleanup_output_staging(
prepared.output_plan,
prepared.working_path,
task,
prepared.cleanup_paths,
)
message = "Complete" if len(final_paths) == 1 else f"Complete ({len(final_paths)} files)"
status_callback("complete", message)
return str(final_paths[0])
+125
View File
@@ -0,0 +1,125 @@
"""Permission/ownership diagnostics for filesystem operations.
This module centralizes best-effort debug logging used by download post-processing
and atomic filesystem operations.
It is intentionally defensive: failures collecting context should never mask the
original error.
"""
from __future__ import annotations
import os
from pathlib import Path
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
def _format_uid(uid: int) -> str:
try:
import pwd
return pwd.getpwuid(uid).pw_name
except Exception:
return str(uid)
def _format_gid(gid: int) -> str:
try:
import grp
return grp.getgrgid(gid).gr_name
except Exception:
return str(gid)
def log_path_permission_context(label: str, path: Path) -> None:
"""Log useful permission/ownership context for a path.
Only call this from failure paths.
"""
try:
euid = os.geteuid() if hasattr(os, "geteuid") else None
egid = os.getegid() if hasattr(os, "getegid") else None
groups = os.getgroups() if hasattr(os, "getgroups") else []
if euid is not None and egid is not None:
logger.debug(
"Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s",
label,
_format_uid(euid),
euid,
_format_gid(egid),
egid,
[f"{_format_gid(g)}({g})" for g in groups],
)
for probe in [path, path.parent]:
try:
resolved = probe.resolve()
except Exception:
resolved = probe
try:
st = probe.stat()
logger.debug(
"Path permissions (%s): path=%s resolved=%s mode=%s owner=%s(%d) group=%s(%d) dir=%s symlink=%s",
label,
probe,
resolved,
oct(st.st_mode & 0o777),
_format_uid(st.st_uid),
st.st_uid,
_format_gid(st.st_gid),
st.st_gid,
probe.is_dir(),
probe.is_symlink(),
)
except Exception as stat_error:
logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error)
except Exception as context_error:
logger.debug("Permission context (%s): failed to collect: %s", label, context_error)
def log_transfer_permission_context(label: str, source: Path, dest: Path, error: Exception) -> None:
"""Log useful permission/ownership context when a file transfer fails."""
try:
euid = os.geteuid() if hasattr(os, "geteuid") else None
egid = os.getegid() if hasattr(os, "getegid") else None
groups = os.getgroups() if hasattr(os, "getgroups") else []
if euid is not None and egid is not None:
logger.debug(
"Permission context (%s): euid=%s(%d) egid=%s(%d) groups=%s error=%s",
label,
_format_uid(euid),
euid,
_format_gid(egid),
egid,
[f"{_format_gid(g)}({g})" for g in groups],
error,
)
for probe in [source, dest, dest.parent]:
try:
st = probe.stat()
logger.debug(
"Path permissions (%s): path=%s mode=%s owner=%s(%d) group=%s(%d) exists=%s dir=%s",
label,
probe,
oct(st.st_mode & 0o777),
_format_uid(st.st_uid),
st.st_uid,
_format_gid(st.st_gid),
st.st_gid,
probe.exists(),
probe.is_dir(),
)
except Exception as stat_error:
logger.debug("Path permissions (%s): stat failed for %s: %s", label, probe, stat_error)
except Exception as context_error:
logger.debug("Permission context (%s): failed to collect: %s", label, context_error)
@@ -0,0 +1,11 @@
"""Post-download processing pipeline.
This package contains the post-download processing pipeline (staging, scanning,
archive extraction, transfers, and safe cleanup) and the router that selects an
output handler.
Output handlers live in `shelfmark.download.outputs` and should depend on
`pipeline` (not `router`) to avoid circular imports.
"""
from .router import post_process_download
@@ -0,0 +1,69 @@
from __future__ import annotations
import uuid
from pathlib import Path
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import (
get_aa_content_type_dir,
get_destination,
is_audiobook as check_audiobook,
)
from shelfmark.download.permissions_debug import log_path_permission_context
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def validate_destination(destination: Path, status_callback) -> bool:
"""Validate destination path is absolute, exists, and writable."""
if not destination.is_absolute():
logger.warning(f"Destination must be absolute: {destination}")
status_callback("error", f"Destination must be absolute: {destination}")
return False
if destination.exists() and not destination.is_dir():
logger.warning(f"Destination is not a directory: {destination}")
status_callback("error", f"Destination is not a directory: {destination}")
return False
if not destination.exists():
try:
destination.mkdir(parents=True, exist_ok=True)
except (OSError, PermissionError) as exc:
log_path_permission_context("destination_create", destination)
logger.warning(f"Cannot create destination: {destination} ({exc})")
status_callback("error", f"Cannot create destination: {destination} ({exc})")
return False
test_path = destination / f".shelfmark_write_test_{uuid.uuid4().hex}.tmp"
try:
test_content = (
f"This file was created to verify if '{destination}' is writable. "
"It should've been automatically deleted. Feel free to delete it.\n"
)
test_path.write_text(test_content)
test_path.unlink(missing_ok=True)
except Exception as exc:
logger.debug("Destination write probe path: %s", test_path)
log_path_permission_context("destination_write_probe", destination)
logger.warning(f"Destination not writable: {destination} ({exc})")
status_callback("error", f"Destination not writable: {destination} ({exc})")
return False
return True
def get_final_destination(task: DownloadTask) -> Path:
"""Get final destination directory, with content-type routing support."""
is_audiobook = check_audiobook(task.content_type)
if task.source == "direct_download" and not is_audiobook:
override = get_aa_content_type_dir(task.content_type)
if override:
return override
return get_destination(is_audiobook)
@@ -0,0 +1,76 @@
"""Post-download processing pipeline.
This module is the public API surface for post-download processing.
Implementation lives in submodules in this package:
- `types`: dataclasses used across the pipeline
- `workspace`: managed workspace + cleanup rules
- `scan`: directory scanning + archive extraction
- `transfer`: hardlink/copy/move + naming/organization
- `prepare`: staging plan + prepared file selection
- `steps`: lightweight plan logging helpers
Keeping this file as a facade avoids churn in call sites while letting the
implementation stay modular.
"""
from __future__ import annotations
from .destination import get_final_destination, validate_destination
from .prepare import build_output_plan, prepare_output_files
from .scan import (
collect_directory_files,
collect_staged_files,
extract_archive_files,
get_supported_formats,
scan_directory_tree,
)
from .steps import log_plan_steps, record_step
from .transfer import (
build_metadata_dict,
is_torrent_source,
process_directory,
resolve_hardlink_source,
should_hardlink,
transfer_book_files,
transfer_directory_to_library,
transfer_file_to_library,
)
from .types import OutputPlan, PlanStep, PreparedFiles, TransferPlan
from .workspace import (
cleanup_output_staging,
is_managed_workspace_path,
is_within_tmp_dir,
safe_cleanup_path,
)
__all__ = [
"OutputPlan",
"PlanStep",
"PreparedFiles",
"TransferPlan",
"build_metadata_dict",
"build_output_plan",
"cleanup_output_staging",
"collect_directory_files",
"collect_staged_files",
"extract_archive_files",
"get_final_destination",
"get_supported_formats",
"is_managed_workspace_path",
"is_torrent_source",
"is_within_tmp_dir",
"log_plan_steps",
"prepare_output_files",
"process_directory",
"record_step",
"resolve_hardlink_source",
"safe_cleanup_path",
"scan_directory_tree",
"should_hardlink",
"transfer_book_files",
"transfer_directory_to_library",
"transfer_file_to_library",
"validate_destination",
]
+100
View File
@@ -0,0 +1,100 @@
"""Post-download processing policy.
This module holds configuration-driven *policy* decisions that are shared across
post-download processing components, but are not specific to archive extraction.
Examples:
- Which file formats are enabled
- How files should be organized (none/rename/organize)
- Which naming templates to use
Implementation note:
Keep this module free of dependencies on archive extraction mechanics to avoid
circular imports (`archive` is used by the pipeline).
"""
from __future__ import annotations
from typing import List
import shelfmark.core.config as core_config
def get_supported_formats() -> List[str]:
"""Get current supported formats from config singleton."""
formats = core_config.config.get(
"SUPPORTED_FORMATS",
["epub", "mobi", "azw3", "fb2", "djvu", "cbz", "cbr"],
)
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def get_supported_audiobook_formats() -> List[str]:
"""Get current supported audiobook formats from config singleton."""
formats = core_config.config.get("SUPPORTED_AUDIOBOOK_FORMATS", ["m4b", "mp3"])
# Handle both list (from MultiSelectField) and comma-separated string (legacy/env)
if isinstance(formats, str):
return [fmt.strip().lower() for fmt in formats.split(",") if fmt.strip()]
return [fmt.lower() for fmt in formats]
def get_file_organization(is_audiobook: bool) -> str:
"""Get the file organization mode for the content type."""
key = "FILE_ORGANIZATION_AUDIOBOOK" if is_audiobook else "FILE_ORGANIZATION"
mode = core_config.config.get(key, "rename")
# Handle legacy settings migration
if mode not in ("none", "rename", "organize"):
legacy_key = "PROCESSING_MODE_AUDIOBOOK" if is_audiobook else "PROCESSING_MODE"
legacy_mode = core_config.config.get(legacy_key, "ingest")
if legacy_mode == "library":
return "organize"
if core_config.config.get("USE_BOOK_TITLE", True):
return "rename"
return "none"
return mode
def get_template(is_audiobook: bool, organization_mode: str) -> str:
"""Get the template for the content type and organization mode."""
# Determine the correct key based on content type and organization mode
if is_audiobook:
if organization_mode == "organize":
key = "TEMPLATE_AUDIOBOOK_ORGANIZE"
else:
key = "TEMPLATE_AUDIOBOOK_RENAME"
else:
if organization_mode == "organize":
key = "TEMPLATE_ORGANIZE"
else:
key = "TEMPLATE_RENAME"
template = core_config.config.get(key, "")
# Fallback to legacy keys if new keys are empty
if not template:
legacy_key = "TEMPLATE_AUDIOBOOK" if is_audiobook else "TEMPLATE"
template = core_config.config.get(legacy_key, "")
if not template:
legacy_key = "LIBRARY_TEMPLATE_AUDIOBOOK" if is_audiobook else "LIBRARY_TEMPLATE"
template = core_config.config.get(legacy_key, "")
if not template:
if organization_mode == "organize":
return "{Author}/{Title} ({Year})"
return "{Author} - {Title} ({Year})"
return template
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
import shelfmark.core.config as core_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.download.archive import is_archive
from shelfmark.download.staging import STAGE_COPY, STAGE_NONE, get_staging_dir, stage_path
from .scan import collect_staged_files
from .transfer import resolve_hardlink_source
from .types import OutputPlan, PreparedFiles
from .workspace import cleanup_output_staging, is_managed_workspace_path
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def build_output_plan(
temp_file: Path,
task: DownloadTask,
output_mode: str,
destination: Optional[Path] = None,
status_callback=None,
) -> OutputPlan:
"""Build an output plan that describes staging behavior for file-based outputs."""
transfer_plan = resolve_hardlink_source(temp_file, task, destination, status_callback)
runs_custom_script = bool(core_config.config.CUSTOM_SCRIPT) and temp_file.is_file() and not is_archive(temp_file)
stage_action = STAGE_COPY if runs_custom_script and not is_managed_workspace_path(temp_file) else STAGE_NONE
staging_dir = get_staging_dir()
return OutputPlan(
mode=output_mode,
stage_action=stage_action,
staging_dir=staging_dir,
allow_archive_extraction=transfer_plan.allow_archive_extraction,
transfer_plan=transfer_plan,
)
def prepare_output_files(
temp_file: Path,
task: DownloadTask,
output_mode: str,
status_callback,
destination: Optional[Path] = None,
output_plan: Optional[OutputPlan] = None,
) -> Optional[PreparedFiles]:
if output_plan is None:
output_plan = build_output_plan(
temp_file,
task,
output_mode=output_mode,
destination=destination,
status_callback=status_callback,
)
working_path = temp_file
if output_plan.stage_action != STAGE_NONE:
step_label = "Staging torrent files" if output_plan.stage_action == STAGE_COPY else "Staging files"
status_callback("resolving", step_label)
working_path = stage_path(working_path, output_plan.staging_dir, output_plan.stage_action)
can_delete_source_archives = output_plan.stage_action != STAGE_NONE or is_managed_workspace_path(working_path)
files, rejected_files, cleanup_paths, error = collect_staged_files(
working_path=working_path,
task=task,
allow_archive_extraction=output_plan.allow_archive_extraction,
status_callback=status_callback,
cleanup_archives=can_delete_source_archives,
)
if error:
status_callback("error", error)
cleanup_output_staging(output_plan, working_path, task, cleanup_paths)
return None
if output_plan.stage_action == STAGE_NONE and is_managed_workspace_path(working_path):
cleanup_paths = [*cleanup_paths, working_path]
return PreparedFiles(
output_plan=output_plan,
working_path=working_path,
files=files,
rejected_files=rejected_files,
cleanup_paths=cleanup_paths,
)
+52
View File
@@ -0,0 +1,52 @@
"""Output routing for post-download processing.
This module selects the appropriate output handler and invokes it.
Keeping this separate from `pipeline.py` avoids circular imports:
- output handlers depend on `pipeline`
- router depends on the output registry
"""
from __future__ import annotations
from pathlib import Path
from threading import Event
from typing import Optional
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask, SearchMode
from shelfmark.download.outputs import resolve_output_handler
logger = setup_logger(__name__)
def post_process_download(
temp_file: Path,
task: DownloadTask,
cancel_flag: Event,
status_callback,
) -> Optional[str]:
"""Post-process download using the selected output handler."""
if task.search_mode is None:
logger.warning(
"Task %s: missing search_mode; defaulting to Direct mode behavior",
task.task_id,
)
elif task.search_mode not in (SearchMode.DIRECT, SearchMode.UNIVERSAL):
logger.warning(
"Task %s: invalid search_mode=%s; defaulting to Direct mode behavior",
task.task_id,
task.search_mode,
)
output_handler = resolve_output_handler(task)
if output_handler:
logger.info("Task %s: using output mode %s", task.task_id, output_handler.mode)
return output_handler.handler(temp_file, task, cancel_flag, status_callback)
from shelfmark.download.outputs.folder import process_folder_output
logger.info("Task %s: using output mode folder", task.task_id)
return process_folder_output(temp_file, task, cancel_flag, status_callback)
+318
View File
@@ -0,0 +1,318 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import List, Optional, Tuple
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.archive import ArchiveExtractionError, extract_archive, is_archive
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats as get_book_formats,
)
from shelfmark.download.staging import build_staging_dir
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def get_supported_formats(content_type: Optional[str] = None) -> List[str]:
if check_audiobook(content_type):
return get_supported_audiobook_formats()
return get_book_formats()
def _format_not_supported_error(rejected_files: List[Path], task: DownloadTask) -> str:
content_type = task.content_type
file_type_label = "audiobook" if check_audiobook(content_type) else "book"
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
rejected_list = ", ".join(rejected_exts)
supported_formats = get_supported_formats(content_type)
logger.warning(
"Task %s: found %d %s(s) but none match supported formats. Rejected formats: %s. Supported: %s",
task.task_id,
len(rejected_files),
file_type_label,
rejected_list,
", ".join(sorted(supported_formats)),
)
return (
f"Found {len(rejected_files)} {file_type_label}(s) but format not supported ({rejected_list}). "
"Enable in Settings > Formats."
)
def extract_archive_files(
archive_path: Path,
output_dir: Path,
task: DownloadTask,
cleanup_archive: bool,
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
content_type = task.content_type
try:
extracted_files, warnings, rejected_files = extract_archive(archive_path, output_dir, content_type)
except ArchiveExtractionError as exc:
logger.warning(
"Task %s: archive extraction failed for %s: %s",
task.task_id,
archive_path.name,
exc,
)
return [], [], [], str(exc)
if warnings:
logger.debug(
"Task %s: archive warnings for %s: %s",
task.task_id,
archive_path.name,
"; ".join(warnings),
)
if cleanup_archive:
archive_path.unlink(missing_ok=True)
cleanup_paths = [output_dir]
if not extracted_files:
if rejected_files:
return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task)
file_type_label = "audiobook" if check_audiobook(content_type) else "book"
return [], rejected_files, cleanup_paths, f"No {file_type_label} files found in archive"
logger.debug(
"Task %s: extracted %d file(s) from archive %s",
task.task_id,
len(extracted_files),
archive_path.name,
)
return extracted_files, rejected_files, cleanup_paths, None
def scan_directory_tree(
directory: Path,
content_type: Optional[str],
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
"""Scan a directory tree for book files, trackable-but-unsupported files, and archives."""
try:
with os.scandir(directory) as it:
next(it, None)
except PermissionError as exc:
log_path_permission_context("scan_directory", directory)
logger.warning(f"Permission denied scanning directory: {directory} ({exc})")
return [], [], [], f"Permission denied accessing download folder: {directory}"
except (FileNotFoundError, NotADirectoryError, OSError) as exc:
logger.warning(f"Cannot access download folder: {directory} ({exc})")
return [], [], [], f"Cannot access download folder: {directory} ({exc})"
book_files: List[Path] = []
rejected_files: List[Path] = []
archive_files: List[Path] = []
supported_formats = get_supported_formats(content_type)
supported_exts = {f".{fmt}" for fmt in supported_formats}
is_audiobook = check_audiobook(content_type)
if is_audiobook:
trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'}
else:
trackable_exts = {
'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr',
'.doc', '.docx', '.rtf', '.txt',
}
logged_walk_permission_context = False
def onerror(error: OSError) -> None:
nonlocal logged_walk_permission_context
if isinstance(error, PermissionError):
if not logged_walk_permission_context:
try:
error_path = Path(getattr(error, "filename", "") or str(directory))
except Exception:
error_path = directory
log_path_permission_context("scan_directory_walk", error_path)
logged_walk_permission_context = True
logger.debug(f"Skipping inaccessible path during scan: {error}")
else:
logger.debug(f"Error scanning directory tree: {error}")
for root, _, files in os.walk(directory, onerror=onerror):
for filename in files:
file_path = Path(root) / filename
suffix = file_path.suffix.lower()
if suffix in supported_exts:
book_files.append(file_path)
elif suffix in trackable_exts:
rejected_files.append(file_path)
if is_archive(file_path):
archive_files.append(file_path)
return book_files, rejected_files, archive_files, None
def collect_directory_files(
directory: Path,
task: DownloadTask,
allow_archive_extraction: bool,
status_callback=None,
cleanup_archives: bool = False,
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
content_type = task.content_type
book_files, rejected_files, archive_files, scan_error = scan_directory_tree(directory, content_type)
if scan_error:
return [], [], [], scan_error
if book_files:
if archive_files:
logger.debug(
"Task %s: ignoring %d archive(s) - already have %d book file(s)",
task.task_id,
len(archive_files),
len(book_files),
)
if rejected_files:
rejected_exts = sorted(set(f.suffix.lower() for f in rejected_files))
logger.debug(
"Task %s: also found %d file(s) with unsupported formats: %s",
task.task_id,
len(rejected_files),
", ".join(rejected_exts),
)
return book_files, rejected_files, [], None
if archive_files:
if not allow_archive_extraction:
logger.warning(
"Task %s: archive extraction disabled (torrent hardlinking enabled) for %s",
task.task_id,
directory,
)
return [], rejected_files, [], "Archive extraction is disabled when torrent hardlinking is enabled"
if status_callback:
status_callback("resolving", "Extracting archives")
logger.info("Task %s: extracting %d archive(s)", task.task_id, len(archive_files))
all_files: List[Path] = []
all_errors: List[str] = []
cleanup_paths: List[Path] = []
for archive in archive_files:
extract_dir = build_staging_dir("extract", task.task_id)
extracted_files, archive_rejected, archive_cleanup, error = extract_archive_files(
archive_path=archive,
output_dir=extract_dir,
task=task,
cleanup_archive=cleanup_archives,
)
if error:
all_errors.append(f"{archive.name}: {error}")
if archive_rejected:
rejected_files.extend(archive_rejected)
if extracted_files:
all_files.extend(extracted_files)
if archive_cleanup:
cleanup_paths.extend(archive_cleanup)
if all_files:
logger.info(
"Task %s: extracted %d file(s) from %d archive(s)",
task.task_id,
len(all_files),
len(archive_files),
)
return all_files, rejected_files, cleanup_paths, None
if all_errors:
return [], rejected_files, cleanup_paths, "; ".join(all_errors)
if rejected_files:
return [], rejected_files, cleanup_paths, _format_not_supported_error(rejected_files, task)
return [], rejected_files, cleanup_paths, "No book files found in archives"
if rejected_files:
return [], rejected_files, [], _format_not_supported_error(rejected_files, task)
return [], rejected_files, [], "No book files found in download"
def collect_staged_files(
working_path: Path,
task: DownloadTask,
allow_archive_extraction: bool,
status_callback,
cleanup_archives: bool,
) -> Tuple[List[Path], List[Path], List[Path], Optional[str]]:
if working_path.is_dir():
if status_callback:
status_callback("resolving", "Processing download folder")
return collect_directory_files(
working_path,
task,
allow_archive_extraction=allow_archive_extraction,
status_callback=status_callback,
cleanup_archives=cleanup_archives,
)
if is_archive(working_path) and allow_archive_extraction:
if status_callback:
status_callback("resolving", "Extracting archive")
logger.info("Task %s: extracting archive %s", task.task_id, working_path.name)
extract_dir = build_staging_dir("extract", task.task_id)
extracted_files, rejected_files, cleanup_paths, error = extract_archive_files(
archive_path=working_path,
output_dir=extract_dir,
task=task,
cleanup_archive=cleanup_archives,
)
if extracted_files:
logger.info(
"Task %s: extracted %d file(s) from archive %s",
task.task_id,
len(extracted_files),
working_path.name,
)
return extracted_files, rejected_files, cleanup_paths, error
# Single-file download result (non-archive).
# Ensure we respect the user's supported format settings.
suffix = working_path.suffix.lower()
supported_formats = get_supported_formats(task.content_type)
supported_exts = {f".{fmt}" for fmt in supported_formats}
is_audiobook = check_audiobook(task.content_type)
if is_audiobook:
trackable_exts = {'.m4b', '.mp3', '.m4a', '.flac', '.ogg', '.wma', '.aac', '.wav'}
else:
trackable_exts = {
'.pdf', '.epub', '.mobi', '.azw', '.azw3', '.fb2', '.djvu', '.cbz', '.cbr',
'.doc', '.docx', '.rtf', '.txt',
}
if suffix in supported_exts:
return [working_path], [], [], None
if suffix in trackable_exts:
return [], [working_path], [], _format_not_supported_error([working_path], task)
file_type_label = "audiobook" if is_audiobook else "book"
return [], [], [], f"Unsupported {file_type_label} file type: {suffix or working_path.name}"
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Any, List
from shelfmark.core.logger import setup_logger
from .types import PlanStep
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def record_step(steps: List[PlanStep], name: str, **details: Any) -> None:
steps.append(PlanStep(name=name, details=details))
def log_plan_steps(task_id: str, steps: List[PlanStep]) -> None:
if not steps:
return
summary = " -> ".join(step.name for step in steps)
logger.debug("Processing plan for %s: %s", task_id, summary)
+369
View File
@@ -0,0 +1,369 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import List, Optional, Tuple
import shelfmark.core.config as core_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.core.naming import (
assign_part_numbers,
build_library_path,
parse_naming_template,
same_filesystem,
sanitize_filename,
)
from shelfmark.core.utils import is_audiobook as check_audiobook
from shelfmark.download.fs import atomic_copy, atomic_hardlink, atomic_move
from shelfmark.download.postprocess.policy import get_file_organization, get_template
from .scan import collect_directory_files, scan_directory_tree
from .types import TransferPlan
from .workspace import safe_cleanup_path
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def should_hardlink(task: DownloadTask) -> bool:
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
if task.source != "prowlarr":
return False
if not task.original_download_path:
return False
is_audiobook = check_audiobook(task.content_type)
key = "HARDLINK_TORRENTS_AUDIOBOOK" if is_audiobook else "HARDLINK_TORRENTS"
hardlink_enabled = core_config.config.get(key)
if hardlink_enabled is None:
hardlink_enabled = core_config.config.get("TORRENT_HARDLINK", False)
return bool(hardlink_enabled)
def build_metadata_dict(task: DownloadTask) -> dict:
return {
"Author": task.author,
"Title": task.title,
"Subtitle": task.subtitle,
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
}
def resolve_hardlink_source(
temp_file: Path,
task: DownloadTask,
destination: Optional[Path],
status_callback=None,
) -> TransferPlan:
"""Resolve hardlink eligibility and source path for transfers."""
use_hardlink = False
source_path = temp_file
hardlink_enabled = should_hardlink(task)
if hardlink_enabled and task.original_download_path:
hardlink_source = Path(task.original_download_path)
if destination and hardlink_source.exists() and same_filesystem(hardlink_source, destination):
use_hardlink = True
source_path = hardlink_source
elif hardlink_source.exists():
logger.warning(
f"Cannot hardlink: {hardlink_source} and {destination} are on different filesystems. "
"Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination."
)
if status_callback:
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
return TransferPlan(
source_path=source_path,
use_hardlink=use_hardlink,
allow_archive_extraction=not hardlink_enabled,
hardlink_enabled=hardlink_enabled,
)
def is_torrent_source(source_path: Path, task: DownloadTask) -> bool:
"""Check if source is the torrent client path (needs copy to preserve seeding)."""
if not task.original_download_path:
return False
original_path = Path(task.original_download_path)
try:
return source_path.resolve() == original_path.resolve()
except (OSError, ValueError):
try:
return os.path.normpath(str(source_path)) == os.path.normpath(str(original_path))
except Exception:
return False
def _transfer_single_file(
source_path: Path,
dest_path: Path,
use_hardlink: bool,
is_torrent: bool,
preserve_source: bool = False,
) -> Tuple[Path, str]:
if use_hardlink:
final_path = atomic_hardlink(source_path, dest_path)
try:
if os.stat(source_path).st_ino == os.stat(final_path).st_ino:
return final_path, "hardlink"
except OSError:
return final_path, "hardlink"
return final_path, "copy"
if is_torrent or preserve_source:
return atomic_copy(source_path, dest_path), "copy"
return atomic_move(source_path, dest_path), "move"
def transfer_book_files(
book_files: List[Path],
destination: Path,
task: DownloadTask,
use_hardlink: bool,
is_torrent: bool,
preserve_source: bool = False,
organization_mode: Optional[str] = None,
) -> Tuple[List[Path], Optional[str]]:
if not book_files:
return [], "No book files found"
is_audiobook = check_audiobook(task.content_type)
organization_mode = organization_mode or get_file_organization(is_audiobook)
final_paths: List[Path] = []
if organization_mode == "organize":
template = get_template(is_audiobook, "organize")
metadata = build_metadata_dict(task)
if len(book_files) == 1:
source_file = book_files[0]
ext = source_file.suffix.lstrip(".") or task.format or ""
dest_path = build_library_path(str(destination), template, metadata, extension=ext or None)
dest_path.parent.mkdir(parents=True, exist_ok=True)
final_path, op = _transfer_single_file(
source_file,
dest_path,
use_hardlink,
is_torrent,
preserve_source=preserve_source,
)
final_paths.append(final_path)
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
else:
zero_pad_width = max(len(str(len(book_files))), 2)
files_with_parts = assign_part_numbers(book_files, zero_pad_width)
for source_file, part_number in files_with_parts:
ext = source_file.suffix.lstrip(".") or task.format or ""
file_metadata = {**metadata, "PartNumber": part_number}
dest_path = build_library_path(str(destination), template, file_metadata, extension=ext or None)
dest_path.parent.mkdir(parents=True, exist_ok=True)
final_path, op = _transfer_single_file(
source_file,
dest_path,
use_hardlink,
is_torrent,
preserve_source=preserve_source,
)
final_paths.append(final_path)
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
return final_paths, None
for book_file in book_files:
if len(book_files) == 1 and organization_mode != "none":
if not task.format:
task.format = book_file.suffix.lower().lstrip(".")
template = get_template(is_audiobook, "rename")
metadata = build_metadata_dict(task)
extension = book_file.suffix.lstrip(".") or task.format or ""
filename = parse_naming_template(template, metadata)
filename = Path(filename).name if filename else ""
if filename and extension:
filename = f"{sanitize_filename(filename)}.{extension}"
else:
filename = book_file.name
else:
filename = book_file.name
dest_path = destination / filename
final_path, op = _transfer_single_file(
book_file,
dest_path,
use_hardlink,
is_torrent,
preserve_source=preserve_source,
)
final_paths.append(final_path)
logger.debug(f"{op.capitalize()} to destination: {final_path.name}")
return final_paths, None
def process_directory(
directory: Path,
ingest_dir: Path,
task: DownloadTask,
allow_archive_extraction: bool = True,
use_hardlink: Optional[bool] = None,
) -> Tuple[List[Path], Optional[str]]:
"""Process staged directory: find book files, extract archives, move to ingest."""
try:
is_torrent = is_torrent_source(directory, task)
book_files, _, cleanup_paths, error = collect_directory_files(
directory,
task,
allow_archive_extraction=allow_archive_extraction,
status_callback=None,
cleanup_archives=not is_torrent,
)
if error:
if not is_torrent:
safe_cleanup_path(directory, task)
for cleanup_path in cleanup_paths:
safe_cleanup_path(cleanup_path, task)
return [], error
if use_hardlink is None:
use_hardlink = should_hardlink(task)
final_paths, error = transfer_book_files(
book_files,
destination=ingest_dir,
task=task,
use_hardlink=use_hardlink,
is_torrent=is_torrent,
)
if error:
return [], error
if not is_torrent:
safe_cleanup_path(directory, task)
for cleanup_path in cleanup_paths:
safe_cleanup_path(cleanup_path, task)
return final_paths, None
except Exception as exc:
logger.error_trace("Task %s: error processing directory %s: %s", task.task_id, directory, exc)
if not is_torrent_source(directory, task):
safe_cleanup_path(directory, task)
return [], str(exc)
def transfer_file_to_library(
source_path: Path,
library_base: str,
template: str,
metadata: dict,
task: DownloadTask,
temp_file: Optional[Path],
status_callback,
use_hardlink: bool,
) -> Optional[str]:
extension = source_path.suffix.lstrip(".") or task.format
dest_path = build_library_path(library_base, template, metadata, extension)
dest_path.parent.mkdir(parents=True, exist_ok=True)
is_torrent = is_torrent_source(source_path, task)
final_path, op = _transfer_single_file(source_path, dest_path, use_hardlink, is_torrent)
logger.info(f"Library {op}: {final_path}")
if use_hardlink and temp_file and not is_torrent_source(temp_file, task):
safe_cleanup_path(temp_file, task)
status_callback("complete", "Complete")
return str(final_path)
def transfer_directory_to_library(
source_dir: Path,
library_base: str,
template: str,
metadata: dict,
task: DownloadTask,
temp_file: Optional[Path],
status_callback,
use_hardlink: bool,
) -> Optional[str]:
content_type = task.content_type.lower() if task.content_type else None
source_files, _, _, scan_error = scan_directory_tree(source_dir, content_type)
if scan_error:
logger.warning(scan_error)
status_callback("error", scan_error)
if temp_file:
safe_cleanup_path(temp_file, task)
return None
if not source_files:
logger.warning(f"No supported files in {source_dir.name}")
status_callback("error", "No supported file formats found")
if temp_file:
safe_cleanup_path(temp_file, task)
return None
base_library_path = build_library_path(library_base, template, metadata, extension=None)
base_library_path.parent.mkdir(parents=True, exist_ok=True)
is_torrent = is_torrent_source(source_dir, task)
transferred_paths: List[Path] = []
if len(source_files) == 1:
source_file = source_files[0]
ext = source_file.suffix.lstrip(".")
dest_path = base_library_path.with_suffix(f".{ext}")
final_path, op = _transfer_single_file(source_file, dest_path, use_hardlink, is_torrent)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
else:
zero_pad_width = max(len(str(len(source_files))), 2)
files_with_parts = assign_part_numbers(source_files, zero_pad_width)
for source_file, part_number in files_with_parts:
ext = source_file.suffix.lstrip(".")
file_metadata = {**metadata, "PartNumber": part_number}
file_path = build_library_path(library_base, template, file_metadata, extension=ext)
file_path.parent.mkdir(parents=True, exist_ok=True)
final_path, op = _transfer_single_file(source_file, file_path, use_hardlink, is_torrent)
logger.debug(f"Library {op}: {source_file.name} -> {final_path}")
transferred_paths.append(final_path)
if use_hardlink:
operation = "hardlinks"
elif is_torrent:
operation = "copies"
else:
operation = "files"
logger.info(f"Created {len(transferred_paths)} library {operation} in {base_library_path.parent}")
if use_hardlink and temp_file and not is_torrent_source(temp_file, task):
safe_cleanup_path(temp_file, task)
elif not is_torrent:
safe_cleanup_path(temp_file, task)
safe_cleanup_path(source_dir, task)
message = f"Complete ({len(transferred_paths)} files)" if len(transferred_paths) > 1 else "Complete"
status_callback("complete", message)
return str(transferred_paths[0])
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional
from shelfmark.download.staging import StageAction
@dataclass(frozen=True)
class TransferPlan:
source_path: Path
use_hardlink: bool
allow_archive_extraction: bool
hardlink_enabled: bool
@dataclass(frozen=True)
class OutputPlan:
mode: str
stage_action: StageAction
staging_dir: Path
allow_archive_extraction: bool
transfer_plan: Optional[TransferPlan] = None
@dataclass(frozen=True)
class PreparedFiles:
output_plan: OutputPlan
working_path: Path
files: List[Path]
rejected_files: List[Path]
cleanup_paths: List[Path]
@dataclass(frozen=True)
class PlanStep:
name: str
details: Dict[str, Any]
@@ -0,0 +1,83 @@
from __future__ import annotations
import shutil
from pathlib import Path
from typing import List, Optional
from shelfmark.config import env as env_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import DownloadTask
from shelfmark.download.staging import STAGE_NONE
from .types import OutputPlan
logger = setup_logger("shelfmark.download.postprocess.pipeline")
def _tmp_dir() -> Path:
return env_config.TMP_DIR
def is_within_tmp_dir(path: Path) -> bool:
"""Legacy helper: True if path is inside TMP_DIR."""
try:
path.resolve().relative_to(_tmp_dir().resolve())
return True
except (OSError, ValueError):
return False
def is_managed_workspace_path(path: Path) -> bool:
"""True if Shelfmark should treat this path as mutable.
The managed workspace is `TMP_DIR`. Anything outside it should be treated as
read-only for safety (e.g. torrent seeding directories).
"""
return is_within_tmp_dir(path)
def _is_original_download(path: Optional[Path], task: DownloadTask) -> bool:
if not path or not task.original_download_path:
return False
try:
return path.resolve() == Path(task.original_download_path).resolve()
except (OSError, ValueError):
return False
def safe_cleanup_path(path: Optional[Path], task: DownloadTask) -> None:
"""Remove a temp path only if it is safe and in our managed workspace."""
if not path or _is_original_download(path, task):
return
if not is_managed_workspace_path(path):
logger.debug("Skip cleanup (outside TMP_DIR) for task %s: %s", task.task_id, path)
return
try:
if path.is_dir():
shutil.rmtree(path, ignore_errors=True)
elif path.exists():
path.unlink(missing_ok=True)
except (OSError, PermissionError) as exc:
logger.warning("Cleanup failed for task %s (%s): %s", task.task_id, path, exc)
def cleanup_output_staging(
output_plan: OutputPlan,
working_path: Path,
task: DownloadTask,
cleanup_paths: Optional[List[Path]] = None,
) -> None:
if output_plan.stage_action != STAGE_NONE:
cleanup_target = output_plan.staging_dir
if output_plan.staging_dir == _tmp_dir():
cleanup_target = working_path
safe_cleanup_path(cleanup_target, task)
if cleanup_paths:
for path in cleanup_paths:
safe_cleanup_path(path, task)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import hashlib
import shutil
from pathlib import Path
from typing import Literal
from shelfmark.config import env as env_config
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
StageAction = Literal["none", "copy", "move"]
STAGE_NONE: StageAction = "none"
STAGE_COPY: StageAction = "copy"
STAGE_MOVE: StageAction = "move"
def get_staging_dir() -> Path:
"""Get the staging directory for downloads."""
tmp_dir = env_config.TMP_DIR
tmp_dir.mkdir(parents=True, exist_ok=True)
return tmp_dir
def get_staging_path(task_id: str, extension: str) -> Path:
"""Get a staging path for a download."""
staging_dir = get_staging_dir()
safe_id = hashlib.md5(task_id.encode()).hexdigest()[:16]
return staging_dir / f"{safe_id}.{extension.lstrip('.')}"
def build_staging_dir(prefix: str | None, task_id: str) -> Path:
"""Build a dedicated staging directory for output processing."""
base_dir = get_staging_dir()
if not prefix:
return base_dir
safe_id = hashlib.md5(task_id.encode()).hexdigest()[:8]
staging_dir = base_dir / f"{prefix}_{safe_id}"
counter = 1
while staging_dir.exists():
staging_dir = base_dir / f"{prefix}_{safe_id}_{counter}"
counter += 1
staging_dir.mkdir(parents=True, exist_ok=True)
return staging_dir
def stage_file(source_path: Path, task_id: str, copy: bool = False) -> Path:
"""Stage a file for ingest processing. Use copy=True for torrents to preserve seeding."""
staging_dir = get_staging_dir()
return stage_path(source_path, staging_dir, STAGE_COPY if copy else STAGE_MOVE)
def stage_path(source: Path, staging_dir: Path, action: StageAction) -> Path:
"""Stage a file or directory into a staging dir."""
if action == STAGE_NONE:
return source
staged_path = staging_dir / source.name
counter = 1
if source.is_dir():
while staged_path.exists():
staged_path = staging_dir / f"{source.name}_{counter}"
counter += 1
if action == STAGE_COPY:
shutil.copytree(str(source), str(staged_path))
else:
shutil.move(str(source), str(staged_path))
else:
while staged_path.exists():
staged_path = staging_dir / f"{source.stem}_{counter}{source.suffix}"
counter += 1
if action == STAGE_COPY:
shutil.copy2(str(source), str(staged_path))
else:
shutil.move(str(source), str(staged_path))
staged_kind = "directory" if source.is_dir() else "file"
logger.debug("Staged %s via %s: %s -> %s", staged_kind, action, source, staged_path)
return staged_path
+2 -2
View File
@@ -744,7 +744,7 @@ def api_cover(cover_id: str) -> Union[Response, Tuple[Response, int]]:
return jsonify({"error": str(e)}), 500
@app.route('/api/download/<book_id>/cancel', methods=['DELETE'])
@app.route('/api/download/<path:book_id>/cancel', methods=['DELETE'])
@login_required
def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
"""
@@ -765,7 +765,7 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/queue/<book_id>/priority', methods=['PUT'])
@app.route('/api/queue/<path:book_id>/priority', methods=['PUT'])
@login_required
def api_set_priority(book_id: str) -> Union[Response, Tuple[Response, int]]:
"""
+95
View File
@@ -159,6 +159,101 @@ class BookMetadata:
titles_by_language: Dict[str, str] = field(default_factory=dict)
def group_languages_by_localized_title(
base_title: str,
languages: Optional[List[str]],
titles_by_language: Optional[Dict[str, str]] = None,
) -> List[tuple[str, Optional[List[str]]]]:
"""Group language codes by localized title.
Release sources that support language filtering (e.g., Anna's Archive)
may want to run separate searches per localized title, while still
passing the correct language filters per query.
Args:
base_title: Fallback title when no localized title exists.
languages: Requested language codes (e.g., ["en", "hu"]).
titles_by_language: Mapping of language identifiers to localized titles.
Returns:
List of (title, languages) tuples. If languages is None/empty, returns
[(base_title, None)].
"""
if not base_title:
return []
if not languages:
return [(base_title, None)]
normalized_langs = [lang.strip() for lang in languages if lang and lang.strip()]
if not normalized_langs:
return [(base_title, None)]
if not titles_by_language:
return [(base_title, normalized_langs)]
title_to_langs: Dict[str, List[str]] = {}
for lang in normalized_langs:
localized_title = titles_by_language.get(lang) or base_title
title_to_langs.setdefault(localized_title, []).append(lang)
return list(title_to_langs.items())
def build_localized_search_titles(
base_title: str,
languages: Optional[List[str]],
titles_by_language: Optional[Dict[str, str]] = None,
excluded_languages: Optional[set[str]] = None,
) -> List[str]:
"""Build a list of titles to search for, including localized editions.
This is useful for release sources that *can't* pass language filters to
an upstream search API (e.g., Prowlarr), but still want to broaden matches
by searching for localized edition titles.
The list always includes base_title first.
Args:
base_title: Primary title to search for.
languages: User language preferences (order matters).
titles_by_language: Mapping of language identifiers to localized titles.
excluded_languages: Optional set of normalized language identifiers to skip.
Returns:
List of unique titles to search for, in priority order.
"""
if not base_title:
return []
titles: List[str] = [base_title]
seen = {base_title}
if not languages or not titles_by_language:
return titles
excluded = {lang.lower() for lang in (excluded_languages or set())}
for lang in languages:
if not lang:
continue
normalized_lang = lang.strip()
if not normalized_lang:
continue
if normalized_lang.lower() in excluded:
continue
localized_title = titles_by_language.get(normalized_lang)
if not localized_title:
continue
if localized_title not in seen:
seen.add(localized_title)
titles.append(localized_title)
return titles
@dataclass
class SearchResult:
"""Result from a metadata search with pagination info."""
+5 -3
View File
@@ -390,7 +390,11 @@ class HardcoverProvider(MetadataProvider):
primary_books_count
}
}
editions(limit: 20, order_by: {users_count: desc}) {
editions(
distinct_on: language_id
order_by: [{language_id: asc}, {users_count: desc}]
limit: 200
) {
title
language {
language
@@ -835,7 +839,6 @@ def hardcover_settings():
label="API Key",
description="Get your API key from hardcover.app/account/api",
required=True,
env_supported=False, # UI-only setting, no ENV var support
),
ActionButton(
key="test_connection",
@@ -850,7 +853,6 @@ def hardcover_settings():
description="Default sort order for Hardcover search results.",
options=_HARDCOVER_SORT_OPTIONS,
default="relevance",
env_supported=False, # UI-only setting
),
CheckboxField(
key="HARDCOVER_EXCLUDE_COMPILATIONS",
@@ -558,6 +558,5 @@ def openlibrary_settings():
description="Default sort order for Open Library search results.",
options=_OPENLIBRARY_SORT_OPTIONS,
default="relevance",
env_supported=False, # UI-only setting
),
]
+17 -3
View File
@@ -252,8 +252,14 @@ class ReleaseSource(ABC):
class DownloadHandler(ABC):
"""Interface for executing downloads. Handlers stage files to TMP_DIR;
orchestrator handles post-processing and move to INGEST_DIR.
"""Interface for executing downloads.
A handler may either:
- download directly into ``TMP_DIR`` (managed by Shelfmark), or
- return a path owned by an external client (e.g. torrent/usenet).
The orchestrator is responsible for post-processing (archive extraction, output mode
handling) and transferring files into their final destination.
"""
@abstractmethod
@@ -264,9 +270,17 @@ class DownloadHandler(ABC):
progress_callback: Callable[[float], None],
status_callback: Callable[[str, Optional[str]], None]
) -> Optional[str]:
"""Execute download and return path to staged file in TMP_DIR."""
"""Execute download and return a path to the downloaded payload."""
pass
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
"""Optional hook called after orchestrator post-processing.
This is primarily used for external download clients, where the handler may need
to trigger client-side cleanup only after Shelfmark has safely imported the files.
"""
return
@abstractmethod
def cancel(self, task_id: str) -> bool:
"""Cancel an in-progress download."""
+7 -10
View File
@@ -20,7 +20,7 @@ from shelfmark.core.config import config
from shelfmark.core.utils import CONTENT_TYPES
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import BookInfo, SearchFilters, DownloadTask
from shelfmark.metadata_providers import BookMetadata
from shelfmark.metadata_providers import BookMetadata, group_languages_by_localized_title
from shelfmark.release_sources import (
Release,
ReleaseProtocol,
@@ -1181,14 +1181,11 @@ class DirectDownloadSource(ReleaseSource):
author = book.authors[0] if book.authors else ""
# Group languages by localized title to avoid duplicate searches
if lang_filter and book.titles_by_language:
title_to_langs: Dict[str, List[str]] = {}
for lang in lang_filter:
title = book.titles_by_language.get(lang, book.title)
title_to_langs.setdefault(title, []).append(lang)
searches = list(title_to_langs.items())
else:
searches = [(book.title, lang_filter)]
searches = group_languages_by_localized_title(
base_title=book.title,
languages=lang_filter,
titles_by_language=book.titles_by_language,
)
# Execute searches with deduplication
seen_ids: set = set()
@@ -1300,7 +1297,7 @@ class DirectDownloadHandler(DownloadHandler):
handle bypass, move to final location.
"""
try:
logger.info(f"Starting download: {book_info.title}")
logger.debug("Starting download: %s", book_info.title)
# Prepare paths - use descriptive staging filename, orchestrator will rename
# based on FILE_ORGANIZATION setting
+1 -1
View File
@@ -100,7 +100,7 @@ class IRCDownloadHandler(DownloadHandler):
ext = Path(offer.filename).suffix.lstrip('.') or task.format or "epub"
# Stage to temp directory (lazy import to avoid circular import)
from shelfmark.download.orchestrator import get_staging_path
from shelfmark.download.staging import get_staging_path
staging_path = get_staging_path(task.task_id, ext)
download_dcc(
+1 -2
View File
@@ -1,7 +1,6 @@
"""Prowlarr API client for connection testing, indexer listing, and search."""
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urljoin
import requests
@@ -31,7 +30,7 @@ class ProwlarrClient:
json_data: Optional[Dict[str, Any]] = None,
) -> Any:
"""Make an API request to Prowlarr. Returns parsed JSON response."""
url = urljoin(self.base_url, endpoint)
url = self.base_url + endpoint
logger.debug(f"Prowlarr API: {method} {url}")
try:
@@ -11,13 +11,87 @@ Clients register themselves via the @register_client decorator.
"""
import logging
import os
import random
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple, Type, Union
from functools import wraps
from typing import Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union
import requests
_logger = logging.getLogger(__name__)
# Type variable for generic return type
T = TypeVar('T')
# Exceptions that should trigger a retry
RETRYABLE_EXCEPTIONS = (
requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.HTTPError,
)
def with_retry(
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 10.0,
jitter: float = 0.5,
) -> Callable[[Callable[..., T]], Callable[..., T]]:
"""
Decorator for retrying API calls with exponential backoff.
Args:
max_attempts: Maximum number of attempts (default 3)
base_delay: Initial delay in seconds (default 1.0)
max_delay: Maximum delay cap in seconds (default 10.0)
jitter: Random jitter factor 0-1 to add to delay (default 0.5)
Retries on:
- Connection errors
- Timeouts
- HTTP 5xx server errors
Does NOT retry on:
- HTTP 4xx client errors (bad request, auth failures)
- Other exceptions (programming errors)
"""
def decorator(func: Callable[..., T]) -> Callable[..., T]:
@wraps(func)
def wrapper(*args, **kwargs) -> T:
last_exception = None
for attempt in range(1, max_attempts + 1):
try:
return func(*args, **kwargs)
except requests.exceptions.HTTPError as e:
# Only retry on server errors (5xx), not client errors (4xx)
if e.response is not None and e.response.status_code < 500:
raise
last_exception = e
except RETRYABLE_EXCEPTIONS as e:
last_exception = e
if attempt < max_attempts:
# Calculate delay with exponential backoff
delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
# Add jitter to prevent thundering herd
delay += random.uniform(0, delay * jitter)
_logger.debug(
f"Retry {attempt}/{max_attempts} for {func.__name__} "
f"after {delay:.1f}s (error: {last_exception})"
)
time.sleep(delay)
# All retries exhausted
raise last_exception
return wrapper
return decorator
class DownloadState(Enum):
"""Valid states for a download."""
@@ -97,6 +171,49 @@ class DownloadClient(ABC):
protocol: str
name: str
def _log_error(self, method: str, e: Exception, level: str = "error") -> str:
"""
Log a client error with consistent formatting.
Args:
method: Name of the method that failed (e.g., "get_status")
e: The exception that was raised
level: Log level - "error" or "debug"
Returns:
Formatted error message string (for use in DownloadStatus.error())
"""
error_type = type(e).__name__
msg = f"{self.name} {method} failed ({error_type}): {e}"
if level == "debug":
_logger.debug(msg)
else:
_logger.error(msg)
# Reset connection state if client tracks it (e.g., Deluge)
if hasattr(self, "_connected"):
self._connected = False
return f"{error_type}: {e}"
def _build_path(self, *components: str) -> Optional[str]:
"""
Safely build a file path from components.
Args:
*components: Path components to join (e.g., save_path, name)
Returns:
Normalized path string, or None if any component is empty/None.
"""
# Filter out empty/None components
valid = [c for c in components if c]
if len(valid) != len(components):
return None
# Join and normalize
return os.path.normpath(os.path.join(*valid))
def __init_subclass__(cls, **kwargs):
"""Validate that subclasses define required class attributes."""
super().__init_subclass__(**kwargs)
@@ -139,14 +256,13 @@ class DownloadClient(ABC):
pass
@abstractmethod
def add_download(self, url: str, name: str, category: str = "cwabd") -> str:
"""
Add a download to the client.
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
"""Add a download to the client.
Args:
url: Download URL (magnet link, .torrent URL, or NZB URL)
name: Display name for the download
category: Category/label for organization
category: Category/label for organization (None = client default)
Returns:
Client-specific download ID (hash for torrents, ID for NZBGet).
@@ -1,14 +1,21 @@
"""
Deluge download client for Prowlarr integration.
"""Deluge download client for Prowlarr integration.
Uses the deluge-client library to communicate with Deluge's RPC daemon.
Note: Deluge uses a custom binary RPC protocol over TCP (default port 58846,
configurable via DELUGE_PORT), which requires the daemon to have
"Allow Remote Connections" enabled.
This implementation talks to Deluge via the Web UI JSON-RPC API (``/json``).
Why Web UI API instead of daemon RPC (port 58846)?
- Matches the approach used by common automation apps (e.g. Sonarr/Radarr)
- Avoids requiring Deluge daemon ``auth`` file credentials (username/password)
Requirements:
- ``deluge-web`` must be enabled and reachable from Shelfmark
- Deluge Web UI must be connected (or connectable) to a Deluge daemon
"""
import base64
from typing import Any, Optional, Tuple
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
@@ -24,146 +31,224 @@ from shelfmark.release_sources.prowlarr.clients.torrent_utils import (
logger = setup_logger(__name__)
def _decode(value: Any) -> Any:
"""Decode bytes to string if needed (Deluge returns bytes for strings)."""
return value.decode('utf-8') if isinstance(value, bytes) else value
class DelugeRpcError(RuntimeError):
def __init__(self, message: str, code: int | None = None):
super().__init__(message)
self.code = code
def _get_error_message(error: Any) -> Tuple[str, int | None]:
if isinstance(error, dict):
return str(error.get("message") or error), error.get("code")
return str(error), None
@register_client("torrent")
class DelugeClient(DownloadClient):
"""Deluge download client using deluge-client RPC library."""
"""Deluge download client using Deluge Web UI JSON-RPC."""
protocol = "torrent"
name = "deluge"
def __init__(self):
"""Initialize Deluge client with settings from config."""
from deluge_client import DelugeRPCClient
raw_host = str(config.get("DELUGE_HOST", "localhost") or "")
raw_port = str(config.get("DELUGE_PORT", "8112") or "8112")
password = str(config.get("DELUGE_PASSWORD", "") or "")
host = config.get("DELUGE_HOST", "localhost")
password = config.get("DELUGE_PASSWORD", "")
if not host:
if not raw_host:
raise ValueError("DELUGE_HOST is required")
if not password:
raise ValueError("DELUGE_PASSWORD is required")
port = int(config.get("DELUGE_PORT", "58846"))
username = config.get("DELUGE_USERNAME", "")
scheme = "http"
base_path = ""
self._client = DelugeRPCClient(
host=host,
port=port,
username=username,
password=password,
)
# Allow DELUGE_HOST to be either a hostname OR a full URL
# (useful when Deluge is behind a reverse proxy path).
host = raw_host
port = int(raw_port)
if raw_host.startswith(("http://", "https://")):
parsed = urlparse(raw_host)
scheme = parsed.scheme or "http"
host = parsed.hostname or "localhost"
if parsed.port is not None:
port = parsed.port
base_path = (parsed.path or "").rstrip("/")
else:
# Allow "host:port" in DELUGE_HOST for convenience.
if ":" in raw_host and raw_host.count(":") == 1:
host_part, port_part = raw_host.split(":", 1)
if host_part and port_part.isdigit():
host = host_part
port = int(port_part)
self._rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
self._password = password
self._session = requests.Session()
self._authenticated = False
self._connected = False
self._category = config.get("DELUGE_CATEGORY", "cwabd")
self._rpc_id = 0
def _ensure_connected(self):
"""Ensure we're connected to the Deluge daemon."""
if not self._connected:
logger.debug("Connecting to Deluge daemon...")
self._category = str(config.get("DELUGE_CATEGORY", "cwabd") or "cwabd")
def _next_rpc_id(self) -> int:
self._rpc_id += 1
return self._rpc_id
def _rpc_call(self, method: str, *params: Any, timeout: int = 15) -> Any:
payload = {
"id": self._next_rpc_id(),
"method": method,
"params": list(params),
}
response = self._session.post(self._rpc_url, json=payload, timeout=timeout)
response.raise_for_status()
data = response.json()
if data.get("error"):
message, code = _get_error_message(data["error"])
raise DelugeRpcError(message, code)
return data.get("result")
def _login(self) -> None:
result = self._rpc_call("auth.login", self._password)
if result is not True:
raise DelugeRpcError("Deluge Web UI authentication failed")
self._authenticated = True
def _select_daemon_host_id(self, hosts: list) -> str:
# Hosts returned by web.get_hosts look like:
# [[host_id, host, port, status], ...]
preferred_hosts = {"127.0.0.1", "localhost"}
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in preferred_hosts:
return str(entry[0])
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 4 and str(entry[3]).lower() == "online":
return str(entry[0])
return str(hosts[0][0])
def _ensure_connected(self) -> None:
if not self._authenticated:
self._login()
if self._connected:
return
if self._rpc_call("web.connected") is True:
self._connected = True
return
hosts = self._rpc_call("web.get_hosts") or []
if not hosts:
raise DelugeRpcError(
"Deluge Web UI isn't connected to Deluge core (no hosts configured). "
"Add/connect a daemon in Deluge Web UI → Connection Manager."
)
host_id = self._select_daemon_host_id(hosts)
self._rpc_call("web.connect", host_id)
if self._rpc_call("web.connected") is not True:
raise DelugeRpcError(
"Deluge Web UI couldn't connect to Deluge core. "
"Check daemon status in Deluge Web UI → Connection Manager."
)
self._connected = True
def _try_set_label(self, torrent_id: str, label: str) -> None:
"""Best-effort label assignment (requires Deluge Label plugin)."""
if not label:
return
try:
# label.add will error if the plugin is unavailable or the label exists.
try:
self._client.connect()
self._connected = True
logger.debug("Connected to Deluge daemon")
except Exception as e:
logger.error(f"Failed to connect to Deluge daemon: {type(e).__name__}: {e}")
raise
self._rpc_call("label.add", label)
except Exception:
pass
self._rpc_call("label.set_torrent", torrent_id, label)
except Exception as e:
logger.debug(f"Could not set Deluge label '{label}' for {torrent_id}: {e}")
@staticmethod
def is_configured() -> bool:
"""Check if Deluge is configured and selected as the torrent client."""
client = config.get("PROWLARR_TORRENT_CLIENT", "")
host = config.get("DELUGE_HOST", "")
password = config.get("DELUGE_PASSWORD", "")
return client == "deluge" and bool(host) and bool(password)
def test_connection(self) -> Tuple[bool, str]:
"""Test connection to Deluge."""
try:
self._ensure_connected()
# Get daemon info
version = self._client.call('daemon.info')
version = self._rpc_call("daemon.info")
return True, f"Connected to Deluge {version}"
except Exception as e:
self._authenticated = False
self._connected = False
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
"""
Add torrent by URL (magnet or .torrent).
Args:
url: Magnet link or .torrent URL
name: Display name for the torrent
category: Category for organization (uses configured default if not specified)
Returns:
Torrent hash (info_hash).
Raises:
Exception: If adding fails.
"""
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
try:
self._ensure_connected()
category = category or self._category
category_value = str(category or self._category)
torrent_info = extract_torrent_info(url)
if not torrent_info.is_magnet and not torrent_info.torrent_data:
raise Exception("Failed to fetch torrent file")
options = {}
options: dict[str, Any] = {}
if torrent_info.is_magnet:
# Use magnet URL if available, otherwise original URL
magnet_url = torrent_info.magnet_url or url
torrent_id = self._client.call(
'core.add_torrent_magnet',
magnet_url,
options,
)
torrent_id = self._rpc_call("core.add_torrent_magnet", magnet_url, options)
else:
filedump = base64.b64encode(torrent_info.torrent_data).decode('ascii')
torrent_id = self._client.call(
'core.add_torrent_file',
torrent_data = torrent_info.torrent_data
if torrent_data is None:
raise Exception("Failed to fetch torrent file")
torrent_data_bytes: bytes = torrent_data
filedump = base64.b64encode(torrent_data_bytes).decode("ascii")
torrent_id = self._rpc_call(
"core.add_torrent_file",
f"{name}.torrent",
filedump,
options,
)
if torrent_id:
torrent_id = _decode(torrent_id)
logger.info(f"Added torrent to Deluge: {torrent_id}")
return torrent_id.lower()
if not torrent_id:
raise Exception("Deluge returned no torrent ID")
raise Exception("Deluge returned no torrent ID")
torrent_id = str(torrent_id).lower()
self._try_set_label(torrent_id, category_value)
logger.info(f"Added torrent to Deluge: {torrent_id}")
return torrent_id
except Exception as e:
self._authenticated = False
self._connected = False
logger.error(f"Deluge add failed: {e}")
raise
def get_status(self, download_id: str) -> DownloadStatus:
"""
Get torrent status by hash.
Args:
download_id: Torrent info_hash
Returns:
Current download status.
"""
try:
self._ensure_connected()
# Get torrent status
status = self._client.call(
'core.get_torrent_status',
status = self._rpc_call(
"core.get_torrent_status",
download_id,
['state', 'progress', 'download_payload_rate', 'eta', 'save_path', 'name'],
["state", "progress", "download_payload_rate", "eta", "save_path", "name"],
)
if not status:
@@ -171,35 +256,42 @@ class DelugeClient(DownloadClient):
# Deluge states: Downloading, Seeding, Paused, Checking, Queued, Error, Moving
state_map = {
'Downloading': ('downloading', None),
'Seeding': ('seeding', 'Seeding'),
'Paused': ('paused', 'Paused'),
'Checking': ('checking', 'Checking files'),
'Queued': ('queued', 'Queued'),
'Error': ('error', 'Error'),
'Moving': ('processing', 'Moving files'),
'Allocating': ('downloading', 'Allocating space'),
"Downloading": ("downloading", None),
"Seeding": ("seeding", "Seeding"),
"Paused": ("paused", "Paused"),
"Checking": ("checking", "Checking files"),
"Queued": ("queued", "Queued"),
"Error": ("error", "Error"),
"Moving": ("processing", "Moving files"),
"Allocating": ("downloading", "Allocating space"),
}
deluge_state = _decode(status.get(b'state', b'Unknown'))
state, message = state_map.get(deluge_state, ('unknown', deluge_state))
progress = status.get(b'progress', 0)
deluge_state = status.get("state", "Unknown")
state, message = state_map.get(str(deluge_state), ("unknown", str(deluge_state)))
progress = float(status.get("progress", 0))
# Don't mark complete while files are being moved
complete = progress >= 100 and deluge_state != 'Moving'
complete = progress >= 100 and deluge_state != "Moving"
if complete:
message = "Complete"
eta = status.get(b'eta')
if eta and eta > 604800:
eta = status.get("eta")
if eta is not None:
try:
eta = int(eta)
except Exception:
eta = None
if eta is not None and (eta < 0 or eta > 604800):
eta = None
file_path = None
if complete:
save_path = _decode(status.get(b'save_path', b''))
name = _decode(status.get(b'name', b''))
if save_path and name:
file_path = f"{save_path}/{name}"
file_path = self._build_path(
str(status.get("save_path", "")),
str(status.get("name", "")),
)
return DownloadStatus(
progress=progress,
@@ -207,36 +299,18 @@ class DelugeClient(DownloadClient):
message=message,
complete=complete,
file_path=file_path,
download_speed=status.get(b'download_payload_rate'),
download_speed=status.get("download_payload_rate"),
eta=eta,
)
except Exception as e:
self._connected = False
error_type = type(e).__name__
logger.error(f"Deluge get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a torrent from Deluge.
Args:
download_id: Torrent info_hash
delete_files: Whether to also delete files
Returns:
True if successful.
"""
try:
self._ensure_connected()
result = self._client.call(
'core.remove_torrent',
download_id,
delete_files,
)
result = self._rpc_call("core.remove_torrent", download_id, delete_files)
if result:
logger.info(
f"Removed torrent from Deluge: {download_id}"
@@ -246,45 +320,31 @@ class DelugeClient(DownloadClient):
return False
except Exception as e:
self._connected = False
error_type = type(e).__name__
logger.error(f"Deluge remove failed ({error_type}): {e}")
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where torrent files are located.
Args:
download_id: Torrent info_hash
Returns:
Content path (file or directory), or None.
"""
try:
self._ensure_connected()
status = self._client.call(
'core.get_torrent_status',
status = self._rpc_call(
"core.get_torrent_status",
download_id,
['save_path', 'name'],
["save_path", "name"],
)
if status:
save_path = _decode(status.get(b'save_path', b''))
name = _decode(status.get(b'name', b''))
if save_path and name:
return f"{save_path}/{name}"
return self._build_path(
str(status.get("save_path", "")),
str(status.get("name", "")),
)
return None
except Exception as e:
self._connected = False
error_type = type(e).__name__
logger.debug(f"Deluge get_download_path failed ({error_type}): {e}")
self._log_error("get_download_path", e, level="debug")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
"""Check if a torrent for this URL already exists in Deluge."""
try:
self._ensure_connected()
@@ -292,10 +352,10 @@ class DelugeClient(DownloadClient):
if not torrent_info.info_hash:
return None
status = self._client.call(
'core.get_torrent_status',
status = self._rpc_call(
"core.get_torrent_status",
torrent_info.info_hash,
['state'],
["state"],
)
if status:
@@ -303,7 +363,9 @@ class DelugeClient(DownloadClient):
return (torrent_info.info_hash, full_status)
return None
except Exception as e:
self._authenticated = False
self._connected = False
logger.debug(f"Error checking for existing torrent: {e}")
return None
@@ -15,6 +15,7 @@ from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
with_retry,
)
logger = setup_logger(__name__)
@@ -45,6 +46,7 @@ class NZBGetClient(DownloadClient):
url = config.get("NZBGET_URL", "")
return client == "nzbget" and bool(url)
@with_retry()
def _rpc_call(self, method: str, params: list = None) -> Any:
"""
Make a JSON-RPC call to NZBGet.
@@ -57,7 +59,7 @@ class NZBGetClient(DownloadClient):
Result from NZBGet.
Raises:
Exception: If RPC call fails.
Exception: If RPC call fails after retries.
"""
rpc_url = f"{self.url}/jsonrpc"
@@ -96,7 +98,7 @@ class NZBGetClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
def add_download(self, url: str, name: str, category: Optional[str] = None) -> str:
"""
Add NZB by URL.
@@ -226,7 +228,10 @@ class NZBGetClient(DownloadClient):
for item in history:
if item.get("NZBID") == nzb_id:
status = item.get("Status", "")
dest_dir = item.get("DestDir", "")
# Prefer FinalDir (post-processing result) over DestDir (original)
final_dir = item.get("FinalDir", "") or None
dest_dir = item.get("DestDir", "") or None
file_path = final_dir or dest_dir # Use FinalDir if available
if "SUCCESS" in status:
return DownloadStatus(
@@ -234,7 +239,7 @@ class NZBGetClient(DownloadClient):
state="complete",
message="Complete",
complete=True,
file_path=dest_dir,
file_path=file_path,
)
else:
return DownloadStatus(
@@ -248,35 +253,48 @@ class NZBGetClient(DownloadClient):
# Not found in queue or history
return DownloadStatus.error("Download not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"NZBGet get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
Remove a download from NZBGet.
"""Remove a download from NZBGet.
NZBGet can remove items from either the active queue (Group* commands) or from
history (History* commands). Completed downloads are typically in history.
Args:
download_id: NZBGet NZBID
delete_files: Whether to permanently delete (vs move to history)
delete_files: Whether to permanently delete downloaded files
Returns:
True if successful.
"""
try:
nzb_id = int(download_id)
# editqueue params: Command (str), Param (str), IDs (int[])
# GroupFinalDelete = permanent removal, GroupDelete = move to history
command = "GroupFinalDelete" if delete_files else "GroupDelete"
result = self._rpc_call("editqueue", [command, "", [nzb_id]])
if result:
logger.info(f"Removed NZB from NZBGet: {download_id}")
return bool(result)
except Exception as e:
error_type = type(e).__name__
logger.error(f"NZBGet remove failed ({error_type}): {e}")
except (TypeError, ValueError) as e:
self._log_error("remove", e)
return False
if delete_files:
# Sonarr uses HistoryDelete for NZBGet; keep that as a fallback for
# older NZBGet versions where HistoryFinalDelete may not exist.
commands = ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"]
else:
commands = ["GroupDelete", "HistoryDelete"]
last_error: Optional[Exception] = None
for command in commands:
try:
result = self._rpc_call("editqueue", [command, 0, "", nzb_id])
if result:
logger.info(f"Removed NZB from NZBGet ({command}): {download_id}")
return True
except Exception as e:
last_error = e
if last_error is not None:
self._log_error("remove", last_error)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
"""
Get the path where NZB files are located.
@@ -100,7 +100,7 @@ class QBittorrentClient(DownloadClient):
except Exception as e:
return False, f"Connection failed: {str(e)}"
def add_download(self, url: str, name: str, category: str = None) -> str:
def add_download(self, url: str, name: str, category: str | None = None) -> str:
"""
Add torrent by URL (magnet or .torrent).
@@ -230,10 +230,10 @@ class QBittorrentClient(DownloadClient):
file_path = torrent.content_path
else:
# Fallback for Amarr which doesn't populate content_path
save_path = getattr(torrent, 'save_path', '')
name = getattr(torrent, 'name', '')
if save_path and name:
file_path = f"{save_path}/{name}"
file_path = self._build_path(
getattr(torrent, 'save_path', ''),
getattr(torrent, 'name', ''),
)
return DownloadStatus(
progress=torrent.progress * 100,
@@ -245,9 +245,7 @@ class QBittorrentClient(DownloadClient):
eta=eta,
)
except Exception as e:
error_type = type(e).__name__
logger.error(f"qBittorrent get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
@@ -270,8 +268,7 @@ class QBittorrentClient(DownloadClient):
)
return True
except Exception as e:
error_type = type(e).__name__
logger.error(f"qBittorrent remove failed ({error_type}): {e}")
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
@@ -284,12 +281,12 @@ class QBittorrentClient(DownloadClient):
# Prefer content_path, fall back to save_path/name (for Amarr compatibility)
if getattr(torrent, 'content_path', ''):
return torrent.content_path
save_path = getattr(torrent, 'save_path', '')
name = getattr(torrent, 'name', '')
return f"{save_path}/{name}" if save_path and name else None
return self._build_path(
getattr(torrent, 'save_path', ''),
getattr(torrent, 'name', ''),
)
except Exception as e:
error_type = type(e).__name__
logger.debug(f"qBittorrent get_download_path failed ({error_type}): {e}")
self._log_error("get_download_path", e, level="debug")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
@@ -14,6 +14,7 @@ from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadStatus,
register_client,
with_retry,
)
logger = setup_logger(__name__)
@@ -91,6 +92,7 @@ class SABnzbdClient(DownloadClient):
api_key = config.get("SABNZBD_API_KEY", "")
return client == "sabnzbd" and bool(url) and bool(api_key)
@with_retry()
def _api_call(self, mode: str, params: dict = None) -> Any:
"""
Make an API call to SABnzbd.
@@ -103,7 +105,7 @@ class SABnzbdClient(DownloadClient):
JSON response from SABnzbd.
Raises:
Exception: If API call fails.
Exception: If API call fails after retries.
"""
api_url = f"{self.url}/api"
@@ -241,6 +243,8 @@ class SABnzbdClient(DownloadClient):
if slot.get("nzo_id") == download_id:
status_text = slot.get("status", "").upper()
storage = slot.get("storage", "")
if storage is None:
storage = ""
logger.debug(f"SABnzbd history: {download_id} status={status_text} storage='{storage}'")
if status_text == "COMPLETED":
@@ -276,9 +280,7 @@ class SABnzbdClient(DownloadClient):
logger.warning(f"SABnzbd: download {download_id} not found in queue or history")
return DownloadStatus.error("Download not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"SABnzbd get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False, archive: bool = True) -> bool:
"""
@@ -325,8 +327,7 @@ class SABnzbdClient(DownloadClient):
return False
except Exception as e:
error_type = type(e).__name__
logger.error(f"SABnzbd remove failed ({error_type}): {e}")
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
@@ -163,9 +163,10 @@ class TransmissionClient(DownloadClient):
# Get file path for completed downloads
file_path = None
if complete:
download_dir = torrent.download_dir
name = torrent.name
file_path = f"{download_dir}/{name}"
file_path = self._build_path(
getattr(torrent, 'download_dir', ''),
getattr(torrent, 'name', ''),
)
return DownloadStatus(
progress=progress,
@@ -180,9 +181,7 @@ class TransmissionClient(DownloadClient):
except KeyError:
return DownloadStatus.error("Torrent not found")
except Exception as e:
error_type = type(e).__name__
logger.error(f"Transmission get_status failed ({error_type}): {e}")
return DownloadStatus.error(f"{error_type}: {e}")
return DownloadStatus.error(self._log_error("get_status", e))
def remove(self, download_id: str, delete_files: bool = False) -> bool:
"""
@@ -206,8 +205,7 @@ class TransmissionClient(DownloadClient):
)
return True
except Exception as e:
error_type = type(e).__name__
logger.error(f"Transmission remove failed ({error_type}): {e}")
self._log_error("remove", e)
return False
def get_download_path(self, download_id: str) -> Optional[str]:
@@ -222,12 +220,12 @@ class TransmissionClient(DownloadClient):
"""
try:
torrent = self._client.get_torrent(download_id)
download_dir = torrent.download_dir
name = torrent.name
return f"{download_dir}/{name}"
return self._build_path(
getattr(torrent, 'download_dir', ''),
getattr(torrent, 'name', ''),
)
except Exception as e:
error_type = type(e).__name__
logger.debug(f"Transmission get_download_path failed ({error_type}): {e}")
self._log_error("get_download_path", e, level="debug")
return None
def find_existing(self, url: str) -> Optional[Tuple[str, DownloadStatus]]:
+156 -72
View File
@@ -1,6 +1,5 @@
"""Prowlarr download handler - executes downloads via torrent/usenet clients."""
import shutil
from pathlib import Path
from threading import Event
from typing import Callable, Optional
@@ -12,11 +11,12 @@ from shelfmark.core.utils import is_audiobook
from shelfmark.release_sources import DownloadHandler, register_handler
from shelfmark.release_sources.prowlarr.cache import get_release, remove_release
from shelfmark.release_sources.prowlarr.clients import (
DownloadClient,
DownloadState,
get_client,
list_configured_clients,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol, get_unique_path
from shelfmark.release_sources.prowlarr.utils import get_protocol
logger = setup_logger(__name__)
@@ -24,10 +24,48 @@ logger = setup_logger(__name__)
POLL_INTERVAL = 2
def _diagnose_path_issue(path: str) -> str:
"""
Analyze a path and return diagnostic hints for common issues.
Args:
path: The path that failed to be accessed
Returns:
A hint string to help users diagnose the issue.
"""
# Detect Windows-style paths (won't work in Linux containers)
if len(path) >= 2 and path[1] == ':':
return (
f"Path '{path}' appears to be a Windows path. "
f"Shelfmark runs in Linux and cannot access Windows paths directly. "
f"Ensure your download client uses Linux-style paths (/path/to/files)."
)
# Detect backslashes (Windows path separators)
if '\\' in path:
return (
f"Path '{path}' contains backslashes. "
f"This may indicate a Windows path or incorrect path escaping. "
f"Linux paths should use forward slashes (/)."
)
# Generic hint for Linux paths
return (
f"Path '{path}' is not accessible from Shelfmark's container. "
f"Ensure both containers have matching volume mounts for this directory."
)
@register_handler("prowlarr")
class ProwlarrHandler(DownloadHandler):
"""Handler for Prowlarr downloads via configured torrent or usenet client."""
def __init__(self):
# Track downloads that may need client-side cleanup after Shelfmark completes import.
# task_id -> (client, download_id, protocol)
self._cleanup_refs: dict[str, tuple[DownloadClient, str, str]] = {}
def _get_category_for_task(self, client, task: DownloadTask) -> Optional[str]:
"""Get audiobook category if configured and applicable, else None for default."""
if not is_audiobook(task.content_type):
@@ -44,13 +82,52 @@ class ProwlarrHandler(DownloadHandler):
audiobook_key = audiobook_keys.get(client.name)
return config.get(audiobook_key, "") or None if audiobook_key else None
def _cleanup_client_history(self, client, download_id: str) -> None:
"""Remove completed download from client history if configured."""
if client.name == "sabnzbd" and config.get("SABNZBD_REMOVE_COMPLETED", True):
try:
client.remove(download_id, delete_files=True, archive=True)
except Exception as e:
logger.warning(f"Failed to remove from SABnzbd history: {e}")
def post_process_cleanup(self, task: DownloadTask, success: bool) -> None:
if not success:
self._cleanup_refs.pop(task.task_id, None)
return
client_ref = self._cleanup_refs.pop(task.task_id, None)
if client_ref is None:
return
client, download_id, protocol = client_ref
if protocol != "usenet":
return
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
try:
client.remove(download_id, delete_files=True)
except Exception as e:
logger.warning(f"Failed to cleanup usenet download {download_id} in {getattr(client, 'name', 'client')}: {e}")
def _safe_remove_download(self, client, download_id: str, protocol: str, reason: str) -> None:
"""Best-effort removal of a failed/cancelled download from the client.
Safety policy:
- torrents: never remove or delete client data (avoid breaking seeding)
- usenet: keep legacy behavior (delete client files on removal)
"""
if protocol != "usenet":
logger.info(
"Skipping download client cleanup for protocol=%s after %s (client=%s id=%s)",
protocol,
reason,
getattr(client, "name", "client"),
download_id,
)
return
try:
client.remove(download_id, delete_files=True)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after {reason}: {e}"
)
def _build_progress_message(self, status) -> str:
"""Build a progress message from download status."""
@@ -123,7 +200,16 @@ class ProwlarrHandler(DownloadHandler):
source_path = client.get_download_path(download_id)
if not source_path:
status_callback("error", "Could not locate existing download file")
logger.error(
f"Could not get path for existing download. "
f"Client: {client.name}, ID: {download_id}. "
f"The download may have been moved or deleted."
)
status_callback(
"error",
f"Could not locate existing download in {client.name}. "
f"Check that the file still exists."
)
return None
result = self._handle_completed_file(
@@ -135,7 +221,7 @@ class ProwlarrHandler(DownloadHandler):
if result:
remove_release(task.task_id)
self._cleanup_client_history(client, download_id)
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
return result
# Existing but still downloading - join the progress polling
@@ -186,6 +272,10 @@ class ProwlarrHandler(DownloadHandler):
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Poll the download client for progress and handle completion."""
# Track consecutive "not found" errors - torrents may take time to appear in client
not_found_count = 0
max_not_found_retries = 15 # 15 retries * 2s poll = 30s grace period
try:
logger.debug(f"Starting poll for {download_id} (content_type={task.content_type})")
while not cancel_flag.is_set():
@@ -197,6 +287,7 @@ class ProwlarrHandler(DownloadHandler):
if status.state == DownloadState.ERROR:
logger.error(f"Download {download_id} completed with error: {status.message}")
status_callback("error", status.message or "Download failed")
self._safe_remove_download(client, download_id, protocol, "completion error")
return None
# Download complete - break to handle file
logger.debug(f"Download {download_id} complete, file_path={status.file_path}")
@@ -204,11 +295,31 @@ class ProwlarrHandler(DownloadHandler):
# Check for error state
if status.state == DownloadState.ERROR:
logger.error(f"Download {download_id} error state: {status.message}")
# "Torrent not found" is often transient - the client may not have indexed it yet
if "not found" in (status.message or "").lower():
not_found_count += 1
if not_found_count < max_not_found_retries:
logger.debug(
f"Download {download_id} not yet visible in client "
f"(attempt {not_found_count}/{max_not_found_retries})"
)
status_callback("resolving", "Waiting for download client...")
if cancel_flag.wait(timeout=POLL_INTERVAL):
break
continue
# Exhausted retries
logger.error(
f"Download {download_id} not found after {max_not_found_retries} attempts"
)
else:
logger.error(f"Download {download_id} error state: {status.message}")
status_callback("error", status.message or "Download failed")
client.remove(download_id, delete_files=True)
self._safe_remove_download(client, download_id, protocol, "download error")
return None
# Reset not-found counter on successful status check
not_found_count = 0
# Build status message - use client message if provided, else build progress
msg = status.message or self._build_progress_message(status)
if status.state == DownloadState.PROCESSING:
@@ -223,8 +334,18 @@ class ProwlarrHandler(DownloadHandler):
# Handle cancellation
if cancel_flag.is_set():
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
client.remove(download_id, delete_files=True)
if protocol == "usenet":
logger.info(f"Download cancelled, removing from {client.name}: {download_id}")
try:
client.remove(download_id, delete_files=True)
except Exception as e:
logger.warning(
f"Failed to remove download {download_id} from {client.name} after cancellation: {e}"
)
else:
logger.info(
f"Download cancelled for protocol={protocol}; leaving in {client.name}: {download_id}"
)
status_callback("cancelled", "Cancelled")
return None
@@ -238,7 +359,7 @@ class ProwlarrHandler(DownloadHandler):
)
status_callback(
"error",
f"Download completed in {client.name} but path not returned. "
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
return None
@@ -246,16 +367,12 @@ class ProwlarrHandler(DownloadHandler):
# Verify the path actually exists in our filesystem
source_path_obj = Path(source_path)
if 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}. "
f"The download client's path may not be mounted in Shelfmark's container. "
f"Ensure both containers use identical volume mappings for the download folder."
)
status_callback(
"error",
f"Path not accessible: {source_path}. Check volume mappings between {client.name} and Shelfmark."
f"Client: {client.name}, ID: {download_id}. {hint}"
)
status_callback("error", hint)
return None
result = self._handle_completed_file(
@@ -268,17 +385,14 @@ class ProwlarrHandler(DownloadHandler):
# Clean up on success
if result:
remove_release(task.task_id)
self._cleanup_client_history(client, download_id)
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
return result
except Exception as e:
logger.error(f"Error during download polling: {e}")
status_callback("error", str(e))
try:
client.remove(download_id, delete_files=True)
except Exception as cleanup_error:
logger.error(f"Failed to cleanup download {download_id} after error: {cleanup_error}")
self._safe_remove_download(client, download_id, protocol, "polling exception")
return None
def _handle_completed_file(
@@ -288,56 +402,26 @@ class ProwlarrHandler(DownloadHandler):
task: DownloadTask,
status_callback: Callable[[str, Optional[str]], None],
) -> Optional[str]:
"""Handle completed download. Torrents return original path; usenet stages to temp."""
"""Handle a completed download and return its path.
For external download clients (torrents/usenet), staging large payloads into TMP_DIR
is expensive (and can duplicate multi-GB files). Instead, return the client's
completed path and let the orchestrator perform any required transfer (copy/move/
hardlink) directly from that source.
Torrents also set ``task.original_download_path`` so the orchestrator can detect
seeding data and enable hardlinking when configured.
"""
try:
# For torrents, skip staging - return original path directly
# Orchestrator will hardlink (library mode) or copy (ingest mode) as needed
if protocol == "torrent":
task.original_download_path = str(source_path)
logger.debug(f"Torrent complete, returning original path: {source_path}")
return str(source_path)
# Usenet: stage based on config
status_callback("resolving", "Staging file")
use_copy = config.get("PROWLARR_USENET_ACTION", "move") == "copy"
logger.debug(f"Download complete, returning original path: {source_path}")
return str(source_path)
from shelfmark.download.orchestrator import get_staging_dir
staging_dir = get_staging_dir()
if source_path.is_dir():
staged_path = get_unique_path(staging_dir, source_path.name)
if use_copy:
shutil.copytree(str(source_path), str(staged_path))
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Staged directory: {staged_path.name}")
else:
staged_path = get_unique_path(staging_dir, source_path.stem, source_path.suffix)
if use_copy:
shutil.copy2(str(source_path), str(staged_path))
else:
shutil.move(str(source_path), str(staged_path))
logger.debug(f"Staged: {staged_path.name}")
return str(staged_path)
except FileNotFoundError as e:
logger.error(
f"Source file not found during staging: {source_path}. "
f"The file may have been moved or deleted by the download client. Error: {e}"
)
status_callback("error", f"File not found: {source_path}. It may have been moved or deleted.")
return None
except PermissionError as e:
logger.error(
f"Permission denied staging file from {source_path}. "
f"Check that Shelfmark has read access to the download folder. Error: {e}"
)
status_callback("error", f"Permission denied accessing {source_path}. Check folder permissions.")
return None
except Exception as e:
logger.error(f"Staging failed for {source_path}: {e}")
status_callback("error", f"Failed to stage file: {e}")
logger.error(f"Failed to finalize completed download at {source_path}: {e}")
status_callback("error", f"Failed to finalize completed download: {e}")
return None
def cancel(self, task_id: str) -> bool:
+89 -42
View File
@@ -162,35 +162,96 @@ def _test_transmission_connection(current_values: Dict[str, Any] = None) -> Dict
def _test_deluge_connection(current_values: Dict[str, Any] = None) -> Dict[str, Any]:
"""Test the Deluge connection using current form values."""
"""Test Deluge Web UI JSON-RPC connection using current form values."""
from urllib.parse import urlparse
import requests
from shelfmark.core.config import config
current_values = current_values or {}
host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost")
port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "58846")
username = current_values.get("DELUGE_USERNAME") or config.get("DELUGE_USERNAME", "")
raw_host = current_values.get("DELUGE_HOST") or config.get("DELUGE_HOST", "localhost")
raw_port = current_values.get("DELUGE_PORT") or config.get("DELUGE_PORT", "8112")
password = current_values.get("DELUGE_PASSWORD") or config.get("DELUGE_PASSWORD", "")
if not host:
if not raw_host:
return {"success": False, "message": "Deluge host is required"}
if not password:
return {"success": False, "message": "Deluge password is required"}
try:
from deluge_client import DelugeRPCClient
raw_host = str(raw_host)
raw_port = str(raw_port or "8112")
client = DelugeRPCClient(
host=host,
port=int(port),
username=username,
password=password,
)
client.connect()
version = client.call('daemon.info')
scheme = "http"
base_path = ""
host = raw_host
port = int(raw_port) if raw_port.isdigit() else 8112
# Allow DELUGE_HOST to be a full URL (e.g. http://deluge:8112)
if raw_host.startswith(("http://", "https://")):
parsed = urlparse(raw_host)
scheme = parsed.scheme or "http"
host = parsed.hostname or "localhost"
if parsed.port is not None:
port = parsed.port
base_path = (parsed.path or "").rstrip("/")
else:
# Allow "host:port" in DELUGE_HOST for convenience.
if ":" in raw_host and raw_host.count(":") == 1:
host_part, port_part = raw_host.split(":", 1)
if host_part and port_part.isdigit():
host = host_part
port = int(port_part)
rpc_url = f"{scheme}://{host}:{port}{base_path}/json"
def rpc_call(session: requests.Session, rpc_id: int, method: str, *params: Any) -> Any:
payload = {"id": rpc_id, "method": method, "params": list(params)}
resp = session.post(rpc_url, json=payload, timeout=15)
resp.raise_for_status()
data = resp.json()
if data.get("error"):
error = data["error"]
if isinstance(error, dict):
raise Exception(error.get("message") or str(error))
raise Exception(str(error))
return data.get("result")
try:
session = requests.Session()
if rpc_call(session, 1, "auth.login", password) is not True:
return {"success": False, "message": "Deluge Web UI authentication failed"}
if rpc_call(session, 2, "web.connected") is not True:
hosts = rpc_call(session, 3, "web.get_hosts") or []
if not hosts:
return {
"success": False,
"message": "Deluge Web UI isn't connected to Deluge core (no hosts configured). Add/connect a daemon in Deluge Web UI → Connection Manager.",
}
host_id = hosts[0][0]
for entry in hosts:
if isinstance(entry, list) and len(entry) >= 2 and entry[1] in {"127.0.0.1", "localhost"}:
host_id = entry[0]
break
rpc_call(session, 4, "web.connect", host_id)
if rpc_call(session, 5, "web.connected") is not True:
return {
"success": False,
"message": "Deluge Web UI couldn't connect to Deluge core. Check Deluge Web UI → Connection Manager.",
}
version = rpc_call(session, 6, "daemon.info")
return {"success": True, "message": f"Connected to Deluge {version}"}
except ImportError:
return {"success": False, "message": "deluge-client package not installed"}
except requests.exceptions.ConnectionError:
return {"success": False, "message": "Could not connect to Deluge Web UI"}
except requests.exceptions.Timeout:
return {"success": False, "message": "Connection timed out"}
except Exception as e:
return {"success": False, "message": f"Connection failed: {str(e)}"}
@@ -486,30 +547,24 @@ def prowlarr_clients_settings():
# --- Deluge Settings ---
TextField(
key="DELUGE_HOST",
label="Deluge Host",
description="Hostname or IP of your Deluge daemon",
placeholder="localhost",
label="Deluge Web UI Host/URL",
description="Hostname/IP or full URL of your Deluge Web UI (deluge-web)",
placeholder="http://deluge:8112",
default="localhost",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_PORT",
label="Deluge Port",
description="Deluge daemon RPC port (default: 58846). IMPORTANT: Ensure \"Allow Remote Connections\" is enabled in Deluge settings.",
placeholder="58846",
default="58846",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
TextField(
key="DELUGE_USERNAME",
label="Username",
description="Deluge daemon username (from auth file)",
label="Deluge Web UI Port",
description="Deluge Web UI port (default: 8112)",
placeholder="8112",
default="8112",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
PasswordField(
key="DELUGE_PASSWORD",
label="Password",
description="Deluge daemon password (from auth file)",
description="Deluge Web UI password (default: deluge)",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "deluge"},
),
ActionButton(
@@ -686,22 +741,14 @@ def prowlarr_clients_settings():
default="",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
CheckboxField(
key="SABNZBD_REMOVE_COMPLETED",
label="Remove completed downloads from history",
default=True,
description="Remove downloads from SABnzbd history after successful import (archives them)",
show_when={"field": "PROWLARR_USENET_CLIENT", "value": "sabnzbd"},
),
# Note: Usenet client download path must be mounted identically in both containers.
SelectField(
key="PROWLARR_USENET_ACTION",
label="NZB Completion Action",
description="What to do with usenet files after download completes",
description="Copy files into your ingest folder, optionally cleaning up the usenet client",
options=[
{"value": "move", "label": "Move to ingest"},
{"value": "copy", "label": "Copy to ingest"},
{"value": "move", "label": "Copy and remove from client"},
{"value": "copy", "label": "Copy (keep in client)"},
],
default="move",
show_when={"field": "PROWLARR_USENET_CLIENT", "notEmpty": True},
+61 -22
View File
@@ -5,7 +5,7 @@ from typing import List, Optional
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.metadata_providers import BookMetadata
from shelfmark.metadata_providers import BookMetadata, build_localized_search_titles
from shelfmark.release_sources import (
Release,
ReleaseSource,
@@ -304,12 +304,8 @@ class ProwlarrSource(ReleaseSource):
logger.warning("Prowlarr not configured - skipping search")
return []
# Build search query
query_parts = []
# Prefer search_title if available (cleaner title for searches)
search_title = book.search_title or book.title
if search_title:
query_parts.append(search_title)
# Build search queries (optionally include localized titles)
query_author = ""
if book.authors:
# Use first author only - authors may be a list or a single string
# that contains multiple comma-separated names (from frontend)
@@ -317,14 +313,34 @@ class ProwlarrSource(ReleaseSource):
# If first author contains comma, split and use only the primary author
if "," in first_author:
first_author = first_author.split(",")[0].strip()
query_parts.append(first_author)
query_author = first_author
query = " ".join(query_parts)
if not query:
# Prefer search_title if available (cleaner title for searches)
search_title = book.search_title or book.title
language_preferences = languages or ([book.language] if book.language else None)
search_titles = build_localized_search_titles(
base_title=search_title,
languages=language_preferences,
titles_by_language=book.titles_by_language,
# Keep the existing search_title behavior for English while still
# allowing additional localized searches for other languages.
excluded_languages={"en", "eng", "english"},
)
queries = [
" ".join(part for part in [title, query_author] if part).strip()
for title in search_titles
]
queries = [q for q in queries if q]
if not queries:
# Try ISBN as fallback
query = book.isbn_13 or book.isbn_10 or ""
isbn_query = book.isbn_13 or book.isbn_10 or ""
if isbn_query:
queries = [isbn_query]
if not query:
if not queries:
logger.warning("No search query available for book")
return []
@@ -338,9 +354,12 @@ class ProwlarrSource(ReleaseSource):
self.last_search_type = "expanded" if expand_search else "categories"
indexer_desc = f"indexers={indexer_ids}" if indexer_ids else "all enabled indexers"
logger.debug(f"Searching Prowlarr: query='{query}', {indexer_desc}, categories={categories}")
if len(queries) == 1:
logger.debug(f"Searching Prowlarr: query='{queries[0]}', {indexer_desc}, categories={categories}")
else:
logger.debug(f"Searching Prowlarr: {len(queries)} queries, {indexer_desc}, categories={categories}")
def search_indexers(cats: Optional[List[int]]) -> List[dict]:
def search_indexers(query: str, cats: Optional[List[int]]) -> List[dict]:
"""Search indexers with given categories, collecting results."""
results = []
if indexer_ids:
@@ -362,16 +381,36 @@ class ProwlarrSource(ReleaseSource):
logger.warning(f"Search failed for all indexers: {e}")
return results
all_results = []
try:
all_results = search_indexers(categories)
# Auto-expand: if no results with categories and auto-expand enabled, retry without
auto_expand_enabled = config.get("PROWLARR_AUTO_EXPAND", False)
if not all_results and categories and auto_expand_enabled:
logger.info("Prowlarr: no results with category filter, auto-expanding search")
all_results = search_indexers(None)
self.last_search_type = "expanded"
seen_keys: set[str] = set()
all_results: List[dict] = []
for idx, query in enumerate(queries, start=1):
if len(queries) > 1:
logger.debug(f"Prowlarr query {idx}/{len(queries)}: '{query}'")
raw_results = search_indexers(query=query, cats=categories)
# Auto-expand: if no results with categories and auto-expand enabled, retry without
if not raw_results and categories and auto_expand_enabled:
logger.info(f"Prowlarr: no results for query '{query}' with category filter, auto-expanding search")
raw_results = search_indexers(query=query, cats=None)
self.last_search_type = "expanded"
for r in raw_results:
key = (
r.get("guid")
or r.get("downloadUrl")
or r.get("magnetUrl")
or r.get("infoUrl")
or f"{r.get('indexerId')}:{r.get('title')}"
)
if key in seen_keys:
continue
seen_keys.add(key)
all_results.append(r)
results = [_prowlarr_result_to_release(r, content_type) for r in all_results]
+2
View File
@@ -437,6 +437,7 @@ function App() {
await fetchStatus();
} catch (error) {
console.error('Cancel failed:', error);
showToast('Failed to cancel/clear download', 'error');
}
};
@@ -447,6 +448,7 @@ function App() {
await fetchStatus();
} catch (error) {
console.error('Clear completed failed:', error);
showToast('Failed to clear finished downloads', 'error');
}
};
@@ -142,6 +142,8 @@ export const DownloadsSidebar = ({
};
const isInProgress = ['queued', 'resolving', 'downloading'].includes(statusName);
const isQueued = statusName === 'queued';
const isActive = statusName === 'resolving' || statusName === 'downloading';
const isCompleted = statusName === 'complete';
const hasError = statusName === 'error';
@@ -169,25 +171,27 @@ export const DownloadsSidebar = ({
className="relative rounded-lg border hover:shadow-md transition-shadow overflow-hidden"
style={{ borderColor: 'var(--border-muted)', background: 'var(--bg-soft)' }}
>
{/* Cancel/Clear Button - top right corner */}
{/* Action Button - top right corner */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onCancel(book.id);
}}
className={`absolute top-1 right-1 z-10 flex items-center justify-center rounded-full transition-colors ${
isInProgress
? 'px-2 py-0.5 text-xs font-medium text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
: 'w-6 h-6 text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30'
className={`absolute top-1 right-1 z-10 flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
isActive || isQueued
? 'text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30'
: 'text-gray-500 hover:text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30'
}`}
title={isInProgress ? "Stop download" : "Clear from list"}
aria-label={isInProgress ? "Stop download" : "Clear from list"}
title={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
aria-label={isActive ? 'Stop download' : isQueued ? 'Remove from queue' : 'Clear from list'}
>
{isInProgress ? (
'Stop'
{isActive ? (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
) : (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)}
+22 -12
View File
@@ -16,6 +16,7 @@ import {
HeadingFieldConfig,
ActionButtonConfig,
ActionResult,
ShowWhenCondition,
} from '../types/settings';
import { FieldWrapper } from './settings/shared';
import {
@@ -35,6 +36,24 @@ interface OnboardingModalProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
function evaluateShowWhenCondition(
showWhen: ShowWhenCondition,
values: Record<string, unknown>
): boolean {
const currentValue = values[showWhen.field];
if (showWhen.notEmpty) {
if (Array.isArray(currentValue)) {
return currentValue.length > 0;
}
return currentValue !== undefined && currentValue !== null && currentValue !== '';
}
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
}
// Check if a field should be visible based on showWhen condition
function isFieldVisible(
field: SettingsField,
@@ -43,20 +62,11 @@ function isFieldVisible(
const showWhen = field.showWhen;
if (!showWhen) return true;
const currentValue = values[showWhen.field];
// Handle notEmpty condition
if (showWhen.notEmpty) {
if (Array.isArray(currentValue)) {
return currentValue.length > 0;
}
return currentValue !== undefined && currentValue !== null && currentValue !== '';
if (Array.isArray(showWhen)) {
return showWhen.every((condition) => evaluateShowWhenCondition(condition, values));
}
// Handle array of allowed values or single value
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
return evaluateShowWhenCondition(showWhen, values);
}
// Check if a step should be visible based on its showWhen conditions (all must be true)
@@ -13,6 +13,7 @@ import {
OrderableListItem,
ActionButtonConfig,
HeadingFieldConfig,
ShowWhenCondition,
} from '../../types/settings';
import { FieldWrapper } from './shared';
import {
@@ -38,6 +39,24 @@ interface SettingsContentProps {
isUniversalMode?: boolean; // Whether app is in Universal search mode
}
function evaluateShowWhenCondition(
showWhen: ShowWhenCondition,
values: Record<string, unknown>
): boolean {
const currentValue = values[showWhen.field];
if (showWhen.notEmpty) {
if (Array.isArray(currentValue)) {
return currentValue.length > 0;
}
return currentValue !== undefined && currentValue !== null && currentValue !== '';
}
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
}
// Check if a field should be visible based on showWhen condition and search mode
function isFieldVisible(
field: SettingsField,
@@ -52,20 +71,11 @@ function isFieldVisible(
const showWhen = field.showWhen;
if (!showWhen) return true;
const currentValue = values[showWhen.field];
// Handle notEmpty condition - show when field has any non-empty value
if (showWhen.notEmpty) {
if (Array.isArray(currentValue)) {
return currentValue.length > 0;
}
return currentValue !== undefined && currentValue !== null && currentValue !== '';
if (Array.isArray(showWhen)) {
return showWhen.every((condition) => evaluateShowWhenCondition(condition, values));
}
// Handle array of allowed values or single value
return Array.isArray(showWhen.value)
? showWhen.value.includes(currentValue as string)
: currentValue === showWhen.value;
return evaluateShowWhenCondition(showWhen, values);
}
// Check if a field should be disabled based on disabledWhen condition
@@ -117,7 +127,8 @@ const renderField = (
value: unknown,
onChange: (value: unknown) => void,
onAction: () => Promise<ActionResult>,
isDisabled: boolean
isDisabled: boolean,
allValues: Record<string, unknown> // All form values for cascading dropdown support
) => {
switch (field.type) {
case 'TextField':
@@ -156,15 +167,26 @@ const renderField = (
disabled={isDisabled}
/>
);
case 'SelectField':
case 'SelectField': {
const selectConfig = field as SelectFieldConfig;
// Get filter value for cascading dropdowns
const rawFilterValue = selectConfig.filterByField
? allValues[selectConfig.filterByField]
: undefined;
const filterValue =
rawFilterValue === undefined || rawFilterValue === null || rawFilterValue === ''
? undefined
: String(rawFilterValue);
return (
<SelectField
field={field as SelectFieldConfig}
field={selectConfig}
value={(value as string) ?? ''}
onChange={onChange}
disabled={isDisabled}
filterValue={filterValue}
/>
);
}
case 'MultiSelectField':
return (
<MultiSelectField
@@ -227,24 +249,25 @@ export const SettingsContent = ({
>
<div className="space-y-5">
{visibleFields.map((field) => {
const disabledState = getDisabledState(field, values);
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled
)}
</FieldWrapper>
);
})}
const disabledState = getDisabledState(field, values);
return (
<FieldWrapper
key={`${tab.name}-${field.key}`}
field={field}
disabledOverride={disabledState.disabled}
disabledReasonOverride={disabledState.reason}
>
{renderField(
field,
values[field.key],
(v) => onChange(field.key, v),
() => onAction(field.key),
disabledState.disabled,
values
)}
</FieldWrapper>
);
})}
</div>
</div>
@@ -1,3 +1,4 @@
import { useEffect, useMemo, useRef } from 'react';
import { SelectFieldConfig } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
@@ -6,17 +7,53 @@ interface SelectFieldProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
filterValue?: string;
}
export const SelectField = ({ field, value, onChange, disabled }: SelectFieldProps) => {
// disabled prop is already computed by SettingsContent.getDisabledState()
export const SelectField = ({ field, value, onChange, disabled, filterValue }: SelectFieldProps) => {
const isDisabled = disabled ?? false;
const prevFilterValue = useRef(filterValue);
const normalizedOptions = useMemo(
() =>
field.options.map((opt) => ({
...opt,
value: String(opt.value),
childOf:
opt.childOf === undefined || opt.childOf === null
? undefined
: String(opt.childOf),
label: opt.label ?? String(opt.value),
})),
[field.options]
);
// Filter options based on filterValue (cascading dropdown support)
const filteredOptions = useMemo(() => {
if (!filterValue) {
return normalizedOptions.filter((opt) => !opt.childOf);
}
// Filter to options that belong to the selected parent or have no parent
return normalizedOptions.filter((opt) => !opt.childOf || opt.childOf === filterValue);
}, [normalizedOptions, filterValue]);
// Clear selection when filter value changes and current value is not in filtered options
useEffect(() => {
if (prevFilterValue.current !== filterValue && filterValue !== undefined) {
const currentValueInOptions = filteredOptions.some((opt) => opt.value === value);
if (!currentValueInOptions && value) {
onChange('');
}
}
prevFilterValue.current = filterValue;
}, [filterValue, filteredOptions, value, onChange]);
// Use field's default value as fallback when value is empty
const effectiveValue = value || field.default || '';
// Convert options to DropdownList format
const dropdownOptions = field.options.map((opt) => ({
const dropdownOptions = filteredOptions.map((opt) => ({
value: opt.value,
label: opt.label,
description: opt.description,
@@ -30,7 +67,7 @@ export const SelectField = ({ field, value, onChange, disabled }: SelectFieldPro
if (isDisabled) {
// When disabled, show a static display instead of the dropdown
const selectedOption = field.options.find((opt) => opt.value === effectiveValue);
const selectedOption = filteredOptions.find((opt) => opt.value === effectiveValue);
return (
<div className="w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-60 cursor-not-allowed">
{selectedOption?.label || 'Select...'}
+3 -1
View File
@@ -39,6 +39,7 @@ export class TimeoutError extends Error {
// Default request timeout in milliseconds (30 seconds)
const DEFAULT_TIMEOUT_MS = 30000;
const EXPANDED_RELEASES_TIMEOUT_MS = 60000;
// Utility function for JSON fetch with credentials and timeout
async function fetchJSON<T>(url: string, opts: RequestInit = {}, timeoutMs: number = DEFAULT_TIMEOUT_MS): Promise<T> {
@@ -347,5 +348,6 @@ export const getReleases = async (
if (contentType) {
params.set('content_type', contentType);
}
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`);
const timeoutMs = expandSearch ? EXPANDED_RELEASES_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, timeoutMs);
};
+5 -2
View File
@@ -25,6 +25,8 @@ export interface ShowWhenCondition {
notEmpty?: boolean; // If true, show when field has any non-empty value
}
export type ShowWhen = ShowWhenCondition | ShowWhenCondition[];
// Conditional disable configuration
export interface DisabledWhenCondition {
field: string; // The field key to check
@@ -42,7 +44,7 @@ export interface BaseField {
fromEnv?: boolean; // True if value is set via environment variable
disabled?: boolean; // True if field is disabled/greyed out
disabledReason?: string; // Explanation shown when field is disabled
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
showWhen?: ShowWhen; // Conditional visibility based on another field's value
disabledWhen?: DisabledWhenCondition; // Conditional disable based on another field's value
requiresRestart?: boolean; // True if changing this setting requires a container restart
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
@@ -80,6 +82,7 @@ export interface SelectFieldConfig extends BaseField {
value: string;
options: SelectOption[];
default?: string;
filterByField?: string; // Field key whose value filters options via childOf property
}
export interface MultiSelectFieldConfig extends BaseField {
@@ -122,7 +125,7 @@ export interface HeadingFieldConfig {
description?: string;
linkUrl?: string;
linkText?: string;
showWhen?: ShowWhenCondition; // Conditional visibility based on another field's value
showWhen?: ShowWhen; // Conditional visibility based on another field's value
universalOnly?: boolean; // Only show in Universal search mode (hide in Direct mode)
}
+77 -15
View File
@@ -27,13 +27,13 @@ class TestDirectorySetup:
def test_staging_dir_created_on_demand(self):
"""Staging directory should be created if it doesn't exist."""
from shelfmark.download.orchestrator import get_staging_dir
from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
test_staging = Path(tmpdir) / "staging"
assert not test_staging.exists()
with patch("shelfmark.download.orchestrator.TMP_DIR", test_staging):
with patch("shelfmark.config.env.TMP_DIR", test_staging):
result = get_staging_dir()
assert test_staging.exists()
@@ -41,24 +41,24 @@ class TestDirectorySetup:
def test_staging_dir_handles_existing_directory(self):
"""Staging directory creation should be idempotent."""
from shelfmark.download.orchestrator import get_staging_dir
from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
test_staging = Path(tmpdir) / "staging"
test_staging.mkdir()
with patch("shelfmark.download.orchestrator.TMP_DIR", test_staging):
with patch("shelfmark.config.env.TMP_DIR", test_staging):
result = get_staging_dir()
assert result == test_staging
def test_staging_path_handles_special_characters(self):
"""Staging path should handle task IDs with special characters."""
from shelfmark.download.orchestrator import get_staging_path
from shelfmark.download.staging import get_staging_path
with tempfile.TemporaryDirectory() as tmpdir:
with patch(
"shelfmark.download.orchestrator.TMP_DIR", Path(tmpdir)
"shelfmark.config.env.TMP_DIR", Path(tmpdir)
):
# Task ID with URL-like characters
path = get_staging_path(
@@ -74,11 +74,11 @@ class TestDirectorySetup:
def test_staging_path_normalizes_extension(self):
"""Staging path should handle extensions with or without dot."""
from shelfmark.download.orchestrator import get_staging_path
from shelfmark.download.staging import get_staging_path
with tempfile.TemporaryDirectory() as tmpdir:
with patch(
"shelfmark.download.orchestrator.TMP_DIR", Path(tmpdir)
"shelfmark.config.env.TMP_DIR", Path(tmpdir)
):
path1 = get_staging_path("task1", "epub")
path2 = get_staging_path("task1", ".epub")
@@ -309,14 +309,14 @@ class TestConfigValidation:
def test_missing_required_directory_handling(self):
"""Application should handle missing directories gracefully."""
from shelfmark.download.orchestrator import get_staging_dir
from shelfmark.download.staging import get_staging_dir
with tempfile.TemporaryDirectory() as tmpdir:
# Use a path that doesn't exist yet
nonexistent = Path(tmpdir) / "deeply" / "nested" / "path"
with patch(
"shelfmark.download.orchestrator.TMP_DIR", nonexistent
"shelfmark.config.env.TMP_DIR", nonexistent
):
result = get_staging_dir()
@@ -346,6 +346,68 @@ class TestConfigValidation:
os.chmod(readonly_dir, 0o755) # Restore for cleanup
# =============================================================================
# Settings Validation Tests
# =============================================================================
class TestSettingsValidation:
"""Tests for settings save-time validation."""
def test_downloads_books_rename_template_rejects_path_separators(self):
import shelfmark.config.settings # noqa: F401
from shelfmark.core.settings_registry import update_settings
result = update_settings(
"downloads",
{
"FILE_ORGANIZATION": "rename",
"TEMPLATE_RENAME": "{Author}/{Title}",
},
)
assert result["success"] is False
assert "Naming Template" in result["message"]
assert "Organize" in result["message"]
def test_downloads_audiobooks_rename_template_rejects_path_separators(self):
import shelfmark.config.settings # noqa: F401
from shelfmark.core.settings_registry import update_settings
result = update_settings(
"downloads",
{
"FILE_ORGANIZATION_AUDIOBOOK": "rename",
"TEMPLATE_AUDIOBOOK_RENAME": "{Author}/{Title}",
},
)
assert result["success"] is False
assert "Naming Template" in result["message"]
assert "Organize" in result["message"]
def test_downloads_books_rename_validation_uses_existing_values(self):
import shelfmark.config.settings # noqa: F401
from shelfmark.core.settings_registry import update_settings
with patch(
"shelfmark.config.settings.load_config_file",
return_value={
"BOOKS_OUTPUT_MODE": "folder",
"TEMPLATE_RENAME": "{Author}/{Title}",
},
):
result = update_settings(
"downloads",
{
"FILE_ORGANIZATION": "rename",
},
)
assert result["success"] is False
assert "Naming Template" in result["message"]
# =============================================================================
# Debug and Logging Configuration Tests
# =============================================================================
@@ -462,7 +524,7 @@ class TestFileCollisionHandling:
def test_stage_file_handles_collision(self):
"""stage_file should add suffix on collision."""
from shelfmark.download.orchestrator import stage_file
from shelfmark.download.staging import stage_file
with tempfile.TemporaryDirectory() as tmpdir:
staging = Path(tmpdir) / "staging"
@@ -476,7 +538,7 @@ class TestFileCollisionHandling:
(staging / "book.epub").write_text("existing")
with patch(
"shelfmark.download.orchestrator.TMP_DIR", staging
"shelfmark.config.env.TMP_DIR", staging
):
result = stage_file(source, "task1", copy=True)
@@ -486,7 +548,7 @@ class TestFileCollisionHandling:
def test_stage_file_copy_vs_move(self):
"""stage_file should copy or move based on parameter."""
from shelfmark.download.orchestrator import stage_file
from shelfmark.download.staging import stage_file
with tempfile.TemporaryDirectory() as tmpdir:
staging = Path(tmpdir) / "staging"
@@ -497,7 +559,7 @@ class TestFileCollisionHandling:
source1.write_text("content1")
with patch(
"shelfmark.download.orchestrator.TMP_DIR", staging
"shelfmark.config.env.TMP_DIR", staging
):
result1 = stage_file(source1, "task1", copy=True)
@@ -509,7 +571,7 @@ class TestFileCollisionHandling:
source2.write_text("content2")
with patch(
"shelfmark.download.orchestrator.TMP_DIR", staging
"shelfmark.config.env.TMP_DIR", staging
):
result2 = stage_file(source2, "task2", copy=False)
+170 -361
View File
@@ -62,6 +62,23 @@ def temp_dirs(tmp_path):
}
def _mock_destination_config(ingest_dir: Path, extra=None):
values = {
"DESTINATION": str(ingest_dir),
"INGEST_DIR": str(ingest_dir),
}
if extra:
values.update(extra)
return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
def _sync_core_config(mock_config, mock_core_config, mock_archive_config=None):
mock_core_config.get = mock_config.get
mock_core_config.CUSTOM_SCRIPT = getattr(mock_config, "CUSTOM_SCRIPT", None)
if mock_archive_config is not None:
mock_archive_config.get = mock_config.get
# =============================================================================
# _atomic_copy Tests
# =============================================================================
@@ -71,7 +88,7 @@ class TestAtomicCopy:
def test_copies_file(self, tmp_path):
"""Copies file to destination."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -87,7 +104,7 @@ class TestAtomicCopy:
def test_preserves_source(self, tmp_path):
"""Source file is preserved after copy."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("original content")
@@ -100,7 +117,7 @@ class TestAtomicCopy:
def test_handles_collision_with_counter(self, tmp_path):
"""Appends counter suffix when destination exists."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("new content")
@@ -116,7 +133,7 @@ class TestAtomicCopy:
def test_multiple_collisions(self, tmp_path):
"""Increments counter until finding free slot."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("new")
@@ -130,7 +147,7 @@ class TestAtomicCopy:
def test_preserves_extension(self, tmp_path):
"""Keeps extension when adding counter suffix."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "book.epub"
source.write_bytes(b"epub content")
@@ -143,7 +160,7 @@ class TestAtomicCopy:
def test_creates_distinct_file(self, tmp_path):
"""Copy creates a distinct file (not hardlink)."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -156,7 +173,7 @@ class TestAtomicCopy:
def test_copy_preserves_permissions(self, tmp_path):
"""Copy preserves file permissions (copy2 behavior)."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -170,7 +187,7 @@ class TestAtomicCopy:
def test_atomic_no_partial_file(self, tmp_path):
"""If copy fails, no partial file remains."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -186,7 +203,7 @@ class TestAtomicCopy:
def test_max_attempts_exceeded(self, tmp_path):
"""Raises after max collision attempts."""
from shelfmark.download.orchestrator import _atomic_copy
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
@@ -211,15 +228,20 @@ class TestProcessDirectory:
def test_finds_book_files(self, temp_dirs, sample_task):
"""Finds and moves book files to ingest."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"epub content")
with patch('shelfmark.download.orchestrator.config') as mock_config:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -236,16 +258,21 @@ class TestProcessDirectory:
def test_multiple_book_files(self, temp_dirs, sample_task):
"""Handles multiple book files in directory."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book1.epub").write_bytes(b"epub1")
(directory / "book2.epub").write_bytes(b"epub2")
with patch('shelfmark.download.orchestrator.config') as mock_config:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -258,15 +285,20 @@ class TestProcessDirectory:
def test_no_book_files_returns_error(self, temp_dirs, sample_task):
"""Returns error when no book files found."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
# Use a file type that isn't trackable (not epub, pdf, txt, etc.)
(directory / "readme.log").write_text("not a book")
with patch('shelfmark.download.orchestrator.config') as mock_config:
mock_config.get = MagicMock(return_value=["epub"])
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -280,14 +312,19 @@ class TestProcessDirectory:
def test_unsupported_format_error_message(self, temp_dirs, sample_task):
"""Returns helpful error when files exist but format unsupported."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.pdf").write_bytes(b"pdf content")
with patch('shelfmark.download.orchestrator.config') as mock_config:
mock_config.get = MagicMock(return_value=["epub"]) # PDF not supported
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"], # PDF not supported
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -299,75 +336,22 @@ class TestProcessDirectory:
assert "format not supported" in error
assert ".pdf" in error
def test_extracts_archive_when_no_books(self, temp_dirs, sample_task):
"""Extracts archives when no direct book files found."""
from shelfmark.download.orchestrator import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
# Create a mock archive file
archive = directory / "books.zip"
archive.write_bytes(b"PK...") # ZIP signature
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.is_archive', return_value=True), \
patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
mock_config.get = MagicMock(return_value=["epub"])
# Mock successful extraction
mock_result = MagicMock()
mock_result.success = True
mock_result.final_paths = [temp_dirs["ingest"] / "extracted.epub"]
mock_result.error = None
mock_extract.return_value = mock_result
final_paths, error = process_directory(
directory=directory,
ingest_dir=temp_dirs["ingest"],
task=sample_task,
)
assert error is None
mock_extract.assert_called_once()
def test_prefers_book_files_over_archives(self, temp_dirs, sample_task):
"""Uses book files directly when present, ignores archives."""
from shelfmark.download.orchestrator import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"epub content")
(directory / "extra.zip").write_bytes(b"PK...") # Archive ignored
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(return_value=["epub"])
final_paths, error = process_directory(
directory=directory,
ingest_dir=temp_dirs["ingest"],
task=sample_task,
)
assert error is None
assert len(final_paths) == 1
mock_extract.assert_not_called()
def test_uses_book_title_for_single_file(self, temp_dirs, sample_task):
"""Uses formatted title for single file when USE_BOOK_TITLE enabled."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "random_name.epub").write_bytes(b"content")
with patch('shelfmark.download.orchestrator.config') as mock_config:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "rename",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -382,16 +366,21 @@ class TestProcessDirectory:
def test_preserves_filenames_for_multifile(self, temp_dirs, sample_task):
"""Preserves original filenames for multi-file downloads."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "Part 1.epub").write_bytes(b"part1")
(directory / "Part 2.epub").write_bytes(b"part2")
with patch('shelfmark.download.orchestrator.config') as mock_config:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True # Ignored for multi-file
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -406,16 +395,21 @@ class TestProcessDirectory:
def test_nested_directory_files(self, temp_dirs, sample_task):
"""Finds book files in nested subdirectories."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
subdir = directory / "subdir"
subdir.mkdir(parents=True)
(subdir / "book.epub").write_bytes(b"content")
with patch('shelfmark.download.orchestrator.config') as mock_config:
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -428,17 +422,22 @@ class TestProcessDirectory:
def test_cleans_up_on_error(self, temp_dirs, sample_task):
"""Cleans up directory even on error."""
from shelfmark.download.orchestrator import process_directory
from shelfmark.download.postprocess.pipeline import process_directory
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"content")
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator._atomic_move', side_effect=Exception("Move failed")):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('shelfmark.download.postprocess.transfer.atomic_move', side_effect=Exception("Move failed")):
mock_config.USE_BOOK_TITLE = False
mock_config.get = MagicMock(return_value=["epub"])
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"SUPPORTED_FORMATS": ["epub"],
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
final_paths, error = process_directory(
directory=directory,
@@ -461,7 +460,7 @@ class TestPostProcessDownload:
def test_simple_file_move_to_ingest(self, temp_dirs, sample_direct_task):
"""Simple file is moved to ingest directory."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"epub content")
@@ -469,12 +468,14 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -492,7 +493,7 @@ class TestPostProcessDownload:
def test_uses_formatted_filename(self, temp_dirs, sample_direct_task):
"""Uses task title when USE_BOOK_TITLE enabled."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "random.epub"
temp_file.write_bytes(b"content")
@@ -500,12 +501,14 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -517,9 +520,9 @@ class TestPostProcessDownload:
result_path = Path(result)
assert "The Way of Kings" in result_path.name
def test_library_mode_for_universal(self, temp_dirs, sample_task):
"""Universal mode tries library mode when configured."""
from shelfmark.download.orchestrator import _post_process_download
def test_organize_mode_for_universal(self, temp_dirs, sample_task):
"""Universal mode organizes when configured."""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
library = temp_dirs["base"] / "library"
library.mkdir()
@@ -529,16 +532,18 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"PROCESSING_MODE": "library",
"LIBRARY_PATH": str(library),
"LIBRARY_TEMPLATE": "{Author}/{Title}",
"DESTINATION": str(library),
"FILE_ORGANIZATION": "organize",
"TEMPLATE_ORGANIZE": "{Author}/{Title}",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -552,9 +557,9 @@ class TestPostProcessDownload:
assert library in result_path.parents or result_path.parent == library
status_cb.assert_called_with("complete", "Complete")
def test_direct_mode_skips_library(self, temp_dirs, sample_direct_task):
"""Direct mode skips library mode even when configured."""
from shelfmark.download.orchestrator import _post_process_download
def test_direct_mode_uses_ingest(self, temp_dirs, sample_direct_task):
"""Direct mode keeps ingest destination when not organizing."""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
library = temp_dirs["base"] / "library"
library.mkdir()
@@ -564,15 +569,17 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"PROCESSING_MODE": "library", # Configured but ignored
"LIBRARY_PATH": str(library),
"DESTINATION": str(temp_dirs["ingest"]),
"FILE_ORGANIZATION": "none",
}.get(key, default))
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -587,7 +594,7 @@ class TestPostProcessDownload:
def test_cancellation_before_ingest(self, temp_dirs, sample_direct_task):
"""Respects cancellation before final move."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -596,12 +603,14 @@ class TestPostProcessDownload:
cancel_flag = Event()
cancel_flag.set() # Already cancelled
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -614,109 +623,12 @@ class TestPostProcessDownload:
# File should be cleaned up
assert not temp_file.exists()
def test_archive_extraction(self, temp_dirs, sample_direct_task):
"""Archives are extracted."""
from shelfmark.download.orchestrator import _post_process_download
archive = temp_dirs["staging"] / "book.zip"
archive.write_bytes(b"PK...")
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=True), \
patch('shelfmark.download.orchestrator.process_archive') as mock_extract:
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.success = True
mock_result.final_paths = [temp_dirs["ingest"] / "book.epub"]
mock_result.message = "Extracted 1 file"
mock_extract.return_value = mock_result
result = _post_process_download(
temp_file=archive,
task=sample_direct_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
assert result is not None
mock_extract.assert_called_once()
status_cb.assert_called_with("complete", "Extracted 1 file")
def test_directory_processing(self, temp_dirs, sample_direct_task):
"""Directories are processed via process_directory."""
from shelfmark.download.orchestrator import _post_process_download
directory = temp_dirs["staging"] / "download"
directory.mkdir()
(directory / "book.epub").write_bytes(b"content")
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=["epub"])
result = _post_process_download(
temp_file=directory,
task=sample_direct_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
assert result is not None
status_cb.assert_called_with("complete", "Complete")
def test_torrent_staging_for_ingest_mode(self, temp_dirs, sample_task):
"""Torrent files are copied to staging before ingest."""
from shelfmark.download.orchestrator import _post_process_download
# Simulate torrent client download location
torrent_path = temp_dirs["base"] / "downloads" / "book.epub"
torrent_path.parent.mkdir()
torrent_path.write_bytes(b"content")
sample_task.original_download_path = str(torrent_path)
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.get_staging_dir', return_value=temp_dirs["staging"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
result = _post_process_download(
temp_file=torrent_path,
task=sample_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
assert result is not None
# Original torrent file should still exist
assert torrent_path.exists()
# Result should be in ingest
assert Path(result).parent == temp_dirs["ingest"]
# NOTE: archive extraction and torrent hardlink/copy behaviour are exercised via
# black-box matrix scenarios in `tests/core/test_processing_integration.py`.
def test_audiobook_uses_dedicated_ingest(self, temp_dirs, sample_task):
"""Audiobooks use dedicated ingest directory when configured."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
audiobook_ingest = temp_dirs["base"] / "audiobook_ingest"
audiobook_ingest.mkdir()
@@ -728,15 +640,18 @@ class TestPostProcessDownload:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False):
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
_sync_core_config(mock_config, mock_config)
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"INGEST_DIR_AUDIOBOOK": str(audiobook_ingest),
"DESTINATION": str(temp_dirs["ingest"]),
"INGEST_DIR": str(temp_dirs["ingest"]),
"DESTINATION_AUDIOBOOK": str(audiobook_ingest),
}.get(key, default))
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -758,7 +673,7 @@ class TestCustomScriptExecution:
def test_runs_custom_script(self, temp_dirs, sample_direct_task):
"""Runs custom script when configured."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -767,14 +682,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run') as mock_run:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
mock_run.return_value = MagicMock(stdout="", returncode=0)
@@ -792,7 +708,7 @@ class TestCustomScriptExecution:
def test_script_not_found_error(self, temp_dirs, sample_direct_task):
"""Returns error when script not found."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -800,14 +716,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=FileNotFoundError("not found")):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/nonexistent/script.sh"
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -821,7 +738,7 @@ class TestCustomScriptExecution:
def test_script_not_executable_error(self, temp_dirs, sample_direct_task):
"""Returns error when script not executable."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
@@ -829,14 +746,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=PermissionError("not executable")):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -850,7 +768,7 @@ class TestCustomScriptExecution:
def test_script_timeout_error(self, temp_dirs, sample_direct_task):
"""Returns error when script times out."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -859,14 +777,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run', side_effect=subprocess.TimeoutExpired("script", 300)):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
result = _post_process_download(
temp_file=temp_file,
@@ -880,7 +799,7 @@ class TestCustomScriptExecution:
def test_script_nonzero_exit_error(self, temp_dirs, sample_direct_task):
"""Returns error when script exits non-zero."""
from shelfmark.download.orchestrator import _post_process_download
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
import subprocess
temp_file = temp_dirs["staging"] / "book.epub"
@@ -889,14 +808,15 @@ class TestCustomScriptExecution:
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]), \
patch('shelfmark.download.orchestrator.is_archive', return_value=False), \
with patch('shelfmark.core.config.config') as mock_config, \
patch('shelfmark.config.env.TMP_DIR', temp_dirs["staging"]), \
patch('subprocess.run') as mock_run:
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
mock_config.get = MagicMock(return_value=None)
_sync_core_config(mock_config, mock_config)
mock_config.get = _mock_destination_config(temp_dirs["ingest"])
_sync_core_config(mock_config, mock_config)
error = subprocess.CalledProcessError(1, "script", stderr="Something failed")
mock_run.side_effect = error
@@ -912,116 +832,5 @@ class TestCustomScriptExecution:
status_cb.assert_called_with("error", "Custom script failed: Something failed")
# =============================================================================
# Integration-style Tests
# =============================================================================
class TestDownloadProcessingIntegration:
"""Integration tests for the full download processing pipeline."""
def test_full_direct_download_flow(self, temp_dirs, sample_direct_task):
"""Full flow: download → staging → ingest."""
from shelfmark.download.orchestrator import _post_process_download
# Simulate downloaded file
temp_file = temp_dirs["staging"] / "download.epub"
temp_file.write_bytes(b"epub content")
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(return_value=None)
result = _post_process_download(
temp_file=temp_file,
task=sample_direct_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
assert result is not None
result_path = Path(result)
# File is in ingest
assert result_path.parent == temp_dirs["ingest"]
# File has formatted name
assert "The Way of Kings" in result_path.name
# Staging file is gone
assert not temp_file.exists()
# Final status reported
status_cb.assert_called_with("complete", "Complete")
def test_full_universal_library_flow(self, temp_dirs, sample_task):
"""Full flow: download → library mode with organization."""
from shelfmark.download.orchestrator import _post_process_download
library = temp_dirs["base"] / "library"
library.mkdir()
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
mock_config.USE_BOOK_TITLE = True
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"PROCESSING_MODE": "library",
"LIBRARY_PATH": str(library),
"LIBRARY_TEMPLATE": "{Author}/{Title}",
}.get(key, default))
result = _post_process_download(
temp_file=temp_file,
task=sample_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
result_path = Path(result)
# File is in library with author folder
assert result_path.parent.name == "Brandon Sanderson"
assert "The Way of Kings" in result_path.name
# Staging file cleaned up
assert not temp_file.exists()
status_cb.assert_called_with("complete", "Complete")
def test_library_fallback_to_ingest(self, temp_dirs, sample_task):
"""Falls back to ingest when library mode fails."""
from shelfmark.download.orchestrator import _post_process_download
temp_file = temp_dirs["staging"] / "book.epub"
temp_file.write_bytes(b"content")
status_cb = MagicMock()
cancel_flag = Event()
with patch('shelfmark.download.orchestrator.config') as mock_config, \
patch('shelfmark.download.orchestrator.get_ingest_dir', return_value=temp_dirs["ingest"]):
mock_config.USE_BOOK_TITLE = False
mock_config.CUSTOM_SCRIPT = None
mock_config.get = MagicMock(side_effect=lambda key, default=None: {
"PROCESSING_MODE": "library",
"LIBRARY_PATH": None, # Not configured
}.get(key, default))
result = _post_process_download(
temp_file=temp_file,
task=sample_task,
cancel_flag=cancel_flag,
status_callback=status_cb,
)
result_path = Path(result)
# Falls back to ingest
assert result_path.parent == temp_dirs["ingest"]
# Integration-style end-to-end processing scenarios live in
# `tests/core/test_processing_integration.py`.
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
import errno
from pathlib import Path
from unittest.mock import MagicMock, patch
from shelfmark.core.models import DownloadTask, SearchMode
from shelfmark.download.postprocess.pipeline import collect_directory_files, validate_destination
def test_validate_destination_success_cleans_up_probe(tmp_path):
destination = tmp_path / "dest"
status_cb = MagicMock()
assert validate_destination(destination, status_cb) is True
assert list(destination.glob(".shelfmark_write_test_*")) == []
def test_validate_destination_write_probe_permission_error(tmp_path):
destination = tmp_path / "dest"
destination.mkdir()
status_cb = MagicMock()
real_write_text = Path.write_text
def fake_write_text(self, data, *args, **kwargs):
if ".shelfmark_write_test_" in self.name:
raise PermissionError(errno.EACCES, "Permission denied", str(self))
return real_write_text(self, data, *args, **kwargs)
with patch("pathlib.Path.write_text", new=fake_write_text):
assert validate_destination(destination, status_cb) is False
status_cb.assert_called()
assert status_cb.call_args[0][0] == "error"
assert "Destination not writable" in status_cb.call_args[0][1]
def test_collect_directory_files_ignores_permission_errors(tmp_path):
directory = tmp_path / "download"
directory.mkdir()
(directory / "book.epub").write_text("content")
task = DownloadTask(
task_id="scan-test",
source="direct_download",
title="Test",
format="epub",
search_mode=SearchMode.UNIVERSAL,
)
def fake_walk(top, onerror=None):
if onerror:
onerror(PermissionError(errno.EACCES, "Permission denied", str(Path(top) / "secret")))
yield str(top), [], ["book.epub"]
with patch("shelfmark.download.postprocess.scan.os.walk", side_effect=fake_walk):
files, rejected, cleanup, error = collect_directory_files(
directory,
task,
allow_archive_extraction=True,
status_callback=None,
)
assert error is None
assert rejected == []
assert cleanup == []
assert (directory / "book.epub") in files
def test_collect_directory_files_permission_denied_root(tmp_path):
directory = tmp_path / "download"
directory.mkdir()
task = DownloadTask(
task_id="scan-test",
source="direct_download",
title="Test",
format="epub",
search_mode=SearchMode.UNIVERSAL,
)
with patch(
"shelfmark.download.postprocess.scan.os.scandir",
side_effect=PermissionError(errno.EACCES, "Permission denied", str(directory)),
):
files, rejected, cleanup, error = collect_directory_files(
directory,
task,
allow_archive_extraction=True,
status_callback=None,
)
assert files == []
assert rejected == []
assert cleanup == []
assert error is not None
assert error.startswith("Permission denied")
+969
View File
@@ -0,0 +1,969 @@
"""Integration tests for real filesystem processing flows."""
import os
import zipfile
from pathlib import Path
from threading import Event
from unittest.mock import MagicMock, patch
import pytest
from shelfmark.core.models import DownloadTask, SearchMode
def _build_config(
destination: Path,
organization: str,
hardlink: bool = False,
rename_template: str = "{Author} - {Title}",
supported_formats: list[str] | None = None,
supported_audiobook_formats: list[str] | None = None,
):
values = {
"DESTINATION": str(destination),
"INGEST_DIR": str(destination),
"DESTINATION_AUDIOBOOK": str(destination),
"FILE_ORGANIZATION": organization,
"FILE_ORGANIZATION_AUDIOBOOK": organization,
"TEMPLATE_RENAME": rename_template,
"TEMPLATE_ORGANIZE": "{Author}/{Title}",
"TEMPLATE_AUDIOBOOK_RENAME": rename_template,
"TEMPLATE_AUDIOBOOK_ORGANIZE": "{Author}/{Title}{ - PartNumber}",
"SUPPORTED_FORMATS": supported_formats or ["epub"],
"SUPPORTED_AUDIOBOOK_FORMATS": supported_audiobook_formats or ["mp3"],
"HARDLINK_TORRENTS": hardlink,
"HARDLINK_TORRENTS_AUDIOBOOK": hardlink,
}
return MagicMock(side_effect=lambda key, default=None: values.get(key, default))
def _sync_config(mock_config, mock_core):
mock_core.get = mock_config.get
mock_core.CUSTOM_SCRIPT = mock_config.CUSTOM_SCRIPT
def test_direct_download_rename_moves_file(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
temp_file = staging / "book.epub"
temp_file.write_text("content")
task = DownloadTask(
task_id="direct-1",
source="direct_download",
title="The Way of Kings",
author="Brandon Sanderson",
format="epub",
search_mode=SearchMode.DIRECT,
)
statuses = []
status_cb = lambda status, message: statuses.append((status, message))
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(ingest, organization="rename")
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(temp_file, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest
assert result_path.name == "Brandon Sanderson - The Way of Kings.epub"
assert not temp_file.exists()
assert any("Moving" in msg for _, msg in statuses)
def test_torrent_hardlink_preserves_source(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
ingest = tmp_path / "ingest"
downloads.mkdir()
ingest.mkdir()
original = downloads / "Stormlight.epub"
original.write_text("content")
task = DownloadTask(
task_id="torrent-1",
source="prowlarr",
title="The Way of Kings",
author="Brandon Sanderson",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(original),
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"):
mock_config.get = _build_config(ingest, organization="organize", hardlink=True)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(original, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert original.exists()
assert os.stat(original).st_ino == os.stat(result_path).st_ino
def test_torrent_hardlink_enabled_archive_is_hardlinked_without_extraction(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
ingest = tmp_path / "ingest"
downloads.mkdir()
ingest.mkdir()
original = downloads / "Seed.zip"
with zipfile.ZipFile(original, "w") as zf:
zf.writestr("Seed.epub", "content")
task = DownloadTask(
task_id="torrent-zip-hardlink",
source="prowlarr",
title="Seed",
author="Seeder",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(original),
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", tmp_path / "staging"):
mock_config.get = _build_config(
ingest,
organization="none",
hardlink=True,
supported_formats=["zip"],
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(original, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.suffix == ".zip"
# Torrent source preserved for seeding.
assert original.exists()
# Hardlink success (same inode).
assert os.stat(original).st_ino == os.stat(result_path).st_ino
# No extraction should occur.
assert list(ingest.glob("*.epub")) == []
def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
original = downloads / "Seed.zip"
with zipfile.ZipFile(original, "w") as zf:
zf.writestr("Seed.epub", "content")
task = DownloadTask(
task_id="torrent-zip-fallback",
source="prowlarr",
title="Seed",
author="Seeder",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(original),
)
statuses = []
status_cb = lambda status, message: statuses.append((status, message))
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging), \
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False):
mock_config.get = _build_config(ingest, organization="none", hardlink=True)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(original, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.suffix == ".zip"
# Torrent source must remain for seeding.
assert original.exists()
# Most importantly: hardlink-setting-enabled fallback to copy should NOT extract.
assert list(ingest.glob("*.epub")) == []
assert any(msg.startswith("Copying") for _, msg in statuses)
def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_supported(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
original_dir = downloads / "release"
original_dir.mkdir()
archive_path = original_dir / "Seed.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("Seed.epub", "content")
task = DownloadTask(
task_id="torrent-zip-dir-fallback",
source="prowlarr",
title="Seed",
author="Seeder",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(original_dir),
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging), \
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False):
mock_config.get = _build_config(
ingest,
organization="none",
hardlink=True,
supported_formats=["zip"],
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(original_dir, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest
assert result_path.name == "Seed.zip"
# Torrent source must remain intact for seeding.
assert archive_path.exists()
# Staging copy should be cleaned up.
assert list(staging.iterdir()) == []
def test_torrent_copy_when_hardlink_disabled(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
original = downloads / "Seed.epub"
original.write_text("content")
task = DownloadTask(
task_id="torrent-2",
source="prowlarr",
title="Seed",
author="Seeder",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(original),
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(ingest, organization="none", hardlink=False)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(original, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.name == "Seed.epub"
assert original.exists()
assert os.stat(original).st_ino != os.stat(result_path).st_ino
assert list(staging.iterdir()) == []
def test_archive_extraction_flow(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
archive_path = staging / "book.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("book.epub", "content")
task = DownloadTask(
task_id="direct-archive",
source="direct_download",
title="Archive Test",
author="Tester",
format="epub",
search_mode=SearchMode.DIRECT,
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(ingest, organization="rename")
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(archive_path, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest
def test_archive_extraction_organize_creates_directories(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
archive_path = staging / "book.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("book.epub", "content")
task = DownloadTask(
task_id="direct-archive-organize",
source="direct_download",
title="Archive Test",
author="Tester",
format="epub",
search_mode=SearchMode.DIRECT,
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(ingest, organization="organize")
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(archive_path, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
assert result_path.parent == ingest / "Tester"
assert result_path.name == "Archive Test.epub"
def test_archive_extraction_organize_multifile_assigns_part_numbers(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
staging.mkdir()
ingest.mkdir()
archive_path = staging / "audio.zip"
with zipfile.ZipFile(archive_path, "w") as zf:
zf.writestr("Part 2.mp3", "audio2")
zf.writestr("Part 10.mp3", "audio10")
task = DownloadTask(
task_id="direct-archive-audio",
source="direct_download",
title="Archive Audio",
author="Tester",
format="mp3",
content_type="audiobook",
search_mode=SearchMode.DIRECT,
)
status_cb = lambda *_args: None
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(ingest, organization="organize")
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(archive_path, task, Event(), status_cb)
assert result is not None
author_dir = ingest / "Tester"
files = sorted(author_dir.glob("*.mp3"))
assert len(files) == 2
assert files[0].name == "Archive Audio - 01.mp3"
assert files[1].name == "Archive Audio - 02.mp3"
def test_booklore_mode_uploads_and_cleans_staging(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
staging.mkdir()
temp_file = staging / "book.epub"
temp_file.write_text("content")
task = DownloadTask(
task_id="direct-booklore",
source="direct_download",
title="The Way of Kings",
author="Brandon Sanderson",
format="epub",
search_mode=SearchMode.DIRECT,
)
statuses = []
status_cb = lambda status, message: statuses.append((status, message))
uploaded_files = []
def _upload_stub(_config, _token, file_path):
uploaded_files.append(file_path)
assert file_path.exists()
booklore_values = {
"BOOKS_OUTPUT_MODE": "booklore",
"BOOKLORE_HOST": "http://booklore:6060",
"BOOKLORE_USERNAME": "booklore",
"BOOKLORE_PASSWORD": "secret",
"BOOKLORE_LIBRARY_ID": 1,
"BOOKLORE_PATH_ID": 2,
}
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.download.outputs.booklore.booklore_login", return_value="token"), \
patch("shelfmark.download.outputs.booklore.booklore_upload_file", side_effect=_upload_stub), \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
result = _post_process_download(temp_file, task, Event(), status_cb)
assert result is not None
assert uploaded_files
assert not temp_file.exists()
assert list(staging.iterdir()) == []
assert any("Booklore" in (message or "") for _, message in statuses)
def test_booklore_mode_rejects_unsupported_files(tmp_path):
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
staging.mkdir()
temp_file = staging / "book.mobi"
temp_file.write_text("content")
task = DownloadTask(
task_id="direct-booklore-unsupported",
source="direct_download",
title="Unsupported Book",
author="Tester",
format="mobi",
search_mode=SearchMode.DIRECT,
)
status_cb = MagicMock()
booklore_values = {
"BOOKS_OUTPUT_MODE": "booklore",
"BOOKLORE_HOST": "http://booklore:6060",
"BOOKLORE_USERNAME": "booklore",
"BOOKLORE_PASSWORD": "secret",
"BOOKLORE_LIBRARY_ID": 1,
"BOOKLORE_PATH_ID": 2,
}
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.download.outputs.booklore.booklore_login") as mock_login, \
patch("shelfmark.download.outputs.booklore.booklore_upload_file") as mock_upload, \
patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = MagicMock(side_effect=lambda key, default=None: booklore_values.get(key, default))
result = _post_process_download(temp_file, task, Event(), status_cb)
assert result is None
assert mock_login.call_count == 0
assert mock_upload.call_count == 0
assert not temp_file.exists()
assert list(staging.iterdir()) == []
errors = [call for call in status_cb.call_args_list if call.args[0] == "error"]
assert errors
assert "Booklore does not support" in errors[-1].args[1]
@pytest.mark.parametrize("organization", ["none", "rename", "organize"])
@pytest.mark.parametrize("input_kind", ["file", "directory", "archive"])
@pytest.mark.parametrize("source_kind", ["direct", "usenet"])
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
def test_postprocess_folder_blackbox_matrix(
tmp_path,
source_kind: str,
input_kind: str,
organization: str,
content_kind: str,
):
"""Black-box matrix test over common pipeline knobs.
Goals:
- Exercise the real `post_process_download` flow end-to-end
- Vary key knobs (source semantics, input shape, organization mode)
- Assert invariants (TMP cleanup, external source preservation)
This intentionally avoids mocking internal pipeline helpers.
"""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads = tmp_path / "downloads"
staging.mkdir()
ingest.mkdir()
downloads.mkdir()
author = "Tester"
title = "Matrix Book"
if content_kind == "audiobook":
extension = "mp3"
content_type = "audiobook"
else:
extension = "epub"
content_type = None
task = DownloadTask(
task_id=f"matrix-{source_kind}-{input_kind}-{organization}-{content_kind}",
source="direct_download" if source_kind == "direct" else "prowlarr",
title=title,
author=author,
format=extension,
content_type=content_type,
search_mode=SearchMode.DIRECT,
original_download_path=None,
)
base_dir = staging if source_kind == "direct" else downloads
if input_kind == "file":
input_path = base_dir / f"random.{extension}"
input_path.write_text("content")
expected_original_name = input_path.name
elif input_kind == "directory":
input_path = base_dir / "release"
input_path.mkdir()
(input_path / f"random.{extension}").write_text("content")
expected_original_name = f"random.{extension}"
elif input_kind == "archive":
input_path = base_dir / "release.zip"
with zipfile.ZipFile(input_path, "w") as zf:
zf.writestr(f"book.{extension}", "content")
expected_original_name = f"book.{extension}"
else:
raise AssertionError(f"Unknown input_kind: {input_kind}")
status_cb = lambda *_args: None
supported_formats = [extension] if extension != "mp3" else ["epub"]
supported_audiobook_formats = [extension] if extension == "mp3" else ["mp3"]
with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization=organization,
supported_formats=supported_formats,
supported_audiobook_formats=supported_audiobook_formats,
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(input_path, task, Event(), status_cb)
assert result is not None
result_path = Path(result)
assert result_path.exists()
if organization == "organize":
assert result_path.parent == ingest / author
assert result_path.name == f"{title}.{extension}"
elif organization == "rename":
assert result_path.parent == ingest
assert result_path.name == f"{author} - {title}.{extension}"
else:
assert result_path.parent == ingest
assert result_path.name == expected_original_name
# TMP workspace should be cleaned up fully.
assert list(staging.iterdir()) == []
# Source preservation depends on whether Shelfmark owns the workspace.
if source_kind == "direct":
assert not input_path.exists()
else:
assert input_path.exists()
@pytest.mark.parametrize("input_kind", ["file", "directory"])
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
@pytest.mark.parametrize("organization", ["none", "organize"])
@pytest.mark.parametrize("hardlink_enabled", [False, True])
@pytest.mark.parametrize("same_filesystem", [True, False])
def test_postprocess_torrent_blackbox_matrix(
tmp_path,
input_kind: str,
content_kind: str,
organization: str,
hardlink_enabled: bool,
same_filesystem: bool,
):
"""Torrent-like (original_download_path set) black-box test matrix.
This exercises:
- hardlink enabled/disabled
- same-filesystem hardlink vs copy fallback
- content type differences (book vs audiobook)
Assertions focus on invariants:
- source is never deleted (seeding safety)
- output is imported with expected naming
- hardlink shares inode when expected
- TMP workspace stays clean
"""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
author = "Tester"
title = "Torrent Matrix"
if content_kind == "audiobook":
extension = "mp3"
content_type = "audiobook"
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
else:
extension = "epub"
content_type = None
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
if input_kind == "file":
input_path = downloads / f"random.{extension}"
input_path.write_text("content")
source_file = input_path
else:
input_path = downloads / "release"
input_path.mkdir()
source_file = input_path / f"random.{extension}"
source_file.write_text("content")
task = DownloadTask(
task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{same_filesystem}",
source="prowlarr",
title=title,
author=author,
format=extension,
content_type=content_type,
search_mode=SearchMode.UNIVERSAL,
original_download_path=str(input_path),
)
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging), \
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem):
mock_config.get = _build_config(
ingest,
organization=organization,
hardlink=hardlink_enabled,
supported_formats=supported_formats,
supported_audiobook_formats=supported_audiobook_formats,
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(input_path, task, Event(), lambda *_args: None)
assert result is not None
result_path = Path(result)
assert result_path.exists()
# Source must always remain for seeding.
assert input_path.exists()
assert source_file.exists()
if organization == "organize":
assert result_path.parent == ingest / author
assert result_path.name == f"{title}.{extension}"
else:
assert result_path.parent == ingest
assert result_path.name == f"random.{extension}"
# Hardlink only when enabled and same filesystem.
if hardlink_enabled and same_filesystem:
assert os.stat(source_file).st_ino == os.stat(result_path).st_ino
else:
assert os.stat(source_file).st_ino != os.stat(result_path).st_ino
# TMP workspace should be cleaned.
assert list(staging.iterdir()) == []
def test_custom_script_external_source_stages_copy_and_preserves_source(tmp_path):
"""External (usenet-like) files should be staged into TMP before a custom script runs."""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
original = downloads / "Seed.epub"
original.write_text("content")
task = DownloadTask(
task_id="usenet-custom-script",
source="prowlarr",
title="Seed",
author="Seeder",
format="epub",
search_mode=SearchMode.UNIVERSAL,
original_download_path=None,
)
with patch("shelfmark.core.config.config") as mock_config, \
patch("shelfmark.config.env.TMP_DIR", staging), \
patch("subprocess.run") as mock_run:
mock_config.get = _build_config(ingest, organization="none")
mock_config.CUSTOM_SCRIPT = "/path/to/script.sh"
_sync_config(mock_config, mock_config)
mock_run.return_value = MagicMock(stdout="", returncode=0)
result = _post_process_download(original, task, Event(), lambda *_args: None)
assert result is not None
result_path = Path(result)
assert result_path.exists()
# Original external file must be preserved.
assert original.exists()
# Script should have run against a staged copy inside TMP.
assert mock_run.call_count == 1
script_args = mock_run.call_args[0][0]
assert script_args[0] == "/path/to/script.sh"
staged_path = Path(script_args[1])
assert staging in staged_path.parents
assert staged_path != original
# Staging directory should be cleaned.
assert list(staging.iterdir()) == []
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
def test_external_directory_multiple_archives_extracts_all_and_keeps_source(tmp_path, content_kind: str):
"""External directories with only archives should extract into TMP and not touch source archives."""
# This case is meant to model a usenet-like client "completed" directory containing
# one or more archive releases, where Shelfmark must treat the source as read-only.
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
source_dir = downloads / "release"
source_dir.mkdir()
if content_kind == "audiobook":
extension = "mp3"
content_type = "audiobook"
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
else:
extension = "epub"
content_type = None
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
archive_1 = source_dir / "a.zip"
archive_2 = source_dir / "b.zip"
with zipfile.ZipFile(archive_1, "w") as zf:
zf.writestr(f"a.{extension}", f"content-a-{extension}")
with zipfile.ZipFile(archive_2, "w") as zf:
zf.writestr(f"b.{extension}", f"content-b-{extension}")
task = DownloadTask(
task_id=f"usenet-dir-archives-{content_kind}",
source="prowlarr",
title="Ignored",
author="Ignored",
format=extension,
content_type=content_type,
search_mode=SearchMode.DIRECT,
original_download_path=None,
)
with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization="none",
supported_formats=supported_formats,
supported_audiobook_formats=supported_audiobook_formats,
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(source_dir, task, Event(), lambda *_args: None)
assert result is not None
# Both archives remain in the external source directory.
assert archive_1.exists()
assert archive_2.exists()
# Extracted files should have been imported.
assert (ingest / f"a.{extension}").exists()
assert (ingest / f"b.{extension}").exists()
# TMP staging should be cleaned.
assert list(staging.iterdir()) == []
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
def test_external_directory_prefers_files_over_archives_and_keeps_source(tmp_path, content_kind: str):
"""If supported files exist in an external directory, archives are ignored.
This models a usenet-like client directory that contains both a usable file and
an archive. Shelfmark should import the usable file and leave the archive alone.
"""
from shelfmark.download.postprocess.router import post_process_download as _post_process_download
downloads = tmp_path / "downloads"
staging = tmp_path / "staging"
ingest = tmp_path / "ingest"
downloads.mkdir()
staging.mkdir()
ingest.mkdir()
source_dir = downloads / "release"
source_dir.mkdir()
if content_kind == "audiobook":
extension = "mp3"
content_type = "audiobook"
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
else:
extension = "epub"
content_type = None
supported_formats = ["epub"]
supported_audiobook_formats = ["mp3"]
primary_file = source_dir / f"keep.{extension}"
primary_file.write_text("primary")
archive = source_dir / "extra.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr(f"from_archive.{extension}", "archive")
task = DownloadTask(
task_id=f"usenet-dir-mixed-{content_kind}",
source="prowlarr",
title="Ignored",
author="Ignored",
format=extension,
content_type=content_type,
search_mode=SearchMode.DIRECT,
original_download_path=None,
)
with patch("shelfmark.core.config.config") as mock_config, patch("shelfmark.config.env.TMP_DIR", staging):
mock_config.get = _build_config(
ingest,
organization="none",
supported_formats=supported_formats,
supported_audiobook_formats=supported_audiobook_formats,
)
mock_config.CUSTOM_SCRIPT = None
_sync_config(mock_config, mock_config)
result = _post_process_download(source_dir, task, Event(), lambda *_args: None)
assert result is not None
# External source directory and files must remain untouched.
assert source_dir.exists()
assert primary_file.exists()
assert archive.exists()
# Import should use the existing supported file, not extract the archive.
assert (ingest / f"keep.{extension}").exists()
assert not (ingest / f"from_archive.{extension}").exists()
# TMP staging should be cleaned.
assert list(staging.iterdir()) == []
+23
View File
@@ -312,6 +312,29 @@ class TestReleaseDownloadFlow:
data = resp.json()
assert data.get("status") == "queued"
def test_cancel_release_with_slash_id(
self, api_client: APIClient, download_tracker: DownloadTracker
):
"""Cancelling/clearing should work for IDs containing slashes."""
test_id = "e2e-test-release/with-slash"
resp = api_client.post(
"/api/releases/download",
json={
"source": "test_source",
"source_id": test_id,
"title": "E2E Test Book",
},
)
if resp.status_code != 200:
pytest.skip("Release download endpoint not available")
download_tracker.track(test_id)
cancel_resp = api_client.delete(f"/api/download/{test_id}/cancel")
assert cancel_resp.status_code in [200, 204]
@pytest.mark.e2e
class TestReleasesSearch:
+33 -28
View File
@@ -13,7 +13,6 @@ from threading import Event, Thread
from typing import List, Optional, Tuple
from unittest.mock import MagicMock, patch, PropertyMock
import tempfile
import shutil
import pytest
@@ -217,8 +216,7 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
assert "Tracker returned error" in recorder.last_message
assert mock_client.remove_called
assert mock_client.remove_with_delete # Should delete files on error
assert not mock_client.remove_called
def test_client_returns_error_with_complete_flag(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -229,7 +227,7 @@ class TestClientErrorStates:
progress=100,
state=DownloadState.ERROR,
message="Download corrupted",
complete=True, # Complete but errored
complete=True,
file_path=None,
),
]
@@ -250,6 +248,8 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
assert recorder.last_message == "Download corrupted"
assert not mock_client.remove_called
def test_error_without_message_uses_default(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -259,7 +259,7 @@ class TestClientErrorStates:
DownloadStatus(
progress=0,
state=DownloadState.ERROR,
message=None, # No message
message=None,
complete=False,
file_path=None,
),
@@ -282,6 +282,7 @@ class TestClientErrorStates:
assert result is None
assert recorder.had_error
assert recorder.last_message == "Download failed"
assert not mock_client.remove_called
# =============================================================================
@@ -435,8 +436,7 @@ class TestCancellation:
assert result is None
assert "cancelled" in recorder.statuses
assert mock_client.remove_called
assert mock_client.remove_with_delete
assert not mock_client.remove_called
def test_cancel_before_download_starts(
self, handler, mock_client, recorder, sample_task, sample_release
@@ -461,7 +461,7 @@ class TestCancellation:
assert result is None
# Should have been cancelled quickly
assert mock_client.remove_called
assert not mock_client.remove_called
# =============================================================================
@@ -581,12 +581,10 @@ class TestFileHandlingFailures:
assert recorder.had_error
assert "locate" in recorder.last_message.lower()
def test_permission_denied_on_move(
def test_usenet_returns_original_path(
self, handler, mock_client, recorder, cancel_flag, sample_task
):
"""Handler should report permission errors during file staging (usenet only - torrents skip staging)."""
# Use usenet protocol - torrents skip staging and return original path directly
# Default usenet action is "move", so we mock shutil.move
"""Usenet downloads return the original client path without staging."""
usenet_release = {
"guid": "test-task-123",
"title": "Test Book",
@@ -607,8 +605,6 @@ class TestFileHandlingFailures:
with tempfile.TemporaryDirectory() as tmpdir:
source_file = Path(tmpdir) / "source.epub"
source_file.write_text("test content")
staging_dir = Path(tmpdir) / "staging"
staging_dir.mkdir()
mock_client.get_download_path = lambda x: str(source_file)
@@ -619,11 +615,12 @@ class TestFileHandlingFailures:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.release_sources.prowlarr.handler.shutil.move",
side_effect=PermissionError("Permission denied"),
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
return_value=staging_dir,
"shelfmark.download.staging.get_staging_dir",
) as mock_get_staging, patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
0.01,
):
result = handler.download(
task=sample_task,
@@ -632,9 +629,9 @@ class TestFileHandlingFailures:
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.had_error
assert "permission" in recorder.last_message.lower()
assert result == str(source_file)
assert not recorder.had_error
mock_get_staging.assert_not_called()
# =============================================================================
@@ -675,7 +672,7 @@ class TestProgressCallbacks:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -761,7 +758,7 @@ class TestStatusMessages:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -820,7 +817,7 @@ class TestStatusMessages:
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -851,7 +848,7 @@ class TestErrorCleanup:
def test_cleanup_on_poll_exception(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
):
"""Download should be removed from client after polling exception."""
"""Torrent downloads should not be removed after polling exception."""
call_count = 0
def exploding_get_status(download_id):
@@ -887,8 +884,7 @@ class TestErrorCleanup:
)
assert result is None
assert mock_client.remove_called
assert mock_client.remove_with_delete
assert not mock_client.remove_called
def test_cleanup_continues_even_if_remove_fails(
self, handler, mock_client, recorder, cancel_flag, sample_task, sample_release
@@ -904,14 +900,22 @@ class TestErrorCleanup:
),
]
remove_attempted = False
def failing_remove(download_id, delete_files=False):
nonlocal remove_attempted
remove_attempted = True
raise ConnectionError("Client not responding")
mock_client.remove = failing_remove
usenet_release = dict(sample_release)
usenet_release["protocol"] = "usenet"
usenet_release["downloadUrl"] = "https://indexer.example.com/download/123"
with patch(
"shelfmark.release_sources.prowlarr.handler.get_release",
return_value=sample_release,
return_value=usenet_release,
), patch(
"shelfmark.release_sources.prowlarr.handler.get_client",
return_value=mock_client,
@@ -926,3 +930,4 @@ class TestErrorCleanup:
assert result is None
assert recorder.had_error
assert remove_attempted
+42 -15
View File
@@ -242,7 +242,7 @@ class TestProwlarrHandlerExistingDownload:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
):
handler = ProwlarrHandler()
@@ -321,7 +321,7 @@ class TestProwlarrHandlerPolling:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -392,14 +392,14 @@ class TestProwlarrHandlerPolling:
assert result is None
assert recorder.last_status == "error"
mock_client.remove.assert_called_once()
mock_client.remove.assert_not_called()
class TestProwlarrHandlerCancellation:
"""Tests for download cancellation."""
def test_cancellation_removes_download(self):
"""Test that cancellation removes the download from client."""
def test_cancellation_does_not_remove_torrent(self):
"""Test that torrent cancellation does not remove from client."""
mock_client = MagicMock()
mock_client.name = "test_client"
mock_client.find_existing.return_value = None
@@ -446,7 +446,7 @@ class TestProwlarrHandlerCancellation:
assert result is None
assert "cancelled" in recorder.statuses
mock_client.remove.assert_called_with("download_id", delete_files=True)
mock_client.remove.assert_not_called()
class TestProwlarrHandlerCancel:
@@ -512,7 +512,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -575,7 +575,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -604,7 +604,7 @@ class TestProwlarrHandlerFileStaging:
assert (staged_dir / "cover.jpg").exists()
def test_handles_duplicate_filename(self):
"""Test handling of duplicate filename during staging (usenet only - torrents skip staging)."""
"""Usenet downloads return the original file path (no staging)."""
with tempfile.TemporaryDirectory() as tmp_dir:
source_file = Path(tmp_dir) / "source" / "book.epub"
source_file.parent.mkdir(parents=True)
@@ -641,7 +641,7 @@ class TestProwlarrHandlerFileStaging:
), patch(
"shelfmark.release_sources.prowlarr.handler.remove_release",
), patch(
"shelfmark.download.orchestrator.get_staging_dir",
"shelfmark.download.staging.get_staging_dir",
return_value=staging_dir,
), patch(
"shelfmark.release_sources.prowlarr.handler.POLL_INTERVAL",
@@ -664,8 +664,35 @@ class TestProwlarrHandlerFileStaging:
)
assert result is not None
# Should have a different name (with counter)
staged_file = Path(result)
assert staged_file.exists()
assert staged_file.name != "book.epub"
assert staged_file.read_text() == "new content"
returned_file = Path(result)
assert returned_file == source_file
assert returned_file.exists()
assert returned_file.read_text() == "new content"
class TestProwlarrHandlerPostProcessCleanup:
def test_usenet_move_triggers_client_cleanup(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
mock_client = MagicMock()
mock_client.name = "nzbget"
handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
with patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value="move"):
handler.post_process_cleanup(task, success=True)
mock_client.remove.assert_called_once_with("123", delete_files=True)
def test_usenet_copy_does_not_cleanup(self):
handler = ProwlarrHandler()
task = DownloadTask(task_id="cleanup-test", source="prowlarr", title="Test")
mock_client = MagicMock()
mock_client.name = "nzbget"
handler._cleanup_refs[task.task_id] = (mock_client, "123", "usenet")
with patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value="copy"):
handler.post_process_cleanup(task, success=True)
mock_client.remove.assert_not_called()
+4 -5
View File
@@ -55,9 +55,8 @@ def _setup_deluge_config():
save_config_file("prowlarr_clients", {
"PROWLARR_TORRENT_CLIENT": "deluge",
"DELUGE_HOST": "deluge",
"DELUGE_PORT": "58846",
"DELUGE_USERNAME": "admin",
"DELUGE_PASSWORD": "admin",
"DELUGE_PORT": "8112",
"DELUGE_PASSWORD": "deluge",
"DELUGE_CATEGORY": "test",
})
config.refresh()
@@ -408,8 +407,8 @@ class TestQBittorrentIntegration:
class TestDelugeIntegration:
"""Integration tests for Deluge client.
Uses the Docker test stack's Deluge instance (deluge:58846).
Default credentials from auth file: admin/admin
Uses the Docker test stack's Deluge Web UI instance (http://deluge:8112).
Default password: deluge
"""
def test_test_connection(self, deluge_client):
+2 -3
View File
@@ -59,9 +59,8 @@ def _setup_deluge_config():
save_config_file("prowlarr_clients", {
"PROWLARR_TORRENT_CLIENT": "deluge",
"DELUGE_HOST": "deluge",
"DELUGE_PORT": "58846",
"DELUGE_USERNAME": "admin",
"DELUGE_PASSWORD": "admin",
"DELUGE_PORT": "8112",
"DELUGE_PASSWORD": "deluge",
"DELUGE_CATEGORY": "test",
})
config.refresh()
+37
View File
@@ -593,3 +593,40 @@ class TestNZBGetClientRemove:
# Test with delete_files=False
client.remove("456", delete_files=False)
assert calls[-1][1][0] == "GroupDelete"
def test_remove_falls_back_to_history_delete(self, monkeypatch):
"""If HistoryFinalDelete is unsupported, fall back to HistoryDelete (Sonarr behavior)."""
config_values = {
"NZBGET_URL": "http://localhost:6789",
"NZBGET_USERNAME": "nzbget",
"NZBGET_PASSWORD": "password",
"NZBGET_CATEGORY": "Books",
}
monkeypatch.setattr(
"shelfmark.release_sources.prowlarr.clients.nzbget.config.get",
lambda key, default="": config_values.get(key, default),
)
calls = []
def mock_rpc_call(method, params=None):
if method == "editqueue":
calls.append((method, params))
# Succeed only on HistoryDelete.
return params is not None and params[0] == "HistoryDelete"
return None
from shelfmark.release_sources.prowlarr.clients.nzbget import NZBGetClient
with patch.object(NZBGetClient, "__init__", lambda x: None):
client = NZBGetClient()
client.url = "http://localhost:6789"
client.username = "nzbget"
client.password = "password"
client._category = "Books"
client._rpc_call = mock_rpc_call
result = client.remove("123", delete_files=True)
assert result is True
assert [call[1][0] for call in calls] == ["GroupFinalDelete", "HistoryFinalDelete", "HistoryDelete"]
+84
View File
@@ -8,11 +8,13 @@ import pytest
# Import the functions to test
from shelfmark.release_sources.prowlarr.source import (
ProwlarrSource,
_parse_size,
_extract_format,
_extract_language,
)
from shelfmark.release_sources.prowlarr.utils import get_protocol_display
from shelfmark.metadata_providers import BookMetadata
class TestParseSize:
@@ -220,3 +222,85 @@ class TestExtractLanguage:
assert _extract_language("Book [GERMAN]") == "de"
assert _extract_language("Book [german]") == "de"
assert _extract_language("Book [German]") == "de"
class TestProwlarrLocalizedQueries:
def test_search_uses_localized_titles_when_available(self, monkeypatch):
class FakeClient:
def __init__(self):
self.queries: list[str] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.queries.append(query)
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="219252",
title="The Lightning Thief",
authors=["Rick Riordan"],
titles_by_language={"hu": "A villámtolvaj"},
)
source.search(book, languages=["en", "hu"], content_type="ebook")
assert "The Lightning Thief Rick Riordan" in fake_client.queries
assert "A villámtolvaj Rick Riordan" in fake_client.queries
assert len(fake_client.queries) == 2
def test_search_does_not_override_search_title_for_english(self, monkeypatch):
class FakeClient:
def __init__(self):
self.queries: list[str] = []
def search(self, query: str, indexer_ids=None, categories=None):
self.queries.append(query)
return []
import shelfmark.release_sources.prowlarr.source as prowlarr_source
def fake_get(key: str, default=None):
values = {
"PROWLARR_INDEXERS": "",
"PROWLARR_AUTO_EXPAND": False,
}
return values.get(key, default)
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
fake_client = FakeClient()
source = ProwlarrSource()
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
book = BookMetadata(
provider="hardcover",
provider_id="123",
title="Mistborn: The Final Empire",
search_title="The Final Empire",
authors=["Brandon Sanderson"],
titles_by_language={
"en": "Mistborn: The Final Empire",
"hu": "A végső birodalom",
},
)
source.search(book, languages=["en", "hu"], content_type="ebook")
assert "The Final Empire Brandon Sanderson" in fake_client.queries
assert "A végső birodalom Brandon Sanderson" in fake_client.queries
assert "Mistborn: The Final Empire Brandon Sanderson" not in fake_client.queries