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.
This commit is contained in:
BoulderBadgeDad 2026-06-07 22:06:56 -07:00
parent 6c3e285a49
commit 696119d5ac
8 changed files with 579 additions and 2 deletions

View file

@ -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)
]

View file

@ -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',

View file

@ -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

View file

@ -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',

View file

@ -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)."""

View file

@ -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]

View file

@ -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]

View file

@ -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]);