Files
shelfmark/shelfmark/download/clients/base_handler.py
T
CaliBrain 10710abd4e fix(download): send every file of a multi-file download to the browser (#1489)
Download to Browser only ever sent the first file of a multi-file
release
(audiobook chapters, packs): the folder output returned final_paths[0]
and
/api/localdownload served that one path.

The folder output now keeps every final path on the in-memory task
(download_paths), and /api/localdownload streams them as one
uncompressed
ZIP named after the book when there is more than one. The archive is
never
staged on disk or held in memory, and ZIP64 covers large audiobooks.
History still stores only the first path, so a re-download from history
after a dismiss or restart serves a single file, as before.

Also brings every linter back to green on all files:
- pre-commit: bump the ruff hook to v0.17.1 to match uv.lock;
ruff-format
  the Python examples in docs/dev and skills
- ruff: fix the remaining issues in skills/shelfmark/download_books.py;
  format base_handler.py
- basedpyright: Optional and attrs errors in databazeknih.py
- knip: drop 31 unused exports, reuse ColumnColorHint in colorMaps
- oxfmt 0.72: styles.css
- direct download: fix the "Welib, mirrors" typo and the test that still
  expected the pre-OceanofPDF message

Fixes #1467
2026-10-20 02:33:53 -04:00

1304 lines
52 KiB
Python

"""Shared download handler for external torrent/usenet clients."""
from __future__ import annotations
import errno
import math
import shutil
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Protocol, TypeGuard
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.request_helpers import normalize_optional_text
from shelfmark.core.utils import is_audiobook
from shelfmark.download.activity import release_activity_grace, request_activity_grace
from shelfmark.download.clients import (
DownloadClient,
DownloadState,
DownloadStatus,
get_client,
list_configured_clients,
)
from shelfmark.download.clients.settings import (
STALL_TIMEOUT_DEFAULT_MINUTES,
STALL_TIMEOUT_MAX_MINUTES,
STALL_TIMEOUT_MIN_MINUTES,
STALL_TIMEOUT_SETTING,
)
from shelfmark.download.fs import run_blocking_io
from shelfmark.download.permissions_debug import log_path_permission_context
from shelfmark.release_sources import DownloadHandler, HandoffResult
if TYPE_CHECKING:
from collections.abc import Callable
from threading import Event
from shelfmark.core.models import DownloadTask
logger = setup_logger(__name__)
_CLIENT_CLEANUP_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, TypeError, ValueError)
class _SabnzbdLikeClient(Protocol):
name: str
def remove(
self, download_id: str, *, delete_files: bool = False, archive: bool = True
) -> bool: ...
def _is_sabnzbd_like_client(candidate: DownloadClient) -> TypeGuard[_SabnzbdLikeClient]:
return getattr(candidate, "name", "") == "sabnzbd"
# How often to poll the download client for status (seconds)
POLL_INTERVAL = 2
# A torrent the client has queued is waiting for a free slot, not stalled. Its status never
# changes while it waits, so the orchestrator's stall timer would cancel it after
# STALL_TIMEOUT. Ask for a grace instead (the orchestrator caps one grace at an hour),
# renew it while the torrent is still queued, and stop at a hard ceiling so a queue that
# never moves still ends.
QUEUE_GRACE_SECONDS = 900.0
QUEUE_GRACE_RENEW_SECONDS = 600.0
QUEUE_MAX_WAIT_SECONDS = 7200.0
# Torrents are jerky: a swarm with one seed can sit still for several minutes and then carry
# on, and a magnet can take a while to fetch metadata or find a first peer. The orchestrator
# ends a download after STALL_TIMEOUT (five minutes) without a change. This setting is the
# time a torrent may sit without progress before it is cancelled, given once when the
# torrent starts and restarted every time its progress goes up. The default is 15 minutes;
# five is the shortest, which is the orchestrator's own timeout and so adds nothing.
WINDOWS_DRIVE_PREFIX_LENGTH = 2
SECONDS_PER_MINUTE = 60
SECONDS_PER_HOUR = 3600
# How long to wait for completed files to appear (seconds)
COMPLETED_PATH_RETRY_INTERVAL = 5
COMPLETED_PATH_MAX_ATTEMPTS = 12 # 12 attempts * 5s = 60s grace period
COMPLETED_PATH_TIMEOUT_SETTING = "DOWNLOAD_CLIENT_COMPLETED_PATH_TIMEOUT"
COMPLETED_PATH_TIMEOUT_MAX_SECONDS = 3600
_RETRYABLE_COMPLETED_PATH_ERRNOS = frozenset(
code
for code in (
errno.ENOENT,
getattr(errno, "ESTALE", None),
getattr(errno, "EAGAIN", None),
getattr(errno, "EBUSY", None),
getattr(errno, "ETIMEDOUT", None),
)
if code is not None
)
@dataclass(frozen=True)
class DownloadRequest:
"""Source-specific download parameters resolved before sending to a client."""
url: str
protocol: str
release_name: str
expected_hash: str | None
seeding_time_limit: int | None = None # minutes
ratio_limit: float | None = None
@dataclass(frozen=True)
class _CompletedPathResolution:
path: Path | None
error: str | None
retryable: bool
def _coerce_completed_path_timeout_seconds(value: object, default: float) -> float:
if isinstance(value, bool) or value is None:
return default
if isinstance(value, (int, float)):
parsed = float(value)
elif isinstance(value, str):
try:
parsed = float(value.strip())
except ValueError:
return default
else:
return default
if not math.isfinite(parsed) or parsed < 0:
return default
return min(parsed, float(COMPLETED_PATH_TIMEOUT_MAX_SECONDS))
def _is_retryable_completed_path_probe(error: OSError | None) -> bool:
return error is not None and error.errno in _RETRYABLE_COMPLETED_PATH_ERRNOS
def _path_needs_mapping(path: str) -> bool:
return (len(path) >= WINDOWS_DRIVE_PREFIX_LENGTH and path[1] == ":") or "\\" in path
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) >= WINDOWS_DRIVE_PREFIX_LENGTH 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, "
f"or configure Remote Path Mappings in Settings > Advanced."
)
def _format_probe_error(error: OSError | None) -> str:
"""Render an OSError for inclusion in log/status messages."""
if error is None:
return "none"
code = errno.errorcode.get(error.errno, str(error.errno)) if error.errno else "?"
return f"{code}: {error.strerror or error}"
def _probe_completed_path(path: Path) -> tuple[bool, OSError | None]:
"""Probe a completed download path and preserve the underlying stat error.
`Path.exists()` silently converts every `OSError` to `False`, which hides
whether a failure is ENOENT (not yet written), EACCES (permission denied),
ESTALE (NFS stale handle), or something else. Callers need the real errno
to decide whether the condition is retryable and to surface diagnostics.
"""
try:
run_blocking_io(path.stat)
except OSError as error:
return False, error
return True, None
def _completed_file_signature(
path: Path, content_type: str | None
) -> tuple[tuple[str, int, int], ...] | None:
"""Return sizes and mtimes for supported files in a completed path."""
from shelfmark.download.postprocess.policy import (
get_supported_audiobook_formats,
get_supported_formats,
)
formats = (
get_supported_audiobook_formats() if is_audiobook(content_type) else get_supported_formats()
)
supported_suffixes = {f".{fmt.lstrip('.').lower()}" for fmt in formats}
try:
if path.is_file():
candidates = (path,)
elif path.is_dir():
candidates = path.rglob("*")
else:
return None
signature = []
for candidate in candidates:
if candidate.suffix.lower() not in supported_suffixes:
continue
try:
stat = candidate.stat()
except OSError:
continue
if not candidate.is_file() or stat.st_size <= 0:
continue
name = candidate.relative_to(path).as_posix() if path.is_dir() else candidate.name
signature.append((name, stat.st_size, stat.st_mtime_ns))
except OSError:
return None
return tuple(sorted(signature)) or None
class ExternalClientHandler(DownloadHandler, ABC):
"""Shared lifecycle handler for sources that hand off to torrent/usenet clients."""
def __init__(self) -> None:
"""Initialize cleanup tracking for client-managed downloads."""
# 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]] = {}
@abstractmethod
def _resolve_download(
self,
task: DownloadTask,
status_callback: Callable[[str, str | None], None],
) -> DownloadRequest | None:
"""Resolve source-specific task metadata into a client download request."""
def _on_download_complete(self, task: DownloadTask) -> None:
"""Run post-completion source cleanup hooks."""
return
def _get_client(self, protocol: str) -> DownloadClient | None:
"""Resolve the active client for a protocol."""
return get_client(protocol)
def _list_configured_clients(self) -> list[str]:
"""List protocols with configured clients."""
return list_configured_clients()
@staticmethod
def _stall_window_seconds() -> float:
"""How long a torrent may go without progress, from the user's setting (seconds)."""
raw = config.get(STALL_TIMEOUT_SETTING, STALL_TIMEOUT_DEFAULT_MINUTES)
try:
minutes = float(raw) # pyright: ignore[reportArgumentType]
except TypeError, ValueError:
minutes = float(STALL_TIMEOUT_DEFAULT_MINUTES)
if not math.isfinite(minutes):
minutes = float(STALL_TIMEOUT_DEFAULT_MINUTES)
minutes = min(max(minutes, STALL_TIMEOUT_MIN_MINUTES), STALL_TIMEOUT_MAX_MINUTES)
return minutes * SECONDS_PER_MINUTE
def _poll_interval(self) -> float:
"""Return the polling interval for status checks."""
return POLL_INTERVAL
def _completed_path_retry_interval(self) -> float:
"""Retry interval while waiting for completed files (seconds)."""
return COMPLETED_PATH_RETRY_INTERVAL
def _completed_path_max_attempts(self) -> int:
"""Maximum attempts when waiting for completed files."""
return COMPLETED_PATH_MAX_ATTEMPTS
def _completed_path_timeout_seconds(self) -> float:
"""Total time to wait for completed files to appear on disk."""
fallback = self._completed_path_retry_interval() * self._completed_path_max_attempts()
configured = config.get(COMPLETED_PATH_TIMEOUT_SETTING, fallback)
return _coerce_completed_path_timeout_seconds(configured, fallback)
def _refresh_download_request_after_add_failure(
self,
*,
task: DownloadTask,
request: DownloadRequest,
error: Exception,
status_callback: Callable[[str, str | None], None],
) -> DownloadRequest | None:
"""Give source handlers one chance to refresh stale resolved download data."""
return None
def _get_category_for_task(self, client: DownloadClient, task: DownloadTask) -> str | None:
"""Get audiobook category if configured and applicable, else None for default."""
if not is_audiobook(task.content_type):
return None
# Client-specific audiobook category config keys
audiobook_keys = {
"qbittorrent": "QBITTORRENT_CATEGORY_AUDIOBOOK",
"transmission": "TRANSMISSION_CATEGORY_AUDIOBOOK",
"deluge": "DELUGE_CATEGORY_AUDIOBOOK",
"nzbget": "NZBGET_CATEGORY_AUDIOBOOK",
"sabnzbd": "SABNZBD_CATEGORY_AUDIOBOOK",
}
audiobook_key = audiobook_keys.get(client.name)
if audiobook_key is None:
return None
configured_category = config.get(audiobook_key, "")
normalized_category = normalize_optional_text(configured_category)
if normalized_category is not None:
return normalized_category
if configured_category is None:
return None
fallback_category = str(configured_category).strip()
return fallback_category or None
def post_process_cleanup(self, task: DownloadTask, *, success: bool) -> None:
"""Clean up external-client state after post-processing finishes."""
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":
# "Move" means copy into ingest then let the usenet client delete its own files.
if config.get("PROWLARR_USENET_ACTION", "move") != "move":
return
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to cleanup usenet download %s in %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
elif protocol == "torrent":
torrent_action = config.get("PROWLARR_TORRENT_ACTION", "keep")
if torrent_action in ("remove", "remove_and_delete"):
# Import succeeded; the client handles its own download data.
try:
removed = client.remove(
download_id,
delete_files=torrent_action == "remove_and_delete",
)
if not removed:
logger.warning(
"Failed to remove torrent %s from %s",
download_id,
getattr(client, "name", "client"),
)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove torrent %s from %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
return
if torrent_action != "change_category":
return
post_import_category = normalize_optional_text(
config.get("PROWLARR_TORRENT_POST_IMPORT_CATEGORY", "")
)
if post_import_category is None:
return
try:
category_updated = client.set_category(download_id, post_import_category)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to set post-import category for torrent %s in %s: %s",
download_id,
getattr(client, "name", "client"),
e,
)
return
if not category_updated:
# Clients that cannot label torrents (debrid services) return False here,
# and the ones that can already log the specific failure themselves.
logger.debug(
"Post-import category not applied to torrent %s in %s",
download_id,
getattr(client, "name", "client"),
)
def _remove_usenet_download(
self,
client: DownloadClient,
download_id: str,
*,
delete_files: bool,
archive: bool = True,
) -> None:
"""Remove a usenet download with SABnzbd-specific archive handling."""
if _is_sabnzbd_like_client(client):
client.remove(download_id, delete_files=delete_files, archive=archive)
else:
client.remove(download_id, delete_files=delete_files)
def _delete_local_download_data(self, client: DownloadClient, download_id: str) -> None:
"""Best-effort local deletion of client download data."""
try:
raw_path = client.get_download_path(download_id)
except _CLIENT_CLEANUP_ERRORS as e:
logger.debug(
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
)
return
if not raw_path:
logger.debug("No download path available for %s %s", client.name, download_id)
return
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(raw_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if matched_mapping:
if remapped is None:
logger.warning(
"Refusing to delete download data for %s %s because remote path mapping rejected unsafe path: %s",
client.name,
download_id,
source_path_obj,
)
return
delete_path = remapped
else:
delete_path = source_path_obj
if str(delete_path) in ("", "/"):
logger.warning(
"Refusing to delete unsafe path for %s %s: %s",
client.name,
download_id,
delete_path,
)
return
if not run_blocking_io(delete_path.exists):
logger.debug("Local download path does not exist for cleanup: %s", delete_path)
return
try:
if run_blocking_io(delete_path.is_dir):
run_blocking_io(shutil.rmtree, delete_path)
else:
run_blocking_io(delete_path.unlink)
logger.info(
"Deleted local download data for %s %s: %s", client.name, download_id, delete_path
)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to delete local download data for %s %s: %s", client.name, download_id, e
)
def _safe_remove_download(
self,
client: DownloadClient,
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:
# Permanent delete for failed usenet downloads (SABnzbd archive=0).
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=False)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove download %s from %s after %s: %s",
download_id,
client.name,
reason,
e,
)
def _handle_cancelled_download(
self,
client: DownloadClient,
download_id: str,
protocol: str,
status_callback: Callable[[str, str | None], None],
*,
remove_if_unstarted: bool = False,
) -> None:
if protocol == "usenet":
logger.info("Download cancelled, removing from %s: %s", client.name, download_id)
try:
self._delete_local_download_data(client, download_id)
self._remove_usenet_download(client, download_id, delete_files=True, archive=True)
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Failed to remove download %s from %s after cancellation: %s",
download_id,
client.name,
e,
)
elif remove_if_unstarted and self._remove_unstarted_torrent(client, download_id):
logger.info(
"Download cancelled before it started, removed from %s: %s",
client.name,
download_id,
)
else:
logger.info(
"Download cancelled for protocol=%s; leaving in %s: %s",
protocol,
client.name,
download_id,
)
status_callback("cancelled", "Cancelled")
def _remove_unstarted_torrent(self, client: DownloadClient, download_id: str) -> bool:
"""Remove a torrent that downloaded nothing; True if it was removed.
Torrents are normally left in the client after a cancel so an already-downloaded one
can keep seeding. One at 0% has nothing to seed and nothing to resume, and leaving it
queued only holds a download slot and piles up dead entries.
"""
try:
status = client.get_status(download_id)
if status.progress > 0: # it has data, so it may be seeding or resumable
return False
return bool(client.remove(download_id, delete_files=True))
except _CLIENT_CLEANUP_ERRORS as e:
logger.warning(
"Could not remove unstarted torrent %s from %s: %s", download_id, client.name, e
)
return False
def _resolve_download_path_once(
self,
client: DownloadClient,
download_id: str,
*,
log_details: bool,
) -> tuple[Path | None, str | None]:
"""Resolve and validate the completed download path once."""
result = self._resolve_download_path_once_detailed(
client,
download_id,
log_details=log_details,
)
return result.path, result.error
def _resolve_download_path_once_detailed(
self,
client: DownloadClient,
download_id: str,
*,
log_details: bool,
) -> _CompletedPathResolution:
"""Resolve and validate a completed path, including retryability."""
try:
raw_path = client.get_download_path(download_id)
except Exception as e:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.exception(
"Failed to resolve download path for %s %s", client.name, download_id
)
else:
logger.debug(
"Failed to resolve download path for %s %s: %s", client.name, download_id, e
)
return _CompletedPathResolution(None, message, retryable=False)
if not raw_path:
message = (
f"Could not locate completed download in {client.name} (path not returned). "
f"Check volume mappings and category settings."
)
if log_details:
logger.error(
"Download client returned empty path for %s %s", client.name, download_id
)
else:
logger.debug(
"Download client returned empty path for %s %s", client.name, download_id
)
return _CompletedPathResolution(None, message, retryable=False)
from shelfmark.core.path_mappings import (
get_client_host_identifier,
parse_remote_path_mappings,
remap_remote_to_local_with_match,
)
source_path_obj = Path(raw_path)
host = get_client_host_identifier(client) or ""
mapping_value = config.get("PROWLARR_REMOTE_PATH_MAPPINGS", [])
mappings = parse_remote_path_mappings(mapping_value)
if log_details:
logger.debug(
"Attempting path remap: client=%s, host=%s, path=%s, mappings=%s",
client.name,
host,
source_path_obj,
[(m.host, m.remote_path, m.local_path) for m in mappings],
)
remapped, matched_mapping = remap_remote_to_local_with_match(
mappings=mappings,
host=host,
remote_path=source_path_obj,
)
if matched_mapping:
if remapped is None:
message = (
f"Remote path mapping rejected unsafe path '{source_path_obj}'. "
f"Check Settings > Advanced > Remote Path Mappings."
)
failure_log = "Remote path mapping rejected unsafe path for %s (%s): %s"
failure_args = (client.name, download_id, source_path_obj)
if log_details:
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(None, message, retryable=False)
remapped_exists, remapped_error = _probe_completed_path(remapped)
if log_details:
logger.debug(
"Remap result: %s -> %s (exists=%s, probe_error=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
remapped_exists,
_format_probe_error(remapped_error),
remapped != source_path_obj,
matched_mapping,
)
if remapped_exists:
logger.info(
"Remapped download path for %s (%s): %s -> %s",
client.name,
download_id,
source_path_obj,
remapped,
)
return _CompletedPathResolution(remapped, None, retryable=False)
message = (
f"Remapped path '{remapped}' does not exist. "
f"Check your Docker volume mounts match the Local Path in Settings > Advanced > Remote Path Mappings."
)
failure_log = "Download path does not exist after remapping: %s -> %s (probe_error=%s). Client: %s, ID: %s."
failure_args = (
raw_path,
remapped,
_format_probe_error(remapped_error),
client.name,
download_id,
)
if log_details:
log_path_permission_context("completed_download_remap", remapped)
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(
None,
message,
retryable=_is_retryable_completed_path_probe(remapped_error),
)
source_exists, source_error = _probe_completed_path(source_path_obj)
if log_details:
logger.debug(
"Remap result: %s -> %s (exists=%s, probe_error=%s, changed=%s, matched=%s)",
source_path_obj,
remapped,
source_exists,
_format_probe_error(source_error),
remapped != source_path_obj,
matched_mapping,
)
if source_exists:
if mappings:
logger.info(
"No remote path mapping matched for %s (%s); using client path: %s",
client.name,
download_id,
source_path_obj,
)
return _CompletedPathResolution(source_path_obj, None, retryable=False)
hint = _diagnose_path_issue(raw_path)
if mappings:
message = f"{hint} No remote path mapping matched for client '{client.name}'."
failure_label = "completed_download_original"
failure_log = "Download path does not exist and no remote path mapping matched for %s (%s): %s (probe_error=%s). %s"
failure_args = (
client.name,
download_id,
raw_path,
_format_probe_error(source_error),
hint,
)
else:
message = hint
failure_label = "completed_download_direct"
failure_log = (
"Download path does not exist: %s (probe_error=%s). Client: %s, ID: %s. %s"
)
failure_args = (
raw_path,
_format_probe_error(source_error),
client.name,
download_id,
hint,
)
if log_details:
log_path_permission_context(failure_label, source_path_obj)
logger.error(failure_log, *failure_args)
else:
logger.debug(failure_log, *failure_args)
return _CompletedPathResolution(
None,
message,
retryable=not _path_needs_mapping(raw_path)
and _is_retryable_completed_path_probe(source_error),
)
def _wait_for_completed_path(
self,
client: DownloadClient,
download_id: str,
*,
cancel_flag: Event | None,
status_callback: Callable[[str, str | None], None],
content_type: str | None = None,
) -> tuple[Path | None, str | None]:
"""Wait for supported files to appear and stop changing on disk."""
last_error: str | None = None
previous_signature: tuple[tuple[str, int, int], ...] | None = None
retry_interval = self._completed_path_retry_interval()
timeout_seconds = self._completed_path_timeout_seconds()
if retry_interval <= 0 or timeout_seconds <= 0:
max_attempts = 1
else:
max_attempts = int(math.ceil(timeout_seconds / retry_interval)) + 1
for attempt in range(1, max_attempts + 1):
if cancel_flag and cancel_flag.is_set():
return None, last_error
log_details = attempt == max_attempts
result = self._resolve_download_path_once_detailed(
client,
download_id,
log_details=log_details,
)
if result.path:
if run_blocking_io(result.path.is_file):
return result.path, None
signature = run_blocking_io(
_completed_file_signature,
result.path,
content_type,
)
if signature is not None and signature == previous_signature:
return result.path, None
previous_signature = signature
last_error = "Completed download has no stable supported files yet."
if attempt < max_attempts:
status_callback("locating", "Waiting for completed files...")
logger.debug(
"Supported files are missing or still changing for %s (%s) (attempt %d/%d)",
client.name,
download_id,
attempt,
max_attempts,
)
if cancel_flag:
if cancel_flag.wait(timeout=retry_interval):
return None, last_error
else:
time.sleep(retry_interval)
continue
last_error = result.error
if not result.retryable:
if not log_details:
logger.error(
"Completed path resolution is not retryable for %s (%s): %s",
client.name,
download_id,
last_error,
)
return None, last_error
if attempt < max_attempts:
status_callback("locating", "Waiting for completed files...")
logger.debug(
"Completed files not available yet for %s (%s) (attempt %d/%d)",
client.name,
download_id,
attempt,
max_attempts,
)
if cancel_flag:
if cancel_flag.wait(timeout=retry_interval):
return None, last_error
else:
time.sleep(retry_interval)
return None, last_error
def _build_progress_message(self, status: DownloadStatus) -> str:
"""Build a progress message from download status."""
msg = f"{status.progress:.0f}%"
if status.download_speed and status.download_speed > 0:
speed_mb = status.download_speed / 1024 / 1024
msg += f" ({speed_mb:.1f} MB/s)"
if status.eta and status.eta > 0:
if status.eta < SECONDS_PER_MINUTE:
msg += f" - {status.eta}s left"
elif status.eta < SECONDS_PER_HOUR:
msg += f" - {status.eta // SECONDS_PER_MINUTE}m left"
else:
msg += (
f" - {status.eta // SECONDS_PER_HOUR}h "
f"{(status.eta % SECONDS_PER_HOUR) // SECONDS_PER_MINUTE}m left"
)
return msg
def download(
self,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
) -> str | HandoffResult | None:
"""Execute download via configured torrent/usenet client."""
try:
if cancel_flag.is_set():
status_callback("cancelled", "Cancelled")
return None
request = self._resolve_download(task, status_callback)
if not request:
return None
client = self._get_client(request.protocol)
if not client:
configured = self._list_configured_clients()
if not configured:
status_callback(
"error",
"No download clients configured. Configure qBittorrent or NZBGet in settings.",
)
else:
status_callback("error", f"No {request.protocol} client configured")
return None
# Check if this download already exists in the client
status_callback("resolving", f"Checking {client.name}")
category = self._get_category_for_task(client, task)
existing = client.find_existing(request.url, category=category)
if existing:
download_id, existing_status = existing
logger.info("Found existing download in %s: %s", client.name, download_id)
# If already complete, skip straight to file handling
if existing_status.complete:
logger.info("Existing download is complete, copying file directly")
status_callback("resolving", "Found existing download, copying to library")
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
content_type=task.content_type,
)
if not source_path_obj:
if cancel_flag.is_set():
return None
status_callback(
"error",
path_error
or f"Could not locate existing download in {client.name}. Check that the file still exists.",
)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=request.protocol,
task=task,
status_callback=status_callback,
)
if result:
self._on_download_complete(task)
self._cleanup_refs[task.task_id] = (client, download_id, request.protocol)
return result
# Existing but still downloading - join the progress polling
logger.info("Existing download in progress, joining poll loop")
status_callback("downloading", "Resuming existing download")
else:
# No existing download - add new
refresh_attempted = False
while True:
status_callback("resolving", f"Sending to {client.name}")
try:
download_id = client.add_download(
url=request.url,
name=request.release_name,
category=category,
expected_hash=request.expected_hash,
seeding_time_limit=request.seeding_time_limit,
ratio_limit=request.ratio_limit,
# rTorrent has no category concept, so its audiobook label
# can only be chosen from the content type (#1235).
content_type=task.content_type,
)
except Exception as e:
if not refresh_attempted:
refresh_attempted = True
refreshed_request = self._refresh_download_request_after_add_failure(
task=task,
request=request,
error=e,
status_callback=status_callback,
)
if (
refreshed_request is not None
and refreshed_request.protocol == request.protocol
):
request = refreshed_request
continue
logger.exception("Failed to add to %s", client.name)
status_callback("error", f"Failed to add to {client.name}: {e}")
return None
break
logger.info(
"Added to %s: %s for '%s'", client.name, download_id, request.release_name
)
if getattr(client, "handoff_only", False) is True:
if cancel_flag.is_set():
# The file is published and unpublishing it would race a watcher
# that may already have consumed it, so the handoff stands even
# though the task is cancelled. Say so rather than leaving a bare
# "Cancelled" the user cannot act on.
logger.info(
"Cancelled after handoff to %s; leaving publication in place: %s",
client.name,
download_id,
)
status_callback(
"cancelled",
f"Cancelled, but the torrent was already handed off to {client.name}",
)
return None
# A watcher can consume the publication as soon as add_download returns,
# so the handoff completes here rather than in the poll loop. The
# orchestrator deliberately does not check that this path still exists:
# a consumed publication is indistinguishable from a bogus one, and
# treating it as an error is the failure this avoids (#1345).
progress_callback(100)
self._on_download_complete(task)
return HandoffResult(
path=download_id,
message=f"Torrent file saved to {download_id}",
)
# Poll for progress
return self._poll_and_complete(
client=client,
download_id=download_id,
protocol=request.protocol,
task=task,
cancel_flag=cancel_flag,
progress_callback=progress_callback,
status_callback=status_callback,
added_by_shelfmark=existing is None,
)
except Exception as e:
logger.exception("External client download error")
status_callback("error", str(e))
return None
def _poll_and_complete(
self,
client: DownloadClient,
download_id: str,
protocol: str,
task: DownloadTask,
cancel_flag: Event,
progress_callback: Callable[[float], None],
status_callback: Callable[[str, str | None], None],
*,
added_by_shelfmark: bool = False,
) -> str | None:
"""Poll the download client for progress and handle completion.
``added_by_shelfmark`` is False when the task joined a torrent the client already had;
such a torrent is never removed on cancel.
"""
poll_interval = self._poll_interval()
queued_since: float | None = None
# None, not 0.0: time.monotonic() can be below the renew interval on a host that
# booted recently, which would skip the first request.
grace_requested_at: float | None = None
last_progress = 0.0
start_window_given = False
# The setting is for torrents. Usenet keeps the orchestrator's own timeout, which is
# the floor of the setting, so no window is requested for it.
stall_window = (
self._stall_window_seconds()
if protocol == "torrent"
else float(STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE)
)
# Track consecutive "not found" errors - torrents may take time to appear in client
not_found_count = 0
max_not_found_retries = 15 # 15 retries * poll interval ~= 30s grace period
try:
result: str | None = None
logger.debug("Starting poll for %s (content_type=%s)", download_id, task.content_type)
while not cancel_flag.is_set():
status = client.get_status(download_id)
progress_callback(status.progress)
# Check for completion
if status.complete:
if status.state == DownloadState.ERROR:
logger.error(
"Download %s completed with error: %s", download_id, 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(
"Download %s complete, file_path=%s", download_id, status.file_path
)
break
# Check for error state
if status.state == DownloadState.ERROR:
message = (status.message or "").strip()
message_lower = message.lower()
# Only treat *actual* "not found" as retryable.
# qBittorrent auth/network/API failures should surface immediately (more actionable)
# and must not be confused with "torrent missing".
retryable_not_found = any(
token in message_lower
for token in (
"torrent not found",
"not found in qbittorrent",
"download not found",
)
)
non_retryable = any(
token in message_lower
for token in (
"authentication failed",
"cannot connect",
"timed out",
"api request failed",
)
)
if retryable_not_found and not non_retryable:
not_found_count += 1
if not_found_count < max_not_found_retries:
logger.debug(
"Download %s not yet visible in client (attempt %s/%s)",
download_id,
not_found_count,
max_not_found_retries,
)
status_callback("resolving", "Waiting for download client...")
if cancel_flag.wait(timeout=poll_interval):
break
continue
logger.error(
"Download %s not found after %s attempts",
download_id,
max_not_found_retries,
)
else:
# Fail fast on actionable errors (auth, connectivity, API issues)
logger.error("Download %s error state: %s", download_id, status.message)
status_callback("error", status.message or "Download failed")
self._safe_remove_download(client, download_id, protocol, "download error")
return None
# Reset not-found counter on successful status check
not_found_count = 0
if status.state == DownloadState.QUEUED:
now = time.monotonic()
if queued_since is None:
queued_since = now
if now - queued_since < QUEUE_MAX_WAIT_SECONDS and (
grace_requested_at is None
or now - grace_requested_at >= QUEUE_GRACE_RENEW_SECONDS
):
request_activity_grace(status_callback, QUEUE_GRACE_SECONDS)
grace_requested_at = now
elif queued_since is not None:
release_activity_grace(status_callback)
queued_since = None
grace_requested_at = None
# The release drops the stall deadline back to the orchestrator's own
# timeout, so the torrent needs a fresh window even if it had one before.
start_window_given = False
if status.progress > last_progress:
if stall_window > STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE:
request_activity_grace(status_callback, stall_window)
start_window_given = True
elif not start_window_given and status.state != DownloadState.QUEUED:
# Metadata and the first peer can take longer than the orchestrator's
# stall timeout, so a torrent gets one full window to get going.
if stall_window > STALL_TIMEOUT_MIN_MINUTES * SECONDS_PER_MINUTE:
request_activity_grace(status_callback, stall_window)
start_window_given = True
last_progress = status.progress
# 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:
# Post-processing (e.g., SABnzbd verifying/extracting)
status_callback("resolving", msg)
else:
status_callback("downloading", msg)
# Wait for next poll (interruptible by cancel)
if cancel_flag.wait(timeout=poll_interval):
break
# Handle cancellation
if cancel_flag.is_set():
self._handle_cancelled_download(
client,
download_id,
protocol,
status_callback,
remove_if_unstarted=added_by_shelfmark,
)
return None
# Handle completed file (wait briefly for files to appear)
source_path_obj, path_error = self._wait_for_completed_path(
client=client,
download_id=download_id,
cancel_flag=cancel_flag,
status_callback=status_callback,
content_type=task.content_type,
)
if not source_path_obj:
if cancel_flag.is_set():
self._handle_cancelled_download(
client,
download_id,
protocol,
status_callback,
remove_if_unstarted=added_by_shelfmark,
)
return None
status_callback(
"error",
path_error
or f"Could not locate completed download in {client.name} (path not returned). Check volume mappings and category settings.",
)
return None
result = self._handle_completed_file(
source_path=source_path_obj,
protocol=protocol,
task=task,
status_callback=status_callback,
)
except Exception as e:
logger.exception("Error during download polling")
status_callback("error", str(e))
self._safe_remove_download(client, download_id, protocol, "polling exception")
return None
# Clean up on success
if result:
self._on_download_complete(task)
self._cleanup_refs[task.task_id] = (client, download_id, protocol)
return result
def _handle_completed_file(
self,
source_path: Path,
protocol: str,
task: DownloadTask,
status_callback: Callable[[str, str | None], None],
) -> str | None:
"""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:
if protocol == "torrent":
task.original_download_path = str(source_path)
logger.debug("Download complete, returning original path: %s", source_path)
return str(source_path)
except Exception as e:
logger.exception("Failed to finalize completed download at %s", source_path)
status_callback("error", f"Failed to finalize completed download: {e}")
return None
def cancel(self, task_id: str) -> bool:
"""Default cancellation (primary cancellation happens via cancel_flag)."""
logger.debug("Cancel requested for external client task: %s", task_id)
return True