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.
69 lines
2.1 KiB
Python
69 lines
2.1 KiB
Python
"""Repair Jobs Registry — all available maintenance jobs for the Library Worker."""
|
|
|
|
import importlib
|
|
|
|
from core.repair_jobs.base import RepairJob, JobContext, JobResult
|
|
from utils.logging_config import get_logger
|
|
|
|
logger = get_logger("repair_jobs")
|
|
|
|
# Registry populated at import time by each job module
|
|
JOB_REGISTRY: dict[str, type[RepairJob]] = {}
|
|
|
|
_imports_done = False
|
|
|
|
|
|
def register_job(cls: type[RepairJob]) -> type[RepairJob]:
|
|
"""Decorator to register a RepairJob subclass."""
|
|
JOB_REGISTRY[cls.job_id] = cls
|
|
return cls
|
|
|
|
|
|
def get_all_jobs() -> dict[str, type[RepairJob]]:
|
|
"""Return the full job registry. Ensures all job modules are imported."""
|
|
_import_all_jobs()
|
|
return JOB_REGISTRY
|
|
|
|
|
|
_JOB_MODULES = [
|
|
'core.repair_jobs.track_number_repair',
|
|
'core.repair_jobs.cache_evictor',
|
|
'core.repair_jobs.orphan_file_detector',
|
|
'core.repair_jobs.dead_file_cleaner',
|
|
'core.repair_jobs.duplicate_detector',
|
|
'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',
|
|
'core.repair_jobs.library_reorganize',
|
|
'core.repair_jobs.mbid_mismatch_detector',
|
|
'core.repair_jobs.single_album_dedup',
|
|
'core.repair_jobs.lossy_converter',
|
|
'core.repair_jobs.album_tag_consistency',
|
|
'core.repair_jobs.live_commentary_cleaner',
|
|
'core.repair_jobs.unknown_artist_fixer',
|
|
'core.repair_jobs.discography_backfill',
|
|
'core.repair_jobs.canonical_version_resolve',
|
|
'core.repair_jobs.library_retag',
|
|
]
|
|
|
|
|
|
def _import_all_jobs():
|
|
"""Import all job modules to trigger registration.
|
|
|
|
Each module is imported individually so that a failure in one
|
|
does not prevent the others from loading.
|
|
"""
|
|
global _imports_done
|
|
if _imports_done:
|
|
return
|
|
_imports_done = True
|
|
|
|
for module_name in _JOB_MODULES:
|
|
try:
|
|
importlib.import_module(module_name)
|
|
except Exception as e:
|
|
logger.error("Failed to import job module %s: %s", module_name, e)
|