Files
shelfmark/tests/download/test_stale_torrents.py
T
splitsec2 bb13fae0bb 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 -->
2026-11-09 13:20:21 -06:00

289 lines
9.5 KiB
Python

"""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