soulsync/tests/test_replaygain_filler_job.py
BoulderBadgeDad 29c8f11403 #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.
2026-06-11 18:41:12 -07:00

82 lines
3 KiB
Python

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