mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 22:05:45 +01:00
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:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Configuration
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,3 @@
|
||||
# Developer Documentation
|
||||
|
||||
TODO
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
# Shelfmark Documentation
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,3 @@
|
||||
# Installation
|
||||
|
||||
TODO
|
||||
@@ -0,0 +1,3 @@
|
||||
# Troubleshooting
|
||||
|
||||
TODO
|
||||
@@ -14,4 +14,3 @@ emoji
|
||||
rarfile
|
||||
qbittorrent-api
|
||||
transmission-rpc
|
||||
deluge-client
|
||||
|
||||
Executable
+416
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
@@ -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)
|
||||
@@ -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
@@ -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]]:
|
||||
"""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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]]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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...'}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
+611
-109
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
@@ -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()) == []
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user