From d2adf17ca5737d591a2991b04578a39c085d37bb Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Fri, 20 Feb 2026 22:16:00 -0800 Subject: [PATCH] Add lossy MP3 copy of downloaded FLACs Introduce a configurable "lossy_copy" feature that creates an MP3 copy alongside downloaded FLAC files. Adds default config (example and runtime) and UI controls for enabling the feature and selecting an MP3 bitrate. Implements _create_lossy_copy in web_server.py which checks the FLAC extension, respects the configured bitrate (default 320 kbps), locates ffmpeg (including a local tools/ffmpeg fallback), performs conversion, and attempts to update the QUALITY tag via mutagen. The feature is invoked after post-processing/moving downloads. Logs and graceful failures (missing ffmpeg, timeouts, tag errors) are included. --- config/config.example.json | 4 +++ config/settings.py | 4 +++ web_server.py | 70 +++++++++++++++++++++++++++++++++++++- webui/index.html | 29 ++++++++++++++++ webui/static/script.js | 10 ++++++ 5 files changed, 116 insertions(+), 1 deletion(-) diff --git a/config/config.example.json b/config/config.example.json index b0699ef1..24d8a239 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -56,6 +56,10 @@ "playlist_path": "$playlist/$artist - $title" } }, + "lossy_copy": { + "enabled": false, + "bitrate": "320" + }, "playlist_sync": { "create_backup": true }, diff --git a/config/settings.py b/config/settings.py index ddf5e0a0..a019e68e 100644 --- a/config/settings.py +++ b/config/settings.py @@ -220,6 +220,10 @@ class ConfigManager: "settings": { "audio_quality": "flac" }, + "lossy_copy": { + "enabled": False, + "bitrate": "320" + }, "import": { "staging_path": "./Staging" } diff --git a/web_server.py b/web_server.py index 23e85d5a..c2f7a51f 100644 --- a/web_server.py +++ b/web_server.py @@ -2451,7 +2451,7 @@ def handle_settings(): if 'active_media_server' in new_settings: config_manager.set_active_media_server(new_settings['active_media_server']) - for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'acoustid', 'import']: + for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'listenbrainz', 'acoustid', 'import', 'lossy_copy']: if service in new_settings: for key, value in new_settings[service].items(): config_manager.set(f'{service}.{key}', value) @@ -8245,6 +8245,68 @@ def _get_audio_quality_string(file_path): logger.debug(f"Could not determine audio quality for {file_path}: {e}") return '' +def _create_lossy_copy(final_path): + """Convert a FLAC file to MP3 at the user's configured bitrate. + + Only runs when lossy_copy is enabled and the file is a FLAC. + Places the MP3 alongside the FLAC with the same basename. + """ + if not config_manager.get('lossy_copy.enabled', False): + return + + ext = os.path.splitext(final_path)[1].lower() + if ext != '.flac': + return + + bitrate = config_manager.get('lossy_copy.bitrate', '320') + mp3_quality = f'MP3-{bitrate}' + mp3_path = os.path.splitext(final_path)[0] + '.mp3' + + # If $quality was used in filename, swap FLAC quality for MP3 quality + original_quality = _get_audio_quality_string(final_path) + if original_quality: + mp3_basename = os.path.basename(mp3_path) + if original_quality in mp3_basename: + mp3_basename = mp3_basename.replace(original_quality, mp3_quality) + mp3_path = os.path.join(os.path.dirname(mp3_path), mp3_basename) + + ffmpeg_bin = shutil.which('ffmpeg') + if not ffmpeg_bin: + local = os.path.join(os.path.dirname(__file__), 'tools', 'ffmpeg') + if os.path.isfile(local): + ffmpeg_bin = local + else: + print("⚠️ [Lossy Copy] ffmpeg not found — skipping MP3 conversion") + return + + try: + print(f"🎵 [Lossy Copy] Converting to MP3-{bitrate}: {os.path.basename(final_path)}") + result = subprocess.run([ + ffmpeg_bin, '-i', final_path, + '-codec:a', 'libmp3lame', + '-b:a', f'{bitrate}k', + '-map_metadata', '0', + '-id3v2_version', '3', + '-y', mp3_path + ], capture_output=True, text=True, timeout=120) + + if result.returncode == 0: + print(f"✅ [Lossy Copy] Created MP3-{bitrate} copy: {os.path.basename(mp3_path)}") + # Fix QUALITY tag — the FLAC's tag (e.g. "FLAC 24bit") was copied verbatim + try: + from mutagen.id3 import ID3, TXXX + tags = ID3(mp3_path) + tags.add(TXXX(encoding=3, desc='QUALITY', text=[f'MP3-{bitrate}'])) + tags.save() + except Exception as tag_err: + print(f"⚠️ [Lossy Copy] Could not update QUALITY tag: {tag_err}") + else: + print(f"⚠️ [Lossy Copy] ffmpeg failed: {result.stderr[:200]}") + except subprocess.TimeoutExpired: + print(f"⚠️ [Lossy Copy] Conversion timed out for: {os.path.basename(final_path)}") + except Exception as e: + print(f"⚠️ [Lossy Copy] Conversion error: {e}") + def _apply_path_template(template: str, context: dict) -> str: """ Apply template to build file path. @@ -9747,6 +9809,9 @@ def _post_process_matched_download(context_key, context, file_path): _safe_move_file(file_path, final_path) + # Lossy copy: create MP3 version if enabled + _create_lossy_copy(final_path) + # Clean up empty directories in downloads folder downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) _cleanup_empty_directories(downloads_path, file_path) @@ -10006,6 +10071,9 @@ def _post_process_matched_download(context_key, context, file_path): # 4. Generate LRC lyrics file at final location (elegant addition) _generate_lrc_file(final_path, context, spotify_artist, album_info) + # Lossy copy: create MP3 version if enabled + _create_lossy_copy(final_path) + downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads')) _cleanup_empty_directories(downloads_path, file_path) diff --git a/webui/index.html b/webui/index.html index 64bc869c..3c04de6c 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3190,6 +3190,35 @@ + +
+

📀 Lossy Copy

+ +
+ +
+ + +
+

📁 File Organization

diff --git a/webui/static/script.js b/webui/static/script.js index e2cff1ba..33a1da0c 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -1818,6 +1818,12 @@ async function loadSettingsData() { // Populate Playlist Sync settings document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; + // Populate Lossy Copy settings + document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true; + document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320'; + document.getElementById('lossy-copy-options').style.display = + settings.lossy_copy?.enabled ? 'block' : 'none'; + // Populate Logging information (read-only) document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO'; document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; @@ -2271,6 +2277,10 @@ async function saveSettings(quiet = false) { playlist_sync: { create_backup: document.getElementById('create-backup').checked }, + lossy_copy: { + enabled: document.getElementById('lossy-copy-enabled').checked, + bitrate: document.getElementById('lossy-copy-bitrate').value + }, import: { staging_path: document.getElementById('staging-path').value || './Staging' }