From 07422debb9a69a2dfb3059bef80d096774f87adb Mon Sep 17 00:00:00 2001
From: Daniel <790119+DanTheMan827@users.noreply.github.com>
Date: Fri, 27 Feb 2026 21:05:58 +0000
Subject: [PATCH] feat(downloads): add lossless container option
This uses ffmpeg to ensure that the downloaded lossless audio is in the desired container format.
---
index.html | 11 +++++++++++
js/api.js | 40 +++++++++++++++++++++++++++++++++++++++-
js/downloads.js | 40 +++++++++++++++++++++++++++++++++++++++-
js/settings.js | 10 ++++++++++
js/storage.js | 14 ++++++++++++++
5 files changed, 113 insertions(+), 2 deletions(-)
diff --git a/index.html b/index.html
index 6a3b5c8..9506106 100644
--- a/index.html
+++ b/index.html
@@ -4316,6 +4316,17 @@
+
Cover Art Size
diff --git a/js/api.js b/js/api.js
index ce18fdc..5355a9a 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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) {
diff --git a/js/downloads.js b/js/downloads.js
index 594481f..616e5f8 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -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);
diff --git a/js/settings.js b/js/settings.js
index dd310ff..514fe4f 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -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) {
diff --git a/js/storage.js b/js/storage.js
index a152638..7a6027d 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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() {