Files
shelfmark/tests/download/test_housekeeping.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

111 lines
3.3 KiB
Python

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