From 7448ddce1eef81b092dbfa5020b5d099c6caa226 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:13:35 +0000 Subject: [PATCH] feat(downloads): add FLAC - Max Compression option and refactor transcoding logic --- index.html | 6 +- js/api.js | 41 ++++---- js/customFormats.ts | 161 +++---------------------------- js/downloads.js | 44 +++------ js/ffmpegFormats.ts | 229 ++++++++++++++++++++++++++++++++++++++++++++ js/settings.js | 9 +- 6 files changed, 282 insertions(+), 208 deletions(-) create mode 100644 js/ffmpegFormats.ts diff --git a/index.html b/index.html index d4c5f9a..e837995 100644 --- a/index.html +++ b/index.html @@ -5117,11 +5117,7 @@ Lossless Container Container format for lossless downloads - +
diff --git a/js/api.js b/js/api.js index 3bdfbb6..cf71f83 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 718fba6..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 { 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(); @@ -455,33 +461,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) => {