Add ReplayGain analysis and tagging support
New core/replaygain.py module uses FFmpeg's ebur128 filter (already a project dependency) to analyze integrated loudness and true peak, then writes ReplayGain 2.0 tags (-18 LUFS reference) to MP3 (TXXX frames), FLAC/OGG/Opus (Vorbis comments), and M4A/MP4 (freeform atoms). Three analysis modes in the enhanced library view: - Per-track RG button: synchronous single-track analysis (~1-3 s) - Album "ReplayGain" button: background job writing both track gain and album gain (mean LUFS across all album tracks) to every file - Bulk bar "ReplayGain" button: batch track-gain for selected tracks read_file_tags() in tag_writer.py extended with four new optional keys (replaygain_track_gain/_peak, replaygain_album_gain/_peak) so existing RG values surface in the tag-preview diff view. Purely additive — no existing endpoints or DB schema changed.
This commit is contained in:
parent
3db00ca7ef
commit
ce129010e1
6 changed files with 832 additions and 1 deletions
316
core/replaygain.py
Normal file
316
core/replaygain.py
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
"""
|
||||
ReplayGain analysis and tag writing for SoulSync.
|
||||
|
||||
Analysis is performed via FFmpeg's ebur128 filter (ReplayGain 2.0, -18 LUFS reference).
|
||||
Tag writing uses mutagen directly to stay consistent with the rest of the codebase.
|
||||
|
||||
Supported formats: MP3, FLAC, OGG Vorbis, Opus, M4A/MP4
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional, Tuple, Dict
|
||||
|
||||
# ReplayGain 2.0 reference level (EBU R128)
|
||||
RG_REFERENCE_LUFS = -18.0
|
||||
|
||||
# Tag names used across all formats
|
||||
_TAG_TRACK_GAIN = "REPLAYGAIN_TRACK_GAIN"
|
||||
_TAG_TRACK_PEAK = "REPLAYGAIN_TRACK_PEAK"
|
||||
_TAG_ALBUM_GAIN = "REPLAYGAIN_ALBUM_GAIN"
|
||||
_TAG_ALBUM_PEAK = "REPLAYGAIN_ALBUM_PEAK"
|
||||
|
||||
_AUDIO_EXTENSIONS = {'.mp3', '.flac', '.ogg', '.oga', '.opus', '.m4a', '.mp4'}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FFmpeg availability
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_ffmpeg_available() -> bool:
|
||||
"""Return True if ffmpeg is on PATH."""
|
||||
try:
|
||||
subprocess.run(
|
||||
['ffmpeg', '-version'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=5
|
||||
)
|
||||
return True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Analysis
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def analyze_track(file_path: str) -> Tuple[float, float]:
|
||||
"""
|
||||
Analyze a single audio file and return (integrated_lufs, true_peak_dbfs).
|
||||
|
||||
Uses FFmpeg's ebur128 filter with true peak measurement.
|
||||
Raises:
|
||||
FileNotFoundError: if ffmpeg is not on PATH
|
||||
RuntimeError: if ffmpeg fails or output cannot be parsed
|
||||
"""
|
||||
cmd = [
|
||||
'ffmpeg', '-nostdin', '-v', 'info',
|
||||
'-i', file_path,
|
||||
'-filter:a', 'ebur128=peak=true',
|
||||
'-f', 'null', '-'
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise FileNotFoundError("ffmpeg not found on PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError("ffmpeg timed out analyzing track")
|
||||
|
||||
stderr = result.stderr
|
||||
|
||||
# Parse integrated loudness: " I: -18.3 LUFS"
|
||||
lufs_match = re.search(r'I:\s+([-\d.]+)\s+LUFS', stderr)
|
||||
# Parse true peak: " Peak:\s+([-\d.]+) dBFS" (may appear per-channel; take max)
|
||||
peak_matches = re.findall(r'Peak:\s+([-\d.]+)\s+dBFS', stderr)
|
||||
|
||||
if not lufs_match:
|
||||
raise RuntimeError(
|
||||
f"Could not parse ebur128 output for '{file_path}'. "
|
||||
f"FFmpeg exit code: {result.returncode}"
|
||||
)
|
||||
|
||||
integrated_lufs = float(lufs_match.group(1))
|
||||
|
||||
if peak_matches:
|
||||
true_peak_dbfs = max(float(v) for v in peak_matches)
|
||||
else:
|
||||
# Fall back to 0 dBFS peak if not available
|
||||
true_peak_dbfs = 0.0
|
||||
|
||||
return integrated_lufs, true_peak_dbfs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gain / peak formatting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def format_gain(lufs: float, reference: float = RG_REFERENCE_LUFS) -> str:
|
||||
"""Return a formatted gain string like '-2.50 dB'."""
|
||||
gain = reference - lufs
|
||||
return f"{gain:+.2f} dB"
|
||||
|
||||
|
||||
def format_peak(true_peak_dbfs: float) -> str:
|
||||
"""Convert a true peak in dBFS to a linear peak string like '0.987654'."""
|
||||
linear = 10 ** (true_peak_dbfs / 20.0)
|
||||
# Clamp to [0, 1] — values above 0 dBFS (>1.0) are kept as-is (clipping)
|
||||
return f"{linear:.6f}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag reading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def read_replaygain_tags(file_path: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Read existing ReplayGain tags from an audio file.
|
||||
|
||||
Returns a dict with keys:
|
||||
track_gain, track_peak, album_gain, album_peak
|
||||
All values are strings (e.g. "-2.50 dB") or None if not present.
|
||||
"""
|
||||
result = {
|
||||
'track_gain': None,
|
||||
'track_peak': None,
|
||||
'album_gain': None,
|
||||
'album_peak': None,
|
||||
}
|
||||
|
||||
import os
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in _AUDIO_EXTENSIONS:
|
||||
return result
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(file_path, easy=False)
|
||||
if audio is None:
|
||||
return result
|
||||
|
||||
if ext == '.mp3':
|
||||
result['track_gain'] = _read_id3_txxx(audio, _TAG_TRACK_GAIN)
|
||||
result['track_peak'] = _read_id3_txxx(audio, _TAG_TRACK_PEAK)
|
||||
result['album_gain'] = _read_id3_txxx(audio, _TAG_ALBUM_GAIN)
|
||||
result['album_peak'] = _read_id3_txxx(audio, _TAG_ALBUM_PEAK)
|
||||
elif ext in ('.flac', '.ogg', '.oga', '.opus'):
|
||||
result['track_gain'] = _vorbis_first(audio, _TAG_TRACK_GAIN.lower())
|
||||
result['track_peak'] = _vorbis_first(audio, _TAG_TRACK_PEAK.lower())
|
||||
result['album_gain'] = _vorbis_first(audio, _TAG_ALBUM_GAIN.lower())
|
||||
result['album_peak'] = _vorbis_first(audio, _TAG_ALBUM_PEAK.lower())
|
||||
elif ext in ('.m4a', '.mp4'):
|
||||
result['track_gain'] = _mp4_rg(audio, _TAG_TRACK_GAIN)
|
||||
result['track_peak'] = _mp4_rg(audio, _TAG_TRACK_PEAK)
|
||||
result['album_gain'] = _mp4_rg(audio, _TAG_ALBUM_GAIN)
|
||||
result['album_peak'] = _mp4_rg(audio, _TAG_ALBUM_PEAK)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_id3_txxx(audio, description: str) -> Optional[str]:
|
||||
"""Read a TXXX frame value by description (case-insensitive)."""
|
||||
try:
|
||||
key = f"TXXX:{description}"
|
||||
if key in audio.tags:
|
||||
frame = audio.tags[key]
|
||||
return str(frame.text[0]) if frame.text else None
|
||||
# Also try uppercase/lowercase variants
|
||||
for frame_key in audio.tags.keys():
|
||||
if frame_key.upper() == key.upper():
|
||||
frame = audio.tags[frame_key]
|
||||
return str(frame.text[0]) if frame.text else None
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _vorbis_first(audio, key: str) -> Optional[str]:
|
||||
"""Return the first value of a Vorbis comment key, or None."""
|
||||
try:
|
||||
vals = audio.get(key) or audio.get(key.upper())
|
||||
if vals:
|
||||
return str(vals[0])
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _mp4_rg(audio, tag_name: str) -> Optional[str]:
|
||||
"""Read a ReplayGain freeform atom from MP4."""
|
||||
try:
|
||||
key = f"----:com.apple.iTunes:{tag_name}"
|
||||
if key in audio:
|
||||
raw = audio[key]
|
||||
if raw:
|
||||
val = raw[0]
|
||||
if hasattr(val, 'decode'):
|
||||
return val.decode('utf-8')
|
||||
return str(val)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tag writing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def write_replaygain_tags(
|
||||
file_path: str,
|
||||
track_gain_db: float,
|
||||
track_peak_dbfs: float,
|
||||
album_gain_db: Optional[float] = None,
|
||||
album_peak_dbfs: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Write ReplayGain tags to an audio file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the audio file.
|
||||
track_gain_db: Track gain in dB (gain = ref - lufs, signed).
|
||||
track_peak_dbfs: Track true peak in dBFS.
|
||||
album_gain_db: Album gain in dB, or None to skip writing album tags.
|
||||
album_peak_dbfs: Album true peak in dBFS, or None to skip album tags.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
import os
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext not in _AUDIO_EXTENSIONS:
|
||||
return False
|
||||
|
||||
track_gain_str = f"{track_gain_db:+.2f} dB"
|
||||
track_peak_str = format_peak(track_peak_dbfs)
|
||||
|
||||
album_gain_str = f"{album_gain_db:+.2f} dB" if album_gain_db is not None else None
|
||||
album_peak_str = format_peak(album_peak_dbfs) if album_peak_dbfs is not None else None
|
||||
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
audio = MutagenFile(file_path, easy=False)
|
||||
if audio is None:
|
||||
return False
|
||||
|
||||
if ext == '.mp3':
|
||||
_write_id3_rg(audio, track_gain_str, track_peak_str, album_gain_str, album_peak_str)
|
||||
elif ext in ('.flac', '.ogg', '.oga', '.opus'):
|
||||
_write_vorbis_rg(audio, track_gain_str, track_peak_str, album_gain_str, album_peak_str)
|
||||
elif ext in ('.m4a', '.mp4'):
|
||||
_write_mp4_rg(audio, track_gain_str, track_peak_str, album_gain_str, album_peak_str)
|
||||
else:
|
||||
return False
|
||||
|
||||
audio.save()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _write_id3_rg(audio, track_gain: str, track_peak: str,
|
||||
album_gain: Optional[str], album_peak: Optional[str]) -> None:
|
||||
"""Write ReplayGain TXXX frames to an MP3 file's ID3 tags."""
|
||||
from mutagen.id3 import TXXX
|
||||
|
||||
if audio.tags is None:
|
||||
audio.add_tags()
|
||||
|
||||
def _set_txxx(desc: str, value: str) -> None:
|
||||
# Remove any existing frame with this description (case-insensitive)
|
||||
to_delete = [k for k in audio.tags.keys() if k.upper() == f"TXXX:{desc}".upper()]
|
||||
for k in to_delete:
|
||||
del audio.tags[k]
|
||||
audio.tags.add(TXXX(encoding=3, desc=desc, text=[value]))
|
||||
|
||||
_set_txxx(_TAG_TRACK_GAIN, track_gain)
|
||||
_set_txxx(_TAG_TRACK_PEAK, track_peak)
|
||||
if album_gain is not None:
|
||||
_set_txxx(_TAG_ALBUM_GAIN, album_gain)
|
||||
if album_peak is not None:
|
||||
_set_txxx(_TAG_ALBUM_PEAK, album_peak)
|
||||
|
||||
|
||||
def _write_vorbis_rg(audio, track_gain: str, track_peak: str,
|
||||
album_gain: Optional[str], album_peak: Optional[str]) -> None:
|
||||
"""Write ReplayGain Vorbis comments (FLAC, OGG, Opus)."""
|
||||
audio[_TAG_TRACK_GAIN.lower()] = [track_gain]
|
||||
audio[_TAG_TRACK_PEAK.lower()] = [track_peak]
|
||||
if album_gain is not None:
|
||||
audio[_TAG_ALBUM_GAIN.lower()] = [album_gain]
|
||||
if album_peak is not None:
|
||||
audio[_TAG_ALBUM_PEAK.lower()] = [album_peak]
|
||||
|
||||
|
||||
def _write_mp4_rg(audio, track_gain: str, track_peak: str,
|
||||
album_gain: Optional[str], album_peak: Optional[str]) -> None:
|
||||
"""Write ReplayGain freeform atoms to an MP4/M4A file."""
|
||||
from mutagen.mp4 import MP4FreeForm
|
||||
|
||||
def _set_atom(name: str, value: str) -> None:
|
||||
key = f"----:com.apple.iTunes:{name}"
|
||||
audio[key] = [MP4FreeForm(value.encode('utf-8'))]
|
||||
|
||||
_set_atom(_TAG_TRACK_GAIN, track_gain)
|
||||
_set_atom(_TAG_TRACK_PEAK, track_peak)
|
||||
if album_gain is not None:
|
||||
_set_atom(_TAG_ALBUM_GAIN, album_gain)
|
||||
if album_peak is not None:
|
||||
_set_atom(_TAG_ALBUM_PEAK, album_peak)
|
||||
|
|
@ -40,6 +40,11 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
|||
'has_cover_art': False,
|
||||
'format': None,
|
||||
'error': None,
|
||||
# ReplayGain (None if not present in file)
|
||||
'replaygain_track_gain': None,
|
||||
'replaygain_track_peak': None,
|
||||
'replaygain_album_gain': None,
|
||||
'replaygain_album_peak': None,
|
||||
}
|
||||
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
|
|
@ -118,6 +123,17 @@ def read_file_tags(file_path: str) -> Dict[str, Any]:
|
|||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
|
||||
# Read existing ReplayGain tags (additive — never raises)
|
||||
try:
|
||||
from core.replaygain import read_replaygain_tags
|
||||
rg = read_replaygain_tags(file_path)
|
||||
result['replaygain_track_gain'] = rg.get('track_gain')
|
||||
result['replaygain_track_peak'] = rg.get('track_peak')
|
||||
result['replaygain_album_gain'] = rg.get('album_gain')
|
||||
result['replaygain_album_peak'] = rg.get('album_peak')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
|
|
|
|||
281
web_server.py
281
web_server.py
|
|
@ -12734,6 +12734,287 @@ def get_write_tags_batch_status():
|
|||
return jsonify(state)
|
||||
|
||||
|
||||
|
||||
# ── ReplayGain Analysis endpoints ──
|
||||
|
||||
from core.replaygain import (
|
||||
analyze_track as _rg_analyze_track,
|
||||
write_replaygain_tags as _rg_write_tags,
|
||||
is_ffmpeg_available as _rg_ffmpeg_available,
|
||||
RG_REFERENCE_LUFS as _RG_REFERENCE_LUFS,
|
||||
)
|
||||
|
||||
# State machine for album-level ReplayGain jobs
|
||||
_rg_album_state = {
|
||||
'status': 'idle', # idle | running | done
|
||||
'album_id': None,
|
||||
'total': 0,
|
||||
'processed': 0,
|
||||
'analyzed': 0,
|
||||
'failed': 0,
|
||||
'current_track': '',
|
||||
'errors': [],
|
||||
}
|
||||
_rg_album_lock = threading.Lock()
|
||||
|
||||
# State machine for selected-tracks batch ReplayGain jobs
|
||||
_rg_batch_state = {
|
||||
'status': 'idle',
|
||||
'total': 0,
|
||||
'processed': 0,
|
||||
'analyzed': 0,
|
||||
'failed': 0,
|
||||
'current_track': '',
|
||||
'errors': [],
|
||||
}
|
||||
_rg_batch_lock = threading.Lock()
|
||||
|
||||
|
||||
@app.route('/api/library/track/<int:track_id>/analyze-replaygain', methods=['POST'])
|
||||
def analyze_track_replaygain(track_id):
|
||||
"""
|
||||
Analyze a single track and write ReplayGain track-level tags immediately.
|
||||
Synchronous — runs FFmpeg inline (typically 1–3 s per track).
|
||||
"""
|
||||
if not _rg_ffmpeg_available():
|
||||
return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM tracks WHERE id = ?", (str(track_id),))
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return jsonify({'success': False, 'error': 'Track not found'}), 404
|
||||
|
||||
file_path = _resolve_library_file_path(dict(row).get('file_path'))
|
||||
if not file_path:
|
||||
return jsonify({'success': False, 'error': 'File not found on disk'}), 404
|
||||
|
||||
try:
|
||||
lufs, peak_dbfs = _rg_analyze_track(file_path)
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
track_gain_db = _RG_REFERENCE_LUFS - lufs
|
||||
|
||||
file_lock = _get_file_lock(file_path)
|
||||
with file_lock:
|
||||
ok = _rg_write_tags(file_path, track_gain_db, peak_dbfs)
|
||||
|
||||
if not ok:
|
||||
return jsonify({'success': False, 'error': 'Failed to write tags to file'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'track_gain': f"{track_gain_db:+.2f} dB",
|
||||
'track_peak': f"{10 ** (peak_dbfs / 20.0):.6f}",
|
||||
'lufs': round(lufs, 2),
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/library/album/<int:album_id>/analyze-replaygain', methods=['POST'])
|
||||
def analyze_album_replaygain(album_id):
|
||||
"""
|
||||
Analyze all tracks in an album and write both track-level and album-level
|
||||
ReplayGain tags. Runs in a background thread — poll /status for progress.
|
||||
"""
|
||||
with _rg_album_lock:
|
||||
if _rg_album_state['status'] == 'running':
|
||||
return jsonify({'success': False, 'error': 'An album ReplayGain job is already running'}), 409
|
||||
|
||||
if not _rg_ffmpeg_available():
|
||||
return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT * FROM tracks WHERE album_id = ? ORDER BY track_number, title",
|
||||
(album_id,)
|
||||
)
|
||||
tracks = [dict(r) for r in cursor.fetchall()]
|
||||
if not tracks:
|
||||
return jsonify({'success': False, 'error': 'No tracks found for this album'}), 404
|
||||
|
||||
with _rg_album_lock:
|
||||
_rg_album_state.update({
|
||||
'status': 'running',
|
||||
'album_id': album_id,
|
||||
'total': len(tracks),
|
||||
'processed': 0,
|
||||
'analyzed': 0,
|
||||
'failed': 0,
|
||||
'current_track': '',
|
||||
'errors': [],
|
||||
})
|
||||
|
||||
def _run_album():
|
||||
lufs_values = []
|
||||
peak_values = []
|
||||
track_results = [] # (file_path, track_gain_db, peak_dbfs)
|
||||
|
||||
# Pass 1: analyze every track
|
||||
for track in tracks:
|
||||
file_path = _resolve_library_file_path(track.get('file_path'))
|
||||
title = track.get('title') or track.get('file_path') or ''
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['current_track'] = title
|
||||
|
||||
if not file_path:
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['failed'] += 1
|
||||
_rg_album_state['errors'].append({'track': title, 'error': 'File not found'})
|
||||
_rg_album_state['processed'] += 1
|
||||
track_results.append(None)
|
||||
continue
|
||||
|
||||
try:
|
||||
lufs, peak_dbfs = _rg_analyze_track(file_path)
|
||||
lufs_values.append(lufs)
|
||||
peak_values.append(peak_dbfs)
|
||||
track_gain_db = _RG_REFERENCE_LUFS - lufs
|
||||
track_results.append((file_path, track_gain_db, peak_dbfs))
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['analyzed'] += 1
|
||||
_rg_album_state['processed'] += 1
|
||||
except Exception as e:
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['failed'] += 1
|
||||
_rg_album_state['errors'].append({'track': title, 'error': str(e)})
|
||||
_rg_album_state['processed'] += 1
|
||||
track_results.append(None)
|
||||
|
||||
# Compute album gain from tracks that analyzed successfully
|
||||
album_gain_db = None
|
||||
album_peak_dbfs = None
|
||||
if lufs_values:
|
||||
mean_lufs = sum(lufs_values) / len(lufs_values)
|
||||
album_gain_db = _RG_REFERENCE_LUFS - mean_lufs
|
||||
album_peak_dbfs = max(peak_values)
|
||||
|
||||
# Pass 2: write tags to every successfully analyzed track
|
||||
for i, track in enumerate(tracks):
|
||||
entry = track_results[i]
|
||||
if entry is None:
|
||||
continue
|
||||
file_path, track_gain_db, peak_dbfs = entry
|
||||
try:
|
||||
file_lock = _get_file_lock(file_path)
|
||||
with file_lock:
|
||||
_rg_write_tags(file_path, track_gain_db, peak_dbfs,
|
||||
album_gain_db, album_peak_dbfs)
|
||||
except Exception as e:
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['failed'] += 1
|
||||
_rg_album_state['errors'].append({'track': track.get('title', ''), 'error': str(e)})
|
||||
|
||||
with _rg_album_lock:
|
||||
_rg_album_state['status'] = 'done'
|
||||
_rg_album_state['current_track'] = ''
|
||||
|
||||
threading.Thread(target=_run_album, daemon=True, name='RgAlbum').start()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/library/album/<int:album_id>/analyze-replaygain/status', methods=['GET'])
|
||||
def get_album_replaygain_status(album_id):
|
||||
"""Poll the status of a running album ReplayGain job."""
|
||||
with _rg_album_lock:
|
||||
state = dict(_rg_album_state)
|
||||
state['errors'] = list(_rg_album_state['errors'])
|
||||
return jsonify(state)
|
||||
|
||||
|
||||
@app.route('/api/library/tracks/analyze-replaygain-batch', methods=['POST'])
|
||||
def analyze_tracks_replaygain_batch():
|
||||
"""
|
||||
Analyze a set of selected tracks and write track-level ReplayGain tags.
|
||||
No album gain is computed (tracks may span multiple albums).
|
||||
Runs in a background thread — poll /status for progress.
|
||||
"""
|
||||
with _rg_batch_lock:
|
||||
if _rg_batch_state['status'] == 'running':
|
||||
return jsonify({'success': False, 'error': 'A batch ReplayGain job is already running'}), 409
|
||||
|
||||
if not _rg_ffmpeg_available():
|
||||
return jsonify({'success': False, 'error': 'ffmpeg not found on PATH'}), 500
|
||||
|
||||
data = request.get_json() or {}
|
||||
track_ids = data.get('track_ids', [])
|
||||
if not track_ids:
|
||||
return jsonify({'success': False, 'error': 'No track IDs provided'}), 400
|
||||
|
||||
database = get_database()
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
placeholders = ','.join('?' for _ in track_ids)
|
||||
cursor.execute(
|
||||
f"SELECT * FROM tracks WHERE id IN ({placeholders})",
|
||||
[str(tid) for tid in track_ids]
|
||||
)
|
||||
tracks = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
if not tracks:
|
||||
return jsonify({'success': False, 'error': 'No valid tracks found'}), 404
|
||||
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state.update({
|
||||
'status': 'running',
|
||||
'total': len(tracks),
|
||||
'processed': 0,
|
||||
'analyzed': 0,
|
||||
'failed': 0,
|
||||
'current_track': '',
|
||||
'errors': [],
|
||||
})
|
||||
|
||||
def _run_batch():
|
||||
for track in tracks:
|
||||
file_path = _resolve_library_file_path(track.get('file_path'))
|
||||
title = track.get('title') or track.get('file_path') or ''
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state['current_track'] = title
|
||||
|
||||
if not file_path:
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state['failed'] += 1
|
||||
_rg_batch_state['errors'].append({'track': title, 'error': 'File not found'})
|
||||
_rg_batch_state['processed'] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
lufs, peak_dbfs = _rg_analyze_track(file_path)
|
||||
track_gain_db = _RG_REFERENCE_LUFS - lufs
|
||||
file_lock = _get_file_lock(file_path)
|
||||
with file_lock:
|
||||
_rg_write_tags(file_path, track_gain_db, peak_dbfs)
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state['analyzed'] += 1
|
||||
_rg_batch_state['processed'] += 1
|
||||
except Exception as e:
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state['failed'] += 1
|
||||
_rg_batch_state['errors'].append({'track': title, 'error': str(e)})
|
||||
_rg_batch_state['processed'] += 1
|
||||
|
||||
with _rg_batch_lock:
|
||||
_rg_batch_state['status'] = 'done'
|
||||
_rg_batch_state['current_track'] = ''
|
||||
|
||||
threading.Thread(target=_run_batch, daemon=True, name='RgBatch').start()
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/library/tracks/analyze-replaygain-batch/status', methods=['GET'])
|
||||
def get_tracks_replaygain_batch_status():
|
||||
"""Poll the status of a running batch ReplayGain job."""
|
||||
with _rg_batch_lock:
|
||||
state = dict(_rg_batch_state)
|
||||
state['errors'] = list(_rg_batch_state['errors'])
|
||||
return jsonify(state)
|
||||
|
||||
|
||||
# ── Reorganize Album Files endpoint ──
|
||||
|
||||
_reorganize_state = {
|
||||
|
|
|
|||
|
|
@ -3015,6 +3015,7 @@
|
|||
<div class="enhanced-bulk-bar-actions">
|
||||
<button class="enhanced-bulk-btn secondary" onclick="showBulkEditModal()">Edit Selected</button>
|
||||
<button class="enhanced-bulk-btn tag-write" onclick="batchWriteTagsSelected()">Write Tags</button>
|
||||
<button class="enhanced-bulk-btn rg-analyze" onclick="batchAnalyzeReplayGainSelected()">ReplayGain</button>
|
||||
<button class="enhanced-bulk-btn clear" onclick="clearTrackSelection()">Clear Selection</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45153,6 +45153,14 @@ function renderExpandedAlbumHeader(album) {
|
|||
writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); };
|
||||
enrichRow.appendChild(writeTagsBtn);
|
||||
|
||||
const rgAlbumBtn = document.createElement('button');
|
||||
rgAlbumBtn.className = 'enhanced-rg-album-btn';
|
||||
rgAlbumBtn.innerHTML = '♫ ReplayGain';
|
||||
rgAlbumBtn.title = 'Analyze ReplayGain for all tracks in this album (writes track + album gain)';
|
||||
rgAlbumBtn.dataset.albumId = album.id;
|
||||
rgAlbumBtn.onclick = (e) => { e.stopPropagation(); analyzeAlbumReplayGain(album.id, rgAlbumBtn); };
|
||||
enrichRow.appendChild(rgAlbumBtn);
|
||||
|
||||
const reorganizeBtn = document.createElement('button');
|
||||
reorganizeBtn.className = 'enhanced-reorganize-album-btn';
|
||||
reorganizeBtn.innerHTML = '📁 Reorganize';
|
||||
|
|
@ -45392,6 +45400,12 @@ function _buildTrackRow(track, album, admin) {
|
|||
tagBtn.innerHTML = '✎';
|
||||
tagBtn.title = 'Write tags to file';
|
||||
tagTd.appendChild(tagBtn);
|
||||
|
||||
const rgBtn = document.createElement('button');
|
||||
rgBtn.className = 'enhanced-rg-btn';
|
||||
rgBtn.textContent = 'RG';
|
||||
rgBtn.title = 'Analyze & write ReplayGain (track gain)';
|
||||
tagTd.appendChild(rgBtn);
|
||||
}
|
||||
tr.appendChild(tagTd);
|
||||
|
||||
|
|
@ -45556,6 +45570,13 @@ function _attachTableDelegation(table, album) {
|
|||
return;
|
||||
}
|
||||
|
||||
// ReplayGain analyze button (admin)
|
||||
if (target.closest('.enhanced-rg-btn')) {
|
||||
e.stopPropagation();
|
||||
analyzeTrackReplayGain(track.id, target.closest('.enhanced-rg-btn'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Source info button (admin)
|
||||
if (target.closest('.enhanced-source-info-btn')) {
|
||||
e.stopPropagation();
|
||||
|
|
@ -47765,6 +47786,142 @@ function _pollBatchWriteTagsStatus() {
|
|||
_batchWriteTagsPollTimer = setTimeout(poll, 800);
|
||||
}
|
||||
|
||||
// ── ReplayGain Analysis ──
|
||||
|
||||
let _rgBatchPollTimer = null;
|
||||
let _rgAlbumPollTimer = null;
|
||||
|
||||
/**
|
||||
* Analyze a single track and write track-level ReplayGain tags.
|
||||
* Synchronous on the server side (~1–3 s). Shows spinner on the button.
|
||||
*/
|
||||
async function analyzeTrackReplayGain(trackId, btn) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '…';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/library/track/${trackId}/analyze-replaygain`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(`ReplayGain written: ${data.track_gain} (${data.lufs} LUFS)`, 'success');
|
||||
} else {
|
||||
showToast(`ReplayGain failed: ${data.error}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('ReplayGain analysis failed', 'error');
|
||||
} finally {
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'RG';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze all tracks in an album and write track + album ReplayGain tags.
|
||||
* Kicks off a background job; polls for progress.
|
||||
*/
|
||||
async function analyzeAlbumReplayGain(albumId, btn) {
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '♫ Analyzing…';
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
showToast(`ReplayGain: ${data.error}`, 'error');
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
|
||||
return;
|
||||
}
|
||||
showToast('Album ReplayGain analysis started…', 'info');
|
||||
_pollAlbumRgStatus(albumId, btn);
|
||||
} catch (err) {
|
||||
showToast('Failed to start album ReplayGain analysis', 'error');
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
|
||||
}
|
||||
}
|
||||
|
||||
function _pollAlbumRgStatus(albumId, btn) {
|
||||
if (_rgAlbumPollTimer) clearTimeout(_rgAlbumPollTimer);
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain/status`);
|
||||
const state = await res.json();
|
||||
|
||||
if (state.status === 'running') {
|
||||
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
|
||||
showToast(`ReplayGain: ${state.processed}/${state.total} tracks (${pct}%)`, 'info');
|
||||
_rgAlbumPollTimer = setTimeout(poll, 1200);
|
||||
} else if (state.status === 'done') {
|
||||
const msg = `ReplayGain done: ${state.analyzed} analyzed, ${state.failed} failed`;
|
||||
showToast(msg, state.failed > 0 ? 'warning' : 'success');
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
|
||||
_rgAlbumPollTimer = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ReplayGain album poll failed:', err);
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; }
|
||||
_rgAlbumPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_rgAlbumPollTimer = setTimeout(poll, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze selected tracks (track gain only — they may span albums).
|
||||
*/
|
||||
async function batchAnalyzeReplayGainSelected() {
|
||||
const trackIds = Array.from(artistDetailPageState.selectedTracks);
|
||||
if (trackIds.length === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/library/tracks/analyze-replaygain-batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_ids: trackIds }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) {
|
||||
showToast(`ReplayGain: ${data.error}`, 'error');
|
||||
return;
|
||||
}
|
||||
showToast(`ReplayGain analysis started for ${trackIds.length} tracks…`, 'info');
|
||||
_pollBatchRgStatus();
|
||||
} catch (err) {
|
||||
showToast('Failed to start batch ReplayGain analysis', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _pollBatchRgStatus() {
|
||||
if (_rgBatchPollTimer) clearTimeout(_rgBatchPollTimer);
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const res = await fetch('/api/library/tracks/analyze-replaygain-batch/status');
|
||||
const state = await res.json();
|
||||
|
||||
if (state.status === 'running') {
|
||||
const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0;
|
||||
showToast(`ReplayGain: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info');
|
||||
_rgBatchPollTimer = setTimeout(poll, 1000);
|
||||
} else if (state.status === 'done') {
|
||||
const msg = `ReplayGain done: ${state.analyzed} written, ${state.failed} failed`;
|
||||
showToast(msg, state.failed > 0 ? 'warning' : 'success');
|
||||
_rgBatchPollTimer = null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ReplayGain batch poll failed:', err);
|
||||
_rgBatchPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_rgBatchPollTimer = setTimeout(poll, 800);
|
||||
}
|
||||
|
||||
// ── Reorganize Album Files ──
|
||||
|
||||
let _reorganizeAlbumId = null;
|
||||
|
|
|
|||
|
|
@ -44815,6 +44815,57 @@ textarea.enhanced-meta-field-input {
|
|||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Per-track ReplayGain button — sits beside the pencil in col-writetag */
|
||||
.enhanced-rg-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(147, 112, 219, 0.15);
|
||||
background: rgba(147, 112, 219, 0.04);
|
||||
color: rgba(147, 112, 219, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
.enhanced-rg-btn:hover {
|
||||
background: rgba(147, 112, 219, 0.12);
|
||||
color: rgba(147, 112, 219, 0.9);
|
||||
border-color: rgba(147, 112, 219, 0.4);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.enhanced-rg-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: wait;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Album-level ReplayGain button in the album header action row */
|
||||
.enhanced-rg-album-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: rgba(147, 112, 219, 0.06);
|
||||
border: 1px solid rgba(147, 112, 219, 0.15);
|
||||
color: rgba(147, 112, 219, 0.6);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
.enhanced-rg-album-btn:hover {
|
||||
background: rgba(147, 112, 219, 0.12);
|
||||
color: rgba(147, 112, 219, 0.9);
|
||||
border-color: rgba(147, 112, 219, 0.35);
|
||||
}
|
||||
.enhanced-rg-album-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.enhanced-write-tags-album-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
|
|
@ -45091,9 +45142,18 @@ textarea.enhanced-meta-field-input {
|
|||
background: rgba(29, 185, 84, 0.15);
|
||||
border-color: rgba(29, 185, 84, 0.4);
|
||||
}
|
||||
.enhanced-bulk-btn.rg-analyze {
|
||||
background: rgba(147, 112, 219, 0.08);
|
||||
border-color: rgba(147, 112, 219, 0.2);
|
||||
color: rgba(147, 112, 219, 0.9);
|
||||
}
|
||||
.enhanced-bulk-btn.rg-analyze:hover {
|
||||
background: rgba(147, 112, 219, 0.15);
|
||||
border-color: rgba(147, 112, 219, 0.4);
|
||||
}
|
||||
|
||||
.col-writetag {
|
||||
width: 36px;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue