"""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], } # Has an auto mode (dry_run off → deletes in-scan). auto_fix is a UI/metadata # flag only — the worker never auto-applies from it; scan() self-manages the # dry_run vs delete decision. Setting True surfaces the Scan → Dry Run / # Auto-fix flow badge (without it the job mislabels as "Scan Only"). auto_fix = True 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