feat(downloads): add lossless container option

This uses ffmpeg to ensure that the downloaded lossless audio is in the desired container format.
This commit is contained in:
Daniel 2026-02-27 21:05:58 +00:00 committed by GitHub
parent 2f7f0e61dc
commit 07422debb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 113 additions and 2 deletions

View file

@ -4316,6 +4316,17 @@
<option value="LOW">AAC 96kbps</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Lossless Container</span>
<span class="description">Container format for lossless downloads</span>
</div>
<select id="lossless-container-setting">
<option value="flac">FLAC</option>
<option value="alac">Apple Lossless</option>
<option value="nochange">Don't change</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Cover Art Size</span>

View file

@ -6,11 +6,12 @@ import {
isTrackUnavailable,
getExtensionFromBlob,
} from './utils.js';
import { trackDateSettings } from './storage.js';
import { trackDateSettings, losslessContainerSettings } from './storage.js';
import { APICache } from './cache.js';
import { addMetadataToAudio } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
import { ffmpeg } from './ffmpeg.js';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@ -1208,6 +1209,43 @@ export class LosslessAPI {
}
}
if (quality.endsWith('LOSSLESS')) {
try {
switch (losslessContainerSettings.getContainer()) {
case 'flac':
if ((await getExtensionFromBlob(blob)) != 'flac') {
blob = await ffmpeg(
blob,
{ args: ['-c:a', 'flac'] },
'output.flac',
'audio/flac',
onProgress,
options.signal
);
}
break;
case 'alac':
blob = await ffmpeg(
blob,
{ args: ['-c:a', 'alac'] },
'output.m4a',
'audio/m4a',
onProgress,
options.signal
);
break;
default:
break;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw error;
}
console.error('Lossless container conversion failed:', error);
}
}
// Add metadata if track information is provided
if (track) {
if (onProgress) {

View file

@ -11,11 +11,12 @@ import {
getExtensionFromBlob,
escapeHtml,
} from './utils.js';
import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js';
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
import { addMetadataToAudio } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
import { encodeToMp3 } from './mp3-encoder.js';
import { ffmpeg } from './ffmpeg.js';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@ -350,6 +351,43 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
blob = await encodeToMp3(blob, () => undefined, signal);
}
if (quality.endsWith('LOSSLESS')) {
try {
switch (losslessContainerSettings.getContainer()) {
case 'flac':
if ((await getExtensionFromBlob(blob)) != 'flac') {
blob = await ffmpeg(
blob,
{ args: ['-c:a', 'flac'] },
'output.flac',
'audio/flac',
() => undefined,
signal
);
}
break;
case 'alac':
blob = await ffmpeg(
blob,
{ args: ['-c:a', 'alac'] },
'output.m4a',
'audio/m4a',
() => undefined,
signal
);
break;
default:
break;
}
} catch (error) {
if (error?.name === 'AbortError') {
throw error;
}
console.error('Lossless container conversion failed:', error);
}
}
// Detect actual format from blob signature BEFORE adding metadata
const extension = await getExtensionFromBlob(blob);

View file

@ -11,6 +11,7 @@ import {
replayGainSettings,
smoothScrollingSettings,
downloadQualitySettings,
losslessContainerSettings,
coverArtSizeSettings,
qualityBadgeSettings,
trackDateSettings,
@ -805,6 +806,15 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
const losslessContainerSetting = document.getElementById('lossless-container-setting');
if (losslessContainerSetting) {
losslessContainerSetting.value = losslessContainerSettings.getContainer();
losslessContainerSetting.addEventListener('change', (e) => {
losslessContainerSettings.setContainer(e.target.value);
});
}
// Cover Art Size setting
const coverArtSizeSetting = document.getElementById('cover-art-size-setting');
if (coverArtSizeSetting) {

View file

@ -533,6 +533,20 @@ export const downloadQualitySettings = {
},
};
export const losslessContainerSettings = {
STORAGE_KEY: 'lossless-container',
getContainer() {
try {
return localStorage.getItem(this.STORAGE_KEY) || 'flac';
} catch {
return 'flac';
}
},
setContainer(container) {
localStorage.setItem(this.STORAGE_KEY, container);
},
};
export const coverArtSizeSettings = {
STORAGE_KEY: 'cover-art-size',
getSize() {