From bb13fae0bbe4876d7fa641f4f7e5980b7e929469 Mon Sep 17 00:00:00 2001 From: splitsec2 <35583321+splitsec2@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:20:21 -0600 Subject: [PATCH] feat(download): sweep dead torrents and Soulseek leftovers on their own timer (#1619) This adds two cleanups that nobody notices until they matter, and puts them on their own timer. Both do nothing until their setting and client are configured. The dead-torrent sweep removes a torrent Shelfmark added once it has been stalled, or stuck fetching metadata, for `STALE_TORRENT_HOURS` with no running download owning it, and its partial data goes with it. It only touches torrents this instance added, tracked in a ledger of hashes, whatever category they are in, and only on qBittorrent. The default is 6 hours (3 to 72, and 0 turns it off). I am not sure 6 is right for everyone, so say if you would rather ship it off. The Soulseek sweep covers what a restart leaves in slskd's downloads folder. A finished folder nothing imported gets imported, a partial folder is removed after `SLSKD_CLEAN_LEFTOVERS_DAYS`, and unfinished transfers no running download owns are picked up again when their request still waits for them. Both run from a small background thread every 15 minutes, after a 30 second startup delay. I first ran them from the Hardcover scheduler, but that only runs when Hardcover sync or auto-download is on, so anyone using neither would never get the cleanup. A separate thread keeps them independent, and the interval is fixed so there is no new setting. The new tests mock the sweeps. I also started a scratch instance, and the thread starts and the first cycle is a no-op when nothing is configured. The timer has not run against a real qBittorrent or slskd, though the two sweeps themselves have been in my fork's builds since October. Full Python suite, ruff and basedpyright pass. ## Summary by CodeRabbit * **New Features** * Shelfmark periodically recovers eligible complete Soulseek downloads and reattaches unfinished transfers. It can also clean up eligible old folders while protecting active, wanted, and already-imported downloads. * Shelfmark can remove tracked qBittorrent torrents that have stalled or exceeded the configured inactivity limit, while preserving torrents associated with active tasks. Set the limit to 0 to disable cleanup. * **Bug Fixes** * Isolated Soulseek downloads reject task IDs that could produce unsafe or mismatched destination folders. * **Documentation** * Added configuration guidance for Soulseek recovery and cleanup, and qBittorrent stale-torrent cleanup, including defaults and eligibility rules. --- docs/environment-variables.md | 32 + shelfmark/download/clients/base_handler.py | 5 + shelfmark/download/clients/qbittorrent.py | 9 + shelfmark/download/clients/settings.py | 17 + shelfmark/download/housekeeping.py | 105 ++ shelfmark/download/orchestrator.py | 18 +- shelfmark/download/stale_torrents.py | 166 +++ shelfmark/download/torrent_ledger.py | 95 ++ shelfmark/main.py | 10 + shelfmark/release_sources/slskd/api.py | 4 + shelfmark/release_sources/slskd/handler.py | 35 + shelfmark/release_sources/slskd/leftovers.py | 578 +++++++++++ shelfmark/release_sources/slskd/settings.py | 38 + tests/download/test_housekeeping.py | 110 ++ .../test_orchestrator_retry_persisted.py | 35 + tests/download/test_stale_torrents.py | 288 ++++++ tests/download/test_torrent_ledger.py | 55 + tests/download/test_torrent_removal.py | 21 + tests/slskd/test_handler_isolated.py | 39 +- tests/slskd/test_leftovers.py | 957 ++++++++++++++++++ 20 files changed, 2611 insertions(+), 6 deletions(-) create mode 100644 shelfmark/download/housekeeping.py create mode 100644 shelfmark/download/stale_torrents.py create mode 100644 shelfmark/download/torrent_ledger.py create mode 100644 shelfmark/release_sources/slskd/leftovers.py create mode 100644 tests/download/test_housekeeping.py create mode 100644 tests/download/test_orchestrator_retry_persisted.py create mode 100644 tests/download/test_stale_torrents.py create mode 100644 tests/download/test_torrent_ledger.py create mode 100644 tests/slskd/test_leftovers.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 3c82c200..b8e8a2a3 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1904,6 +1904,8 @@ Automatically retry search without category filtering if no results are found | `SLSKD_ISOLATE_DOWNLOADS` | Download into a folder of its own inside slskd's downloads folder (needs slskd 0.26 or newer). Other apps using the same slskd cannot mix files with it or lose track of it when they clear slskd's list of finished transfers, and Shelfmark confirms a download by the files on disk. | boolean | `true` | | `SLSKD_DESTINATION_PREFIX` | The folder, inside slskd's downloads folder, that holds one folder per download. It must not climb out of slskd's downloads folder. | string | `shelfmark` | | `SLSKD_KEEP_COMPLETED` | Leave the finished files (and their transfer records) in slskd after Shelfmark copies them to your library, so they stay shared with other Soulseek users. Turn off to delete them once imported. | boolean | `true` | +| `SLSKD_FINISH_LEFTOVERS` | A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended. | boolean | `true` | +| `SLSKD_CLEAN_LEFTOVERS_DAYS` | Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting. | number | `0` | | `SLSKD_SEARCH_TIMEOUT` | How long a Soulseek search collects peer responses. Longer waits find more peers but make every search slower. | number | `15` | | `SLSKD_RESPONSE_LIMIT` | Stop a search early once this many peers have answered | number | `100` | | `SLSKD_QUEUE_TIMEOUT_MINUTES` | Give up when the peer has not started sending within this long. Set to 0 to wait indefinitely. | number | `60` | @@ -1976,6 +1978,25 @@ Leave the finished files (and their transfer records) in slskd after Shelfmark c - **Type:** boolean - **Default:** `true` +#### `SLSKD_FINISH_LEFTOVERS` + +**Import finished folders an interruption left behind** + +A restart can land after the last file arrives and before it is copied to your library. Checked every 15 minutes, independent of the Hardcover sync, a download folder that holds every file asked for, whose download never completed and is still wanted, is imported once without downloading anything again (never one someone cancelled). If that import fails, the folder stays until you retry the download, or, with clean-up on, until it is a day old and its request has the book or was given up on. A transfer Shelfmark queued that no download is watching any more is picked up again if its request still needs it, a few per check, and cancelled otherwise, or when it cannot be picked up. Transfers queued by other apps or by hand in slskd are never touched, including the same file queued again after Shelfmark's download of it ended. + +- **Type:** boolean +- **Default:** `true` + +#### `SLSKD_CLEAN_LEFTOVERS_DAYS` + +**Remove leftover download folders after (days)** + +Remove the folder of a failed or cancelled download, or one nothing records, once the download is this old, or one day old when its request already has the book or was given up on. A folder that holds every file asked for is never removed while the download is still wanted (you started it yourself, or its request is still open): it is imported once, and after that it waits for you to retry the download. The folder of a running download is never removed. 0 keeps every leftover folder. Folders already imported follow Keep finished files in slskd, not this setting. + +- **Type:** number +- **Default:** `0` +- **Constraints:** min: 0, max: 365 + #### `SLSKD_SEARCH_TIMEOUT` **Search Wait (seconds)** @@ -2353,6 +2374,7 @@ How long to keep cached search results before they expire. | `TORRENT_MIN_SEEDERS` | Seeds the client must see for a motionless torrent to keep waiting. 0 also turns the no-seed check off. Only qBittorrent reports seed counts. | number | `1` | | `PROWLARR_TORRENT_POST_IMPORT_CATEGORY` | Category or label to assign after a successful import | string | _empty string_ | | `TORRENT_STALL_TIMEOUT_MINUTES` | How long a torrent can go without any progress before Shelfmark cancels it. Applies from when the torrent starts and restarts each time it moves. Raise it for slow swarms or magnets that take a while to find peers, or lower it to give up on dead torrents sooner. | number | `15` | +| `STALE_TORRENT_HOURS` | Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported. | number | `6` | | `PROWLARR_USENET_CLIENT` | Choose which usenet client to use | string (choice) | _empty string_ | | `NZBGET_URL` | URL of your NZBGet instance | string | _none_ | | `NZBGET_USERNAME` | NZBGet control username | string | `nzbget` | @@ -2814,6 +2836,16 @@ How long a torrent can go without any progress before Shelfmark cancels it. Appl - **Default:** `15` - **Constraints:** min: 5, max: 60 +#### `STALE_TORRENT_HOURS` + +**Remove dead torrents after (hours)** + +Remove a torrent Shelfmark added when it is stalled or cannot fetch its metadata, has had no activity for this many hours, and no running download owns it. Its partial data goes with it. Only torrents Shelfmark itself added are ever considered, whatever category they are in; torrents still downloading, queued, or finished are never touched. 3 to 72 hours (a value outside that is held to it); 0 keeps them. Checked every 15 minutes, independent of the Hardcover sync; only qBittorrent is supported. + +- **Type:** number +- **Default:** `6` +- **Constraints:** min: 0, max: 72 + #### `PROWLARR_USENET_CLIENT` **Usenet Client** diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 337d7e1b..dd2c2b9d 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -15,6 +15,7 @@ from shelfmark.core.config import config from shelfmark.core.logger import setup_logger from shelfmark.core.request_helpers import coerce_int, normalize_optional_text from shelfmark.core.utils import AUDIOBOOK_FORMATS, DEFAULT_EBOOK_FORMATS, is_audiobook +from shelfmark.download import torrent_ledger from shelfmark.download.activity import release_activity_grace, request_activity_grace from shelfmark.download.clients import ( DownloadClient, @@ -1197,6 +1198,8 @@ class ExternalClientHandler(DownloadHandler, ABC): return result # Existing but still downloading - join the progress polling + if request.protocol == "torrent": + torrent_ledger.claim(download_id, task.task_id) logger.info("Existing download in progress, joining poll loop") status_callback("downloading", "Resuming existing download") else: @@ -1240,6 +1243,8 @@ class ExternalClientHandler(DownloadHandler, ABC): logger.info( "Added to %s: %s for '%s'", client.name, download_id, request.release_name ) + if request.protocol == "torrent": + torrent_ledger.record(download_id, task.task_id) if getattr(client, "handoff_only", False) is True: if cancel_flag.is_set(): # The file is published and unpublishing it would race a watcher diff --git a/shelfmark/download/clients/qbittorrent.py b/shelfmark/download/clients/qbittorrent.py index 0362a558..87e3172b 100644 --- a/shelfmark/download/clients/qbittorrent.py +++ b/shelfmark/download/clients/qbittorrent.py @@ -389,6 +389,15 @@ class QBittorrentClient(DownloadClient): params = {"category": category} if category else {} return self._request_torrent_info_records(params) + def list_torrents(self, category: str | None = None) -> list[SimpleNamespace] | None: + """Every torrent record in ``category`` (all when unset); None when qBittorrent fails. + + An empty list means qBittorrent answered and holds nothing, which a caller pruning + what it remembers has to tell apart from a read that failed. + """ + torrents, error = self._list_torrents_by_category(category) + return None if error else torrents + def _remember_primary_hash(self, download_id: str, torrent: SimpleNamespace) -> None: """Note the primary hash a listing scan found, so later lookups skip the scan.""" torrent_hash = getattr(torrent, "hash", None) diff --git a/shelfmark/download/clients/settings.py b/shelfmark/download/clients/settings.py index 35b9dfb8..09240343 100644 --- a/shelfmark/download/clients/settings.py +++ b/shelfmark/download/clients/settings.py @@ -1123,6 +1123,23 @@ def prowlarr_clients_settings() -> list[SettingsField]: max_value=STALL_TIMEOUT_MAX_MINUTES, show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True}, ), + NumberField( + key="STALE_TORRENT_HOURS", + label="Remove dead torrents after (hours)", + description=( + "Remove a torrent Shelfmark added when it is stalled or cannot fetch its " + "metadata, has had no activity for this many hours, and no running download " + "owns it. Its partial data goes with it. Only torrents Shelfmark itself added " + "are ever considered, whatever category they are in; torrents still " + "downloading, queued, or finished are never touched. 3 to 72 hours (a value " + "outside that is held to it); 0 keeps them. Checked every 15 minutes, " + "independent of the Hardcover sync; only qBittorrent is supported." + ), + default=6, + min_value=0, + max_value=72, + show_when={"field": "PROWLARR_TORRENT_CLIENT", "notEmpty": True}, + ), # --- Usenet Client Selection --- HeadingField( key="usenet_heading", diff --git a/shelfmark/download/housekeeping.py b/shelfmark/download/housekeeping.py new file mode 100644 index 00000000..fcc7cc12 --- /dev/null +++ b/shelfmark/download/housekeeping.py @@ -0,0 +1,105 @@ +"""Periodic housekeeping for downloads, independent of the Hardcover sync. + +One in-process daemon thread (the same pattern as the Hardcover scheduler) runs +two sweeps every ``INTERVAL_SECONDS``: the dead-torrent sweep and the Soulseek +leftovers sweep. Each is a no-op when its feature is off or its client is not +configured, so the thread costs nothing for installs that use neither. +""" + +from __future__ import annotations + +import threading +import time +from typing import TYPE_CHECKING, Any + +from shelfmark.core.logger import setup_logger + +if TYPE_CHECKING: + from shelfmark.core.download_history_service import DownloadHistoryService + from shelfmark.core.user_db import UserDB + +logger = setup_logger(__name__) + +STARTUP_DELAY_SECONDS = 30 # Lets the download coordinator and startup reconcile run first. +INTERVAL_SECONDS = 15 * 60 + +_thread: threading.Thread | None = None +_thread_lock = threading.Lock() +_ctx: dict[str, Any] = {} + + +def run_once( + user_db: UserDB | None = None, history_service: DownloadHistoryService | None = None +) -> dict[str, Any]: + """Run both sweeps. One failing never skips the other. + + Returns what was done, keyed by sweep; a sweep that did nothing is absent. + A sweep that raised is reported under ``errors``. + """ + summary: dict[str, Any] = {} + errors: list[str] = [] + + try: + from shelfmark.download import stale_torrents + + removed = stale_torrents.run_for_shelfmark().removed + if removed: + summary["torrents_removed"] = list(removed) + except Exception: + logger.exception("Housekeeping: dead-torrent sweep failed") + errors.append("stale_torrents") + + try: + if user_db is not None and history_service is not None: + from shelfmark.release_sources.slskd import leftovers + + report = leftovers.run_with_backend(history_service=history_service, user_db=user_db) + slskd = { + name: list(items) + for name, items in ( + ("adopted", report.adopted), + ("removed", report.removed), + ("reattached", report.reattached), + ("cancelled", report.cancelled), + ) + if items + } + if slskd: + summary["slskd"] = slskd + except Exception: + logger.exception("Housekeeping: slskd leftovers sweep failed") + errors.append("slskd_leftovers") + + if errors: + summary["errors"] = errors + return summary + + +def _loop() -> None: + """Run a housekeeping cycle every interval for the life of the process. + + A failed cycle is logged and the loop carries on. + """ + logger.info("Housekeeping thread started") + time.sleep(STARTUP_DELAY_SECONDS) + while True: + try: + summary = run_once(_ctx.get("user_db"), _ctx.get("history_service")) + if summary: + logger.info("Housekeeping: %s", summary) + except Exception: + logger.exception("Housekeeping: cycle failed") + time.sleep(INTERVAL_SECONDS) + + +def start(user_db: UserDB, history_service: DownloadHistoryService) -> None: + """Start the housekeeping thread (idempotent).""" + global _thread + with _thread_lock: + _ctx["user_db"] = user_db + _ctx["history_service"] = history_service + if _thread is not None and _thread.is_alive(): + logger.debug("Housekeeping already running") + return + _thread = threading.Thread(target=_loop, daemon=True, name="DownloadHousekeeping") + _thread.start() diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 10985bd3..98b22ef6 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -665,8 +665,14 @@ def retry_persisted_download( *, final_status: object, priority: int = -10, + allow_request_linked: bool = False, ) -> tuple[bool, str | None]: - """Retry a persisted download row after the in-memory task has been lost.""" + """Retry a persisted download row after the in-memory task has been lost. + + A request-linked download that failed is normally retried from its request, since the + request decides what to fetch next. ``allow_request_linked`` queues it anyway, for work + that is already done and only needs importing (a finished folder an interrupted run left). + """ task = _restore_task_from_retry_payload(payload) if task is None: return False, "Download cannot be retried" @@ -684,15 +690,21 @@ def retry_persisted_download( if normalized_status in {"active", "cancelled"} and not has_fresh_retry_context: return False, "Download cannot be retried" - if task.request_id is not None and normalized_status == "error" and not has_staged_retry_source: + if ( + task.request_id is not None + and normalized_status == "error" + and not has_staged_retry_source + and not allow_request_linked + ): return False, "Request-linked downloads must be retried from requests" if ( - task.request_id is None + (task.request_id is None or allow_request_linked) and normalized_status == "error" and not has_staged_retry_source and not has_fresh_retry_context ): + # The flag lifts the "retry it from the request" rule, never "nothing to restart from". return False, "Download cannot be retried" task.priority = priority diff --git a/shelfmark/download/stale_torrents.py b/shelfmark/download/stale_torrents.py new file mode 100644 index 00000000..a97f1c8b --- /dev/null +++ b/shelfmark/download/stale_torrents.py @@ -0,0 +1,166 @@ +"""Remove torrents this instance added that are going nowhere and that nothing is watching. + +A torrent outlives the task that added it: a restart, a stall timeout that left it in the client +because it had some data. It then holds a download slot and a partial copy for ever. A torrent that +is still downloading is none of this sweep's business; one that is stalled or stuck fetching its +metadata, with no activity for hours, and that no running task owns, is dead. + +Ownership never rests on a category: only a torrent whose hash is in the ledger of torrents this +instance added (``torrent_ledger``) can be removed, and only when the task that added it is not +running. A torrent from before the ledger, or one somebody else put in the same category, is never +touched. +""" + +from __future__ import annotations + +import math +import time +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.download import torrent_ledger + +if TYPE_CHECKING: + from collections.abc import Callable + +logger = setup_logger(__name__) + +STALE_HOURS_SETTING = "STALE_TORRENT_HOURS" +STALE_HOURS_DEFAULT = 6.0 +STALE_HOURS_MIN = 3.0 +STALE_HOURS_MAX = 72.0 + +# qBittorrent states of a torrent that is not moving: no peers gave it data, or it never got the +# metadata. A queued torrent is waiting for a slot, not dead, and is left alone. +_STUCK_STATES = frozenset({"stalledDL", "metaDL"}) + +# Task states that mean a task is still working with its torrent. +_RUNNING_BUCKETS = frozenset({"queued", "resolving", "locating", "downloading"}) + + +@dataclass +class StaleReport: + removed: list[str] = field(default_factory=list) + + +def stale_hours() -> float: + """The idle limit in hours: 0 turns the sweep off, anything else is held to 3 through 72.""" + raw = config.get(STALE_HOURS_SETTING, STALE_HOURS_DEFAULT) + try: + hours = float(raw) if isinstance(raw, (int, float, str)) else STALE_HOURS_DEFAULT + except ValueError: + return STALE_HOURS_DEFAULT + if not math.isfinite(hours): + # nan would pass every comparison below and turn the idle test off. + return STALE_HOURS_DEFAULT + if hours <= 0: + return 0.0 + return min(max(hours, STALE_HOURS_MIN), STALE_HOURS_MAX) + + +def _ledger_hash(torrent: Any, ledger: dict[str, str]) -> str | None: + """The ledger key this torrent answers to, matched on every identity the client exposes.""" + from shelfmark.download.clients.qbittorrent import _torrent_matches_download_id + + for known in ledger: + if _torrent_matches_download_id(torrent, known): + return known + return None + + +def find_stale( + torrents: list[Any], + *, + ledger: dict[str, str], + running_task_ids: set[str], + idle_hours: float, + now: float | None = None, +) -> list[tuple[str, Any]]: + """``(ledger hash, torrent)`` for each torrent that is ours, stuck, idle and owned by nobody.""" + if idle_hours <= 0: + return [] + moment = time.time() if now is None else now + stale: list[tuple[str, Any]] = [] + for torrent in torrents: + if float(getattr(torrent, "progress", 0.0) or 0.0) >= 1.0: + continue + if getattr(torrent, "state", "") not in _STUCK_STATES: + continue + last = float(getattr(torrent, "last_activity", 0) or 0) + if last <= 0: + last = float(getattr(torrent, "added_on", 0) or 0) + if last <= 0 or moment - last < idle_hours * 3600: + continue + known = _ledger_hash(torrent, ledger) + if known is None or ledger[known] in running_task_ids: + continue + stale.append((known, torrent)) + return stale + + +def run( + client: Any, + *, + running_task_ids: Callable[[], set[str]], + now: float | None = None, +) -> StaleReport: + """Remove the stale torrents (and their partial data) that this instance added.""" + report = StaleReport() + idle = stale_hours() + if idle <= 0: + return report + ledger = torrent_ledger.entries() + if not ledger: + return report + torrents = client.list_torrents() + if torrents is None: + return report # qBittorrent did not answer: nothing is judged, nothing forgotten + # A torrent qBittorrent no longer lists is forgotten, even when it lists none at all, so + # the same torrent added later by someone else is never taken for one Shelfmark added. + listed = {_ledger_hash(t, ledger) for t in torrents} + torrent_ledger.forget(*(known for known in ledger if known not in listed)) + for known, torrent in find_stale( + torrents, + ledger=ledger, + running_task_ids=running_task_ids(), + idle_hours=idle, + now=now, + ): + if torrent_ledger.entries().get(known) != ledger[known]: + # A task claimed the torrent after the ledger was read (a retry, say): it is live. + continue + if client.remove(torrent.hash, delete_files=True): + torrent_ledger.forget(known) + report.removed.append(str(getattr(torrent, "name", torrent.hash))) + logger.info( + "Removed a dead torrent nothing is watching: %s (%.0f%%, %s)", + getattr(torrent, "name", torrent.hash), + float(torrent.progress) * 100, + torrent.state, + ) + return report + + +def run_for_shelfmark(*, now: float | None = None) -> StaleReport: + """``run`` against the configured qBittorrent.""" + from shelfmark.download import orchestrator as backend + from shelfmark.download.clients import get_client + + report = StaleReport() + if stale_hours() <= 0: + return report + client = get_client("torrent") + if client is None or not hasattr(client, "list_torrents"): + return report + + def running_task_ids() -> set[str]: + """Ids of the tasks the queue is running now, whose torrents the sweep leaves alone.""" + ids: set[str] = set() + for bucket, tasks in backend.queue_status().items(): + if bucket in _RUNNING_BUCKETS: + ids.update(str(task_id) for task_id in tasks) + return ids + + return run(client, running_task_ids=running_task_ids, now=now) diff --git a/shelfmark/download/torrent_ledger.py b/shelfmark/download/torrent_ledger.py new file mode 100644 index 00000000..854bea58 --- /dev/null +++ b/shelfmark/download/torrent_ledger.py @@ -0,0 +1,95 @@ +"""The info hashes of the torrents this instance added, and which task added them. + +The stale-torrent sweep may only remove a torrent Shelfmark itself put in the client: a category +says nothing about who else shares it. Every torrent an add returns a hash for is recorded here, +with the task that added it, and the sweep touches nothing that is not in the ledger. A torrent +added before the ledger existed is never in it, so it is never swept. +""" + +from __future__ import annotations + +import sqlite3 +import time +from contextlib import closing +from typing import TYPE_CHECKING + +from shelfmark.config.env import CONFIG_DIR +from shelfmark.core.logger import setup_logger + +if TYPE_CHECKING: + from pathlib import Path + +logger = setup_logger(__name__) + +_SCHEMA = ( + "CREATE TABLE IF NOT EXISTS torrent_ledger (" + "hash TEXT PRIMARY KEY, task_id TEXT NOT NULL, added_at REAL NOT NULL)" +) + + +def _path() -> Path: + return CONFIG_DIR / "torrent_ledger.db" + + +def _connect() -> sqlite3.Connection: + conn = sqlite3.connect(_path()) + conn.execute(_SCHEMA) + return conn + + +def record(torrent_hash: str, task_id: str) -> None: + """Note that ``task_id`` added the torrent. A ledger failure never fails the download.""" + if not torrent_hash: + return + try: + with closing(_connect()) as conn, conn: + conn.execute( + "INSERT INTO torrent_ledger (hash, task_id, added_at) VALUES (?, ?, ?) " + "ON CONFLICT(hash) DO UPDATE SET task_id = excluded.task_id", + (torrent_hash.lower(), task_id, time.time()), + ) + except (sqlite3.Error, OSError) as exc: + logger.warning("Could not record torrent %s in the ledger: %s", torrent_hash, exc) + + +def claim(torrent_hash: str, task_id: str) -> None: + """A task joined a torrent that is already in the ledger: it owns it now. + + A torrent the ledger does not hold stays out of it, so joining something Shelfmark did + not add never makes it sweepable. + """ + if not torrent_hash: + return + try: + with closing(_connect()) as conn, conn: + conn.execute( + "UPDATE torrent_ledger SET task_id = ? WHERE hash = ?", + (task_id, torrent_hash.lower()), + ) + except (sqlite3.Error, OSError) as exc: + logger.warning("Could not update torrent %s in the ledger: %s", torrent_hash, exc) + + +def entries() -> dict[str, str]: + """Every recorded hash with the id of the task that last owned it.""" + try: + with closing(_connect()) as conn: + return { + str(row[0]): str(row[1]) + for row in conn.execute("SELECT hash, task_id FROM torrent_ledger") + } + except (sqlite3.Error, OSError) as exc: + logger.warning("Could not read the torrent ledger: %s", exc) + return {} + + +def forget(*torrent_hashes: str) -> None: + """Drop rows, once their torrent has been removed or is gone from the client.""" + hashes = [(h.lower(),) for h in torrent_hashes if h] + if not hashes: + return + try: + with closing(_connect()) as conn, conn: + conn.executemany("DELETE FROM torrent_ledger WHERE hash = ?", hashes) + except (sqlite3.Error, OSError) as exc: + logger.warning("Could not update the torrent ledger: %s", exc) diff --git a/shelfmark/main.py b/shelfmark/main.py index f65d6bb2..da158718 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -276,6 +276,16 @@ if user_db is not None: except (ImportError, RuntimeError, OSError) as exc: logger.warning("Failed to start Hardcover scheduler: %s", exc) +# Dead-torrent and Soulseek-leftover sweeps run on their own timer, whether or not +# Hardcover sync is on. Each is a no-op unless its setting and client are configured. +if user_db is not None and download_history_service is not None: + try: + from shelfmark.download import housekeeping + + housekeeping.start(user_db, download_history_service) + except (ImportError, RuntimeError, OSError) as exc: + logger.warning("Failed to start download housekeeping: %s", exc) + # Pre-solve the direct-download source's protection challenge in the background so the # first user search does not pay for a cold Chrome bypass. Never blocks startup. warmup.start() diff --git a/shelfmark/release_sources/slskd/api.py b/shelfmark/release_sources/slskd/api.py index 91ec26c3..0ca99200 100644 --- a/shelfmark/release_sources/slskd/api.py +++ b/shelfmark/release_sources/slskd/api.py @@ -399,6 +399,10 @@ class SlskdClient: data = self._json(response) return _flatten_transfers(data) + def list_all_downloads(self) -> list[dict[str, Any]]: + """Return every download transfer slskd tracks, for every user, flattened.""" + return _flatten_transfers(self._json(self._request("GET", "transfers/downloads"))) + def get_transfer(self, username: str, transfer_id: str) -> dict[str, Any] | None: """Return one transfer record, or None when slskd no longer tracks it.""" try: diff --git a/shelfmark/release_sources/slskd/handler.py b/shelfmark/release_sources/slskd/handler.py index 7bce49a1..d78f32d1 100644 --- a/shelfmark/release_sources/slskd/handler.py +++ b/shelfmark/release_sources/slskd/handler.py @@ -336,6 +336,19 @@ def _safe_name(value: str) -> str: return re.sub(r"[^A-Za-z0-9._-]+", "_", value or "").strip("._") or "download" +def folder_maps_back(task_id: str) -> bool: + """Whether a task's own folder name leads back to this task id and no other. + + The folder is ``_safe_name(task_id)``, and the leftovers sweep reads the id back by turning + the ``slskd_`` prefix into ``slskd:``. Search ids (``slskd:`` and hex) always map back. An id + sent in a queued payload may not: ``slskd:ab+cd`` would land in ``slskd_ab_cd``, which the + sweep would read as ``slskd:ab_cd``, find nothing recorded, and remove. + """ + if not task_id.startswith("slskd:"): + return False + return _safe_name(task_id) == "slskd_" + task_id[len("slskd:") :] + + def _clean_prefix(raw: object) -> str: """The folder, relative to slskd's downloads folder, that holds Shelfmark's downloads.""" text = str(raw or "").strip().replace("\\", "/").strip("/") @@ -395,6 +408,23 @@ def _discard_folder(folder: Path) -> None: logger.debug("Could not remove slskd folder %s: %s", folder, e) +# Public names for the leftovers sweep, which reads the same settings and folder rules. +def isolation_enabled() -> bool: + return _bool_setting("SLSKD_ISOLATE_DOWNLOADS", True) + + +def destination_prefix() -> str: + return _clean_prefix(config.get("SLSKD_DESTINATION_PREFIX", DEFAULT_DESTINATION_PREFIX)) + + +def bool_setting(key: str, default: bool) -> bool: + return _bool_setting(key, default) + + +def folder_complete(folder: Path, expected_sizes: list[int]) -> bool: + return _folder_complete(folder, expected_sizes) + + def _completed_remote_files(events: list[dict[str, Any]]) -> set[str]: """Remote filenames slskd's stored events say finished downloading.""" done: set[str] = set() @@ -510,6 +540,11 @@ class SlskdHandler(DownloadHandler): return None if self._isolate_enabled(): + if not folder_maps_back(task.task_id): + # Its folder would read back as another id, and the sweep could remove it. + status_callback("error", "This release's id cannot name its own download folder") + logger.warning("slskd task id %r does not map back from its folder", task.task_id) + return None outcome = self._download_isolated( task, spec, client, cancel_flag, progress_callback, status_callback ) diff --git a/shelfmark/release_sources/slskd/leftovers.py b/shelfmark/release_sources/slskd/leftovers.py new file mode 100644 index 00000000..59175a86 --- /dev/null +++ b/shelfmark/release_sources/slskd/leftovers.py @@ -0,0 +1,578 @@ +"""Folders slskd left under Shelfmark's downloads folder: finish the whole ones, drop the dead ones. + +Each download gets a folder named after its task id. A restart can land between "the last file +arrived" and "the files were copied to the library", leaving a finished folder nothing will ever +import, and a failed or abandoned download leaves a partial one. Both sit in slskd's folder +(and are shared with other Soulseek users) until someone looks. + +A whole folder whose download never completed is queued again through the ordinary retry; the +handler sees the files are already there and goes straight to the import. It is queued once: +if that import fails too, the folder stays, since the files may not be had again, until a +person retries the download or its request is done with (delivered another way, or dropped). +A partial folder, or the folder of a cancelled download, is removed once its download is old +enough that nothing is going to resume it. Running downloads keep their folders, and nothing +is removed while clean-up is off. +""" + +from __future__ import annotations + +import json +import shutil +import time +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING, Any + +from shelfmark.core.config import config +from shelfmark.core.logger import setup_logger +from shelfmark.core.request_helpers import coerce_int +from shelfmark.core.startup_reconcile import INTERRUPTED_MESSAGE +from shelfmark.download.fs import run_blocking_io + +if TYPE_CHECKING: + from pathlib import Path + + from shelfmark.core.download_history_service import DownloadHistoryService + from shelfmark.core.user_db import UserDB + +logger = setup_logger(__name__) + +FINISH_SETTING = "SLSKD_FINISH_LEFTOVERS" +CLEAN_DAYS_SETTING = "SLSKD_CLEAN_LEFTOVERS_DAYS" + +# Folders of a request that already has its book, or that was given up on, go after this long. +DELIVERED_GRACE = timedelta(days=1) +# At most this many downloads are queued per run by each loop (finished folders, transfers picked +# up again), counting only the ones that were queued, so a backlog does not flood the queue. +MAX_ADOPTIONS_PER_RUN = 3 +# Left in a folder once it has been tried for import, queued or not. Hidden, so it never counts as +# a file of the download. A folder is not tried again every run: it waits for a person to retry +# the download, or for its request to be done with. +ADOPTED_MARKER = ".shelfmark-adopted" +_TASK_PREFIX = "slskd_" + + +@dataclass +class LeftoverReport: + adopted: list[str] = field(default_factory=list) + removed: list[str] = field(default_factory=list) + kept: list[str] = field(default_factory=list) + reattached: list[str] = field(default_factory=list) # task ids picked up again + cancelled: list[str] = field(default_factory=list) # ": " transfers dropped + + +def _task_id_for_folder(name: str) -> str | None: + """The task id a folder is named after ('slskd_ab12' is 'slskd:ab12'), or None. + + One-to-one because the handler only gives a task its own folder when the name maps back + (``handler.folder_maps_back``); any other folder here was not made for a task. + """ + if not name.startswith(_TASK_PREFIX) or len(name) == len(_TASK_PREFIX): + return None + return "slskd:" + name[len(_TASK_PREFIX) :] + + +def _parse_stamp(value: object) -> datetime | None: + """Parse a stored ISO timestamp, reading one without a zone as UTC. + + None when it is missing or unreadable. + """ + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).strip()) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + + +def _expected_files(row: dict[str, Any]) -> list[dict[str, Any]]: + """The files a Soulseek download asked for, from its retry payload. + + Empty when the payload is missing or malformed. + """ + payload = row.get("retry_payload") + if isinstance(payload, str): + try: + payload = json.loads(payload) + except ValueError: + return [] + context = payload.get("retry_source_context") if isinstance(payload, dict) else None + files = context.get("files") if isinstance(context, dict) else None + return [f for f in files if isinstance(f, dict)] if isinstance(files, list) else [] + + +def _folder_age(folder: Path, row: dict[str, Any] | None, now: datetime) -> timedelta: + """How old a download folder is. + + Counted from the download's end, else its start, else the folder's own mtime. + """ + stamp = _parse_stamp((row or {}).get("terminal_at")) or _parse_stamp( + (row or {}).get("queued_at") + ) + if stamp is None: + try: + stamp = datetime.fromtimestamp(run_blocking_io(folder.stat).st_mtime, tz=UTC) + except OSError: + return timedelta(0) + return now - stamp + + +def _request_state(user_db: UserDB, row: dict[str, Any]) -> tuple[bool, bool]: + """(needs this download, already has its book or was given up on) for the row's request.""" + request_id = row.get("request_id") + if not request_id: + return True, False + request = user_db.get_request(int(request_id)) + if not request: + return False, True + status = request.get("status") + delivered = request.get("delivery_state") == "complete" + still_waiting = status == "fulfilled" and not delivered + return still_waiting, delivered or status in {"rejected", "cancelled"} + + +def plan( + folders: list[Path], + *, + history_service: DownloadHistoryService, + user_db: UserDB, + folder_complete: Any, + keep_completed: bool, + clean_after: timedelta | None, + now: datetime | None = None, + live_request_ids: set[int] | None = None, +) -> tuple[list[tuple[Path, dict[str, Any]]], list[Path], list[Path]]: + """Sort folders into (to finish, to remove, to keep). Reads only, changes nothing. + + What goes where, with ``clean_after`` the clean-up age (None while clean-up is off): + + - not named after a task, or a running download: kept. + - imported: removed a day on, unless ``keep_completed``. Clean-up has no say here. + - every file asked for is there and its request is still open (or it was a direct + download): queued for import once, when the request waits for it; kept otherwise. + - the request has its book from elsewhere, or was dropped: removed a day on. + - cancelled, partial, or nothing records it: removed at ``clean_after``. + + ``live_request_ids`` are the requests a running task is downloading for now; a folder of an + older attempt for one of them is kept, not imported next to the new attempt. + """ + now = now or datetime.now(UTC) + to_finish: list[tuple[Path, dict[str, Any]]] = [] + to_remove: list[Path] = [] + to_keep: list[Path] = [] + for folder in folders: + task_id = _task_id_for_folder(folder.name) + row = history_service.get_by_task_id(task_id) if task_id else None + if task_id is None or (row is not None and row.get("task_id") != task_id): + to_keep.append(folder) + continue + age = _folder_age(folder, row, now) + status = str((row or {}).get("final_status") or "") + + if row is None: + # Nothing records this download. Old enough, and it is dead weight. + (to_remove if clean_after and age >= clean_after else to_keep).append(folder) + continue + if status == "active": + to_keep.append(folder) # a download is running or about to be reconciled + continue + if status == "complete": + # Imported. The folder is only still here if cleanup failed, or it is kept shared. + removable = not keep_completed and age >= DELIVERED_GRACE + (to_remove if removable else to_keep).append(folder) + continue + + if status == "cancelled": + # Someone cancelled it: never imported automatically. The folder goes with clean-up. + (to_remove if clean_after is not None and age >= clean_after else to_keep).append( + folder + ) + continue + + needs_it, done_with = _request_state(user_db, row) + request_id = coerce_int(row.get("request_id"), 0) + if needs_it and request_id and request_id in (live_request_ids or set()): + to_keep.append(folder) # a newer attempt for this request is running + continue + sizes = [int(f.get("size") or 0) for f in _expected_files(row)] + whole = folder_complete(folder, sizes) + if whole and not done_with: + # Every file asked for is here, and someone may still want it: a download a person + # started, or a request that is still open. Never removed, whatever its age: the + # files may not be had again, and a later attempt at the same release (the task id + # is the release) lands in this folder and imports it without downloading. Queued + # for import once; after that it waits for a person to retry the download. + if needs_it and not run_blocking_io((folder / ADOPTED_MARKER).exists): + to_finish.append((folder, row)) + else: + to_keep.append(folder) + elif done_with and clean_after is not None and age >= DELIVERED_GRACE: + # The book is in the library, or the request was dropped: no need to wait the full + # clean-up period, but only while clean-up is on (0 keeps every folder). + to_remove.append(folder) + elif not whole and clean_after is not None and age >= clean_after: + to_remove.append(folder) # partial and old: nothing will resume it + else: + to_keep.append(folder) + return to_finish, to_remove, to_keep + + +_LIVE_TASK_STATES = frozenset({"queued", "resolving", "locating", "downloading"}) + + +def _is_unfinished(transfer: dict[str, Any]) -> bool: + """slskd transfer states are 'Queued, Remotely', 'InProgress', 'Completed, Errored'...""" + return not str(transfer.get("state") or "").lower().startswith("completed") + + +def _rows_by_file(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]: + """The newest Soulseek history row that asked for each (peer, remote filename).""" + index: dict[tuple[str, str], dict[str, Any]] = {} + for row in rows: # newest first + if row.get("source") != "slskd": + continue + payload = row.get("retry_payload") + if isinstance(payload, str): + try: + payload = json.loads(payload) + except ValueError: + continue + context = payload.get("retry_source_context") if isinstance(payload, dict) else None + if not isinstance(context, dict): + continue + peer = str(context.get("username") or "") + for entry in context.get("files") or []: + name = str(entry.get("filename") or "") if isinstance(entry, dict) else "" + if peer and name: + index.setdefault((peer, name), row) + return index + + +def _interrupted(row: dict[str, Any]) -> bool: + """Whether a download stopped without anyone giving up on it. + + Either its row is still active with no task behind it, or startup marked it as cut short + by a restart (an error with the "Interrupted" message). Any other ending was a real one. + """ + if row.get("final_status") == "active": + return True + return row.get("final_status") == "error" and row.get("status_message") == INTERRUPTED_MESSAGE + + +def _queued_after_it_ended(transfer: dict[str, Any], row: dict[str, Any]) -> bool: + """Whether slskd queued this transfer after the Shelfmark download it matches had ended. + + slskd will not queue a second transfer of a file from a peer while one is unfinished, and + stamps each with ``requestedAt`` when it is queued. So a transfer asked for after the + download ended is not one Shelfmark queued: another app or a person queued that file + again. A running download has not ended, and a transfer with no readable stamp cannot be + told apart, so both answer False. + """ + if row.get("final_status") == "active": + return False + ended = _parse_stamp(row.get("terminal_at")) + requested = _parse_stamp(transfer.get("requestedAt")) + return ended is not None and requested is not None and requested > ended + + +def plan_transfers( + transfers: list[dict[str, Any]], + *, + rows: list[dict[str, Any]], + live_task_ids: set[str], + live_request_ids: set[int], + user_db: UserDB, +) -> tuple[list[tuple[dict[str, Any], list[dict[str, Any]]]], list[dict[str, Any]]]: + """Split the unfinished transfers nothing is watching into (reattach, cancel). + + ``reattach`` holds one (history row, its transfers) pair per download to pick up again, so + a download that cannot be picked up can have its transfers dropped instead. + + Reads only. Only transfers a Shelfmark download asked for are considered, and only those + slskd queued before that download ended; anything else on the slskd instance belongs to + someone else and is left alone. A transfer is watched when a + running task owns it. slskd keeps working on a + transfer after the task that queued it is gone (a restart, a timeout), so these go on + queueing at peers and writing into a folder nobody imports. One whose request still waits + for that download is picked up again; the rest are dropped. A request that is pending is + deciding what to fetch next, and may well choose this very release, so its transfers wait. + """ + index = _rows_by_file(rows) + reattach: dict[str, tuple[dict[str, Any], list[dict[str, Any]]]] = {} + cancel: list[dict[str, Any]] = [] + + def pick_up(row: dict[str, Any], transfer: dict[str, Any]) -> None: + """Note the transfer under its download, which is picked up once however many it has.""" + reattach.setdefault(str(row["task_id"]), (row, []))[1].append(transfer) + + for transfer in transfers: + if not _is_unfinished(transfer): + continue + row = index.get((str(transfer.get("username") or ""), str(transfer.get("filename") or ""))) + if row is not None and row.get("task_id") in live_task_ids: + continue # a running task owns it + if row is None: + # Not one Shelfmark asked for: slskd may be shared with other apps or used by hand. + continue + if _queued_after_it_ended(transfer, row): + continue # the same file queued again, by another app or by hand + if row.get("final_status") == "complete": + # That download finished; an unfinished transfer of the same file is someone else's. + continue + if row.get("final_status") == "cancelled": + cancel.append(transfer) # someone cancelled this download; slskd should stop too + continue + request_id = row.get("request_id") + if not request_id: + if _interrupted(row): + pick_up(row, transfer) # interrupted, not abandoned + else: + cancel.append(transfer) + continue + request = user_db.get_request(int(request_id)) + if not request: + cancel.append(transfer) + elif request.get("status") == "pending": + continue + elif int(request_id) in live_request_ids: + cancel.append(transfer) # a newer attempt is running; this is an old one + elif request.get("status") == "fulfilled" and request.get("delivery_state") != "complete": + pick_up(row, transfer) + else: + cancel.append(transfer) + return list(reattach.values()), cancel + + +def _subfolders(base: Path) -> list[Path]: + """The folders directly inside base, sorted; run off the event loop by the caller.""" + return sorted(p for p in base.iterdir() if p.is_dir()) + + +def _is_inside(folder: Path, root: Path) -> bool: + """Whether folder is strictly inside root. + + Both are resolved first, so a link cannot lead the sweep elsewhere. + """ + try: + run_blocking_io(folder.resolve).relative_to(run_blocking_io(root.resolve)) + except OSError, ValueError: + return False + return run_blocking_io(folder.resolve) != run_blocking_io(root.resolve) + + +def run( + *, + history_service: DownloadHistoryService, + user_db: UserDB, + requeue: Any, + live_tasks: Any = None, + now: datetime | None = None, +) -> LeftoverReport: + """Finish and clean up the folders under Shelfmark's slskd downloads folder. + + Does nothing while the Soulseek source is switched off, or without a client, isolated + downloads or a readable downloads folder. + """ + from shelfmark.release_sources.slskd import handler as slskd_handler + + report = LeftoverReport() + if not config.get("SLSKD_ENABLED", False): + # Soulseek is switched off: an import queued now would be refused and still mark the + # folder as tried, so it would not be imported when Soulseek is switched back on. + return report + client = slskd_handler.build_client_from_config() + if client is None or not slskd_handler.isolation_enabled(): + return report + root, error = slskd_handler.resolve_local_download_root(client) + if root is None: + logger.debug("slskd leftovers: %s", error) + return report + prefix = slskd_handler.destination_prefix() + base = root.joinpath(*prefix.split("/")) + try: + folders = run_blocking_io(_subfolders, base) + except OSError: + return report + + finish = slskd_handler.bool_setting(FINISH_SETTING, True) + clean_days = _clean_days() + clean_after = timedelta(days=clean_days) if clean_days > 0 else None + keep_completed = slskd_handler.bool_setting("SLSKD_KEEP_COMPLETED", True) + + live_now = live_tasks() if live_tasks is not None else {} + to_finish, to_remove, to_keep = plan( + folders, + history_service=history_service, + user_db=user_db, + folder_complete=slskd_handler.folder_complete, + keep_completed=keep_completed, + clean_after=clean_after, + now=now, + live_request_ids={coerce_int(r, 0) for r in live_now.values() if r}, + ) + report.kept = [f.name for f in to_keep] + + if finish: + adopted_now = 0 + for folder, row in to_finish: + if adopted_now >= MAX_ADOPTIONS_PER_RUN: + report.kept.append(folder.name) # the next run takes it + continue + ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status")) + if ok: + adopted_now += 1 + report.adopted.append(folder.name) + logger.info("slskd leftovers: importing the finished folder %s", folder.name) + else: + logger.warning("slskd leftovers: could not import %s: %s", folder.name, message) + report.kept.append(folder.name) + # Tried once either way: a folder that cannot be queued is not retried every run. + try: + run_blocking_io((folder / ADOPTED_MARKER).touch) + except OSError as exc: + logger.warning("slskd leftovers: could not mark %s: %s", folder.name, exc) + else: + report.kept.extend(f.name for f, _ in to_finish) + + if finish and live_tasks is not None: + _settle_transfers( + client, + history_service=history_service, + user_db=user_db, + requeue=requeue, + live_tasks=live_tasks, + report=report, + ) + + # A folder whose download this run picked up again, or that a task is using now, is in use. + busy = set(report.reattached) + if live_tasks is not None: + busy |= set(live_tasks()) + for folder in to_remove: + if not _is_inside(folder, base): + continue + if _task_id_for_folder(folder.name) in busy: + report.kept.append(folder.name) + continue + try: + run_blocking_io(shutil.rmtree, folder) + except OSError as exc: + logger.warning("slskd leftovers: could not remove %s: %s", folder, exc) + report.kept.append(folder.name) + continue + report.removed.append(folder.name) + logger.info("slskd leftovers: removed %s", folder.name) + return report + + +def _settle_transfers( + client: Any, + *, + history_service: DownloadHistoryService, + user_db: UserDB, + requeue: Any, + live_tasks: Any, + report: LeftoverReport, +) -> None: + """Pick up or drop the unfinished slskd transfers no running task owns. + + As with folders, only downloads that were queued count towards the per-run cap; the rest + wait for the next run, their transfers untouched. One that cannot be queued has its + transfers dropped, since nothing is ever going to watch them. + """ + try: + transfers = client.list_all_downloads() + except Exception as exc: # noqa: BLE001 - slskd being down is not this sweep's problem + logger.debug("slskd leftovers: could not list transfers: %s", exc) + return + live = live_tasks() # {task id: request id or None} of the tasks running now + reattach, cancel = plan_transfers( + transfers, + rows=history_service.list_recent(user_id=None, limit=1000), + live_task_ids=set(live), + live_request_ids={int(r) for r in live.values() if r}, + user_db=user_db, + ) + picked_up = 0 + for row, owned in reattach: + if picked_up >= MAX_ADOPTIONS_PER_RUN: + break # the next run takes the rest + ok, message = requeue(row.get("retry_payload"), final_status=row.get("final_status")) + if ok: + picked_up += 1 + report.reattached.append(str(row["task_id"])) + logger.info("slskd leftovers: picked %s up again", row["task_id"]) + else: + logger.warning( + "slskd leftovers: could not pick up %s, dropping its transfers: %s", + row["task_id"], + message, + ) + cancel.extend(owned) + _drop_transfers(client, cancel, report) + + +def _cancel_once(client: Any, transfer: dict[str, Any]) -> bool: + """Ask slskd to cancel one transfer and remove its record. False when slskd refused.""" + try: + client.cancel_transfer(str(transfer.get("username")), str(transfer.get("id")), remove=True) + except Exception as exc: # noqa: BLE001 + logger.warning("slskd leftovers: could not cancel %s: %s", _label(transfer), exc) + return False + return True + + +def _drop_transfers(client: Any, transfers: list[dict[str, Any]], report: LeftoverReport) -> None: + """Cancel the transfers and remove their records, noting each in the report when both land. + + A live transfer needs a second pass once the cancel lands, so every transfer is cancelled + first, then one wait covers them all before the second pass. One slskd refuses is left for + the next run. + """ + pending = [t for t in transfers if _cancel_once(client, t)] + if pending: + time.sleep(2.0) + for transfer in pending: + if _cancel_once(client, transfer): + report.cancelled.append(_label(transfer)) + logger.info("slskd leftovers: dropped an unwatched transfer, %s", _label(transfer)) + + +def _label(transfer: dict[str, Any]) -> str: + """': ', as the report and the log name a transfer.""" + return f"{transfer.get('username')}: {transfer.get('filename')}" + + +def _clean_days() -> int: + return max(coerce_int(config.get(CLEAN_DAYS_SETTING, 0), 0), 0) + + +def run_with_backend( + *, history_service: DownloadHistoryService, user_db: UserDB, now: datetime | None = None +) -> LeftoverReport: + """``run`` wired to the live download queue: requeue through the retry, read what is running.""" + from shelfmark.download import orchestrator as backend + + def requeue(payload: object, final_status: object) -> tuple[bool, str | None]: + return backend.retry_persisted_download( + payload, final_status=final_status, allow_request_linked=True + ) + + def live_tasks() -> dict[str, Any]: + """Task id to request id for every task the queue is still working on.""" + return { + str(task_id): payload.get("request_id") + for bucket, tasks in backend.queue_status().items() + if bucket in _LIVE_TASK_STATES + for task_id, payload in tasks.items() + if isinstance(payload, dict) + } + + return run( + history_service=history_service, + user_db=user_db, + requeue=requeue, + live_tasks=live_tasks, + now=now, + ) diff --git a/shelfmark/release_sources/slskd/settings.py b/shelfmark/release_sources/slskd/settings.py index f0770b3c..14da7e9e 100644 --- a/shelfmark/release_sources/slskd/settings.py +++ b/shelfmark/release_sources/slskd/settings.py @@ -148,6 +148,44 @@ def slskd_config_settings() -> list[SettingsField]: ), show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True}, ), + CheckboxField( + key="SLSKD_FINISH_LEFTOVERS", + label="Import finished folders an interruption left behind", + default=True, + description=( + "A restart can land after the last file arrives and before it is copied to " + "your library. Checked every 15 minutes, independent of the Hardcover sync, " + "a download folder that holds every file asked for, whose download never " + "completed and is still wanted, is imported once without downloading anything " + "again (never one someone cancelled). If that import fails, the folder stays " + "until you retry the download, or, with clean-up on, until it is a day old and " + "its request has the book or was given up on. A transfer Shelfmark queued that " + "no download is " + "watching any more is picked up again if its request still needs it, a few per " + "check, and cancelled otherwise, or when it cannot be picked up. Transfers " + "queued by other apps or by hand in slskd are never touched, including the same " + "file queued again after Shelfmark's download of it ended." + ), + show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True}, + ), + NumberField( + key="SLSKD_CLEAN_LEFTOVERS_DAYS", + label="Remove leftover download folders after (days)", + default=0, + min_value=0, + max_value=365, + description=( + "Remove the folder of a failed or cancelled download, or one nothing records, " + "once the download is this old, or one day old when its request already has " + "the book or was given up on. A folder that holds every file asked for is " + "never removed while the download is still wanted (you started it yourself, " + "or its request is still open): it is imported once, and after that it waits " + "for you to retry the download. The folder of a running download is never " + "removed. 0 keeps every leftover folder. Folders already imported follow " + "Keep finished files in slskd, not this setting." + ), + show_when={"field": "SLSKD_ISOLATE_DOWNLOADS", "value": True}, + ), NumberField( key="SLSKD_SEARCH_TIMEOUT", label="Search Wait (seconds)", diff --git a/tests/download/test_housekeeping.py b/tests/download/test_housekeeping.py new file mode 100644 index 00000000..06d09b30 --- /dev/null +++ b/tests/download/test_housekeeping.py @@ -0,0 +1,110 @@ +"""The housekeeping thread runs both sweeps, isolated from each other.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from shelfmark.download import housekeeping as mod + + +def _leftovers(**lists): + base = {"adopted": [], "removed": [], "reattached": [], "cancelled": [], "kept": []} + base.update(lists) + return SimpleNamespace(**base) + + +@pytest.fixture +def sweeps(): + with ( + patch("shelfmark.download.stale_torrents.run_for_shelfmark") as torrents, + patch("shelfmark.release_sources.slskd.leftovers.run_with_backend") as slskd, + ): + torrents.return_value = SimpleNamespace(removed=[]) + slskd.return_value = _leftovers() + yield torrents, slskd + + +def test_run_once_runs_both_sweeps(sweeps): + torrents, slskd = sweeps + torrents.return_value = SimpleNamespace(removed=["Dead Book"]) + slskd.return_value = _leftovers(adopted=["slskd_ab12"], kept=["x"]) + user_db, history = MagicMock(), MagicMock() + + summary = mod.run_once(user_db, history) + + torrents.assert_called_once_with() + slskd.assert_called_once_with(history_service=history, user_db=user_db) + assert summary == { + "torrents_removed": ["Dead Book"], + "slskd": {"adopted": ["slskd_ab12"]}, + } + + +def test_nothing_done_reports_nothing(sweeps): + assert mod.run_once(MagicMock(), MagicMock()) == {} + + +def test_torrent_sweep_failure_does_not_skip_slskd(sweeps): + torrents, slskd = sweeps + torrents.side_effect = RuntimeError("qbittorrent down") + slskd.return_value = _leftovers(removed=["slskd_old"]) + + summary = mod.run_once(MagicMock(), MagicMock()) + + slskd.assert_called_once() + assert summary == {"slskd": {"removed": ["slskd_old"]}, "errors": ["stale_torrents"]} + + +def test_slskd_failure_does_not_skip_torrents(sweeps): + torrents, slskd = sweeps + torrents.return_value = SimpleNamespace(removed=["Dead Book"]) + slskd.side_effect = OSError("disk") + + summary = mod.run_once(MagicMock(), MagicMock()) + + assert summary == {"torrents_removed": ["Dead Book"], "errors": ["slskd_leftovers"]} + + +def test_slskd_sweep_skipped_without_context(sweeps): + torrents, slskd = sweeps + mod.run_once() + torrents.assert_called_once() + slskd.assert_not_called() + + +def test_start_is_idempotent(monkeypatch): + monkeypatch.setattr(mod, "_thread", None) + monkeypatch.setattr(mod, "_loop", lambda: __import__("time").sleep(5)) + mod.start(MagicMock(), MagicMock()) + first = mod._thread + assert first is not None + assert first.name == "DownloadHousekeeping" + assert first.daemon + mod.start(MagicMock(), MagicMock()) + assert mod._thread is first + + +def test_loop_survives_a_failing_cycle(monkeypatch): + """A cycle that raises is logged and the loop goes on to the next one.""" + calls = [] + + def fake_run_once(*_args): + calls.append(1) + raise RuntimeError("boom") + + class Stop(BaseException): + pass + + def fake_sleep(seconds): + """Stop the loop once two failing cycles have run.""" + if seconds == mod.INTERVAL_SECONDS and len(calls) >= 2: + raise Stop + + monkeypatch.setattr(mod, "run_once", fake_run_once) + monkeypatch.setattr(mod.time, "sleep", fake_sleep) + with pytest.raises(Stop): + mod._loop() + assert len(calls) == 2 diff --git a/tests/download/test_orchestrator_retry_persisted.py b/tests/download/test_orchestrator_retry_persisted.py new file mode 100644 index 00000000..90f24aa9 --- /dev/null +++ b/tests/download/test_orchestrator_retry_persisted.py @@ -0,0 +1,35 @@ +"""retry_persisted_download: what allow_request_linked lifts, and what it does not.""" + +from shelfmark.download import orchestrator + + +def _payload(**extra): + """A minimal persisted payload for a request-linked download.""" + payload = { + "task_id": "slskd:aa", + "source": "slskd", + "title": "Book", + "request_id": 7, + } + payload.update(extra) + return payload + + +def test_the_flag_still_refuses_a_download_with_nothing_to_restart_from(): + """allow_request_linked lifts the retry-from-requests rule, not the no-source rule.""" + ok, message = orchestrator.retry_persisted_download( + _payload(can_retry_without_staged_source=False), + final_status="error", + allow_request_linked=True, + ) + + assert ok is False + assert message == "Download cannot be retried" + + +def test_without_the_flag_a_request_linked_failure_goes_back_to_its_request(): + """Without the flag, a failed request download is retried from the request.""" + ok, message = orchestrator.retry_persisted_download(_payload(), final_status="error") + + assert ok is False + assert message == "Request-linked downloads must be retried from requests" diff --git a/tests/download/test_stale_torrents.py b/tests/download/test_stale_torrents.py new file mode 100644 index 00000000..6ba96327 --- /dev/null +++ b/tests/download/test_stale_torrents.py @@ -0,0 +1,288 @@ +"""Dead torrents nothing is watching are removed; everything else is left alone.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from shelfmark.download import stale_torrents as mod +from shelfmark.download import torrent_ledger + +NOW = 1_000_000.0 +HOUR = 3600.0 + + +def _torrent(name="Book", *, state="stalledDL", progress=0.3, idle=10.0, h="h1", **kw): + return SimpleNamespace( + name=name, + hash=h, + state=state, + progress=progress, + last_activity=NOW - idle * HOUR, + added_on=NOW - 100 * HOUR, + **kw, + ) + + +@pytest.fixture(autouse=True) +def _ledger_file(tmp_path, monkeypatch): + monkeypatch.setattr(torrent_ledger, "_path", lambda: tmp_path / "torrent_ledger.db") + + +def _find(torrents, *, ledger=None, running=(), hours=6.0): + """Run find_stale with a default ledger entry for h1 and the test clock.""" + ledger = {"h1": "task-1"} if ledger is None else ledger + return mod.find_stale( + list(torrents), ledger=ledger, running_task_ids=set(running), idle_hours=hours, now=NOW + ) + + +def test_a_stalled_torrent_in_the_ledger_idle_for_hours_with_no_owner_is_stale(): + assert [t.name for _known, t in _find([_torrent()])] == ["Book"] + + +def test_a_torrent_not_in_the_ledger_is_never_stale_whatever_its_category(): + torrent = _torrent(h="theirs", category="books") + + assert _find([torrent]) == [] + + +def test_a_torrent_is_matched_on_any_hash_the_client_exposes(): + hybrid = _torrent(h="trunc-v2", infohash_v1="h1") + + assert [known for known, _t in _find([hybrid])] == ["h1"] + + +@pytest.mark.parametrize("state", ["stalledDL", "metaDL"]) +def test_a_torrent_stuck_fetching_data_or_metadata_counts(state): + assert _find([_torrent(state=state)]) + + +@pytest.mark.parametrize( + "state", ["downloading", "queuedDL", "pausedDL", "uploading", "stalledUP", "forcedDL"] +) +def test_any_other_state_is_left_alone(state): + assert _find([_torrent(state=state)]) == [] + + +def test_a_finished_torrent_is_left_alone(): + assert _find([_torrent(progress=1.0)]) == [] + + +def test_a_torrent_active_within_the_window_is_left_alone(): + assert _find([_torrent(idle=2.0)]) == [] + + +def test_a_torrent_owned_by_a_running_task_is_left_alone(): + assert _find([_torrent()], running={"task-1"}) == [] + + +def test_a_task_that_is_not_running_does_not_protect_its_torrent(): + assert _find([_torrent()], running={"task-2"}) + + +def test_without_activity_the_time_it_was_added_decides(): + torrent = _torrent() + torrent.last_activity = 0 + + assert _find([torrent]) # added 100 hours ago + + +def test_a_torrent_with_no_times_at_all_is_not_judged(): + torrent = _torrent() + torrent.last_activity = 0 + torrent.added_on = 0 + + assert _find([torrent]) == [] + + +def test_switched_off_finds_nothing(): + assert _find([_torrent()], hours=0) == [] + + +def _client(*torrents, removed=True, failed=False): + """A qBittorrent stand-in listing torrents, or failing to list when failed is set.""" + client = MagicMock() + client.list_torrents.return_value = None if failed else list(torrents) + client.remove.return_value = removed + return client + + +def test_run_removes_a_ledger_torrent_idle_past_the_limit_with_its_data(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + client = _client(_torrent("Dead")) + + report = mod.run(client, running_task_ids=lambda: set(), now=NOW) + + assert report.removed == ["Dead"] + client.remove.assert_called_once_with("h1", delete_files=True) + assert torrent_ledger.entries() == {} # the row goes with the torrent + + +def test_run_never_touches_a_torrent_in_the_same_category_that_is_not_in_the_ledger(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + client = _client( + _torrent("Ours", h="h1"), _torrent("Somebody else's", h="other", category="books") + ) + + report = mod.run(client, running_task_ids=lambda: set(), now=NOW) + + assert report.removed == ["Ours"] + assert [call.args[0] for call in client.remove.call_args_list] == ["h1"] + + +def test_run_skips_a_torrent_a_running_task_owns(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + client = _client(_torrent()) + + report = mod.run(client, running_task_ids=lambda: {"task-1"}, now=NOW) + + assert report.removed == [] + client.remove.assert_not_called() + assert torrent_ledger.entries() == {"h1": "task-1"} + + +def test_a_task_that_joined_the_torrent_owns_it_now(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + torrent_ledger.claim("h1", "task-2") + client = _client(_torrent()) + + assert mod.run(client, running_task_ids=lambda: {"task-2"}, now=NOW).removed == [] + assert mod.run(client, running_task_ids=lambda: {"task-1"}, now=NOW).removed == ["Book"] + + +def test_a_torrent_claimed_while_the_sweep_lists_the_client_is_left_alone(monkeypatch): + """A retry that claims the torrent mid-sweep owns it, so its files are not deleted.""" + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + client = _client(_torrent()) + + def listing_while_a_retry_claims_it(): + """The client listing, during which a retried task claims the torrent.""" + torrent_ledger.claim("h1", "task-2") + return [_torrent()] + + client.list_torrents.side_effect = listing_while_a_retry_claims_it + + report = mod.run(client, running_task_ids=lambda: set(), now=NOW) + + assert report.removed == [] + client.remove.assert_not_called() + assert torrent_ledger.entries() == {"h1": "task-2"} + + +def test_run_does_nothing_when_switched_off(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 0.0) + torrent_ledger.record("h1", "task-1") + client = _client(_torrent()) + + report = mod.run(client, running_task_ids=lambda: set(), now=NOW) + + assert report.removed == [] + client.list_torrents.assert_not_called() + + +def test_a_failed_removal_is_not_reported_and_keeps_the_row(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + client = _client(_torrent(), removed=False) + + assert mod.run(client, running_task_ids=lambda: set(), now=NOW).removed == [] + assert torrent_ledger.entries() == {"h1": "task-1"} + + +def test_a_row_whose_torrent_is_gone_is_dropped_but_a_failed_listing_drops_nothing(monkeypatch): + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("gone", "task-9") + torrent_ledger.record("h1", "task-1") + + mod.run(_client(failed=True), running_task_ids=lambda: set(), now=NOW) + assert set(torrent_ledger.entries()) == {"gone", "h1"} + + mod.run(_client(_torrent(idle=1.0)), running_task_ids=lambda: set(), now=NOW) + assert set(torrent_ledger.entries()) == {"h1"} + + +def test_an_empty_listing_forgets_every_row_so_a_later_torrent_with_its_hash_is_not_taken( + monkeypatch, +): + """qBittorrent answered with no torrents: the ledger is emptied, and the same hash added + later by someone else is not removed as one of Shelfmark's.""" + monkeypatch.setattr(mod, "stale_hours", lambda: 6.0) + torrent_ledger.record("h1", "task-1") + + mod.run(_client(), running_task_ids=lambda: set(), now=NOW) + assert torrent_ledger.entries() == {} + + client = _client(_torrent("Someone else's", h="h1")) + report = mod.run(client, running_task_ids=lambda: set(), now=NOW) + + assert report.removed == [] + client.remove.assert_not_called() + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("6", 6.0), + (24, 24.0), + (6, 6.0), + (1, 3.0), + (2.5, 3.0), + (500, 72.0), + (72, 72.0), + (0, 0.0), + (-3, 0.0), + (None, 6.0), + ("junk", 6.0), + ("nan", 6.0), + (float("nan"), 6.0), + ("inf", 6.0), + ], +) +def test_the_limit_is_held_to_3_through_72_hours_and_zero_means_off(monkeypatch, raw, expected): + monkeypatch.setattr(mod.config, "get", lambda _k, _d=None: raw) + + assert mod.stale_hours() == expected + + +def test_the_qbittorrent_client_lists_a_category_and_returns_nothing_on_failure(): + """list_torrents returns a category's torrents, an empty list when it holds none, and None + when qBittorrent is unreachable.""" + from shelfmark.download.clients.qbittorrent import QBittorrentClient + + client = object.__new__(QBittorrentClient) + seen = [] + + def listing(category): + """Stand in for qBittorrent: category ok lists one torrent, any other fails to connect.""" + seen.append(category) + if category == "ok": + return [_torrent()], None + return ([], None) if category == "empty" else ([], "Cannot connect") + + client._list_torrents_by_category = listing # type: ignore[method-assign] + + assert [t.name for t in client.list_torrents("ok")] == ["Book"] + assert client.list_torrents("empty") == [] + assert client.list_torrents("down") is None + assert seen == ["ok", "empty", "down"] + + +def test_the_setting_defaults_to_six_hours_in_the_app_and_in_the_settings_form(monkeypatch): + """STALE_TORRENT_HOURS is 6 both where the sweep reads it and in the settings form.""" + from shelfmark.download.clients.settings import prowlarr_clients_settings + + field = next( + f for f in prowlarr_clients_settings() if getattr(f, "key", "") == mod.STALE_HOURS_SETTING + ) + monkeypatch.setattr(mod.config, "get", lambda _k, default=None: default) + + assert field.default == 6 + assert mod.stale_hours() == 6.0 diff --git a/tests/download/test_torrent_ledger.py b/tests/download/test_torrent_ledger.py new file mode 100644 index 00000000..ca83bc98 --- /dev/null +++ b/tests/download/test_torrent_ledger.py @@ -0,0 +1,55 @@ +"""The ledger of torrents this instance added.""" + +from __future__ import annotations + +import pytest + +from shelfmark.download import torrent_ledger + + +@pytest.fixture(autouse=True) +def _ledger_file(tmp_path, monkeypatch): + monkeypatch.setattr(torrent_ledger, "_path", lambda: tmp_path / "torrent_ledger.db") + + +def test_a_recorded_hash_is_kept_lower_case_with_its_task(): + torrent_ledger.record("ABCDEF", "task-1") + + assert torrent_ledger.entries() == {"abcdef": "task-1"} + + +def test_recording_a_hash_again_moves_it_to_the_new_task(): + torrent_ledger.record("abc", "task-1") + torrent_ledger.record("ABC", "task-2") + + assert torrent_ledger.entries() == {"abc": "task-2"} + + +def test_claiming_moves_a_known_hash_and_never_adds_an_unknown_one(): + torrent_ledger.record("abc", "task-1") + + torrent_ledger.claim("abc", "task-2") + torrent_ledger.claim("unknown", "task-2") + + assert torrent_ledger.entries() == {"abc": "task-2"} + + +def test_forget_drops_only_the_named_hashes(): + torrent_ledger.record("a", "t1") + torrent_ledger.record("b", "t2") + + torrent_ledger.forget("A") + torrent_ledger.forget() + torrent_ledger.forget("") + + assert torrent_ledger.entries() == {"b": "t2"} + + +def test_an_unusable_ledger_never_raises(monkeypatch, tmp_path): + monkeypatch.setattr(torrent_ledger, "_path", lambda: tmp_path / "missing" / "ledger.db") + + torrent_ledger.record("a", "t1") + torrent_ledger.claim("a", "t2") + torrent_ledger.forget("a") + + assert torrent_ledger.entries() == {} diff --git a/tests/download/test_torrent_removal.py b/tests/download/test_torrent_removal.py index d6e77a56..48502a3d 100644 --- a/tests/download/test_torrent_removal.py +++ b/tests/download/test_torrent_removal.py @@ -217,3 +217,24 @@ def test_remove_still_removes_a_torrent_the_client_already_had() -> None: client = _import_then_clean_up(_status(DownloadState.SEEDING, 100.0, complete=True), "remove") client.remove.assert_called_once_with("dl", delete_files=False) + + +# --- the ledger of torrents this instance added ------------------------------------------- + + +def test_a_torrent_this_task_added_is_recorded_in_the_ledger() -> None: + with patch("shelfmark.download.torrent_ledger.record") as record: + _cancel(_status(D, 0.5), existing=False) + + record.assert_called_once_with("dl", "c") + + +def test_a_torrent_the_client_already_had_is_claimed_but_never_recorded() -> None: + with ( + patch("shelfmark.download.torrent_ledger.record") as record, + patch("shelfmark.download.torrent_ledger.claim") as claim, + ): + _cancel(_status(D, 0.5), existing=True) + + record.assert_not_called() + claim.assert_called_once_with("dl", "c") diff --git a/tests/slskd/test_handler_isolated.py b/tests/slskd/test_handler_isolated.py index d26ad9cb..851a9545 100644 --- a/tests/slskd/test_handler_isolated.py +++ b/tests/slskd/test_handler_isolated.py @@ -19,7 +19,7 @@ import shelfmark.release_sources.slskd.handler as mod from shelfmark.core.models import DownloadTask from shelfmark.download.activity import ACTIVITY_GRACE_STATUS from shelfmark.release_sources.slskd.api import SlskdBatchUnsupportedError -from shelfmark.release_sources.slskd.handler import SlskdHandler +from shelfmark.release_sources.slskd.handler import SlskdHandler, folder_maps_back REMOTE_DIR = "@@share\\Books\\Author" FILE_A = f"{REMOTE_DIR}\\Book One.epub" @@ -210,11 +210,14 @@ class TestHappyPath: def test_a_failure_to_name_a_safe_folder_never_escapes_the_downloads_root( self, handler, cfg, tmp_path ): + """A path-like id is refused before slskd is asked, and its folder name stays inside.""" fake = FakeSlskd(tmp_path) - _run(handler, fake, task=_task(task_id="../../etc/passwd")) + result, _, rec = _run(handler, fake, task=_task(task_id="../../etc/passwd")) - destination = fake.batches[0]["destination"] + assert result is None and rec.real[-1][0] == "error" + assert not fake.batches # refused before slskd is asked for anything + destination = handler._destination_for(_task(task_id="../../etc/passwd")) assert ".." not in destination and not destination.startswith("/") assert destination.startswith("shelfmark/") @@ -232,6 +235,36 @@ class TestHappyPath: assert fake2.batches[0]["destination"] == "shelfmark/slskd_def" +class TestTaskIdsThatCannotNameTheirFolder: + @pytest.mark.parametrize("task_id", ["slskd:ab+cd", "slskd:+ab", "slskd:ab_", "other:ab", "ab"]) + def test_an_id_whose_folder_reads_back_as_another_is_refused( + self, handler, cfg, tmp_path, task_id + ): + """An id whose folder the sweep would read as another id never gets a folder.""" + fake = FakeSlskd(tmp_path) + + result, _, rec = _run(handler, fake, task=_task(task_id=task_id)) + + assert result is None + assert rec.real[-1][0] == "error" + assert not (tmp_path / "shelfmark").exists() or not any((tmp_path / "shelfmark").iterdir()) + + @pytest.mark.parametrize( + ("task_id", "maps_back"), + [ + ("slskd:0123456789abcdef01234567", True), + ("slskd:a_b", True), + ("slskd:ab+cd", False), + ("slskd:+ab", False), + ("slskd:ab.", False), + ("slskd:", False), + ("prowlarr:ab", False), + ], + ) + def test_folder_maps_back(self, task_id, maps_back): + assert folder_maps_back(task_id) is maps_back + + class TestAnotherAppClearsSlskdsList: def test_a_transfer_slskd_no_longer_lists_is_confirmed_by_the_files_on_disk( self, handler, cfg, tmp_path diff --git a/tests/slskd/test_leftovers.py b/tests/slskd/test_leftovers.py new file mode 100644 index 00000000..6699ae4c --- /dev/null +++ b/tests/slskd/test_leftovers.py @@ -0,0 +1,957 @@ +"""Finished and abandoned folders under Shelfmark's slskd downloads folder.""" + +from __future__ import annotations + +import json +import sqlite3 +from datetime import UTC, datetime, timedelta + +import pytest + +import shelfmark.release_sources.slskd.handler as handler_mod +from shelfmark.core.download_history_service import DownloadHistoryService +from shelfmark.core.startup_reconcile import INTERRUPTED_MESSAGE +from shelfmark.core.user_db import UserDB +from shelfmark.release_sources.slskd import leftovers + +NOW = datetime(2026, 10, 16, 18, 0, tzinfo=UTC) +FILES = [{"filename": "@@s\\Book\\a.mp3", "size": 5}, {"filename": "@@s\\Book\\b.mp3", "size": 7}] + + +@pytest.fixture +def env(tmp_path): + db_path = str(tmp_path / "users.db") + user_db = UserDB(db_path) + user_db.initialize() + base = tmp_path / "complete" / "shelfmark" + base.mkdir(parents=True) + return user_db, DownloadHistoryService(db_path), db_path, base + + +def _folder(base, task_id, *, sizes=(5, 7), age_days=0.0): + """Make a download folder named after task_id, holding one file per size.""" + folder = base / task_id.replace(":", "_") + folder.mkdir() + for n, size in enumerate(sizes): + (folder / f"f{n}.mp3").write_bytes(b"x" * size) + return folder + + +def _row(env, task_id, *, status, request_id=None, age_days=0.0, files=FILES, message="x"): + """Record a Soulseek download in the history, with the files its retry payload asked for.""" + _user_db, history, db_path, _base = env + history.record_download( + task_id=task_id, + user_id=None, + username=None, + request_id=request_id, + source="slskd", + source_display_name=None, + title="Book", + author=None, + file_format=None, + size=None, + preview=None, + content_type="audiobook", + downloads=None, + origin="requested" if request_id else "direct", + retry_payload={"retry_source_context": {"username": "peer", "files": files}}, + ) + if status != "active": + history.finalize_download(task_id=task_id, final_status=status, status_message=message) + stamp = (NOW - timedelta(days=age_days)).isoformat(timespec="seconds") + conn = sqlite3.connect(db_path) + conn.execute( + "UPDATE download_history SET queued_at = ?, terminal_at = ? WHERE task_id = ?", + (stamp, stamp, task_id), + ) + conn.commit() + conn.close() + + +def _request(env, *, status="fulfilled", delivery="none"): + """Create a request for the test's one reader, creating that user the first time.""" + user_db = env[0] + user = user_db.create_user(username="reader", role="user") if not user_db.list_users() else None + user_id = user["id"] if user else user_db.list_users()[0]["id"] + row = user_db.create_request( + user_id=user_id, + content_type="audiobook", + request_level="book", + policy_mode="request_book", + book_data={"title": "T", "author": "A", "provider": "hardcover", "provider_id": "1"}, + ) + return user_db.update_request(row["id"], status=status, delivery_state=delivery) + + +def _plan(env, folders, *, keep_completed=False, clean_days=7): + """Run leftovers.plan over folders with the test clock and the given clean-up setting.""" + user_db, history, _db, _base = env + return leftovers.plan( + folders, + history_service=history, + user_db=user_db, + folder_complete=handler_mod.folder_complete, + keep_completed=keep_completed, + clean_after=timedelta(days=clean_days) if clean_days else None, + now=NOW, + ) + + +def _names(paths): + """Sorted folder names from a plan result, whose entries are paths or (path, row) pairs.""" + return sorted(p.name if hasattr(p, "name") else p[0].name for p in paths) + + +def test_a_whole_folder_whose_download_never_completed_is_finished(env): + base = env[3] + folder = _folder(base, "slskd:aa") + _row(env, "slskd:aa", status="error") + + finish, remove, keep = _plan(env, [folder]) + + assert _names(finish) == ["slskd_aa"] + assert not remove + assert not keep + + +def test_a_whole_folder_of_a_request_that_still_waits_for_it_is_finished(env): + request = _request(env, delivery="cancelled") + folder = _folder(env[3], "slskd:bb") + _row(env, "slskd:bb", status="error", request_id=request["id"]) + + finish, _remove, _keep = _plan(env, [folder]) + + assert _names(finish) == ["slskd_bb"] + + +def test_a_whole_folder_of_a_request_that_already_has_its_book_is_not_imported_twice(env): + request = _request(env, delivery="complete") + folder = _folder(env[3], "slskd:cc") + _row(env, "slskd:cc", status="error", request_id=request["id"], age_days=2) + + finish, remove, _keep = _plan(env, [folder]) + + assert not finish + assert _names(remove) == ["slskd_cc"] + + +@pytest.mark.parametrize("sizes", [None, (5,)], ids=["whole", "partial"]) +def test_a_delivered_requests_folder_stays_when_cleanup_is_off(env, sizes): + """With clean-up off, a delivered request's folder stays, whole or partial.""" + request = _request(env, delivery="complete") + folder = ( + _folder(env[3], "slskd:ff") if sizes is None else _folder(env[3], "slskd:ff", sizes=sizes) + ) + _row(env, "slskd:ff", status="error", request_id=request["id"], age_days=30) + + finish, remove, keep = _plan(env, [folder], clean_days=0) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_ff"] + + +def test_a_whole_folder_of_a_pending_request_is_kept(env): + request = _request(env, status="pending") + folder = _folder(env[3], "slskd:dd") + _row(env, "slskd:dd", status="error", request_id=request["id"], age_days=30) + + finish, remove, keep = _plan(env, [folder]) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_dd"] + + +def test_a_partial_folder_goes_once_it_is_old_enough(env): + old = _folder(env[3], "slskd:old", sizes=(5,)) + young = _folder(env[3], "slskd:new", sizes=(5,)) + _row(env, "slskd:old", status="error", age_days=10) + _row(env, "slskd:new", status="error", age_days=3) + + _finish, remove, keep = _plan(env, [old, young]) + + assert _names(remove) == ["slskd_old"] + assert _names(keep) == ["slskd_new"] + + +def test_a_partial_folder_stays_when_cleanup_is_off(env): + folder = _folder(env[3], "slskd:old", sizes=(5,)) + _row(env, "slskd:old", status="error", age_days=400) + + _finish, remove, keep = _plan(env, [folder], clean_days=0) + + assert not remove + assert _names(keep) == ["slskd_old"] + + +def test_a_partial_folder_of_a_delivered_request_goes_after_a_day_not_a_week(env): + request = _request(env, delivery="complete") + folder = _folder(env[3], "slskd:ee", sizes=(5,)) + _row(env, "slskd:ee", status="error", request_id=request["id"], age_days=2) + + _finish, remove, _keep = _plan(env, [folder]) + + assert _names(remove) == ["slskd_ee"] + + +def test_the_folder_of_a_running_download_is_never_touched(env): + folder = _folder(env[3], "slskd:run", sizes=(5,)) + _row(env, "slskd:run", status="active", age_days=100) + + finish, remove, keep = _plan(env, [folder]) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_run"] + + +@pytest.mark.parametrize(("keep_completed", "removed"), [(False, True), (True, False)]) +def test_an_imported_folder_is_removed_unless_finished_files_are_kept_shared( + env, keep_completed, removed +): + folder = _folder(env[3], "slskd:done") + _row(env, "slskd:done", status="complete", age_days=3) + + _finish, remove, _keep = _plan(env, [folder], keep_completed=keep_completed) + + assert bool(remove) is removed + + +def test_a_folder_nothing_records_is_removed_only_when_old(env): + folder = _folder(env[3], "slskd:mystery") + + _finish, remove, keep = _plan(env, [folder]) + + assert _names(keep) == ["slskd_mystery"] # its mtime is now + assert not remove + + +def test_a_folder_that_is_not_named_after_a_task_is_left_alone(env): + other = env[3] / "something-else" + other.mkdir() + + finish, remove, keep = _plan(env, [other]) + + assert not finish + assert not remove + assert _names(keep) == ["something-else"] + + +class _Client: + pass + + +@pytest.fixture +def wired(env, monkeypatch): + base = env[3] + values = {"SLSKD_ENABLED": True, "SLSKD_CLEAN_LEFTOVERS_DAYS": 7, "SLSKD_KEEP_COMPLETED": False} + monkeypatch.setattr(leftovers.config, "get", lambda k, d=None: values.get(k, d)) + monkeypatch.setattr(handler_mod.config, "get", lambda k, d=None: values.get(k, d)) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: _Client()) + monkeypatch.setattr(handler_mod, "resolve_local_download_root", lambda _c: (base.parent, None)) + return values + + +def _run(env, requeue): + return leftovers.run(history_service=env[1], user_db=env[0], requeue=requeue, now=NOW) + + +def test_run_queues_a_whole_folder_and_removes_an_old_partial_one(env, wired): + whole = _folder(env[3], "slskd:aa") + partial = _folder(env[3], "slskd:bb", sizes=(5,)) + _row(env, "slskd:aa", status="error") + _row(env, "slskd:bb", status="error", age_days=10) + queued = [] + + def requeue(payload, final_status): + queued.append((payload, final_status)) + return True, None + + report = _run(env, requeue) + + assert report.adopted == ["slskd_aa"] + assert report.removed == ["slskd_bb"] + assert len(queued) == 1 + assert queued[0][1] == "error" + assert whole.exists() # left for the import to read, and removed after it + assert not partial.exists() + + +def test_run_queues_at_most_a_few_folders_at_a_time(env, wired): + """Only MAX_ADOPTIONS_PER_RUN whole folders are queued in one run; the rest wait.""" + for n in range(leftovers.MAX_ADOPTIONS_PER_RUN + 2): + _folder(env[3], f"slskd:t{n}") + _row(env, f"slskd:t{n}", status="error") + + report = _run(env, lambda payload, final_status: (True, None)) + + assert len(report.adopted) == leftovers.MAX_ADOPTIONS_PER_RUN + + +def test_run_does_not_import_when_finishing_is_switched_off(env, wired): + _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + wired["SLSKD_FINISH_LEFTOVERS"] = False + + report = _run(env, lambda payload, final_status: (_ for _ in ()).throw(AssertionError())) + + assert report.adopted == [] + + +def test_run_keeps_a_folder_whose_import_could_not_be_queued(env, wired): + folder = _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + + report = _run(env, lambda payload, final_status: (False, "Download cannot be retried")) + + assert report.adopted == [] + assert folder.exists() + + +def test_run_does_nothing_while_soulseek_is_switched_off(env, wired): + folder = _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + wired["SLSKD_ENABLED"] = False + + report = _run(env, lambda payload, final_status: (_ for _ in ()).throw(AssertionError())) + + assert (report.adopted, report.removed, report.cancelled) == ([], [], []) + assert not (folder / leftovers.ADOPTED_MARKER).exists() # still imported once it is back on + + +def test_run_does_nothing_without_a_downloads_folder(env, wired, monkeypatch): + monkeypatch.setattr(handler_mod, "resolve_local_download_root", lambda _c: (None, "no path")) + _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + + report = _run(env, lambda payload, final_status: (True, None)) + + assert (report.adopted, report.removed) == ([], []) + + +def test_the_task_id_comes_back_from_a_folder_name(): + assert leftovers._task_id_for_folder("slskd_ab12") == "slskd:ab12" + assert leftovers._task_id_for_folder("slskd_") is None + assert leftovers._task_id_for_folder("other") is None + + +def test_the_payload_files_are_read_whether_stored_as_text_or_dict(): + payload = {"retry_source_context": {"files": FILES}} + + assert leftovers._expected_files({"retry_payload": payload}) == FILES + assert leftovers._expected_files({"retry_payload": json.dumps(payload)}) == FILES + assert leftovers._expected_files({"retry_payload": "not json"}) == [] + + +# --- transfers nothing is watching ------------------------------------------------------- + + +def _transfer( + name="@@s\\Book\\a.mp3", state="Queued, Remotely", peer="peer", tid="x1", requested=None +): + """An slskd transfer record; requested is its requestedAt stamp, left out when None.""" + transfer = {"username": peer, "filename": name, "state": state, "id": tid, "size": 5} + if requested is not None: + transfer["requestedAt"] = requested + return transfer + + +def _plan_transfers(env, transfers, *, live=None): + """Run leftovers.plan_transfers against the test history. live maps task id to request id.""" + live = live or {} + return leftovers.plan_transfers( + transfers, + rows=env[1].list_recent(user_id=None, limit=1000), + live_task_ids=set(live), + live_request_ids={r for r in live.values() if r}, + user_db=env[0], + ) + + +def test_a_finished_transfer_is_only_a_record_and_is_left_alone(env): + _row(env, "slskd:aa", status="error") + + reattach, cancel = _plan_transfers(env, [_transfer(state="Completed, Errored")]) + + assert (reattach, cancel) == ([], []) + + +def test_a_transfer_a_running_task_owns_is_left_alone(env): + _row(env, "slskd:aa", status="active") + + reattach, cancel = _plan_transfers(env, [_transfer()], live={"slskd:aa": None}) + + assert (reattach, cancel) == ([], []) + + +def test_an_interrupted_manual_download_is_picked_up_again(env): + _row(env, "slskd:aa", status="active") + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert [row["task_id"] for row, _owned in reattach] == ["slskd:aa"] + assert cancel == [] + + +def test_a_manual_download_a_restart_cut_short_is_picked_up_again(env): + _row(env, "slskd:aa", status="error", message=INTERRUPTED_MESSAGE) + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert [row["task_id"] for row, _owned in reattach] == ["slskd:aa"] + assert cancel == [] + + +def test_a_manual_download_that_already_failed_is_not_brought_back(env): + _row(env, "slskd:aa", status="error") + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert reattach == [] + assert len(cancel) == 1 + + +def test_a_transfer_of_a_request_still_waiting_for_it_is_picked_up_again(env): + request = _request(env, delivery="queued") + _row(env, "slskd:aa", status="error", request_id=request["id"]) + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert [row["task_id"] for row, _owned in reattach] == ["slskd:aa"] + assert cancel == [] + + +def test_an_older_attempt_is_dropped_when_a_newer_one_is_running(env): + request = _request(env, delivery="queued") + _row(env, "slskd:old", status="error", request_id=request["id"]) + + reattach, cancel = _plan_transfers(env, [_transfer()], live={"slskd:new": request["id"]}) + + assert reattach == [] + assert len(cancel) == 1 + + +def test_a_pending_request_may_still_choose_the_same_release_so_its_transfer_waits(env): + request = _request(env, status="pending") + _row(env, "slskd:aa", status="error", request_id=request["id"]) + + assert _plan_transfers(env, [_transfer()]) == ([], []) + + +@pytest.mark.parametrize( + ("status", "delivery"), + [("fulfilled", "complete"), ("rejected", "none"), ("cancelled", "none")], +) +def test_a_transfer_of_a_request_that_is_done_or_dropped_is_cancelled(env, status, delivery): + request = _request(env, status=status, delivery=delivery) + _row(env, "slskd:aa", status="error", request_id=request["id"]) + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert reattach == [] + assert len(cancel) == 1 + + +def test_a_transfer_shelfmark_never_asked_for_is_left_alone(env): + """slskd can be shared with other apps or used by hand; their transfers are not ours.""" + _row(env, "slskd:aa", status="error") + + reattach, cancel = _plan_transfers(env, [_transfer(name="@@s\\Other\\z.mp3")]) + + assert (reattach, cancel) == ([], []) + + +class _FakeSlskd: + def __init__(self, transfers): + self.transfers = transfers + self.cancelled = [] + + def list_all_downloads(self): + return self.transfers + + def cancel_transfer(self, username, transfer_id, *, remove): + self.cancelled.append((username, transfer_id, remove)) + return True + + +def test_run_cancels_an_unwatched_transfer_and_picks_up_a_waiting_one(env, wired, monkeypatch): + monkeypatch.setattr(leftovers.time, "sleep", lambda _s: None) + waiting = _request(env, delivery="queued") + _row(env, "slskd:aa", status="error", request_id=waiting["id"]) + _row(env, "slskd:bb", status="error", files=[{"filename": "@@s\\Old\\q.mp3", "size": 5}]) + fake = _FakeSlskd( + [ + _transfer(tid="keep"), + _transfer(name="@@s\\Old\\q.mp3", tid="drop"), + _transfer(name="@@s\\Stranger\\q.mp3", tid="theirs"), + ] + ) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + queued = [] + + def requeue(payload, final_status): + queued.append(final_status) + return True, None + + report = leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=requeue, + live_tasks=lambda: {}, + now=NOW, + ) + + assert report.reattached == ["slskd:aa"] + assert queued == ["error"] + assert [c[1] for c in fake.cancelled] == ["drop", "drop"] # cancel, then the second pass + assert all(c[2] is True for c in fake.cancelled) + assert len(report.cancelled) == 1 + + +def test_run_leaves_transfers_alone_when_it_is_not_given_the_running_tasks(env, wired, monkeypatch): + fake = _FakeSlskd([_transfer(name="@@s\\Stranger\\q.mp3", tid="drop")]) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + + leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=lambda *a, **k: (True, None), + now=NOW, + ) + + assert fake.cancelled == [] + + +def test_run_with_backend_reads_the_running_tasks_and_requeues_through_the_retry( + env, wired, monkeypatch +): + from shelfmark.download import orchestrator as backend + + request = _request(env, delivery="queued") + _row(env, "slskd:aa", status="error", request_id=request["id"]) + fake = _FakeSlskd([_transfer(tid="t1")]) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + monkeypatch.setattr( + backend, + "queue_status", + lambda: {"downloading": {"slskd:other": {"request_id": None}}, "error": {}}, + ) + retried = [] + + def retry(payload, *, final_status, allow_request_linked): + retried.append((final_status, allow_request_linked)) + return True, None + + monkeypatch.setattr(backend, "retry_persisted_download", retry) + + report = leftovers.run_with_backend(history_service=env[1], user_db=env[0], now=NOW) + + assert report.reattached == ["slskd:aa"] + assert retried == [("error", True)] + + +def test_run_leaves_transfers_alone_when_finishing_is_switched_off(env, wired, monkeypatch): + _row(env, "slskd:bb", status="error", files=[{"filename": "@@s\\Old\\q.mp3", "size": 5}]) + fake = _FakeSlskd([_transfer(name="@@s\\Old\\q.mp3", tid="drop")]) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + wired["SLSKD_FINISH_LEFTOVERS"] = False + + report = leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=lambda *a, **k: (True, None), + live_tasks=lambda: {}, + now=NOW, + ) + + assert fake.cancelled == [] + assert report.cancelled == [] + + +def test_a_cancelled_download_is_never_imported(env): + """A whole folder of a download someone cancelled stays put until clean-up removes it.""" + young = _folder(env[3], "slskd:cx") + old = _folder(env[3], "slskd:co") + _row(env, "slskd:cx", status="cancelled", age_days=1) + _row(env, "slskd:co", status="cancelled", age_days=30) + + finish, remove, keep = _plan(env, [young, old]) + + assert not finish + assert _names(remove) == ["slskd_co"] + assert _names(keep) == ["slskd_cx"] + + +def test_a_folder_queued_once_is_not_queued_again(env): + """The marker from an earlier run keeps a folder whose import failed out of the next ones.""" + folder = _folder(env[3], "slskd:aa") + (folder / leftovers.ADOPTED_MARKER).touch() + _row(env, "slskd:aa", status="error") + + finish, remove, keep = _plan(env, [folder]) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_aa"] + + +def test_run_marks_a_folder_it_queues_and_leaves_it_on_the_next_run(env, wired): + """A folder is queued once; a failed import does not bring it back every run.""" + folder = _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + queued = [] + + def requeue(payload, final_status): + """Accept every requeue and count it.""" + queued.append(final_status) + return True, None + + first = _run(env, requeue) + second = _run(env, requeue) + + assert first.adopted == ["slskd_aa"] + assert (folder / leftovers.ADOPTED_MARKER).exists() + assert second.adopted == [] + assert queued == ["error"] + + +def test_a_transfer_of_a_cancelled_download_is_cancelled_not_picked_up(env): + """A download someone cancelled is not brought back, even while its request still waits.""" + waiting = _request(env, delivery="queued") + _row(env, "slskd:aa", status="cancelled", request_id=waiting["id"]) + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert reattach == [] + assert len(cancel) == 1 + + +def test_run_does_not_remove_a_folder_whose_download_it_just_picked_up(env, wired, monkeypatch): + """An old partial folder stays when the same run restarts the download writing into it.""" + monkeypatch.setattr(leftovers.time, "sleep", lambda _s: None) + waiting = _request(env, delivery="queued") + folder = _folder(env[3], "slskd:aa", sizes=(5,)) + _row(env, "slskd:aa", status="error", request_id=waiting["id"], age_days=30) + fake = _FakeSlskd([_transfer()]) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + + report = leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=lambda payload, final_status: (True, None), + live_tasks=lambda: {}, + now=NOW, + ) + + assert report.reattached == ["slskd:aa"] + assert folder.exists() + assert "slskd_aa" in report.kept + + +def test_run_does_not_remove_the_folder_of_a_running_download(env, wired, monkeypatch): + """A task that is running now owns its folder, however old the history says it is.""" + folder = _folder(env[3], "slskd:bb", sizes=(5,)) + _row(env, "slskd:bb", status="error", age_days=30) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: _FakeSlskd([])) + + report = leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=lambda payload, final_status: (True, None), + live_tasks=lambda: {"slskd:bb": None}, + now=NOW, + ) + + assert folder.exists() + assert "slskd_bb" in report.kept + + +def test_a_transfer_matching_a_finished_download_is_someone_elses(env): + """A finished download has no unfinished transfers, so a match is another app's or a person's.""" + _row(env, "slskd:aa", status="complete") + + reattach, cancel = _plan_transfers(env, [_transfer()]) + + assert (reattach, cancel) == ([], []) + + +def test_folders_that_cannot_be_queued_do_not_use_up_the_runs_imports(env, wired): + """Only successful imports count towards the cap, so failing folders do not block the rest.""" + for n in range(leftovers.MAX_ADOPTIONS_PER_RUN): + _folder(env[3], f"slskd:bad{n}") + _row(env, f"slskd:bad{n}", status="error") + _folder(env[3], "slskd:good") + _row(env, "slskd:good", status="error") + + calls = [] + + def requeue(payload, final_status): + """Refuse the first folders (the bad ones, by name order) and accept the rest.""" + calls.append(final_status) + return (False, "refused") if len(calls) <= leftovers.MAX_ADOPTIONS_PER_RUN else (True, None) + + report = _run(env, requeue) + + assert report.adopted == ["slskd_good"] + + +def test_a_folder_that_could_not_be_queued_is_not_tried_every_run(env, wired): + """A failed import is tried once; the next run leaves the folder for a person or clean-up.""" + folder = _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error") + calls = [] + + def requeue(payload, final_status): + """Refuse, counting each attempt.""" + calls.append(final_status) + return False, "refused" + + _run(env, requeue) + _run(env, requeue) + + assert calls == ["error"] + assert folder.exists() + + +def test_a_whole_folder_tried_once_stays_however_old_while_still_wanted(env): + """A failed import is not a reason to delete the files: clean-up never takes a whole folder + of a direct download, or of a request that is still open, whatever its age.""" + direct = _folder(env[3], "slskd:aa") + (direct / leftovers.ADOPTED_MARKER).touch() + _row(env, "slskd:aa", status="error", age_days=400) + waiting = _request(env, delivery="queued") + requested = _folder(env[3], "slskd:bb") + (requested / leftovers.ADOPTED_MARKER).touch() + _row(env, "slskd:bb", status="error", request_id=waiting["id"], age_days=400) + + finish, remove, keep = _plan(env, [direct, requested], clean_days=7) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_aa", "slskd_bb"] + + +def test_a_whole_folder_tried_once_goes_a_day_after_its_request_is_done_with(env): + """Once the request has its book another way, or was dropped, the marked folder is dead + weight and goes like any other folder of a finished request.""" + delivered = _request(env, delivery="complete") + folder = _folder(env[3], "slskd:aa") + (folder / leftovers.ADOPTED_MARKER).touch() + _row(env, "slskd:aa", status="error", request_id=delivered["id"], age_days=2) + + finish, remove, _keep = _plan(env, [folder], clean_days=7) + + assert not finish + assert _names(remove) == ["slskd_aa"] + + +def test_a_folder_of_an_older_attempt_is_kept_while_a_newer_one_runs(env): + """A request already downloading another release does not get the old folder imported too.""" + waiting = _request(env, delivery="queued") + folder = _folder(env[3], "slskd:aa") + _row(env, "slskd:aa", status="error", request_id=waiting["id"]) + user_db, history, _db, _base = env + + finish, remove, keep = leftovers.plan( + [folder], + history_service=history, + user_db=user_db, + folder_complete=handler_mod.folder_complete, + keep_completed=False, + clean_after=timedelta(days=7), + now=NOW, + live_request_ids={waiting["id"]}, + ) + + assert not finish + assert not remove + assert _names(keep) == ["slskd_aa"] + + +def test_a_download_with_several_transfers_is_picked_up_once_with_all_of_them(env): + """One requeue per download, and every one of its transfers is listed under it.""" + _row(env, "slskd:aa", status="active") + + reattach, cancel = _plan_transfers( + env, [_transfer(tid="t1"), _transfer(name="@@s\\Book\\b.mp3", tid="t2")] + ) + + assert [(row["task_id"], sorted(t["id"] for t in owned)) for row, owned in reattach] == [ + ("slskd:aa", ["t1", "t2"]) + ] + assert cancel == [] + + +def _settle(env, fake, requeue, monkeypatch): + """Run the sweep against a fake slskd with no running tasks, and no waits between cancels.""" + monkeypatch.setattr(leftovers.time, "sleep", lambda _s: None) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + return leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=requeue, + live_tasks=lambda: {}, + now=NOW, + ) + + +def test_a_download_that_cannot_be_picked_up_has_its_transfers_dropped(env, wired, monkeypatch): + """Nothing will ever watch a transfer whose download was refused, so it goes; the refusal + does not use up the run's pick-ups, and a second run has nothing left to retry.""" + waiting = _request(env, delivery="queued") + _row(env, "slskd:bad", status="error", request_id=waiting["id"]) + _row( + env, + "slskd:good", + status="error", + request_id=waiting["id"], + files=[{"filename": "@@s\\Other\\z.mp3", "size": 5}], + ) + fake = _FakeSlskd( + [ + _transfer(tid="bad1"), + _transfer(name="@@s\\Book\\b.mp3", tid="bad2"), + _transfer(name="@@s\\Other\\z.mp3", tid="good1"), + ] + ) + + def requeue(payload, final_status): + """Refuse the download whose files are the Book ones; accept the other.""" + files = payload["retry_source_context"]["files"] + return (False, "refused") if files == FILES else (True, None) + + report = _settle(env, fake, requeue, monkeypatch) + + assert report.reattached == ["slskd:good"] + assert sorted({c[1] for c in fake.cancelled}) == ["bad1", "bad2"] + assert len(report.cancelled) == 2 + + +def test_refused_pick_ups_do_not_use_up_the_runs_cap(env, wired, monkeypatch): + """Only downloads that were queued count towards the cap, as with finished folders.""" + cap = leftovers.MAX_ADOPTIONS_PER_RUN + waiting = _request(env, delivery="queued") + transfers = [] + for n in range(cap + 1): + name = f"@@s\\Book{n}\\a.mp3" + _row( + env, + f"slskd:t{n}", + status="error", + request_id=waiting["id"], + files=[{"filename": name, "size": 5}], + ) + transfers.append(_transfer(name=name, tid=f"t{n}")) + calls = [] + + def requeue(payload, final_status): + """Refuse the first `cap` downloads and accept the rest.""" + calls.append(payload["retry_source_context"]["files"][0]["filename"]) + return (False, "refused") if len(calls) <= cap else (True, None) + + report = _settle(env, _FakeSlskd(transfers), requeue, monkeypatch) + + assert len(report.reattached) == 1 + assert len(report.cancelled) == cap + + +def test_downloads_past_the_cap_keep_their_transfers_for_the_next_run(env, wired, monkeypatch): + """A backlog is picked up a few per run; the rest are neither queued nor dropped.""" + cap = leftovers.MAX_ADOPTIONS_PER_RUN + waiting = _request(env, delivery="queued") + transfers = [] + for n in range(cap + 2): + name = f"@@s\\Book{n}\\a.mp3" + _row( + env, + f"slskd:t{n}", + status="error", + request_id=waiting["id"], + files=[{"filename": name, "size": 5}], + ) + transfers.append(_transfer(name=name, tid=f"t{n}")) + fake = _FakeSlskd(transfers) + + report = _settle(env, fake, lambda payload, final_status: (True, None), monkeypatch) + + assert len(report.reattached) == cap + assert fake.cancelled == [] + + +def test_dropping_many_transfers_waits_once_not_once_per_transfer(env, wired, monkeypatch): + """Every transfer is cancelled, then one wait covers them all before the second pass.""" + waiting = _request(env, status="rejected") + transfers = [] + for n in range(5): + name = f"@@s\\Book{n}\\a.mp3" + _row( + env, + f"slskd:t{n}", + status="error", + request_id=waiting["id"], + files=[{"filename": name, "size": 5}], + ) + transfers.append(_transfer(name=name, tid=f"t{n}")) + fake = _FakeSlskd(transfers) + waits = [] + monkeypatch.setattr(leftovers.time, "sleep", lambda s: waits.append(s)) + monkeypatch.setattr(handler_mod, "build_client_from_config", lambda: fake) + + report = leftovers.run( + history_service=env[1], + user_db=env[0], + requeue=lambda *a, **k: (True, None), + live_tasks=lambda: {}, + now=NOW, + ) + + assert len(report.cancelled) == 5 + assert len(waits) == 1 + assert [c[1] for c in fake.cancelled] == [f"t{n}" for n in range(5)] * 2 + + +def _slskd_stamp(when): + """A time as slskd writes requestedAt: UTC, seven fractional digits, no zone.""" + return when.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%S.%f") + "0" + + +@pytest.mark.parametrize("status", ["error", "cancelled"]) +def test_the_same_file_queued_again_after_the_download_ended_is_left_alone(env, status): + _row(env, "slskd:aa", status=status, age_days=1) + + later = _transfer(requested=_slskd_stamp(NOW - timedelta(hours=1))) + + assert _plan_transfers(env, [later]) == ([], []) + + +def test_a_transfer_queued_before_the_download_ended_is_still_cancelled(env): + _row(env, "slskd:aa", status="cancelled", age_days=1) + + earlier = _transfer(requested=_slskd_stamp(NOW - timedelta(days=2))) + + reattach, cancel = _plan_transfers(env, [earlier]) + + assert reattach == [] + assert cancel == [earlier] + + +def test_a_request_waiting_for_its_file_does_not_take_one_queued_after_its_download_ended(env): + request = _request(env, delivery="queued") + _row(env, "slskd:aa", status="error", request_id=request["id"], age_days=1) + + later = _transfer(requested=_slskd_stamp(NOW - timedelta(hours=1))) + + assert _plan_transfers(env, [later]) == ([], []) + + +def test_a_running_download_keeps_its_transfers_whatever_their_stamp(env): + _row(env, "slskd:aa", status="active") + + later = _transfer(requested=_slskd_stamp(NOW + timedelta(hours=1))) + + reattach, cancel = _plan_transfers(env, [later]) + + assert [row["task_id"] for row, _owned in reattach] == ["slskd:aa"] + assert cancel == []