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 -->
289 lines
9.5 KiB
Python
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
|