"""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_dry_run_default_is_findings_only(): # No dry_run in settings → defaults to True → findings, never deletes. db = _DB([_cand(1, created=OLD, path="/music/x.flac")]) findings = [] res = ExpiredDownloadCleanerJob().scan(_ctx(db, {'playlist_retention': '2mo'}, findings)) assert res.findings_created == 1 and db.deleted_history == [] # nothing deleted def test_scan_auto_delete_when_dry_run_off(): db = _DB([_cand(1, created=OLD, path="/music/x.flac")]) findings = [] res = ExpiredDownloadCleanerJob().scan(_ctx( db, {'playlist_retention': '2mo', 'dry_run': False}, 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]