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.
This commit is contained in:
parent
d858a7c85f
commit
d2adf17ca5
5 changed files with 116 additions and 1 deletions
|
|
@ -56,6 +56,10 @@
|
||||||
"playlist_path": "$playlist/$artist - $title"
|
"playlist_path": "$playlist/$artist - $title"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"lossy_copy": {
|
||||||
|
"enabled": false,
|
||||||
|
"bitrate": "320"
|
||||||
|
},
|
||||||
"playlist_sync": {
|
"playlist_sync": {
|
||||||
"create_backup": true
|
"create_backup": true
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,10 @@ class ConfigManager:
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio_quality": "flac"
|
"audio_quality": "flac"
|
||||||
},
|
},
|
||||||
|
"lossy_copy": {
|
||||||
|
"enabled": False,
|
||||||
|
"bitrate": "320"
|
||||||
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"staging_path": "./Staging"
|
"staging_path": "./Staging"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2451,7 +2451,7 @@ def handle_settings():
|
||||||
if 'active_media_server' in new_settings:
|
if 'active_media_server' in new_settings:
|
||||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
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:
|
if service in new_settings:
|
||||||
for key, value in new_settings[service].items():
|
for key, value in new_settings[service].items():
|
||||||
config_manager.set(f'{service}.{key}', value)
|
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}")
|
logger.debug(f"Could not determine audio quality for {file_path}: {e}")
|
||||||
return ''
|
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:
|
def _apply_path_template(template: str, context: dict) -> str:
|
||||||
"""
|
"""
|
||||||
Apply template to build file path.
|
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)
|
_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
|
# Clean up empty directories in downloads folder
|
||||||
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
_cleanup_empty_directories(downloads_path, file_path)
|
_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)
|
# 4. Generate LRC lyrics file at final location (elegant addition)
|
||||||
_generate_lrc_file(final_path, context, spotify_artist, album_info)
|
_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'))
|
downloads_path = docker_resolve_path(config_manager.get('soulseek.download_path', './downloads'))
|
||||||
_cleanup_empty_directories(downloads_path, file_path)
|
_cleanup_empty_directories(downloads_path, file_path)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3190,6 +3190,35 @@
|
||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- File Organization Settings -->
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<h3>📁 File Organization</h3>
|
<h3>📁 File Organization</h3>
|
||||||
|
|
|
||||||
|
|
@ -1818,6 +1818,12 @@ async function loadSettingsData() {
|
||||||
// Populate Playlist Sync settings
|
// Populate Playlist Sync settings
|
||||||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
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)
|
// Populate Logging information (read-only)
|
||||||
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
document.getElementById('log-level-display').textContent = settings.logging?.level || 'INFO';
|
||||||
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log';
|
||||||
|
|
@ -2271,6 +2277,10 @@ async function saveSettings(quiet = false) {
|
||||||
playlist_sync: {
|
playlist_sync: {
|
||||||
create_backup: document.getElementById('create-backup').checked
|
create_backup: document.getElementById('create-backup').checked
|
||||||
},
|
},
|
||||||
|
lossy_copy: {
|
||||||
|
enabled: document.getElementById('lossy-copy-enabled').checked,
|
||||||
|
bitrate: document.getElementById('lossy-copy-bitrate').value
|
||||||
|
},
|
||||||
import: {
|
import: {
|
||||||
staging_path: document.getElementById('staging-path').value || './Staging'
|
staging_path: document.getElementById('staging-path').value || './Staging'
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue