Add hi-res FLAC to CD quality downsampling in post-processing

New toggle under Post-Download Conversion: automatically converts 24-bit
or high sample rate FLAC files to 16-bit/44.1kHz after download, replacing
the original. Uses ffmpeg with temp file + verify + atomic swap for safety.
Runs before lossy copy so MP3s are made from the downsampled version.
Also prevents bit depth strict mode from rejecting files that will be
downsampled anyway.
This commit is contained in:
Broque Thomas 2026-03-17 11:53:56 -07:00
parent a62cc821ee
commit a9d0607d25
5 changed files with 161 additions and 11 deletions

View file

@ -58,7 +58,8 @@
},
"lossy_copy": {
"enabled": false,
"bitrate": "320"
"bitrate": "320",
"downsample_hires": false
},
"playlist_sync": {
"create_backup": true

View file

@ -425,7 +425,8 @@ class ConfigManager:
"lossy_copy": {
"enabled": False,
"bitrate": "320",
"delete_original": False
"delete_original": False,
"downsample_hires": False
},
"import": {
"staging_path": "./Staging"

View file

@ -14137,6 +14137,124 @@ def _get_audio_quality_string(file_path):
logger.debug(f"Could not determine audio quality for {file_path}: {e}")
return ''
def _downsample_hires_flac(final_path, context):
"""Downsample a 24-bit hi-res FLAC to 16-bit/44.1kHz CD quality.
Only runs when downsample_hires is enabled and the file is a 24-bit FLAC.
Replaces the original file in-place (write to temp, verify, swap).
Returns the (possibly renamed) final_path, or None if no conversion needed.
"""
if not config_manager.get('lossy_copy.downsample_hires', False):
return None
ext = os.path.splitext(final_path)[1].lower()
if ext != '.flac':
return None
# Check current bit depth — only downsample if hi-res (>16 bit)
try:
from mutagen.flac import FLAC
audio = FLAC(final_path)
original_bits = audio.info.bits_per_sample
original_rate = audio.info.sample_rate
except Exception as e:
print(f"⚠️ [Downsample] Could not read FLAC info: {e}")
return None
if original_bits <= 16 and original_rate <= 44100:
return None # Already CD quality or below
print(f"📀 [Downsample] Converting {original_bits}-bit/{original_rate}Hz → 16-bit/44100Hz: {os.path.basename(final_path)}")
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("⚠️ [Downsample] ffmpeg not found — skipping hi-res conversion")
return None
temp_path = final_path + '.tmp.flac'
try:
result = subprocess.run([
ffmpeg_bin, '-i', final_path,
'-sample_fmt', 's16',
'-ar', '44100',
'-map_metadata', '0',
'-compression_level', '8',
'-y', temp_path
], capture_output=True, text=True, timeout=300)
if result.returncode != 0:
print(f"⚠️ [Downsample] ffmpeg failed: {result.stderr[:200]}")
if os.path.exists(temp_path):
os.remove(temp_path)
return None
# Verify the output is a valid 16-bit FLAC
if not os.path.isfile(temp_path) or os.path.getsize(temp_path) == 0:
print(f"⚠️ [Downsample] Output file missing or empty")
if os.path.exists(temp_path):
os.remove(temp_path)
return None
verify_audio = FLAC(temp_path)
if verify_audio.info.bits_per_sample != 16:
print(f"⚠️ [Downsample] Output not 16-bit ({verify_audio.info.bits_per_sample}-bit), aborting")
os.remove(temp_path)
return None
# Atomic swap — replace original with downsampled version
os.replace(temp_path, final_path)
print(f"✅ [Downsample] Converted to 16-bit/44.1kHz: {os.path.basename(final_path)}")
# Update QUALITY tag in the new file
new_quality = 'FLAC 16bit'
try:
updated_audio = FLAC(final_path)
updated_audio['QUALITY'] = new_quality
updated_audio.save()
except Exception as tag_err:
print(f"⚠️ [Downsample] Could not update QUALITY tag: {tag_err}")
# Update context so downstream (lossy copy, metadata) reflects new quality
old_quality = context.get('_audio_quality', '')
context['_audio_quality'] = new_quality
# If filename contains old quality string (from $quality template), rename
if old_quality and old_quality != new_quality and old_quality in os.path.basename(final_path):
new_basename = os.path.basename(final_path).replace(old_quality, new_quality)
new_path = os.path.join(os.path.dirname(final_path), new_basename)
try:
os.rename(final_path, new_path)
print(f"📝 [Downsample] Renamed: {os.path.basename(final_path)}{new_basename}")
# Rename matching LRC file if it exists
old_lrc = os.path.splitext(final_path)[0] + '.lrc'
if os.path.isfile(old_lrc):
new_lrc = os.path.splitext(new_path)[0] + '.lrc'
os.rename(old_lrc, new_lrc)
return new_path
except Exception as rename_err:
print(f"⚠️ [Downsample] Could not rename file: {rename_err}")
return final_path
except subprocess.TimeoutExpired:
print(f"⚠️ [Downsample] Conversion timed out for: {os.path.basename(final_path)}")
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
print(f"⚠️ [Downsample] Conversion error: {e}")
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except Exception:
pass
return None
def _create_lossy_copy(final_path):
"""Convert a FLAC file to MP3 at the user's configured bitrate.
@ -15528,14 +15646,18 @@ def _check_flac_bit_depth(file_path, context, context_key):
if _actual_bits == _flac_pref:
return False
# Bit depth doesn't match preference — check if fallback is enabled
# Bit depth doesn't match preference — check if fallback or downsample is enabled
_flac_fallback = _flac_config.get('bit_depth_fallback', True)
_downsample_enabled = config_manager.get('lossy_copy.downsample_hires', False)
if _flac_fallback:
# Accept the file — a FLAC at any bit depth is better than a failed download
if _flac_fallback or _downsample_enabled:
# Accept the file — it will be downsampled or a FLAC at any bit depth is better than a failed download
track_info = context.get('track_info', {})
track_name = track_info.get('name', os.path.basename(file_path))
print(f"📀 [FLAC Fallback] Accepted {_actual_bits}-bit FLAC (preferred {_flac_pref}-bit): {track_name}")
if _downsample_enabled:
print(f"📀 [FLAC Downsample] Accepted {_actual_bits}-bit FLAC (will be downsampled to {_flac_pref}-bit): {track_name}")
else:
print(f"📀 [FLAC Fallback] Accepted {_actual_bits}-bit FLAC (preferred {_flac_pref}-bit): {track_name}")
return False
# Strict mode — reject and quarantine
@ -16028,9 +16150,15 @@ def _post_process_matched_download(context_key, context, file_path):
print(f"🚚 Moving '{os.path.basename(file_path)}' to '{final_path}'")
_safe_move_file(file_path, final_path)
# Store final path for verification wrapper (before lossy copy may override)
# Store final path for verification wrapper (before conversions may override)
context['_final_processed_path'] = final_path
# Downsample hi-res FLAC to CD quality if enabled (must run before lossy copy)
downsampled_path = _downsample_hires_flac(final_path, context)
if downsampled_path:
final_path = downsampled_path
context['_final_processed_path'] = final_path
# Lossy copy: create MP3 version if enabled
blasphemy_path = _create_lossy_copy(final_path)
if blasphemy_path:
@ -16380,6 +16508,12 @@ 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)
# Downsample hi-res FLAC to CD quality if enabled (must run before lossy copy)
downsampled_path = _downsample_hires_flac(final_path, context)
if downsampled_path:
final_path = downsampled_path
context['_final_processed_path'] = final_path
# Lossy copy: create MP3 version if enabled
blasphemy_path = _create_lossy_copy(final_path)
if blasphemy_path:

View file

@ -4373,11 +4373,23 @@
</div>
</div>
<!-- Lossy Copy Settings -->
<!-- Post-Download Conversion Settings -->
<div class="settings-group">
<h3>📀 Lossy Copy</h3>
<h3>📀 Post-Download Conversion</h3>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="downsample-hires">
Downsample Hi-Res FLAC to CD quality (16-bit / 44.1kHz)
</label>
</div>
<div class="help-text">
When a 24-bit or high sample rate FLAC is downloaded, it will be
automatically converted to 16-bit/44.1kHz. The hi-res original is
replaced — not kept alongside. Requires ffmpeg.
</div>
<div class="form-group" style="margin-top: 16px;">
<label class="checkbox-label">
<input type="checkbox" id="lossy-copy-enabled"
onchange="document.getElementById('lossy-copy-options').style.display = this.checked ? 'block' : 'none'">

View file

@ -4893,7 +4893,8 @@ async function loadSettingsData() {
// Populate Playlist Sync settings
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
// Populate Lossy Copy settings
// Populate Post-Download Conversion settings
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
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-delete-original').checked = settings.lossy_copy?.delete_original === true;
@ -5744,7 +5745,8 @@ async function saveSettings(quiet = false) {
lossy_copy: {
enabled: document.getElementById('lossy-copy-enabled').checked,
bitrate: document.getElementById('lossy-copy-bitrate').value,
delete_original: document.getElementById('lossy-copy-delete-original').checked
delete_original: document.getElementById('lossy-copy-delete-original').checked,
downsample_hires: document.getElementById('downsample-hires').checked
},
import: {
staging_path: document.getElementById('staging-path').value || './Staging'