diff --git a/js/api.js b/js/api.js
index 8d9d719..25d6532 100644
--- a/js/api.js
+++ b/js/api.js
@@ -12,9 +12,15 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js';
import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.js';
-import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js';
+import { loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
-import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@@ -1437,33 +1443,18 @@ export class LosslessAPI {
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') {
diff --git a/js/customFormats.ts b/js/customFormats.ts
index 5d9bb7c..f8d5c2e 100644
--- a/js/customFormats.ts
+++ b/js/customFormats.ts
@@ -1,148 +1,13 @@
-import { ffmpeg } from './ffmpeg';
-
-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;
-}
-
-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',
- },
-];
-
-/** 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);
-}
-
-/**
- * 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-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 6494fda..dd4fce9 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 { ffmpeg, loadFfmpeg } from './ffmpeg.js';
-import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts';
+import { loadFfmpeg } from './ffmpeg.js';
+import {
+ isCustomFormat,
+ getCustomFormat,
+ transcodeWithCustomFormat,
+ getContainerFormat,
+ transcodeWithContainerFormat,
+} from './ffmpegFormats.ts';
const downloadTasks = new Map();
const bulkDownloadTasks = new Map();
@@ -389,33 +395,13 @@ async function downloadTrackBlob(
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 d18d5b9..da9cd81 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -42,7 +42,7 @@ 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 { customFormats } from './customFormats.ts';
+import { containerFormats, customFormats } from './ffmpegFormats.ts';
export function initializeSettings(scrobbler, player, api, ui) {
// Restore last active settings tab
@@ -867,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) => {