From 29c8f11403b399c8781c5cf88fe34d58f2e3266b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 11 Jun 2026 18:41:12 -0700 Subject: [PATCH] #437: add ReplayGain Filler library-maintenance job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-processing applies ReplayGain only to slskd/WebUI downloads — content added via Lidarr, the REST API, or by hand never got it, and there was no way to (re)apply RG to existing tracks or fix ones where analysis failed (raised in #437 + comments). New ReplayGain Filler repair job (sibling of Lyrics/Cover Art fillers): scans for tracks with no ReplayGain track-gain tag and creates a finding per track; the scan only READS tags (cheap) and no-ops when ffmpeg is absent. Applying a finding runs the same ffmpeg ebur128 analysis the import pipeline uses (gain = ref - LUFS) and writes the RG tags in place — no moves, no re-matching. Opt-in (default off), schedulable like the other maintenance jobs. Wired: job registry, repair_worker apply handler (_fix_missing_replaygain) + fixable-types, and the findings UI (label / fix-button / detail rows). Tests: pure needs_replaygain decision (missing/blank/present/+0.00-is-tagged) + the apply handler's analyze→compute→write seam with the pipeline gain formula, ffmpeg-absent + missing-file guards, and registration. 93 repair tests green. --- core/repair_jobs/__init__.py | 1 + core/repair_jobs/replaygain_filler.py | 197 ++++++++++++++++++++++++++ core/repair_worker.py | 30 +++- tests/test_replaygain_filler_job.py | 82 +++++++++++ webui/static/enrichment.js | 7 + 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 core/repair_jobs/replaygain_filler.py create mode 100644 tests/test_replaygain_filler_job.py diff --git a/core/repair_jobs/__init__.py b/core/repair_jobs/__init__.py index 4a60b2e0..1e2c3e11 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.replaygain_filler', 'core.repair_jobs.expired_download_cleaner', 'core.repair_jobs.metadata_gap_filler', 'core.repair_jobs.album_completeness', diff --git a/core/repair_jobs/replaygain_filler.py b/core/repair_jobs/replaygain_filler.py new file mode 100644 index 00000000..bcda36bc --- /dev/null +++ b/core/repair_jobs/replaygain_filler.py @@ -0,0 +1,197 @@ +"""ReplayGain Filler maintenance job (#437) — the loudness sibling of the Lyrics +and Cover Art fillers. + +Post-processing applies ReplayGain to slskd/WebUI downloads, but content that +enters the library another way — Lidarr, the REST API, manual adds — never gets +it, and there was no way to (re)apply RG to existing tracks or fix the ones where +analysis failed (a recurring ask on #437). + +This scans the library for tracks with no ReplayGain track-gain tag and creates a +finding for each. Applying a finding runs the same ffmpeg ebur128 analysis the +import pipeline uses and writes the RG tags in place — no moves, no re-matching. + +Scan only READS tags (cheap); the expensive ffmpeg analysis happens on apply. +Requires ffmpeg (RG analysis can't run without it), so the scan no-ops when ffmpeg +isn't on PATH rather than surfacing findings that could never be applied. +""" + +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +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.replaygain_filler") + + +def needs_replaygain(rg_tags: Optional[Dict[str, Any]]) -> bool: + """Pure decision: does this track need ReplayGain written? + + True when the track-gain tag is absent or blank. ``rg_tags`` is the dict from + ``core.replaygain.read_replaygain_tags`` (keys: track_gain, track_peak, …). + A present track_gain — even "+0.00 dB" — counts as already-tagged. + """ + if not rg_tags: + return True + val = rg_tags.get('track_gain') + return val is None or str(val).strip() == '' + + +def _resolve(file_path: str) -> Optional[str]: + """Resolve a stored library path to one this process can read (Docker/host + prefix mapping), falling back to the raw path if it's already a real file.""" + resolved = resolve_library_file_path(file_path) if file_path else None + if not resolved and file_path and os.path.isfile(file_path): + resolved = file_path + return resolved + + +@register_job +class ReplayGainFillerJob(RepairJob): + job_id = 'replaygain_filler' + display_name = 'ReplayGain Filler' + description = 'Finds tracks with no ReplayGain tag and analyzes + writes loudness tags' + help_text = ( + 'Scans your library for tracks that have no ReplayGain track-gain tag — ' + 'common for albums added by Lidarr, the REST API, or by hand, which skip ' + "the download post-processing where ReplayGain normally runs.\n\n" + 'A finding is created for each. Applying one runs the same ffmpeg loudness ' + 'analysis (EBU R128) the import pipeline uses and writes the ReplayGain ' + 'tags in place — no files are moved or renamed. This also lets you re-fill ' + 'tracks where the original analysis failed.\n\n' + 'Requires ffmpeg to be installed (the analysis cannot run without it).' + ) + icon = 'repair-icon-replaygain' + default_enabled = False + default_interval_hours = 48 + default_settings = {} + auto_fix = False + + def scan(self, context: JobContext) -> JobResult: + result = JobResult() + + try: + from core.replaygain import is_ffmpeg_available, read_replaygain_tags + except Exception as e: + logger.warning("[ReplayGain Filler] replaygain module unavailable: %s", e) + return result + if not is_ffmpeg_available(): + logger.info("[ReplayGain Filler] ffmpeg not available — skipping scan " + "(analysis cannot run without it)") + return result + + rows = [] + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT t.id, t.title, ar.name, t.file_path + FROM tracks t + LEFT JOIN artists ar ON ar.id = t.artist_id + WHERE t.file_path IS NOT NULL AND t.file_path != '' + """) + rows = cursor.fetchall() + except Exception as e: + logger.error("[ReplayGain Filler] Error reading tracks: %s", e, exc_info=True) + result.errors += 1 + return result + finally: + if conn: + conn.close() + + total = len(rows) + if context.update_progress: + context.update_progress(0, total) + if context.report_progress: + context.report_progress(phase=f'Checking ReplayGain on {total} tracks...', total=total) + + for i, row in enumerate(rows): + if context.check_stop(): + return result + if i % 10 == 0 and context.wait_if_paused(): + return result + + track_id, title, artist_name, file_path = row[:4] + result.scanned += 1 + + resolved = _resolve(file_path) + if not resolved: + # Can't read the file from here → can't analyze it on apply either. + result.skipped += 1 + continue + + try: + rg = read_replaygain_tags(resolved) + except Exception as e: + logger.debug("[ReplayGain Filler] tag read failed for '%s': %s", title, e) + result.skipped += 1 + continue + + if not needs_replaygain(rg): + result.skipped += 1 + if context.update_progress and (i + 1) % 25 == 0: + context.update_progress(i + 1, total) + continue + + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + log_line=f'No ReplayGain: {title} — {artist_name or "Unknown"}', + log_type='info') + + if context.create_finding: + try: + inserted = context.create_finding( + job_id=self.job_id, + finding_type='missing_replaygain', + severity='info', + entity_type='track', + entity_id=str(track_id), + file_path=file_path, + title=f'No ReplayGain: {title or "Unknown"}', + description=(f'"{title}" by {artist_name or "Unknown"} has no ' + 'ReplayGain tag — loudness can be analyzed + written.'), + details={ + 'track_id': track_id, + 'track_title': title, + 'artist': artist_name, + 'file_path': file_path, + }) + if inserted: + result.findings_created += 1 + else: + result.findings_skipped_dedup += 1 + except Exception as e: + logger.debug("[ReplayGain Filler] create finding failed for track %s: %s", track_id, e) + result.errors += 1 + + if context.update_progress and (i + 1) % 10 == 0: + context.update_progress(i + 1, total) + + if context.update_progress: + context.update_progress(total, total) + logger.info("[ReplayGain Filler] %d tracks checked, %d missing ReplayGain, %d skipped", + result.scanned, result.findings_created, result.skipped) + return result + + def estimate_scope(self, context: JobContext) -> int: + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COUNT(*) FROM tracks + WHERE file_path IS NOT NULL AND file_path != '' + """) + row = cursor.fetchone() + return row[0] if row else 0 + except Exception: + return 0 + finally: + if conn: + conn.close() diff --git a/core/repair_worker.py b/core/repair_worker.py index 27dffe3b..9babe245 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, + 'missing_replaygain': self._fix_missing_replaygain, 'expired_download': self._fix_expired_download, 'metadata_gap': self._fix_metadata_gap, 'duplicate_tracks': self._fix_duplicates, @@ -1479,6 +1480,33 @@ 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_missing_replaygain(self, entity_type, entity_id, file_path, details): + """Apply a missing-ReplayGain finding: run the same ffmpeg ebur128 loudness + analysis the import pipeline uses and write the RG tags in place (#437).""" + raw_path = details.get('file_path') or file_path + if not raw_path: + return {'success': False, 'error': 'No file path in finding'} + download_folder = self._config_manager.get('soulseek.download_path', '') if self._config_manager else None + resolved = _resolve_file_path(raw_path, self.transfer_folder, download_folder, + config_manager=self._config_manager) or raw_path + if not os.path.isfile(resolved): + return {'success': False, 'error': f'File not found on disk: {os.path.basename(raw_path)}'} + try: + from core.replaygain import (analyze_track, write_replaygain_tags, + is_ffmpeg_available, RG_REFERENCE_LUFS) + if not is_ffmpeg_available(): + return {'success': False, 'error': 'ffmpeg not available — cannot analyze ReplayGain'} + lufs, peak_dbfs = analyze_track(resolved) + gain_db = RG_REFERENCE_LUFS - lufs # same formula as the import pipeline + ok = write_replaygain_tags(resolved, gain_db, peak_dbfs) + except Exception as e: + logger.error("ReplayGain fix failed for %s: %s", os.path.basename(raw_path), e) + return {'success': False, 'error': str(e)} + if not ok: + return {'success': False, 'error': 'Could not write ReplayGain tags'} + return {'success': True, 'action': 'applied_replaygain', + 'message': f'Wrote ReplayGain ({gain_db:+.2f} dB)'} + 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.""" @@ -3284,7 +3312,7 @@ class RepairWorker: 'album_mbid_mismatch', 'album_tag_inconsistency', 'incomplete_album', 'path_mismatch', - 'missing_lossy_copy', + 'missing_lossy_copy', 'missing_replaygain', 'missing_discography_track', 'acoustid_mismatch') placeholders = ','.join(['?'] * len(fixable_types)) where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"] diff --git a/tests/test_replaygain_filler_job.py b/tests/test_replaygain_filler_job.py new file mode 100644 index 00000000..bce087be --- /dev/null +++ b/tests/test_replaygain_filler_job.py @@ -0,0 +1,82 @@ +"""ReplayGain Filler job (#437) — fills ReplayGain on library content that skipped +download post-processing (Lidarr / REST API / manual adds). Pure flag decision + +the apply handler's analyze→compute→write seam (ffmpeg mocked).""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import patch + +from core.repair_jobs.replaygain_filler import needs_replaygain +from core.repair_worker import RepairWorker + + +# ── pure decision: does a track need ReplayGain? ──────────────────────────── +def test_needs_rg_when_no_tags(): + assert needs_replaygain(None) is True + + +def test_needs_rg_when_track_gain_missing(): + assert needs_replaygain({'track_gain': None, 'track_peak': None}) is True + + +def test_needs_rg_when_track_gain_blank(): + assert needs_replaygain({'track_gain': ' '}) is True + + +def test_no_rg_needed_when_gain_present(): + assert needs_replaygain({'track_gain': '-6.50 dB'}) is False + + +def test_zero_gain_counts_as_tagged(): + # A legitimate "+0.00 dB" is already analyzed — must NOT be re-flagged forever. + assert needs_replaygain({'track_gain': '+0.00 dB'}) is False + + +# ── apply handler: analyze → compute gain → write (ffmpeg mocked) ──────────── +def _worker(): + w = RepairWorker(database=SimpleNamespace()) + w._config_manager = None + return w + + +def test_apply_writes_rg_with_pipeline_gain_formula(tmp_path): + f = tmp_path / 'song.flac' + f.write_bytes(b'\x00' * 64) + written = {} + + def fake_write(path, gain, peak, *a, **k): + written.update(path=path, gain=gain, peak=peak) + return True + + with patch('core.replaygain.is_ffmpeg_available', return_value=True), \ + patch('core.replaygain.analyze_track', return_value=(-12.0, -1.5)), \ + patch('core.replaygain.write_replaygain_tags', side_effect=fake_write), \ + patch('core.replaygain.RG_REFERENCE_LUFS', -18.0): + res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)}) + + assert res['success'] is True and res['action'] == 'applied_replaygain' + # gain = reference - lufs = -18.0 - (-12.0) = -6.0 (same as the import pipeline) + assert written['gain'] == -6.0 + assert written['peak'] == -1.5 + assert written['path'] == str(f) + + +def test_apply_errors_without_ffmpeg(tmp_path): + f = tmp_path / 's.flac' + f.write_bytes(b'\x00' * 64) + with patch('core.replaygain.is_ffmpeg_available', return_value=False): + res = _worker()._fix_missing_replaygain('track', '1', str(f), {'file_path': str(f)}) + assert res['success'] is False and 'ffmpeg' in res['error'].lower() + + +def test_apply_errors_when_file_missing(): + res = _worker()._fix_missing_replaygain( + 'track', '1', '/no/such/file.flac', {'file_path': '/no/such/file.flac'}) + assert res['success'] is False + + +def test_job_is_registered_and_opt_in(): + from core.repair_jobs import get_all_jobs + j = get_all_jobs().get('replaygain_filler') + assert j is not None and j.default_enabled is False diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js index 3ec070e0..91affc21 100644 --- a/webui/static/enrichment.js +++ b/webui/static/enrichment.js @@ -2735,6 +2735,7 @@ async function loadRepairFindings() { path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', missing_lyrics: 'Missing Lyrics', expired_download: 'Expired', + missing_replaygain: 'No ReplayGain', missing_lossy_copy: 'No Lossy Copy', library_retag: 'Re-tag' }; @@ -2745,6 +2746,7 @@ async function loadRepairFindings() { track_number_mismatch: 'Fix', missing_cover_art: 'Apply Art', missing_lyrics: 'Apply Lyrics', + missing_replaygain: 'Apply RG', expired_download: 'Delete', metadata_gap: 'Apply', duplicate_tracks: 'Keep Best', @@ -3138,6 +3140,11 @@ function _renderFindingDetail(f) { if (d.album_title) rows.push(['Album', d.album_title]); return _gridRows(rows); + case 'missing_replaygain': + if (d.track_title) rows.push(['Track', d.track_title]); + if (d.artist) rows.push(['Artist', d.artist]); + return _gridRows(rows); + case 'expired_download': if (d.title) rows.push(['Track', d.title]); if (d.artist) rows.push(['Artist', d.artist]);