feat(downloads): add FLAC - Max Compression option and refactor transcoding logic

This commit is contained in:
Daniel 2026-03-11 19:13:35 +00:00
parent 2db782d74f
commit 7448ddce1e
6 changed files with 282 additions and 208 deletions

View file

@ -5117,11 +5117,7 @@
<span class="label">Lossless Container</span> <span class="label">Lossless Container</span>
<span class="description">Container format for lossless downloads</span> <span class="description">Container format for lossless downloads</span>
</div> </div>
<select id="lossless-container-setting"> <select id="lossless-container-setting"></select>
<option value="flac">FLAC</option>
<option value="alac">Apple Lossless</option>
<option value="nochange">Don't change</option>
</select>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<div class="info"> <div class="info">

View file

@ -12,9 +12,15 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { DashDownloader } from './dash-downloader.js'; import { DashDownloader } from './dash-downloader.js';
import { HlsDownloader } from './hls-downloader.js'; import { HlsDownloader } from './hls-downloader.js';
import { MP3EncodingError } from './mp3-encoder.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 { 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'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@ -1437,33 +1443,18 @@ export class LosslessAPI {
if (quality.endsWith('LOSSLESS')) { if (quality.endsWith('LOSSLESS')) {
try { try {
switch (losslessContainerSettings.getContainer()) { const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
case 'flac': if (containerFmt) {
if ((await getExtensionFromBlob(blob)) != 'flac') { if (await containerFmt.needsTranscode(blob)) {
blob = await ffmpeg( blob = await transcodeWithContainerFormat(
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(
blob, blob,
{ args: ['-c:a', 'alac'] }, containerFmt,
'output.m4a',
'audio/mp4',
onProgress, onProgress,
options.signal options.signal
); );
break; } else if ((await getExtensionFromBlob(blob)) == 'flac') {
default: blob = await rebuildFlacWithoutMetadata(blob);
break; }
} }
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {

View file

@ -1,148 +1,13 @@
import { ffmpeg } from './ffmpeg'; // Re-exports for backwards compatibility canonical source is ffmpegFormats.ts
export {
export interface ProgressEvent { type ProgressEvent,
stage?: string; type CustomFormat,
message?: string; type ContainerFormat,
progress?: number; customFormats,
receivedBytes?: number; containerFormats,
totalBytes?: number; isCustomFormat,
} getCustomFormat,
getContainerFormat,
export interface CustomFormat { transcodeWithCustomFormat,
/** Human-readable label shown in the UI */ transcodeWithContainerFormat,
displayName: string; } from './ffmpegFormats';
/** 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<Blob> {
return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
}

View file

@ -16,8 +16,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
import { DashDownloader } from './dash-downloader.js'; import { DashDownloader } from './dash-downloader.js';
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; import { loadFfmpeg } from './ffmpeg.js';
import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; import {
isCustomFormat,
getCustomFormat,
transcodeWithCustomFormat,
getContainerFormat,
transcodeWithContainerFormat,
} from './ffmpegFormats.ts';
const downloadTasks = new Map(); const downloadTasks = new Map();
const bulkDownloadTasks = new Map(); const bulkDownloadTasks = new Map();
@ -455,33 +461,13 @@ async function downloadTrackBlob(
if (quality.endsWith('LOSSLESS')) { if (quality.endsWith('LOSSLESS')) {
try { try {
switch (losslessContainerSettings.getContainer()) { const containerFmt = getContainerFormat(losslessContainerSettings.getContainer());
case 'flac': if (containerFmt) {
if ((await getExtensionFromBlob(blob)) != 'flac') { if (await containerFmt.needsTranscode(blob)) {
blob = await ffmpeg( blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
blob, } else if ((await getExtensionFromBlob(blob)) == 'flac') {
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, blob = await rebuildFlacWithoutMetadata(blob);
'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;
} }
} catch (error) { } catch (error) {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {

229
js/ffmpegFormats.ts Normal file
View file

@ -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<CustomFormat, 'category'> {
/**
* 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<boolean>;
}
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<Blob> {
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<Blob> {
return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal);
}

View file

@ -42,7 +42,7 @@ import { db } from './db.js';
import { authManager } from './accounts/auth.js'; import { authManager } from './accounts/auth.js';
import { syncManager } from './accounts/pocketbase.js'; import { syncManager } from './accounts/pocketbase.js';
import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.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) { export function initializeSettings(scrobbler, player, api, ui) {
// Restore last active settings tab // Restore last active settings tab
@ -867,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
const losslessContainerSetting = document.getElementById('lossless-container-setting'); const losslessContainerSetting = document.getElementById('lossless-container-setting');
if (losslessContainerSetting) { 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.value = losslessContainerSettings.getContainer();
losslessContainerSetting.addEventListener('change', (e) => { losslessContainerSetting.addEventListener('change', (e) => {