diff --git a/index.html b/index.html
index 55d6df0..3be4032 100644
--- a/index.html
+++ b/index.html
@@ -5117,7 +5117,6 @@
@@ -5127,11 +5126,7 @@
Lossless Container
Container format for lossless downloads
-
+
diff --git a/js/api.js b/js/api.js
index 30c3d9f..cf71f83 100644
--- a/js/api.js
+++ b/js/api.js
@@ -11,9 +11,16 @@ import { APICache } from './cache.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { HlsDownloader } from './hls-downloader.js';
-import { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
-import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
+import { MP3EncodingError } from './mp3-encoder.js';
+import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@@ -1294,8 +1301,8 @@ export class LosslessAPI {
const isVideo = track?.type === 'video';
try {
- // MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
- const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
+ // Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
+ const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
let lookup;
if (isVideo) {
@@ -1416,50 +1423,38 @@ export class LosslessAPI {
}
if (!isVideo) {
- // Convert to MP3 320kbps if requested
- if (quality === 'MP3_320') {
- try {
- blob = await encodeToMp3(blob, onProgress, options.signal);
- } catch (encodingError) {
- if (onProgress) {
- onProgress({
- stage: 'error',
- message: `Encoding failed: ${encodingError.message}`,
- });
+ // Transcode to custom format if requested
+ if (isCustomFormat(quality)) {
+ const format = getCustomFormat(quality);
+ if (format) {
+ try {
+ blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal);
+ } catch (encodingError) {
+ if (onProgress) {
+ onProgress({
+ stage: 'error',
+ message: `Encoding failed: ${encodingError.message}`,
+ });
+ }
+ throw encodingError;
}
- throw encodingError;
}
}
if (quality.endsWith('LOSSLESS')) {
try {
- switch (losslessContainerSettings.getContainer()) {
- case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
- blob = await ffmpeg(
- blob,
- { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
- 'output.flac',
- 'audio/flac',
- onProgress,
- options.signal
- );
- } else {
- blob = await rebuildFlacWithoutMetadata(blob);
- }
- break;
- case 'alac':
- blob = await ffmpeg(
+ const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
+ if (containerFmt) {
+ if (await containerFmt.needsTranscode(blob)) {
+ blob = await transcodeWithContainerFormat(
blob,
- { args: ['-c:a', 'alac'] },
- 'output.m4a',
- 'audio/mp4',
+ containerFmt,
onProgress,
options.signal
);
- break;
- default:
- break;
+ } else if ((await getExtensionFromBlob(blob)) == 'flac') {
+ blob = await rebuildFlacWithoutMetadata(blob);
+ }
}
} catch (error) {
if (error?.name === 'AbortError') {
@@ -1559,7 +1554,11 @@ export class LosslessAPI {
throw error;
}
console.error('Download failed:', error);
- if (error instanceof MP3EncodingError || error.code === 'MP3_ENCODING_FAILED') {
+ if (
+ error instanceof MP3EncodingError ||
+ error instanceof FfmpegError ||
+ error.code === 'MP3_ENCODING_FAILED'
+ ) {
throw error;
}
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
diff --git a/js/customFormats.ts b/js/customFormats.ts
new file mode 100644
index 0000000..f8d5c2e
--- /dev/null
+++ b/js/customFormats.ts
@@ -0,0 +1,13 @@
+// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts
+export {
+ type ProgressEvent,
+ type CustomFormat,
+ type ContainerFormat,
+ customFormats,
+ containerFormats,
+ isCustomFormat,
+ getCustomFormat,
+ getContainerFormat,
+ transcodeWithCustomFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats';
diff --git a/js/downloads.js b/js/downloads.js
index bd05f19..a8f629a 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -16,8 +16,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.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, loadFfmpeg } from './ffmpeg.js';
+import { loadFfmpeg } from './ffmpeg.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@@ -355,8 +361,8 @@ async function downloadTrackBlob(
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
- // MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
- const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
+ // Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
+ const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
try {
const fullTrack = await api.getTrackMetadata(track.id);
@@ -445,40 +451,23 @@ async function downloadTrackBlob(
blob = await response.blob();
}
- // Convert to MP3 320kbps if requested
- if (quality === 'MP3_320') {
- blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
+ // Transcode to custom format if requested
+ if (isCustomFormat(quality)) {
+ const format = getCustomFormat(quality);
+ if (format) {
+ blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal);
+ }
}
if (quality.endsWith('LOSSLESS')) {
try {
- switch (losslessContainerSettings.getContainer()) {
- case 'flac':
- if ((await getExtensionFromBlob(blob)) != 'flac') {
- blob = await ffmpeg(
- blob,
- { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
- 'output.flac',
- 'audio/flac',
- onProgress,
- signal
- );
- } else {
- blob = await rebuildFlacWithoutMetadata(blob);
- }
- break;
- case 'alac':
- blob = await ffmpeg(
- blob,
- { args: ['-c:a', 'alac'] },
- 'output.m4a',
- 'audio/mp4',
- onProgress,
- signal
- );
- break;
- default:
- break;
+ const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
+ if (containerFmt) {
+ if (await containerFmt.needsTranscode(blob)) {
+ blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
+ } else if ((await getExtensionFromBlob(blob)) == 'flac') {
+ blob = await rebuildFlacWithoutMetadata(blob);
+ }
}
} catch (error) {
if (error?.name === 'AbortError') {
diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts
new file mode 100644
index 0000000..5e5a9cb
--- /dev/null
+++ b/js/ffmpegFormats.ts
@@ -0,0 +1,229 @@
+import { ffmpeg } from './ffmpeg';
+import { getExtensionFromBlob } from './utils';
+
+export interface ProgressEvent {
+ stage?: string;
+ message?: string;
+ progress?: number;
+ receivedBytes?: number;
+ totalBytes?: number;
+}
+
+export interface CustomFormat {
+ /** Human-readable label shown in the UI */
+ displayName: string;
+ /** Internal identifier, must start with `FFMPEG_` */
+ internalName: string;
+ /** Arguments passed to ffmpeg (excluding input/output file args) */
+ ffmpegArgs: string[];
+ /** Output filename used when calling ffmpeg */
+ outputFilename: string;
+ /** MIME type of the encoded output */
+ outputMime: string;
+ /** File extension of the encoded output */
+ extension: string;
+ /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */
+ category: string;
+}
+
+/**
+ * A container format definition for lossless re-muxing/re-encoding.
+ * Extends CustomFormat with a callback that decides whether ffmpeg needs to run
+ * at all (e.g. FLAC can skip if the source is already FLAC).
+ */
+export interface ContainerFormat extends Omit {
+ /**
+ * Returns true when the source blob must be passed through ffmpeg to produce
+ * the desired container. Return false to skip the ffmpeg step (the caller
+ * may still apply a lightweight metadata-strip pass instead).
+ */
+ needsTranscode: (blob: Blob) => Promise;
+}
+
+export const customFormats: CustomFormat[] = [
+ {
+ displayName: 'MP3 320kbps',
+ internalName: 'FFMPEG_MP3_320',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'MP3 256kbps',
+ internalName: 'FFMPEG_MP3_256',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'MP3 128kbps',
+ internalName: 'FFMPEG_MP3_128',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'],
+ outputFilename: 'output.mp3',
+ outputMime: 'audio/mpeg',
+ extension: 'mp3',
+ category: 'MP3',
+ },
+ {
+ displayName: 'OGG 320kbps',
+ internalName: 'FFMPEG_OGG_320',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '320k',
+ '-minrate',
+ '320k',
+ '-maxrate',
+ '320k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'OGG 256kbps',
+ internalName: 'FFMPEG_OGG_256',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '256k',
+ '-minrate',
+ '256k',
+ '-maxrate',
+ '256k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'OGG 128kbps',
+ internalName: 'FFMPEG_OGG_128',
+ ffmpegArgs: [
+ '-map_metadata',
+ '-1',
+ '-c:a',
+ 'libvorbis',
+ '-b:a',
+ '128k',
+ '-minrate',
+ '128k',
+ '-maxrate',
+ '128k',
+ ],
+ outputFilename: 'output.ogg',
+ outputMime: 'audio/ogg',
+ extension: 'ogg',
+ category: 'OGG',
+ },
+ {
+ displayName: 'AAC 256kbps',
+ internalName: 'FFMPEG_AAC_256',
+ ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ category: 'AAC',
+ },
+];
+
+/**
+ * Container format definitions for lossless re-muxing. Each entry describes
+ * the ffmpeg arguments needed to produce that container and provides a
+ * `needsTranscode` predicate so callers can skip the ffmpeg step when the
+ * source is already in the correct container.
+ */
+export const containerFormats: ContainerFormat[] = [
+ {
+ displayName: 'FLAC',
+ internalName: 'flac',
+ ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
+ outputFilename: 'output.flac',
+ outputMime: 'audio/flac',
+ extension: 'flac',
+ // Only transcode when the source is NOT already a FLAC file.
+ needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac',
+ },
+ {
+ displayName: 'FLAC - Max Compression',
+ internalName: 'flac_max',
+ // `-compression_level 12` is the highest FLAC compression level; audio
+ // data is bit-identical to the source — only the compressed size changes.
+ ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'],
+ outputFilename: 'output.flac',
+ outputMime: 'audio/flac',
+ extension: 'flac',
+ needsTranscode: async () => true,
+ },
+ {
+ displayName: 'Apple Lossless',
+ internalName: 'alac',
+ ffmpegArgs: ['-c:a', 'alac'],
+ outputFilename: 'output.m4a',
+ outputMime: 'audio/mp4',
+ extension: 'm4a',
+ needsTranscode: async () => true,
+ },
+ {
+ displayName: "Don't change",
+ internalName: 'nochange',
+ ffmpegArgs: [],
+ outputFilename: '',
+ outputMime: '',
+ extension: '',
+ needsTranscode: async () => false,
+ },
+];
+
+/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */
+export function isCustomFormat(quality: string): boolean {
+ return getCustomFormat(quality) !== undefined;
+}
+
+/** Looks up a custom format by its internal name, or returns undefined */
+export function getCustomFormat(internalName: string): CustomFormat | undefined {
+ return customFormats.find((f) => f.internalName === internalName);
+}
+
+/** Looks up a container format by its internal name, or returns undefined */
+export function getContainerFormat(internalName: string): ContainerFormat | undefined {
+ return containerFormats.find((f) => f.internalName === internalName);
+}
+
+/**
+ * Transcodes an audio blob using the specified custom format via ffmpeg.
+ * Throws if ffmpeg fails during transcoding.
+ */
+export async function transcodeWithCustomFormat(
+ audioBlob: Blob,
+ format: CustomFormat,
+ onProgress: ((progress: ProgressEvent) => void) | null = null,
+ signal: AbortSignal | null = null
+): Promise {
+ return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
+}
+
+/**
+ * Re-muxes / re-encodes an audio blob into the specified container format via ffmpeg.
+ * Throws if ffmpeg fails during transcoding.
+ */
+export async function transcodeWithContainerFormat(
+ audioBlob: Blob,
+ format: ContainerFormat,
+ onProgress: ((progress: ProgressEvent) => void) | null = null,
+ signal: AbortSignal | null = null
+): Promise {
+ return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
+}
diff --git a/js/settings.js b/js/settings.js
index e9893ff..d562de4 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -41,6 +41,8 @@ import { getButterchurnPresets } from './visualizers/butterchurn.js';
import { db } from './db.js';
import { authManager } from './accounts/auth.js';
import { syncManager } from './accounts/pocketbase.js';
+import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js';
+import { containerFormats, customFormats } from './ffmpegFormats.ts';
export function initializeSettings(scrobbler, player, api, ui) {
// Restore last active settings tab
@@ -799,6 +801,63 @@ export function initializeSettings(scrobbler, player, api, ui) {
// Download Quality setting
const downloadQualitySetting = document.getElementById('download-quality-setting');
if (downloadQualitySetting) {
+ // Assign categories to the static (native) options already in the HTML
+ const staticCategories = {
+ HI_RES_LOSSLESS: 'Lossless',
+ LOSSLESS: 'Lossless',
+ HIGH: 'AAC',
+ LOW: 'AAC',
+ };
+
+ // Collect static options first (preserving their original order)
+ const allOptions = Array.from(downloadQualitySetting.options).map((opt) => ({
+ value: opt.value,
+ text: opt.textContent,
+ category: staticCategories[opt.value] || 'Other',
+ }));
+
+ // Append custom (ffmpeg-transcoded) format options
+ for (const fmt of customFormats) {
+ allOptions.push({ value: fmt.internalName, text: fmt.displayName, category: fmt.category });
+ }
+
+ // Sort by category order first, then by bitrate descending within each category
+ // so higher-quality options always appear before lower-quality ones.
+ // Options without an explicit kbps value (lossless) use Infinity so they
+ // sort to the top; ties fall back to display-name descending.
+ const getBitrate = (text) => {
+ const m = text.match(/(\d+)\s*kbps/i);
+ return m ? parseInt(m[1], 10) : Infinity;
+ };
+ const categoryOrder = ['Lossless', 'AAC', 'MP3', 'OGG'];
+ allOptions.sort((a, b) => {
+ const ai = categoryOrder.indexOf(a.category);
+ const bi = categoryOrder.indexOf(b.category);
+ const categoryDiff = (ai === -1 ? categoryOrder.length : ai) - (bi === -1 ? categoryOrder.length : bi);
+ if (categoryDiff !== 0) return categoryDiff;
+ const bitrateA = getBitrate(a.text);
+ const bitrateB = getBitrate(b.text);
+ if (bitrateA !== bitrateB) return bitrateB - bitrateA;
+ return b.text.localeCompare(a.text);
+ });
+
+ // Rebuild the select with optgroup elements per category
+ downloadQualitySetting.innerHTML = '';
+ let currentGroup = null;
+ let currentCategory = null;
+ for (const opt of allOptions) {
+ if (opt.category !== currentCategory) {
+ currentCategory = opt.category;
+ currentGroup = document.createElement('optgroup');
+ currentGroup.label = opt.category;
+ downloadQualitySetting.appendChild(currentGroup);
+ }
+ const option = document.createElement('option');
+ option.value = opt.value;
+ option.textContent = opt.text;
+ currentGroup.appendChild(option);
+ }
+
downloadQualitySetting.value = downloadQualitySettings.getQuality();
downloadQualitySetting.addEventListener('change', (e) => {
@@ -808,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
const losslessContainerSetting = document.getElementById('lossless-container-setting');
if (losslessContainerSetting) {
+ for (const { internalName, displayName } of containerFormats) {
+ const option = document.createElement('option');
+ option.value = internalName;
+ option.textContent = displayName;
+ losslessContainerSetting.appendChild(option);
+ }
+
losslessContainerSetting.value = losslessContainerSettings.getContainer();
losslessContainerSetting.addEventListener('change', (e) => {
diff --git a/js/storage.js b/js/storage.js
index dc1cbc8..de7c470 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -539,7 +539,13 @@ export const downloadQualitySettings = {
STORAGE_KEY: 'download-quality',
getQuality() {
try {
- return localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS';
+ const stored = localStorage.getItem(this.STORAGE_KEY) || 'HI_RES_LOSSLESS';
+ // Migrate legacy value to renamed format
+ if (stored === 'MP3_320') {
+ this.setQuality('FFMPEG_MP3_320');
+ return 'FFMPEG_MP3_320';
+ }
+ return stored;
} catch {
return 'HI_RES_LOSSLESS';
}
diff --git a/js/utils.js b/js/utils.js
index 3b5bf8f..728ddb3 100644
--- a/js/utils.js
+++ b/js/utils.js
@@ -108,6 +108,17 @@ export const detectAudioFormat = (view, mimeType = '') => {
return 'flac';
}
+ // Check for OGG signature: "OggS" (0x4F 0x67 0x67 0x53)
+ if (
+ view.byteLength >= 4 &&
+ view.getUint8(0) === 0x4f && // O
+ view.getUint8(1) === 0x67 && // g
+ view.getUint8(2) === 0x67 && // g
+ view.getUint8(3) === 0x53 // S
+ ) {
+ return 'ogg';
+ }
+
// Check for MP4/M4A signature: "ftyp" at offset 4
if (
view.byteLength >= 8 &&
@@ -153,6 +164,7 @@ export const detectAudioFormat = (view, mimeType = '') => {
// Fallback to MIME type
if (mimeType === 'audio/flac') return 'flac';
+ if (mimeType === 'audio/ogg') return 'ogg';
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
@@ -177,8 +189,10 @@ export const getExtensionFromBlob = async (blob) => {
if (format) return format;
if (blob.type.includes('video')) return 'mp4';
- if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a';
- if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3';
+ if (mimeType === 'audio/flac') return 'flac';
+ if (mimeType === 'audio/ogg') return 'ogg';
+ if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
+ if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
return 'flac';
};
@@ -188,8 +202,6 @@ export const getExtensionForQuality = (quality) => {
case 'LOW':
case 'HIGH':
return 'm4a';
- case 'MP3_320':
- return 'mp3';
default:
return 'flac';
}