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'; }