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.
pull/153/head
Broque Thomas 3 months ago
parent d858a7c85f
commit d2adf17ca5

@ -56,6 +56,10 @@
"playlist_path": "$playlist/$artist - $title"
}
},
"lossy_copy": {
"enabled": false,
"bitrate": "320"
},
"playlist_sync": {
"create_backup": true
},

@ -220,6 +220,10 @@ class ConfigManager:
"settings": {
"audio_quality": "flac"
},
"lossy_copy": {
"enabled": False,
"bitrate": "320"
},
"import": {
"staging_path": "./Staging"
}

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

@ -3190,6 +3190,35 @@
</div>
</div>
<!-- Lossy Copy Settings -->
<div class="settings-group">
<h3>📀 Lossy Copy</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="lossy-copy-enabled"
onchange="document.getElementById('lossy-copy-options').style.display = this.checked ? 'block' : 'none'">
Create MP3 copy of downloaded FLAC files
</label>
</div>
<div id="lossy-copy-options" style="display: none;">
<div class="form-group">
<label>MP3 Bitrate:</label>
<select id="lossy-copy-bitrate">
<option value="320">320 kbps</option>
<option value="256">256 kbps</option>
<option value="192">192 kbps</option>
<option value="128">128 kbps</option>
</select>
</div>
<div class="help-text">
After downloading a FLAC, an MP3 copy at the selected bitrate
will be created in the same folder. Requires ffmpeg.
</div>
</div>
</div>
<!-- File Organization Settings -->
<div class="settings-group">
<h3>📁 File Organization</h3>

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

Loading…
Cancel
Save