Merge pull request #298 from DanTheMan827/copilot/add-custom-download-formats
Add custom ffmpeg download formats; consolidate transcode logic into customFormats.ts
This commit is contained in:
commit
eda53f29c1
8 changed files with 393 additions and 84 deletions
|
|
@ -5117,7 +5117,6 @@
|
||||||
<select id="download-quality-setting">
|
<select id="download-quality-setting">
|
||||||
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
<option value="HI_RES_LOSSLESS">Hi-Res FLAC (24-bit)</option>
|
||||||
<option value="LOSSLESS">FLAC (Lossless)</option>
|
<option value="LOSSLESS">FLAC (Lossless)</option>
|
||||||
<option value="MP3_320">MP3 320kbps</option>
|
|
||||||
<option value="HIGH">AAC 320kbps</option>
|
<option value="HIGH">AAC 320kbps</option>
|
||||||
<option value="LOW">AAC 96kbps</option>
|
<option value="LOW">AAC 96kbps</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
@ -5127,11 +5126,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">
|
||||||
|
|
|
||||||
57
js/api.js
57
js/api.js
|
|
@ -11,9 +11,16 @@ import { APICache } from './cache.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
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 { encodeToMp3, MP3EncodingError } from './mp3-encoder.js';
|
import { MP3EncodingError } from './mp3-encoder.js';
|
||||||
import { ffmpeg, loadFfmpeg } 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,
|
||||||
|
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';
|
||||||
|
|
@ -1294,8 +1301,8 @@ export class LosslessAPI {
|
||||||
const isVideo = track?.type === 'video';
|
const isVideo = track?.type === 'video';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
||||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
let lookup;
|
let lookup;
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
|
|
@ -1416,10 +1423,12 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isVideo) {
|
if (!isVideo) {
|
||||||
// Convert to MP3 320kbps if requested
|
// Transcode to custom format if requested
|
||||||
if (quality === 'MP3_320') {
|
if (isCustomFormat(quality)) {
|
||||||
|
const format = getCustomFormat(quality);
|
||||||
|
if (format) {
|
||||||
try {
|
try {
|
||||||
blob = await encodeToMp3(blob, onProgress, options.signal);
|
blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal);
|
||||||
} catch (encodingError) {
|
} catch (encodingError) {
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
onProgress({
|
onProgress({
|
||||||
|
|
@ -1430,36 +1439,22 @@ export class LosslessAPI {
|
||||||
throw encodingError;
|
throw encodingError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
blob,
|
||||||
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
containerFmt,
|
||||||
'output.flac',
|
|
||||||
'audio/flac',
|
|
||||||
onProgress,
|
onProgress,
|
||||||
options.signal
|
options.signal
|
||||||
);
|
);
|
||||||
} else {
|
} else if ((await getExtensionFromBlob(blob)) == 'flac') {
|
||||||
blob = await rebuildFlacWithoutMetadata(blob);
|
blob = await rebuildFlacWithoutMetadata(blob);
|
||||||
}
|
}
|
||||||
break;
|
|
||||||
case 'alac':
|
|
||||||
blob = await ffmpeg(
|
|
||||||
blob,
|
|
||||||
{ args: ['-c:a', 'alac'] },
|
|
||||||
'output.m4a',
|
|
||||||
'audio/mp4',
|
|
||||||
onProgress,
|
|
||||||
options.signal
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.name === 'AbortError') {
|
if (error?.name === 'AbortError') {
|
||||||
|
|
@ -1559,7 +1554,11 @@ export class LosslessAPI {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
console.error('Download failed:', 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;
|
throw error;
|
||||||
}
|
}
|
||||||
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
if (error.message === RATE_LIMIT_ERROR_MESSAGE) {
|
||||||
|
|
|
||||||
13
js/customFormats.ts
Normal file
13
js/customFormats.ts
Normal file
|
|
@ -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';
|
||||||
|
|
@ -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 { encodeToMp3 } from './mp3-encoder.js';
|
import { loadFfmpeg } from './ffmpeg.js';
|
||||||
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
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();
|
||||||
|
|
@ -355,8 +361,8 @@ async function downloadTrackBlob(
|
||||||
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
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
|
// Custom FFMPEG formats are not native TIDAL qualities; download LOSSLESS and transcode
|
||||||
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
const downloadQuality = isCustomFormat(quality) ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fullTrack = await api.getTrackMetadata(track.id);
|
const fullTrack = await api.getTrackMetadata(track.id);
|
||||||
|
|
@ -445,40 +451,23 @@ async function downloadTrackBlob(
|
||||||
blob = await response.blob();
|
blob = await response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to MP3 320kbps if requested
|
// Transcode to custom format if requested
|
||||||
if (quality === 'MP3_320') {
|
if (isCustomFormat(quality)) {
|
||||||
blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
|
const format = getCustomFormat(quality);
|
||||||
|
if (format) {
|
||||||
|
blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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'] },
|
|
||||||
'output.flac',
|
|
||||||
'audio/flac',
|
|
||||||
onProgress,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
blob = await rebuildFlacWithoutMetadata(blob);
|
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
229
js/ffmpegFormats.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,8 @@ import { getButterchurnPresets } from './visualizers/butterchurn.js';
|
||||||
import { db } from './db.js';
|
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 { 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
|
||||||
|
|
@ -799,6 +801,63 @@ export function initializeSettings(scrobbler, player, api, ui) {
|
||||||
// Download Quality setting
|
// Download Quality setting
|
||||||
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
const downloadQualitySetting = document.getElementById('download-quality-setting');
|
||||||
if (downloadQualitySetting) {
|
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.value = downloadQualitySettings.getQuality();
|
||||||
|
|
||||||
downloadQualitySetting.addEventListener('change', (e) => {
|
downloadQualitySetting.addEventListener('change', (e) => {
|
||||||
|
|
@ -808,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) => {
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,13 @@ export const downloadQualitySettings = {
|
||||||
STORAGE_KEY: 'download-quality',
|
STORAGE_KEY: 'download-quality',
|
||||||
getQuality() {
|
getQuality() {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return 'HI_RES_LOSSLESS';
|
return 'HI_RES_LOSSLESS';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
js/utils.js
20
js/utils.js
|
|
@ -108,6 +108,17 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
||||||
return 'flac';
|
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
|
// Check for MP4/M4A signature: "ftyp" at offset 4
|
||||||
if (
|
if (
|
||||||
view.byteLength >= 8 &&
|
view.byteLength >= 8 &&
|
||||||
|
|
@ -153,6 +164,7 @@ export const detectAudioFormat = (view, mimeType = '') => {
|
||||||
|
|
||||||
// Fallback to MIME type
|
// Fallback to MIME type
|
||||||
if (mimeType === 'audio/flac') return 'flac';
|
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/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
||||||
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
||||||
|
|
||||||
|
|
@ -177,8 +189,10 @@ export const getExtensionFromBlob = async (blob) => {
|
||||||
if (format) return format;
|
if (format) return format;
|
||||||
|
|
||||||
if (blob.type.includes('video')) return 'mp4';
|
if (blob.type.includes('video')) return 'mp4';
|
||||||
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'm4a';
|
if (mimeType === 'audio/flac') return 'flac';
|
||||||
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') return 'mp3';
|
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';
|
return 'flac';
|
||||||
};
|
};
|
||||||
|
|
@ -188,8 +202,6 @@ export const getExtensionForQuality = (quality) => {
|
||||||
case 'LOW':
|
case 'LOW':
|
||||||
case 'HIGH':
|
case 'HIGH':
|
||||||
return 'm4a';
|
return 'm4a';
|
||||||
case 'MP3_320':
|
|
||||||
return 'mp3';
|
|
||||||
default:
|
default:
|
||||||
return 'flac';
|
return 'flac';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue