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.
82 lines
3 KiB
Python
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
|