mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-10 12:28:27 +00:00
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 -->
111 lines
3.3 KiB
Python
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
|