soulsync/tests/test_expired_download_cleaner.py
BoulderBadgeDad 696119d5ac Expired Download Cleaner: retention-based cleanup of watchlist/playlist downloads (Boulder)
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.

A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.

- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
  is_expired, select_expired) — no DB/clock, fully tested. play_count is the
  reliable listen signal (last_played is often unpopulated, so recency isn't
  used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
  get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
  watch via get_watchlist_artists) and either creates 'expired_download'
  findings or, with auto_delete on, deletes in-scan. Default OFF, both
  retentions default 'off'. Settings auto-render in the Library Maintenance
  panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
  track row → drop history row); a file that won't delete keeps its row +
  reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.

Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
2026-06-07 22:06:56 -07:00

137 lines
5.2 KiB
Python

"""Expired Download Cleaner job: scan protection + findings vs auto-delete,
and the shared delete helper.
The pure expiry logic is tested in tests/library/test_expired_cleanup.py; this
covers the job's fact-gathering (play_count, active-mirror/watch protection)
and the two modes.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from core.repair_jobs.expired_download_cleaner import (
ExpiredDownloadCleanerJob,
delete_origin_download,
)
OLD = (datetime.now(timezone.utc) - timedelta(days=120)).strftime("%Y-%m-%d %H:%M:%S")
NEW = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
class _DB:
def __init__(self, candidates, mirrored=None, watched=None):
self._candidates = candidates
self._mirrored = mirrored or []
self._watched = watched or []
self.deleted_paths = []
self.deleted_history = []
def get_origin_cleanup_candidates(self):
return [dict(c) for c in self._candidates]
def get_mirrored_playlists(self, profile_id=1):
return [{'name': n} for n in self._mirrored]
def get_watchlist_artists(self, profile_id=1):
return [SimpleNamespace(artist_name=n) for n in self._watched]
def delete_track_by_file_path(self, p):
self.deleted_paths.append(p)
return 1
def delete_library_history_rows(self, ids):
self.deleted_history.extend(ids)
return len(ids)
def _ctx(db, settings, findings):
return SimpleNamespace(
db=db,
config_manager=SimpleNamespace(get=lambda k, d=None: settings if k.endswith('.settings') else d),
check_stop=lambda: False, wait_if_paused=lambda: False,
update_progress=lambda *a, **k: None, report_progress=lambda *a, **k: None,
create_finding=lambda **kw: (findings.append(kw) or True),
)
def _cand(eid, origin="playlist", created=OLD, play_count=0, ctx="Some Playlist", path=None):
return {"id": eid, "origin": origin, "origin_context": ctx, "created_at": created,
"file_path": path or f"/music/{eid}.flac", "title": f"T{eid}",
"artist_name": "Artist", "play_count": play_count}
# ── scan: findings mode + protections ────────────────────────────────────────
def test_scan_noop_when_both_retentions_off():
db = _DB([_cand(1)])
findings = []
res = ExpiredDownloadCleanerJob().scan(_ctx(db, {}, findings)) # defaults: both off
assert res.findings_created == 0 and findings == []
def test_scan_creates_findings_for_expired():
db = _DB([
_cand(1, created=OLD, play_count=0), # expired
_cand(2, created=NEW, play_count=0), # too new
_cand(3, created=OLD, play_count=5), # listened → keep
])
findings = []
res = ExpiredDownloadCleanerJob().scan(_ctx(
db, {'playlist_retention': '2mo', 'keep_if_played_at_least': 2}, findings))
assert res.findings_created == 1
assert findings[0]['details']['history_id'] == 1
assert findings[0]['finding_type'] == 'expired_download'
def test_scan_protects_actively_mirrored_playlist():
db = _DB([_cand(1, origin="playlist", ctx="My Mix", created=OLD)],
mirrored=["My Mix"])
findings = []
ExpiredDownloadCleanerJob().scan(_ctx(db, {'playlist_retention': '1w'}, findings))
assert findings == [] # still mirrored → protected
def test_scan_protects_watched_artist():
db = _DB([_cand(1, origin="watchlist", ctx="Drake", created=OLD)],
watched=["Drake"])
findings = []
ExpiredDownloadCleanerJob().scan(_ctx(db, {'watchlist_retention': '1w'}, findings))
assert findings == [] # still watched → protected
def test_scan_auto_delete_mode():
db = _DB([_cand(1, created=OLD, path="/music/x.flac")])
findings = []
res = ExpiredDownloadCleanerJob().scan(_ctx(
db, {'playlist_retention': '2mo', 'auto_delete': True}, findings))
assert findings == [] # no findings in auto mode
assert res.auto_fixed == 1
assert 1 in db.deleted_history # history row removed
assert "/music/x.flac" in db.deleted_paths # track row removed
# ── delete helper ────────────────────────────────────────────────────────────
def test_delete_origin_download_missing_file(tmp_path):
# File doesn't exist → still cleans up the history row (orphan), no error.
db = _DB([])
entry = {"id": 9, "file_path": str(tmp_path / "gone.flac")}
cfg = SimpleNamespace(get=lambda k, d=None: d)
res = delete_origin_download(db, entry, cfg)
assert res["error"] is None and res["file_deleted"] is False
assert db.deleted_history == [9]
def test_delete_origin_download_removes_real_file(tmp_path):
f = tmp_path / "song.flac"; f.write_bytes(b"x")
db = _DB([])
entry = {"id": 5, "file_path": str(f)}
cfg = SimpleNamespace(get=lambda k, d=None: d)
res = delete_origin_download(db, entry, cfg)
assert res["file_deleted"] is True and not f.exists()
assert db.deleted_history == [5]