soulsync/core/repair_jobs/expired_download_cleaner.py
BoulderBadgeDad 46730d1661 Expired Cleaner: rename the safety toggle to dry_run (default ON), matching Re-tag
The destructive job's findings-vs-auto toggle was 'auto_delete: False'. Renamed
to 'dry_run: True' to match the Re-tag job's convention and make the safe
default unmistakable: dry run ON (default) = findings only, deletes nothing;
dry run OFF = hands-off auto-delete. Behaviour-identical to the previous
default — just clearer + consistent. Help text + tests updated.
2026-06-07 22:13:30 -07:00

202 lines
9.1 KiB
Python

"""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'
'Dry run is ON by default: it only creates findings for you to review '
'and delete — nothing is deleted automatically. Turn Dry run OFF for '
'hands-off auto-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'
'- Dry run: ON = findings only (default); OFF = delete automatically'
)
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,
'dry_run': True,
}
setting_options = {
'watchlist_retention': RETENTION_OPTIONS,
'playlist_retention': RETENTION_OPTIONS,
'dry_run': [True, False],
}
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
dry_run = bool(settings.get('dry_run', True))
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 not dry_run:
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),
"findings created (dry run)" if dry_run else "auto-deleted")
return result
def estimate_scope(self, context: JobContext) -> int:
try:
return len(context.db.get_origin_cleanup_candidates())
except Exception:
return 0