Add Opus and AAC codec options to lossy copy (Blasphemy Mode)

Lossy copy now supports MP3, Opus, and AAC (M4A) codecs with a
configurable dropdown in settings. Each codec uses the appropriate
ffmpeg encoder (libmp3lame/libopus/aac) and Mutagen tag writer
(ID3/Vorbis/MP4). Quality tag, filename substitution, and Blasphemy
Mode file cleanup all work per-codec. Backward compatible — existing
configs default to MP3.
This commit is contained in:
Broque Thomas 2026-03-21 20:19:36 -07:00
parent a3592d2a14
commit f9fc95c9f5
4 changed files with 75 additions and 39 deletions

View file

@ -428,6 +428,7 @@ class ConfigManager:
},
"lossy_copy": {
"enabled": False,
"codec": "mp3",
"bitrate": "320",
"delete_original": False,
"downsample_hires": False

View file

@ -14728,12 +14728,13 @@ def _downsample_hires_flac(final_path, context):
def _create_lossy_copy(final_path):
"""Convert a FLAC file to MP3 at the user's configured bitrate.
"""Convert a FLAC file to a lossy codec at the user's configured bitrate.
Supported codecs: mp3 (libmp3lame), opus (libopus), aac (aac/libfdk_aac).
Only runs when lossy_copy is enabled and the file is a FLAC.
Places the MP3 alongside the FLAC with the same basename.
Places the output alongside the FLAC with the same basename.
Returns the MP3 path if Blasphemy Mode deleted the original, else None.
Returns the output path if Blasphemy Mode deleted the original, else None.
"""
if not config_manager.get('lossy_copy.enabled', False):
return None
@ -14742,17 +14743,30 @@ def _create_lossy_copy(final_path):
if ext != '.flac':
return None
codec = config_manager.get('lossy_copy.codec', 'mp3').lower()
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
# Codec configuration: (ffmpeg_codec, extension, quality_label, extra_args)
codec_map = {
'mp3': ('libmp3lame', '.mp3', f'MP3-{bitrate}', ['-id3v2_version', '3']),
'opus': ('libopus', '.opus', f'OPUS-{bitrate}', ['-vbr', 'on']),
'aac': ('aac', '.m4a', f'AAC-{bitrate}', ['-movflags', '+faststart']),
}
if codec not in codec_map:
print(f"⚠️ [Lossy Copy] Unknown codec '{codec}' — skipping conversion")
return None
ffmpeg_codec, out_ext, quality_label, extra_args = codec_map[codec]
out_path = os.path.splitext(final_path)[0] + out_ext
# If $quality was used in filename, swap FLAC quality for lossy 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)
out_basename = os.path.basename(out_path)
if original_quality in out_basename:
out_basename = out_basename.replace(original_quality, quality_label)
out_path = os.path.join(os.path.dirname(out_path), out_basename)
ffmpeg_bin = shutil.which('ffmpeg')
if not ffmpeg_bin:
@ -14760,54 +14774,63 @@ def _create_lossy_copy(final_path):
if os.path.isfile(local):
ffmpeg_bin = local
else:
print("⚠️ [Lossy Copy] ffmpeg not found — skipping MP3 conversion")
print(f"⚠️ [Lossy Copy] ffmpeg not found — skipping {codec.upper()} conversion")
return None
try:
print(f"🎵 [Lossy Copy] Converting to MP3-{bitrate}: {os.path.basename(final_path)}")
result = subprocess.run([
print(f"🎵 [Lossy Copy] Converting to {quality_label}: {os.path.basename(final_path)}")
cmd = [
ffmpeg_bin, '-i', final_path,
'-codec:a', 'libmp3lame',
'-codec:a', ffmpeg_codec,
'-b:a', f'{bitrate}k',
'-map_metadata', '0',
'-id3v2_version', '3',
'-y', mp3_path
], capture_output=True, text=True, timeout=120)
] + extra_args + ['-y', out_path]
result = subprocess.run(cmd, 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
print(f"✅ [Lossy Copy] Created {quality_label} copy: {os.path.basename(out_path)}")
# Fix QUALITY tag — the FLAC's tag was copied verbatim by ffmpeg
try:
from mutagen.id3 import ID3, TXXX
tags = ID3(mp3_path)
tags.add(TXXX(encoding=3, desc='QUALITY', text=[f'MP3-{bitrate}']))
tags.save()
from mutagen import File as MutagenFile
audio = MutagenFile(out_path)
if audio is not None:
if codec == 'mp3':
from mutagen.id3 import TXXX
audio.tags.add(TXXX(encoding=3, desc='QUALITY', text=[quality_label]))
elif codec == 'opus':
audio['QUALITY'] = [quality_label]
elif codec == 'aac':
from mutagen.mp4 import MP4FreeForm
audio['----:com.apple.iTunes:QUALITY'] = [MP4FreeForm(quality_label.encode('utf-8'))]
audio.save()
except Exception as tag_err:
print(f"⚠️ [Lossy Copy] Could not update QUALITY tag: {tag_err}")
# Blasphemy Mode: delete original FLAC if enabled and MP3 is verified
# Blasphemy Mode: delete original FLAC if enabled and output is verified
if config_manager.get('lossy_copy.delete_original', False):
try:
if os.path.isfile(mp3_path) and os.path.getsize(mp3_path) > 0:
if os.path.isfile(out_path) and os.path.getsize(out_path) > 0:
from mutagen import File as MutagenFile
test_audio = MutagenFile(mp3_path)
test_audio = MutagenFile(out_path)
if test_audio is not None:
os.remove(final_path)
print(f"🔥 [Blasphemy Mode] Deleted original: {os.path.basename(final_path)}")
# Rename LRC file to match the MP3 filename
# Rename LRC file to match the output filename
flac_lrc = os.path.splitext(final_path)[0] + '.lrc'
if os.path.isfile(flac_lrc):
mp3_lrc = os.path.splitext(mp3_path)[0] + '.lrc'
out_lrc = os.path.splitext(out_path)[0] + '.lrc'
try:
os.rename(flac_lrc, mp3_lrc)
print(f"🔥 [Blasphemy Mode] Renamed LRC: {os.path.basename(flac_lrc)} -> {os.path.basename(mp3_lrc)}")
os.rename(flac_lrc, out_lrc)
print(f"🔥 [Blasphemy Mode] Renamed LRC: {os.path.basename(flac_lrc)} -> {os.path.basename(out_lrc)}")
except Exception as lrc_err:
print(f"⚠️ [Blasphemy Mode] Could not rename LRC: {lrc_err}")
return mp3_path
return out_path
else:
print(f"⚠️ [Blasphemy Mode] MP3 failed audio validation, keeping original: {os.path.basename(final_path)}")
print(f"⚠️ [Blasphemy Mode] Output failed audio validation, keeping original: {os.path.basename(final_path)}")
else:
print(f"⚠️ [Blasphemy Mode] MP3 missing or empty, keeping original: {os.path.basename(final_path)}")
print(f"⚠️ [Blasphemy Mode] Output missing or empty, keeping original: {os.path.basename(final_path)}")
except Exception as del_err:
print(f"⚠️ [Blasphemy Mode] Error during original deletion, keeping original: {del_err}")
else:
@ -17495,10 +17518,12 @@ def _post_process_matched_download(context_key, context, file_path):
expected_stem = os.path.splitext(os.path.basename(final_path))[0]
expected_ext = os.path.splitext(final_path)[1]
found_variant = None
# Also check for .mp3 if Blasphemy Mode may have deleted the .flac
# Also check for lossy output if Blasphemy Mode may have deleted the .flac
check_exts = {expected_ext}
if expected_ext == '.flac' and config_manager.get('lossy_copy.enabled', False) and config_manager.get('lossy_copy.delete_original', False):
check_exts.add('.mp3')
_lossy_ext_map = {'mp3': '.mp3', 'opus': '.opus', 'aac': '.m4a'}
_lossy_codec = config_manager.get('lossy_copy.codec', 'mp3')
check_exts.add(_lossy_ext_map.get(_lossy_codec, '.mp3'))
if os.path.exists(expected_dir):
for f in os.listdir(expected_dir):
# Match files that start with the expected stem and have a matching extension

View file

@ -4621,13 +4621,21 @@
<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
Create lossy copy of downloaded FLAC files
</label>
</div>
<div id="lossy-copy-options" style="display: none;">
<div class="form-group">
<label>MP3 Bitrate:</label>
<label>Codec:</label>
<select id="lossy-copy-codec">
<option value="mp3">MP3</option>
<option value="opus">Opus</option>
<option value="aac">AAC (M4A)</option>
</select>
</div>
<div class="form-group">
<label>Bitrate:</label>
<select id="lossy-copy-bitrate">
<option value="320">320 kbps</option>
<option value="256">256 kbps</option>
@ -4636,13 +4644,13 @@
</select>
</div>
<div class="help-text">
After downloading a FLAC, an MP3 copy at the selected bitrate
After downloading a FLAC, a lossy copy at the selected codec and bitrate
will be created in the same folder. Requires ffmpeg.
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="checkbox-label">
<input type="checkbox" id="lossy-copy-delete-original">
Blasphemy Mode — Delete original FLAC after MP3 is created
Blasphemy Mode — Delete original FLAC after conversion
</label>
</div>
<div class="help-text" style="color: rgba(255, 100, 100, 0.8);">

View file

@ -5576,6 +5576,7 @@ async function loadSettingsData() {
// 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-codec').value = settings.lossy_copy?.codec || 'mp3';
document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320';
document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true;
document.getElementById('lossy-copy-options').style.display =
@ -6558,6 +6559,7 @@ async function saveSettings(quiet = false) {
},
lossy_copy: {
enabled: document.getElementById('lossy-copy-enabled').checked,
codec: document.getElementById('lossy-copy-codec').value,
bitrate: document.getElementById('lossy-copy-bitrate').value,
delete_original: document.getElementById('lossy-copy-delete-original').checked,
downsample_hires: document.getElementById('downsample-hires').checked