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.
pull/301/head
Broque Thomas 1 month ago
parent 3db00ca7ef
commit ce129010e1

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

@ -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 13 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 = '&#9835; 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 = '&#128193; Reorganize';
@ -45392,6 +45400,12 @@ function _buildTrackRow(track, album, admin) {
tagBtn.innerHTML = '&#9998;';
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 (~13 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 = '&#9835; 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 = '&#9835; 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 = '&#9835; 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 = '&#9835; ReplayGain'; }
_rgAlbumPollTimer = null;
}
} catch (err) {
console.error('ReplayGain album poll failed:', err);
if (btn) { btn.disabled = false; btn.innerHTML = '&#9835; 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…
Cancel
Save