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.
197 lines
7.9 KiB
Python
197 lines
7.9 KiB
Python
"""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()
|