From 3ef50cb6cef1908e77f3ea054adf954b8f5b0ebe Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 04:50:45 +0000 Subject: [PATCH 1/8] fix(downloads): fix m3u generation artist [object Object] bug and mismatched file extensions --- js/downloads.js | 623 ++++++++++++++++++++------------------- js/playlist-generator.js | 60 +++- 2 files changed, 357 insertions(+), 326 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index ae947b2..bbb9469 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -112,10 +112,6 @@ function buildZipTrackPath(rootFolder, filename, separateByDisc, discNumber = 1) return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`; } -function getPlaylistAudioExtension(quality) { - return quality === 'LOW' || quality === 'HIGH' ? 'm4a' : 'flac'; -} - function createDownloadNotification() { if (!downloadNotificationContainer) { downloadNotificationContainer = document.createElement('div'); @@ -505,43 +501,67 @@ async function bulkDownloadToZipStream( yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; } - // Generate playlist files first const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const playlistAudioExtension = getPlaylistAudioExtension(quality); const discLayout = await createDiscLayoutContext(tracks, api); const separateByDisc = discLayout.separateByDisc; - const playlistPathResolver = separateByDisc - ? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` - : null; - if (playlistSettings.shouldGenerateM3U()) { - const m3uContent = generateM3U( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, - lastModified: new Date(), - input: m3uContent, - }; - } + // Download tracks, yielding each immediately and collecting actual paths for playlist generation + const trackPaths = []; + for (let i = 0; i < tracks.length; i++) { + if (signal.aborted) break; + const track = tracks[i]; + const trackTitle = getTrackTitle(track); - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); + + try { + const { blob, extension } = await downloadTrackBlob( + track, + quality, + api, + null, + signal, + (p) => { + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); + }, + coverBlob + ); + const filename = buildTrackFilename(track, quality, extension); + const discNumber = discLayout.resolveDiscNumber(i); + const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; + + console.log(`[Playlist] Track ${i + 1}: ${discPath}`); + trackPaths.push(discPath); + + yield { + name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), + lastModified: new Date(), + input: blob, + }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); + } } if (playlistSettings.shouldGenerateNFO()) { @@ -573,56 +593,37 @@ async function bulkDownloadToZipStream( }; } - // Download tracks - for (let i = 0; i < tracks.length; i++) { - if (signal.aborted) break; - const track = tracks[i]; - const trackTitle = getTrackTitle(track); + // Generate m3u/m3u8 last, using actual track paths collected during download + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); - - try { - const { blob, extension } = await downloadTrackBlob( - track, - quality, - api, - null, - signal, - (p) => { - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); - }, - coverBlob - ); - const filename = buildTrackFilename(track, quality, extension); - const discNumber = discLayout.resolveDiscNumber(i); - yield { - name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), - lastModified: new Date(), - input: blob, - }; - - if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { - try { - const lyricsData = await lyricsManager.fetchLyrics(track.id, track); - if (lyricsData) { - const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); - if (lrcContent) { - const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - yield { - name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), - lastModified: new Date(), - input: lrcContent, - }; - } - } - } catch { - /* ignore */ - } - } - } catch (err) { - if (err.name === 'AbortError') throw err; - console.error(`Failed to download track ${trackTitle}:`, err); - } + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; } } @@ -657,43 +658,67 @@ async function bulkDownloadToZipBlob( yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; } - // Generate playlist files first const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const playlistAudioExtension = getPlaylistAudioExtension(quality); const discLayout = await createDiscLayoutContext(tracks, api); const separateByDisc = discLayout.separateByDisc; - const playlistPathResolver = separateByDisc - ? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` - : null; - if (playlistSettings.shouldGenerateM3U()) { - const m3uContent = generateM3U( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, - lastModified: new Date(), - input: m3uContent, - }; - } + // Download tracks, yielding each immediately and collecting actual paths for playlist generation + const trackPaths = []; + for (let i = 0; i < tracks.length; i++) { + if (signal.aborted) break; + const track = tracks[i]; + const trackTitle = getTrackTitle(track); - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); + + try { + const { blob, extension } = await downloadTrackBlob( + track, + quality, + api, + null, + signal, + (p) => { + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); + }, + coverBlob + ); + const filename = buildTrackFilename(track, quality, extension); + const discNumber = discLayout.resolveDiscNumber(i); + const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; + + console.log(`[Playlist] Track ${i + 1}: ${discPath}`); + trackPaths.push(discPath); + + yield { + name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), + lastModified: new Date(), + input: blob, + }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); + } } if (playlistSettings.shouldGenerateNFO()) { @@ -725,56 +750,37 @@ async function bulkDownloadToZipBlob( }; } - // Download tracks - for (let i = 0; i < tracks.length; i++) { - if (signal.aborted) break; - const track = tracks[i]; - const trackTitle = getTrackTitle(track); + // Generate m3u/m3u8 last, using actual track paths collected during download + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); - - try { - const { blob, extension } = await downloadTrackBlob( - track, - quality, - api, - null, - signal, - (p) => { - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); - }, - coverBlob - ); - const filename = buildTrackFilename(track, quality, extension); - const discNumber = discLayout.resolveDiscNumber(i); - yield { - name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), - lastModified: new Date(), - input: blob, - }; - - if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { - try { - const lyricsData = await lyricsManager.fetchLyrics(track.id, track); - if (lyricsData) { - const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); - if (lrcContent) { - const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - yield { - name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), - lastModified: new Date(), - input: lrcContent, - }; - } - } - } catch { - /* ignore */ - } - } - } catch (err) { - if (err.name === 'AbortError') throw err; - console.error(`Failed to download track ${trackTitle}:`, err); - } + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; } } @@ -810,43 +816,67 @@ async function bulkDownloadToZipNeutralino( yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; } - // Generate playlist files first const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const playlistAudioExtension = getPlaylistAudioExtension(quality); const discLayout = await createDiscLayoutContext(tracks, api); const separateByDisc = discLayout.separateByDisc; - const playlistPathResolver = separateByDisc - ? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` - : null; - if (playlistSettings.shouldGenerateM3U()) { - const m3uContent = generateM3U( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, - lastModified: new Date(), - input: m3uContent, - }; - } + // Download tracks, yielding each immediately and collecting actual paths for playlist generation + const trackPaths = []; + for (let i = 0; i < tracks.length; i++) { + if (signal.aborted) break; + const track = tracks[i]; + const trackTitle = getTrackTitle(track); - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); + + try { + const { blob, extension } = await downloadTrackBlob( + track, + quality, + api, + null, + signal, + (p) => { + updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); + }, + coverBlob + ); + const filename = buildTrackFilename(track, quality, extension); + const discNumber = discLayout.resolveDiscNumber(i); + const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; + + console.log(`[Playlist] Track ${i + 1}: ${discPath}`); + trackPaths.push(discPath); + + yield { + name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), + lastModified: new Date(), + input: blob, + }; + + if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { + try { + const lyricsData = await lyricsManager.fetchLyrics(track.id, track); + if (lyricsData) { + const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); + if (lrcContent) { + const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); + yield { + name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), + lastModified: new Date(), + input: lrcContent, + }; + } + } + } catch { + /* ignore */ + } + } + } catch (err) { + if (err.name === 'AbortError') throw err; + console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); + } } if (playlistSettings.shouldGenerateNFO()) { @@ -878,56 +908,37 @@ async function bulkDownloadToZipNeutralino( }; } - // Download tracks - for (let i = 0; i < tracks.length; i++) { - if (signal.aborted) break; - const track = tracks[i]; - const trackTitle = getTrackTitle(track); + // Generate m3u/m3u8 last, using actual track paths collected during download + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle); - - try { - const { blob, extension } = await downloadTrackBlob( - track, - quality, - api, - null, - signal, - (p) => { - updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p); - }, - coverBlob - ); - const filename = buildTrackFilename(track, quality, extension); - const discNumber = discLayout.resolveDiscNumber(i); - yield { - name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber), - lastModified: new Date(), - input: blob, - }; - - if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { - try { - const lyricsData = await lyricsManager.fetchLyrics(track.id, track); - if (lyricsData) { - const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); - if (lrcContent) { - const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - yield { - name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber), - lastModified: new Date(), - input: lrcContent, - }; - } - } - } catch { - /* ignore */ - } - } - } catch (err) { - if (err.name === 'AbortError') throw err; - console.error(`Failed to download track ${trackTitle}:`, err); - } + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8( + metadata || { title: folderName }, + tracks, + useRelativePaths, + null, + 'flac', + trackPaths + ); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; } } @@ -1155,72 +1166,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality // Generate playlist files for each album const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const playlistAudioExtension = getPlaylistAudioExtension(quality); const discLayout = await createDiscLayoutContext(tracks, api); const separateByDisc = discLayout.separateByDisc; - const playlistPathResolver = separateByDisc - ? (_track, filename, index) => - `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}` - : null; - - if (playlistSettings.shouldGenerateM3U()) { - const m3uContent = generateM3U( - fullAlbum, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`, - lastModified: new Date(), - input: m3uContent, - }; - } - - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - fullAlbum, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; - } - - if (playlistSettings.shouldGenerateNFO()) { - const nfoContent = generateNFO(fullAlbum, tracks, 'album'); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(fullAlbum, tracks, 'album'); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - if (playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(fullAlbum.title)}.flac`; - const cueContent = generateCUE(fullAlbum, tracks, audioFilename); - yield { - name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.cue`, - lastModified: new Date(), - input: cueContent, - }; - } + // Download tracks, yielding each immediately and collecting actual paths for playlist generation + const trackPaths = []; for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; if (signal.aborted) break; @@ -1236,6 +1186,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality ); const filename = buildTrackFilename(track, quality, extension); const discNumber = discLayout.resolveDiscNumber(i); + const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; + + console.log(`[Playlist] Track ${i + 1}: ${discPath}`); + trackPaths.push(discPath); + yield { name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber), lastModified: new Date(), @@ -1268,8 +1223,56 @@ export async function downloadDiscography(artist, selectedReleases, api, quality } catch (err) { if (err.name === 'AbortError') throw err; console.error(`Failed to download track ${track.title}:`, err); + trackPaths.push(null); } } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(fullAlbum, tracks, 'album'); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(fullAlbum, tracks, 'album'); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + if (playlistSettings.shouldGenerateCUE()) { + const audioFilename = `${sanitizeForFilename(fullAlbum.title)}.flac`; + const cueContent = generateCUE(fullAlbum, tracks, audioFilename); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.cue`, + lastModified: new Date(), + input: cueContent, + }; + } + + // Generate m3u/m3u8 last, using actual track paths collected during download + if (playlistSettings.shouldGenerateM3U()) { + const m3uContent = generateM3U(fullAlbum, tracks, useRelativePaths, null, 'flac', trackPaths); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`, + lastModified: new Date(), + input: m3uContent, + }; + } + + if (playlistSettings.shouldGenerateM3U8()) { + const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths, null, 'flac', trackPaths); + yield { + name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`, + lastModified: new Date(), + input: m3u8Content, + }; + } } catch (error) { if (error.name === 'AbortError') throw error; console.error(`Failed to download album ${album.title}:`, error); diff --git a/js/playlist-generator.js b/js/playlist-generator.js index 3b22b21..a6b0e4e 100644 --- a/js/playlist-generator.js +++ b/js/playlist-generator.js @@ -4,12 +4,20 @@ import { sanitizeForFilename } from './utils.js'; * Generates M3U playlist content * @param {Object} playlist - Playlist metadata (title, artist, etc.) * @param {Array} tracks - Array of track objects - * @param {boolean} useRelativePaths - Whether to use relative paths - * @param {Function|null} pathResolver - Optional resolver for per-track relative path - * @param {string} audioExtension - Audio file extension used in generated paths + * @param {boolean} _useRelativePaths - Unused; kept for API compatibility + * @param {Function|null} pathResolver - Optional resolver for per-track relative path (used when trackPaths is null) + * @param {string} audioExtension - Audio file extension for generated paths (used when trackPaths is null) + * @param {Array|null} trackPaths - Actual per-track resolved paths; when provided, overrides pathResolver/audioExtension * @returns {string} M3U content */ -export function generateM3U(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') { +export function generateM3U( + playlist, + tracks, + _useRelativePaths = true, + pathResolver = null, + audioExtension = 'flac', + trackPaths = null +) { let content = '#EXTM3U\n'; if (playlist.title) { @@ -17,13 +25,16 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol } if (playlist.artist) { - content += `#ARTIST:${playlist.artist}\n`; + content += `#ARTIST:${playlist.artist?.name || playlist.artist}\n`; } const date = new Date().toISOString().split('T')[0]; content += `#DATE:${date}\n\n`; tracks.forEach((track, index) => { + const resolvedPath = trackPaths ? trackPaths[index] : null; + if (trackPaths && !resolvedPath) return; + const duration = Math.round(track.duration || 0); const artists = getTrackArtists(track); const title = track.title || 'Unknown Title'; @@ -31,9 +42,12 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol content += `#EXTINF:${duration},${displayName}\n`; - const filename = getTrackFilename(track, index + 1, audioExtension); - const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename; - const path = useRelativePaths ? relativePath : relativePath; + const path = + resolvedPath ?? + (() => { + const filename = getTrackFilename(track, index + 1, audioExtension); + return typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename; + })(); content += `${path}\n\n`; }); @@ -45,12 +59,20 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol * Generates M3U8 playlist content (UTF-8 extended) * @param {Object} playlist - Playlist metadata * @param {Array} tracks - Array of track objects - * @param {boolean} useRelativePaths - Whether to use relative paths - * @param {Function|null} pathResolver - Optional resolver for per-track relative path - * @param {string} audioExtension - Audio file extension used in generated paths + * @param {boolean} _useRelativePaths - Unused; kept for API compatibility + * @param {Function|null} pathResolver - Optional resolver for per-track relative path (used when trackPaths is null) + * @param {string} audioExtension - Audio file extension for generated paths (used when trackPaths is null) + * @param {Array|null} trackPaths - Actual per-track resolved paths; when provided, overrides pathResolver/audioExtension * @returns {string} M3U8 content */ -export function generateM3U8(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') { +export function generateM3U8( + playlist, + tracks, + _useRelativePaths = true, + pathResolver = null, + audioExtension = 'flac', + trackPaths = null +) { let content = '#EXTM3U\n'; content += '#EXT-X-VERSION:3\n'; content += '#EXT-X-PLAYLIST-TYPE:VOD\n'; @@ -63,13 +85,16 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso } if (playlist.artist) { - content += `#ARTIST:${playlist.artist}\n`; + content += `#ARTIST:${playlist.artist?.name || playlist.artist}\n`; } const date = new Date().toISOString().split('T')[0]; content += `#DATE:${date}\n\n`; tracks.forEach((track, index) => { + const resolvedPath = trackPaths ? trackPaths[index] : null; + if (trackPaths && !resolvedPath) return; + const duration = Math.round(track.duration || 0); const artists = getTrackArtists(track); const title = track.title || 'Unknown Title'; @@ -77,9 +102,12 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso content += `#EXTINF:${duration}.000,${displayName}\n`; - const filename = getTrackFilename(track, index + 1, audioExtension); - const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename; - const path = useRelativePaths ? relativePath : relativePath; + const path = + resolvedPath ?? + (() => { + const filename = getTrackFilename(track, index + 1, audioExtension); + return typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename; + })(); content += `${path}\n\n`; }); From 8cf7979d4768d46eff67bd7d8d8de96df422d8f7 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:00:44 +0000 Subject: [PATCH 2/8] fix(downloads): cue generation now properly outputs correct tracks numbers and splits by disc --- js/downloads.js | 35 ++++++++++++++++++++++------------- js/playlist-generator.js | 35 +++++++++++++++++------------------ 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index bbb9469..a093fb9 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -584,13 +584,25 @@ async function bulkDownloadToZipStream( // For albums, generate CUE file if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(folderName)}.flac`; // Assume FLAC for CUE - const cueContent = generateCUE(metadata, tracks, audioFilename); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, - lastModified: new Date(), - input: cueContent, - }; + // Split tracks by volumeNumber and iterate those groups. + const tracksByVolume = Object.groupBy( + tracks.map((track, index) => ({ + ...track, + trackPath: trackPaths[index], + })), + (track) => String(getExplicitTrackDiscNumber(track) || 1) + ); + const multiDisc = Object.keys(tracksByVolume).length > 1; + + for (const [volumeNumber, tracks] of Object.entries(tracksByVolume)) { + const trackPaths = tracks.map((track) => track.trackPath); + const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`, + lastModified: new Date(), + input: cueContent, + }; + } } // Generate m3u/m3u8 last, using actual track paths collected during download @@ -741,8 +753,7 @@ async function bulkDownloadToZipBlob( // For albums, generate CUE file if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(folderName)}.flac`; // Assume FLAC for CUE - const cueContent = generateCUE(metadata, tracks, audioFilename); + const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths); yield { name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, lastModified: new Date(), @@ -899,8 +910,7 @@ async function bulkDownloadToZipNeutralino( // For albums, generate CUE file if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(folderName)}.flac`; // Assume FLAC for CUE - const cueContent = generateCUE(metadata, tracks, audioFilename); + const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths); yield { name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, lastModified: new Date(), @@ -1246,8 +1256,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality } if (playlistSettings.shouldGenerateCUE()) { - const audioFilename = `${sanitizeForFilename(fullAlbum.title)}.flac`; - const cueContent = generateCUE(fullAlbum, tracks, audioFilename); + const cueContent = generateCUE(fullAlbum, tracks, sanitizeForFilename(fullAlbum.title), trackPaths); yield { name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.cue`, lastModified: new Date(), diff --git a/js/playlist-generator.js b/js/playlist-generator.js index a6b0e4e..59eb011 100644 --- a/js/playlist-generator.js +++ b/js/playlist-generator.js @@ -120,40 +120,39 @@ export function generateM3U8( * Generates CUE sheet content for albums * @param {Object} album - Album metadata * @param {Array} tracks - Array of track objects - * @param {string} audioFilename - The main audio file name + * @param {string} _audioFilenameBase - Unused; kept for API compatibility + * @param {Array|null} trackPaths - Actual per-track resolved paths; when provided, each track gets its own FILE entry + * @param {string} audioExtension - Audio file extension for generated paths (used when trackPaths is null) * @returns {string} CUE content */ -export function generateCUE(album, tracks, audioFilename) { +export function generateCUE(album, tracks, _audioFilenameBase, trackPaths = null, audioExtension = 'flac') { const performer = album.artist?.name || album.artist || 'Unknown Artist'; const title = album.title || 'Unknown Album'; let content = `PERFORMER "${performer}"\n`; content += `TITLE "${title}"\n`; - // Add file reference - const fileExtension = audioFilename.split('.').pop().toUpperCase(); - content += `FILE "${audioFilename}" ${fileExtension}\n`; - - let currentTime = 0; - tracks.forEach((track, index) => { + const resolvedPath = trackPaths ? trackPaths[index] : null; + if (trackPaths && !resolvedPath) return; + const trackNumber = String(track.trackNumber || index + 1).padStart(2, '0'); const trackTitle = track.title || 'Unknown Track'; const trackPerformer = track.artist?.name || getTrackArtists(track) || performer; - const duration = track.duration || 0; + const path = + resolvedPath ?? + (() => { + const filename = getTrackFilename(track, index + 1, audioExtension); + return filename; + })(); + + const fileExtension = path.split('.').pop().toUpperCase(); + content += `FILE "${path}" ${fileExtension}\n`; content += ` TRACK ${trackNumber} AUDIO\n`; content += ` TITLE "${trackTitle}"\n`; content += ` PERFORMER "${trackPerformer}"\n`; - - // Calculate time in MM:SS:FF format (Frames = 75 per second) - const minutes = Math.floor(currentTime / 60); - const seconds = Math.floor(currentTime % 60); - const frames = Math.floor((currentTime % 1) * 75); - - content += ` INDEX 01 ${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(frames).padStart(2, '0')}\n`; - - currentTime += duration; + content += ` INDEX 01 00:00:00\n`; }); return content; From aa728f970ba59e225941d1043710c257edf75e40 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:10:38 +0000 Subject: [PATCH 3/8] feat(downloads): add discNumber template for file name. Also update disc number handling in download logic and metadata extraction --- index.html | 4 ++-- js/downloads.js | 37 ++++--------------------------------- js/metadata.js | 13 ++++++++++--- js/utils.js | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 38 deletions(-) diff --git a/index.html b/index.html index 5a253c3..e1c3223 100644 --- a/index.html +++ b/index.html @@ -5141,8 +5141,8 @@
+
From e1d7744ab2e9fcd5513b9f033b451f5e75673762 Mon Sep 17 00:00:00 2001
From: Daniel <790119+DanTheMan827@users.noreply.github.com>
Date: Wed, 11 Mar 2026 04:46:33 +0000
Subject: [PATCH 5/8] fix: correct total tracks per disc and add total discs to
metadata for multi-disc albums
---
js/api.js | 49 +++++++++++++++++++++++
js/downloads.js | 101 ++++++++++++++++++++++++++++++++++++++++++++----
js/metadata.js | 3 +-
3 files changed, 145 insertions(+), 8 deletions(-)
diff --git a/js/api.js b/js/api.js
index fd56dc2..30c3d9f 100644
--- a/js/api.js
+++ b/js/api.js
@@ -1489,6 +1489,55 @@ export class LosslessAPI {
};
}
+ if (
+ track.album?.id &&
+ (track.album?.totalDiscs == null || track.album?.numberOfTracksOnDisc == null)
+ ) {
+ try {
+ // Broad disc-field resolver — mirrors getExplicitTrackDiscNumber in downloads.js
+ const resolveDiscNumber = (t) => {
+ const candidates = [
+ t.volumeNumber,
+ t.discNumber,
+ t.mediaNumber,
+ t.media_number,
+ t.volume,
+ t.disc,
+ t.disc_no,
+ t.discNo,
+ t.disc_number,
+ t.mediaMetadata?.discNumber,
+ ];
+ for (const c of candidates) {
+ const parsed = parseInt(c, 10);
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
+ }
+ return 1;
+ };
+
+ const albumData = await this.getAlbum(track.album.id);
+ if (albumData.tracks?.length > 0) {
+ const discTrackCounts = new Map();
+ let maxDiscNumber = 0;
+ for (const t of albumData.tracks) {
+ const dn = resolveDiscNumber(t);
+ discTrackCounts.set(dn, (discTrackCounts.get(dn) || 0) + 1);
+ if (dn > maxDiscNumber) maxDiscNumber = dn;
+ }
+ const totalDiscs = maxDiscNumber || 1;
+ const discNumber = resolveDiscNumber(track);
+ enrichedTrack.album = {
+ ...(enrichedTrack.album || {}),
+ totalDiscs: track.album?.totalDiscs ?? totalDiscs,
+ numberOfTracksOnDisc:
+ track.album?.numberOfTracksOnDisc ?? discTrackCounts.get(discNumber),
+ };
+ }
+ } catch (e) {
+ console.warn('Failed to fetch album for disc info:', e);
+ }
+ }
+
blob = await addMetadataToAudio(blob, enrichedTrack, this, quality, prefetchPromises);
}
}
diff --git a/js/downloads.js b/js/downloads.js
index ae947b2..bd05f19 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -103,6 +103,63 @@ async function createDiscLayoutContext(tracks, api) {
return { separateByDisc: false, resolveDiscNumber: () => 1 };
}
+async function computeDiscInfo(tracks, api = null) {
+ // First pass: collect explicit disc numbers from the raw track objects.
+ const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track));
+ const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
+
+ let resolvedDiscNumbers = explicitDiscNumbers;
+
+ // Some providers omit disc fields in the album payload. When we can't
+ // distinguish discs from the raw data and an API instance is provided,
+ // hydrate missing disc numbers via full-track metadata (mirrors the logic
+ // in createDiscLayoutContext).
+ if (explicitDistinct.size <= 1 && api) {
+ const hydratedDiscNumbers = await Promise.all(
+ tracks.map(async (track, index) => {
+ if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
+ try {
+ const fullTrack = await api.getTrackMetadata(track.id);
+ return getExplicitTrackDiscNumber(fullTrack);
+ } catch {
+ return null;
+ }
+ })
+ );
+ const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean));
+ if (hydratedDistinct.size > 1) {
+ resolvedDiscNumbers = hydratedDiscNumbers;
+ }
+ }
+
+ const tracksPerDisc = new Map();
+ let maxDiscNumber = 0;
+ for (let i = 0; i < tracks.length; i++) {
+ const discNumber = resolvedDiscNumbers[i] || 1;
+ tracksPerDisc.set(discNumber, (tracksPerDisc.get(discNumber) || 0) + 1);
+ if (discNumber > maxDiscNumber) {
+ maxDiscNumber = discNumber;
+ }
+ }
+
+ return { totalDiscs: maxDiscNumber || 1, tracksPerDisc, resolvedDiscNumbers };
+}
+
+async function annotateTracksWithDiscInfo(tracks, api = null) {
+ const { totalDiscs, tracksPerDisc, resolvedDiscNumbers } = await computeDiscInfo(tracks, api);
+ return tracks.map((track, index) => {
+ const discNumber = resolvedDiscNumbers[index] || 1;
+ return {
+ ...track,
+ album: {
+ ...(track.album || {}),
+ totalDiscs,
+ numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
+ },
+ };
+ });
+}
+
function getDiscFolderName(discNumber) {
return `Disc ${discNumber}`;
}
@@ -321,15 +378,24 @@ async function downloadTrackBlob(
// Non-fatal: continue with best available track payload
}
- if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
+ if (enrichedTrack.album?.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
- if (albumData.album) {
+ if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
enrichedTrack.album = {
...enrichedTrack.album,
...albumData.album,
};
}
+ if (albumData.tracks?.length > 0) {
+ const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
+ const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
+ enrichedTrack.album = {
+ ...enrichedTrack.album,
+ totalDiscs,
+ numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
+ };
+ }
} catch (error) {
console.warn('Failed to fetch album data for metadata:', error);
}
@@ -1090,7 +1156,17 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
});
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
- await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'album', album.title, coverBlob, album);
+ await startBulkDownload(
+ await annotateTracksWithDiscInfo(tracks, api),
+ folderName,
+ api,
+ quality,
+ lyricsManager,
+ 'album',
+ album.title,
+ coverBlob,
+ album
+ );
}
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
@@ -1132,7 +1208,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
try {
- const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
+ const { album: fullAlbum, tracks: rawTracks } = await api.getAlbum(album.id);
+ const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
const releaseDateStr =
fullAlbum.releaseDate ||
@@ -1303,7 +1380,8 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
if (signal.aborted) break;
const album = selectedReleases[albumIndex];
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
- const { tracks } = await api.getAlbum(album.id);
+ const { tracks: rawTracks } = await api.getAlbum(album.id);
+ const tracks = await annotateTracksWithDiscInfo(rawTracks, api);
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
}
completeBulkDownload(notification, true);
@@ -1447,15 +1525,24 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
// Continue with available track payload
}
- if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
+ if (enrichedTrack.album?.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
- if (albumData.album) {
+ if (albumData.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist)) {
enrichedTrack.album = {
...enrichedTrack.album,
...albumData.album,
};
}
+ if (albumData.tracks?.length > 0) {
+ const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api);
+ const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1;
+ enrichedTrack.album = {
+ ...enrichedTrack.album,
+ totalDiscs,
+ numberOfTracksOnDisc: tracksPerDisc.get(discNumber),
+ };
+ }
} catch (error) {
console.warn('Failed to fetch album data for metadata:', error);
}
diff --git a/js/metadata.js b/js/metadata.js
index 76e24b9..93c2e94 100644
--- a/js/metadata.js
+++ b/js/metadata.js
@@ -48,7 +48,8 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
data.albumArtist = track.album?.artist?.name || track.artist?.name;
data.trackNumber = track.trackNumber;
data.discNumber = track.volumeNumber ?? track.discNumber;
- data.totalTracks = track.album.numberOfTracks;
+ data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks;
+ data.totalDiscs = track.album.totalDiscs;
data.copyright = track.copyright;
data.isrc = track.isrc;
data.explicit = Boolean(track.explicit);
From 2db782d74f72e1d7083253316d86277fd180635e Mon Sep 17 00:00:00 2001
From: Daniel <790119+DanTheMan827@users.noreply.github.com>
Date: Wed, 11 Mar 2026 04:49:08 +0000
Subject: [PATCH 6/8] feat(downloads): add custom download formats
---
index.html | 1 -
js/api.js | 40 +++++++-----
js/customFormats.ts | 148 ++++++++++++++++++++++++++++++++++++++++++++
js/downloads.js | 15 +++--
js/settings.js | 58 +++++++++++++++++
js/storage.js | 8 ++-
js/utils.js | 20 ++++--
7 files changed, 262 insertions(+), 28 deletions(-)
create mode 100644 js/customFormats.ts
diff --git a/index.html b/index.html
index 5a253c3..d4c5f9a 100644
--- a/index.html
+++ b/index.html
@@ -5108,7 +5108,6 @@
diff --git a/js/api.js b/js/api.js
index 30c3d9f..3bdfbb6 100644
--- a/js/api.js
+++ b/js/api.js
@@ -11,9 +11,10 @@ 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 { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js';
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
+import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts';
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
@@ -1294,8 +1295,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,18 +1417,21 @@ 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;
}
}
@@ -1559,7 +1563,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..5d9bb7c
--- /dev/null
+++ b/js/customFormats.ts
@@ -0,0 +1,148 @@
+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