diff --git a/core/replaygain.py b/core/replaygain.py new file mode 100644 index 00000000..8f8630d6 --- /dev/null +++ b/core/replaygain.py @@ -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) diff --git a/core/tag_writer.py b/core/tag_writer.py index 09bf611a..9ea89a34 100644 --- a/core/tag_writer.py +++ b/core/tag_writer.py @@ -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 diff --git a/web_server.py b/web_server.py index b126a3f6..741869c8 100644 --- a/web_server.py +++ b/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//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//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//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 = { diff --git a/webui/index.html b/webui/index.html index 36dc7cd7..e343363b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3015,6 +3015,7 @@
+
diff --git a/webui/static/script.js b/webui/static/script.js index ba0638fb..e69b4ac9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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; diff --git a/webui/static/style.css b/webui/static/style.css index 560f111e..f1a2a2cd 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }