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.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-09 13:20:21 -06:00
1 parent e575ff0fcb
commit bb13fae0bb
20 files changed
+2611 -6

No files matched your search

+32
View File
@@ -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**
@@ -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
@@ -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)
+17
View File
@@ -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",
+105
View File
@@ -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()
+15 -3
View File
@@ -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
+166
View File
@@ -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)
+95
View File
@@ -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)
+10
View File
@@ -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()
+4
View File
@@ -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:
@@ -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
)
@@ -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) # "<peer>: <file>" 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:
"""'<peer>: <file>', 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,
)
@@ -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)",
+110
View File
@@ -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
@@ -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"
+288
View File
@@ -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
+55
View File
@@ -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() == {}
+21
View File
@@ -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")
+36 -3
View File
@@ -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
+957
View File
@@ -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 == []