#437: add ReplayGain Filler library-maintenance job

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.
This commit is contained in:
BoulderBadgeDad 2026-06-11 18:41:12 -07:00
parent 68acf89b83
commit 29c8f11403
5 changed files with 316 additions and 1 deletions

View file

@ -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',

View file

@ -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()

View file

@ -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'"]

View file

@ -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

View file

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