From 696119d5acf46b03bf426d93b3e35a4a2008f480 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Sun, 7 Jun 2026 22:06:56 -0700 Subject: [PATCH] Expired Download Cleaner: retention-based cleanup of watchlist/playlist downloads (Boulder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- core/library/expired_cleanup.py | 98 +++++++++ core/repair_jobs/__init__.py | 1 + core/repair_jobs/expired_download_cleaner.py | 201 +++++++++++++++++++ core/repair_worker.py | 18 +- database/music_database.py | 24 +++ tests/library/test_expired_cleanup.py | 91 +++++++++ tests/test_expired_download_cleaner.py | 137 +++++++++++++ webui/static/enrichment.js | 11 +- 8 files changed, 579 insertions(+), 2 deletions(-) create mode 100644 core/library/expired_cleanup.py create mode 100644 core/repair_jobs/expired_download_cleaner.py create mode 100644 tests/library/test_expired_cleanup.py create mode 100644 tests/test_expired_download_cleaner.py diff --git a/core/library/expired_cleanup.py b/core/library/expired_cleanup.py new file mode 100644 index 00000000..1d46f8a8 --- /dev/null +++ b/core/library/expired_cleanup.py @@ -0,0 +1,98 @@ +"""Pure expiry decision for the Expired Download Cleaner job. + +Decides which origin-tracked downloads (watchlist / playlist, recorded by the +Download Origins provenance) are past their retention window and safe to +propose for deletion. No DB, no clock, no I/O — the job annotates each entry +with the facts (play_count, whether it's still in an active mirror) and this +module decides. Fully unit-testable. + +A download is proposed for deletion ONLY when ALL hold: +- its origin's retention is set (not 'off') and it's older than that window, +- it's NOT protected (still in an actively-mirrored playlist / watched artist), +- it has been played FEWER than ``min_plays`` times (default 2 → "played more + than once is kept"; play_count is the reliable signal, last_played is not). + +Anything failing a check is kept. Deliberately conservative — this deletes the +user's files. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Optional + +# Retention option → days. 'off' (or anything unmapped) disables that origin. +RETENTION_DAYS = { + "1w": 7, "2w": 14, "3w": 21, "4w": 28, + "2mo": 60, "3mo": 90, "6mo": 180, +} +RETENTION_OPTIONS = ["off", "1w", "2w", "3w", "4w", "2mo", "3mo", "6mo"] + + +def retention_cutoff(retention: Optional[str], now: datetime) -> Optional[datetime]: + """Datetime before which an entry of this retention is expired, or None + when the retention is off/unknown (origin never auto-cleaned).""" + days = RETENTION_DAYS.get((retention or "").strip().lower()) + if not days: + return None + return now - timedelta(days=days) + + +def _parse_ts(value: Any) -> Optional[datetime]: + """Parse a SQLite CURRENT_TIMESTAMP (UTC, no zone) or ISO string.""" + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=timezone.utc) + if not value: + return None + text = str(value).strip().replace(" ", "T") + try: + dt = datetime.fromisoformat(text) + return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc) + except ValueError: + return None + + +def is_expired( + entry: Dict[str, Any], + *, + watchlist_retention: Optional[str], + playlist_retention: Optional[str], + min_plays: int, + now: datetime, +) -> bool: + """True if this origin entry should be proposed for deletion. + + ``entry`` needs: ``origin`` ('watchlist'|'playlist'), ``created_at``, + ``play_count`` (int, may be None), ``protected`` (bool — still in an active + mirror/watch).""" + if entry.get("protected"): + return False + if (entry.get("play_count") or 0) >= max(1, int(min_plays or 1)): + return False # listened to enough to keep + origin = (entry.get("origin") or "").strip().lower() + retention = watchlist_retention if origin == "watchlist" else playlist_retention + cutoff = retention_cutoff(retention, now) + if cutoff is None: + return False # this origin's auto-clean is off + created = _parse_ts(entry.get("created_at")) + if created is None: + return False # unknown age → never delete + return created < cutoff + + +def select_expired( + entries: Iterable[Dict[str, Any]], + *, + watchlist_retention: Optional[str], + playlist_retention: Optional[str], + min_plays: int = 2, + now: Optional[datetime] = None, +) -> List[Dict[str, Any]]: + """Return the subset of ``entries`` that are expired + safe to delete.""" + now = now or datetime.now(timezone.utc) + return [ + e for e in (entries or []) + if is_expired(e, watchlist_retention=watchlist_retention, + playlist_retention=playlist_retention, + min_plays=min_plays, now=now) + ] diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 137548ac..4a60b2e0 100644 --- a/core/repair_jobs/__init__.py +++ b/core/repair_jobs/__init__.py @@ -34,6 +34,7 @@ _JOB_MODULES = [ 'core.repair_jobs.acoustid_scanner', 'core.repair_jobs.missing_cover_art', 'core.repair_jobs.missing_lyrics', + 'core.repair_jobs.expired_download_cleaner', 'core.repair_jobs.metadata_gap_filler', 'core.repair_jobs.album_completeness', 'core.repair_jobs.fake_lossless_detector', diff --git a/core/repair_jobs/expired_download_cleaner.py b/core/repair_jobs/expired_download_cleaner.py new file mode 100644 index 00000000..bc5fd02a --- /dev/null +++ b/core/repair_jobs/expired_download_cleaner.py @@ -0,0 +1,201 @@ +"""Expired Download Cleaner (Boulder) — retention-based cleanup of +origin-tracked downloads. + +Watchlist- and playlist-sourced downloads (recorded by the Download Origins +provenance) get a per-origin retention window. Past it, a download is proposed +for deletion UNLESS it's still in an actively-mirrored playlist / watched +artist, or you've played it more than once. By default it creates findings to +review; flip ``auto_delete`` to true for hands-off cleanup. + +The expiry decision is the pure core in core.library.expired_cleanup; this job +gathers the facts (play_count via DB, active-mirror/watch protection) and +deletes via the shared helper the Download Origins delete also conceptually +uses (resolve path → remove file → drop track row → drop history row). +""" + +from __future__ import annotations + +import os + +from core.library.expired_cleanup import RETENTION_OPTIONS, select_expired +from core.library.path_resolver import resolve_library_file_path +from core.repair_jobs import register_job +from core.repair_jobs.base import JobContext, JobResult, RepairJob +from utils.logging_config import get_logger + +logger = get_logger("repair_jobs.expired_download_cleaner") + + +def delete_origin_download(db, entry, config_manager) -> dict: + """Delete one origin-tracked download: the file on disk (resolved through + the shared resolver), its library track row, and the history entry. A file + that refuses deletion keeps its history row and reports the error. Returns + {removed, file_deleted, error}.""" + raw_path = entry.get('file_path') or '' + file_deleted = False + error = None + if raw_path: + resolved = resolve_library_file_path(raw_path, config_manager=config_manager) + if resolved and os.path.isfile(resolved): + try: + os.remove(resolved) + file_deleted = True + except OSError as e: + error = str(e) + # File gone or deleted → clean up the library track row either way. + if error is None: + try: + db.delete_track_by_file_path(raw_path) + except Exception as e: + logger.debug("expired cleanup: track row delete failed: %s", e) + removed = 0 + if error is None: + removed = db.delete_library_history_rows([entry['id']]) + return {'removed': removed, 'file_deleted': file_deleted, 'error': error} + + +@register_job +class ExpiredDownloadCleanerJob(RepairJob): + job_id = 'expired_download_cleaner' + display_name = 'Expired Download Cleaner' + description = 'Deletes watchlist/playlist downloads past a retention window (keeps active + played ones)' + help_text = ( + 'Cleans up downloads that came in via the watchlist or playlist sync ' + '(tracked by Download Origins) once they pass a retention window you set ' + 'per origin.\n\n' + 'A download is only ever proposed for deletion when ALL are true: it is ' + 'older than its origin\'s retention, it is NOT still in a playlist you ' + 'actively mirror (or an artist you still watch), and you have played it ' + 'fewer than the keep-threshold (default: played more than once is kept). ' + 'It only touches downloads recorded from the Download Origins feature ' + 'forward — never your pre-existing or manually-added library.\n\n' + 'By default it creates findings for you to review and delete. Set ' + 'Auto-delete to true for hands-off cleanup.\n\n' + 'Settings:\n' + '- Watchlist retention / Playlist retention: off, or a window\n' + '- Keep if played at least: play count that protects a track (default 2)\n' + '- Auto-delete: delete automatically instead of creating findings' + ) + icon = 'repair-icon-cleanup' + default_enabled = False + default_interval_hours = 24 + default_settings = { + 'watchlist_retention': 'off', + 'playlist_retention': 'off', + 'keep_if_played_at_least': 2, + 'auto_delete': False, + } + setting_options = { + 'watchlist_retention': RETENTION_OPTIONS, + 'playlist_retention': RETENTION_OPTIONS, + 'auto_delete': [False, True], + } + auto_fix = False + + def _get_settings(self, context: JobContext) -> dict: + merged = dict(self.default_settings) + if context.config_manager: + cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {}) or {} + merged.update(cfg) + return merged + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + settings = self._get_settings(context) + wl = (settings.get('watchlist_retention') or 'off') + pl = (settings.get('playlist_retention') or 'off') + if wl == 'off' and pl == 'off': + return result # nothing configured — no-op + try: + min_plays = int(settings.get('keep_if_played_at_least', 2)) + except (TypeError, ValueError): + min_plays = 2 + auto_delete = bool(settings.get('auto_delete', False)) + + candidates = context.db.get_origin_cleanup_candidates() + if not candidates: + return result + + # Build the "protected" set: still-mirrored playlists + still-watched + # artists (by name — what origin_context stores). Case-folded. + mirrored_names, watched_names = set(), set() + try: + for p in (context.db.get_mirrored_playlists() or []): + n = (p.get('name') if isinstance(p, dict) else None) or '' + if n: + mirrored_names.add(n.strip().casefold()) + except Exception as e: + logger.debug("expired cleanup: mirrored-playlist lookup failed: %s", e) + try: + for a in (context.db.get_watchlist_artists() or []): + n = getattr(a, 'artist_name', None) or '' + if n: + watched_names.add(n.strip().casefold()) + except Exception as e: + logger.debug("expired cleanup: watchlist lookup failed: %s", e) + + for c in candidates: + ctx = (c.get('origin_context') or '').strip().casefold() + origin = (c.get('origin') or '').strip().lower() + c['protected'] = bool( + (origin == 'playlist' and ctx and ctx in mirrored_names) or + (origin == 'watchlist' and ctx and ctx in watched_names)) + + expired = select_expired(candidates, watchlist_retention=wl, + playlist_retention=pl, min_plays=min_plays) + result.scanned = len(candidates) + if context.update_progress: + context.update_progress(0, len(expired)) + + for i, entry in enumerate(expired): + if context.check_stop(): + return result + if auto_delete: + try: + res = delete_origin_download(context.db, entry, context.config_manager) + if res.get('removed') or res.get('file_deleted'): + result.auto_fixed += 1 + except Exception as e: + logger.error("expired auto-delete failed for %s: %s", entry.get('title'), e) + result.errors += 1 + elif context.create_finding: + try: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='expired_download', + severity='info', + entity_type='track', + entity_id=str(entry.get('id')), + file_path=entry.get('file_path'), + title=f'Expired: {entry.get("title") or "Unknown"}', + description=(f'"{entry.get("title")}" by {entry.get("artist_name") or "Unknown"} ' + f'— via {entry.get("origin")} ({entry.get("origin_context") or "?"}), ' + f'past retention, not active, not replayed.'), + details={ + 'history_id': entry.get('id'), + 'file_path': entry.get('file_path'), + 'title': entry.get('title'), + 'artist': entry.get('artist_name'), + 'origin': entry.get('origin'), + 'origin_context': entry.get('origin_context'), + }) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + except Exception as e: + logger.debug("expired finding create failed: %s", e) + result.errors += 1 + if context.update_progress and (i + 1) % 5 == 0: + context.update_progress(i + 1, len(expired)) + + logger.info("[Expired Cleaner] %d candidates, %d expired (%s)", + len(candidates), len(expired), + "auto-deleted" if auto_delete else "findings created") + return result + + def estimate_scope(self, context: JobContext) -> int: + try: + return len(context.db.get_origin_cleanup_candidates()) + except Exception: + return 0 diff --git a/core/repair_worker.py b/core/repair_worker.py index cd17ee8c..311109c9 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -964,6 +964,7 @@ class RepairWorker: 'track_number_mismatch': self._fix_track_number, 'missing_cover_art': self._fix_missing_cover_art, 'missing_lyrics': self._fix_missing_lyrics, + 'expired_download': self._fix_expired_download, 'metadata_gap': self._fix_metadata_gap, 'duplicate_tracks': self._fix_duplicates, 'single_album_redundant': self._fix_single_album_redundant, @@ -1445,6 +1446,21 @@ class RepairWorker: return {'success': False, 'error': 'Could not fetch lyrics (no longer available?)'} return {'success': True, 'action': 'applied_lyrics', 'message': 'Wrote lyrics (.lrc) + embedded'} + def _fix_expired_download(self, entity_type, entity_id, file_path, details): + """Apply an expired-download finding: delete the file + library row + + history entry, via the same helper the cleaner's auto mode uses.""" + from core.repair_jobs.expired_download_cleaner import delete_origin_download + entry = {'id': details.get('history_id') or entity_id, + 'file_path': details.get('file_path') or file_path} + if not entry['id']: + return {'success': False, 'error': 'No history id in finding'} + res = delete_origin_download(self.db, entry, self._config_manager) + if res.get('error'): + return {'success': False, 'action': 'deleted_expired', + 'error': f"Could not delete file: {res['error']}"} + verb = 'deleted file + entry' if res.get('file_deleted') else 'removed entry (file already gone)' + return {'success': True, 'action': 'deleted_expired', 'message': f'Expired download — {verb}'} + def _fix_library_retag(self, entity_type, entity_id, file_path, details): """Apply a library re-tag finding: write each track's planned tags in place (core.tag_writer.write_tags_to_file) + optionally embed/refresh @@ -3179,7 +3195,7 @@ class RepairWorker: # Build query for pending fixable findings fixable_types = ('dead_file', 'orphan_file', 'track_number_mismatch', - 'missing_cover_art', 'missing_lyrics', 'metadata_gap', 'duplicate_tracks', + 'missing_cover_art', 'missing_lyrics', 'expired_download', 'metadata_gap', 'duplicate_tracks', 'single_album_redundant', 'mbid_mismatch', 'album_mbid_mismatch', 'album_tag_inconsistency', diff --git a/database/music_database.py b/database/music_database.py index b2efbb69..1e7c3a6b 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -12478,6 +12478,30 @@ class MusicDatabase: logger.debug(f"Error adding library history entry: {e}") return False + def get_origin_cleanup_candidates(self): + """Origin-tracked downloads (watchlist/playlist) annotated with the + matching library track's play_count, for the Expired Download Cleaner. + play_count is 0 when no library track matches the recorded path + (orphan history row → treated as not-listened).""" + try: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT lh.id, lh.origin, lh.origin_context, lh.created_at, + lh.file_path, lh.title, lh.artist_name, + COALESCE(t.play_count, 0) AS play_count + FROM library_history lh + LEFT JOIN tracks t ON t.file_path = lh.file_path + WHERE lh.event_type = 'download' + AND lh.origin IN ('watchlist', 'playlist') + """) + cols = ['id', 'origin', 'origin_context', 'created_at', + 'file_path', 'title', 'artist_name', 'play_count'] + return [dict(zip(cols, row, strict=True)) for row in cursor.fetchall()] + except Exception as e: + logger.debug(f"Error getting origin cleanup candidates: {e}") + return [] + def get_download_origin_entries(self, origin, limit=200, offset=0): """Downloads triggered by ``origin`` ('watchlist' / 'playlist'), newest first. Returns (entries, total_count).""" diff --git a/tests/library/test_expired_cleanup.py b/tests/library/test_expired_cleanup.py new file mode 100644 index 00000000..c79098ac --- /dev/null +++ b/tests/library/test_expired_cleanup.py @@ -0,0 +1,91 @@ +"""Pure expiry decision for the Expired Download Cleaner.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from core.library.expired_cleanup import ( + retention_cutoff, + is_expired, + select_expired, +) + +NOW = datetime(2026, 6, 7, tzinfo=timezone.utc) + + +def _entry(origin="playlist", days_old=100, play_count=0, protected=False, eid=1): + return { + "id": eid, "origin": origin, "play_count": play_count, "protected": protected, + "created_at": (NOW - timedelta(days=days_old)).strftime("%Y-%m-%d %H:%M:%S"), + } + + +def _check(entry, wl="off", pl="2mo", min_plays=2): + return is_expired(entry, watchlist_retention=wl, playlist_retention=pl, + min_plays=min_plays, now=NOW) + + +# ── retention windows ──────────────────────────────────────────────────────── + +def test_retention_cutoff_maps_durations(): + assert retention_cutoff("2mo", NOW) == NOW - timedelta(days=60) + assert retention_cutoff("1w", NOW) == NOW - timedelta(days=7) + assert retention_cutoff("off", NOW) is None + assert retention_cutoff(None, NOW) is None + assert retention_cutoff("bogus", NOW) is None + + +def test_expired_only_past_window(): + assert _check(_entry(days_old=70), pl="2mo") is True # 70 > 60d + assert _check(_entry(days_old=50), pl="2mo") is False # 50 < 60d + + +def test_off_retention_never_expires(): + assert _check(_entry(origin="watchlist", days_old=999), wl="off") is False + + +def test_origin_uses_its_own_window(): + wl = _entry(origin="watchlist", days_old=30) + # watchlist=1w (expired at 30d), playlist=off + assert is_expired(wl, watchlist_retention="1w", playlist_retention="off", + min_plays=2, now=NOW) is True + pl = _entry(origin="playlist", days_old=30) + assert is_expired(pl, watchlist_retention="1w", playlist_retention="off", + min_plays=2, now=NOW) is False # playlist off + + +# ── the keep guards ────────────────────────────────────────────────────────── + +def test_protected_kept_even_if_old(): + assert _check(_entry(days_old=999, protected=True), pl="1w") is False + + +def test_played_more_than_once_kept(): + assert _check(_entry(days_old=999, play_count=2), pl="1w", min_plays=2) is False + assert _check(_entry(days_old=999, play_count=1), pl="1w", min_plays=2) is True # one play = deletable + assert _check(_entry(days_old=999, play_count=0), pl="1w", min_plays=2) is True + + +def test_min_plays_threshold_configurable(): + e = _entry(days_old=999, play_count=1) + assert _check(e, pl="1w", min_plays=1) is False # keep-if-played-at-least-1 + assert _check(e, pl="1w", min_plays=3) is True # needs 3 plays to keep + + +def test_unknown_age_never_deleted(): + e = _entry(days_old=999) + e["created_at"] = "garbage" + assert _check(e, pl="1w") is False + + +# ── select_expired ─────────────────────────────────────────────────────────── + +def test_select_expired_filters(): + entries = [ + _entry(eid=1, days_old=70, play_count=0), # expired + _entry(eid=2, days_old=70, play_count=5), # listened → keep + _entry(eid=3, days_old=70, protected=True), # mirrored → keep + _entry(eid=4, days_old=10), # too new → keep + ] + out = select_expired(entries, watchlist_retention="off", playlist_retention="2mo") + assert [e["id"] for e in out] == [1] diff --git a/tests/test_expired_download_cleaner.py b/tests/test_expired_download_cleaner.py new file mode 100644 index 00000000..740d9fae --- /dev/null +++ b/tests/test_expired_download_cleaner.py @@ -0,0 +1,137 @@ +"""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] diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index c1d69bb5..a63d93e5 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -2734,7 +2734,7 @@ async function loadRepairFindings() { duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete', path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', - missing_lyrics: 'Missing Lyrics', + missing_lyrics: 'Missing Lyrics', expired_download: 'Expired', missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' }; @@ -2745,6 +2745,7 @@ async function loadRepairFindings() { track_number_mismatch: 'Fix', missing_cover_art: 'Apply Art', missing_lyrics: 'Apply Lyrics', + expired_download: 'Delete', metadata_gap: 'Apply', duplicate_tracks: 'Keep Best', incomplete_album: 'Auto-Fill', @@ -2762,6 +2763,7 @@ async function loadRepairFindings() { already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed', applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied', applied_lyrics: 'Lyrics Applied', + deleted_expired: 'Deleted', removed_duplicates: 'Duplicates Removed', }; let statusBadge = ''; @@ -3136,6 +3138,13 @@ function _renderFindingDetail(f) { if (d.album_title) rows.push(['Album', d.album_title]); return _gridRows(rows); + case 'expired_download': + if (d.title) rows.push(['Track', d.title]); + if (d.artist) rows.push(['Artist', d.artist]); + if (d.origin) rows.push(['Source', `${d.origin}${d.origin_context ? ' — ' + d.origin_context : ''}`]); + if (d.file_path) rows.push(['File', d.file_path.split(/[\\/]/).pop()]); + return _gridRows(rows); + case 'track_number_mismatch': if (d.album_title) rows.push(['Album', d.album_title]); if (d.artist_name) rows.push(['Artist', d.artist_name]);