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