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 @@
Filename Template Customize download filenames. Available: {trackNumber}, {artist}, {title}, - {album}Customize download filenames. Available: {discNumber}, {trackNumber}, + {artist}, {title}, {album}
0 ? parsed : null; -} - -function getExplicitTrackDiscNumber(track) { - const candidates = [ - track?.volumeNumber, - track?.discNumber, - track?.mediaNumber, - track?.media_number, - track?.volume, - track?.disc, - track?.volume?.number, - track?.disc?.number, - track?.media?.number, - track?.disc, - track?.disc_no, - track?.discNo, - track?.disc_number, - track?.mediaMetadata?.discNumber, - ]; - - for (const candidate of candidates) { - const parsed = toPositiveInt(candidate); - if (parsed) return parsed; - } - return null; -} - async function createDiscLayoutContext(tracks, api) { if (!playlistSettings.shouldSeparateDiscsInZip()) { return { separateByDisc: false, resolveDiscNumber: () => 1 }; } - const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track)); + const explicitDiscNumbers = tracks.map((track) => getTrackDiscNumber(track)); const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean)); if (explicitDistinct.size > 1) { @@ -85,7 +56,7 @@ async function createDiscLayoutContext(tracks, api) { if (explicitDiscNumbers[index]) return explicitDiscNumbers[index]; try { const fullTrack = await api.getTrackMetadata(track.id); - return getExplicitTrackDiscNumber(fullTrack); + return getTrackDiscNumber(fullTrack); } catch { return null; } @@ -590,7 +561,7 @@ async function bulkDownloadToZipStream( ...track, trackPath: trackPaths[index], })), - (track) => String(getExplicitTrackDiscNumber(track) || 1) + (track) => String(getTrackDiscNumber(track) || 1) ); const multiDisc = Object.keys(tracksByVolume).length > 1; diff --git a/js/metadata.js b/js/metadata.js index 76e24b9..d8bfb61 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -1,4 +1,11 @@ -import { getCoverBlob, getTrackTitle, getFullArtistString, getMimeType, getTrackCoverId } from './utils.js'; +import { + getCoverBlob, + getTrackTitle, + getFullArtistString, + getMimeType, + getTrackCoverId, + getTrackDiscNumber, +} from './utils.js'; import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { doTimed, doTimedAsync } from './doTimed.ts'; import { managers } from './app.js'; @@ -35,7 +42,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet const { coverFetch, lyricsFetch } = prefetchPromises; /** - * @type {import("./taglib.worker.ts").TagLibMetadata} + * @type {import("./taglib.types.ts").TagLibMetadata} */ const data = {}; @@ -47,7 +54,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet data.albumTitle = track.album.title; data.albumArtist = track.album?.artist?.name || track.artist?.name; data.trackNumber = track.trackNumber; - data.discNumber = track.volumeNumber ?? track.discNumber; + data.discNumber = getTrackDiscNumber(track) || undefined; data.totalTracks = track.album.numberOfTracks; data.copyright = track.copyright; data.isrc = track.isrc; diff --git a/js/utils.js b/js/utils.js index 3b5bf8f..b9790e0 100644 --- a/js/utils.js +++ b/js/utils.js @@ -202,6 +202,7 @@ export const buildTrackFilename = (track, quality, extension = null) => { const artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist'; const data = { + discNumber: getTrackDiscNumber(track) || 1, trackNumber: track.trackNumber, artist: artistName, title: getTrackTitle(track), @@ -629,3 +630,43 @@ export function getTrackCoverId(track) { null ); } + +/** + * Converts a value to a positive integer. + * @param {*} value - The value to convert to a positive integer. + * @returns {number|null} The parsed positive integer, or null if the value is not a finite positive number. + */ +export function toPositiveInt(value) { + const parsed = parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + +/** + * Extracts the disc number from a track object by checking multiple possible property names. + * @param {Object} track - The track object to extract the disc number from. + * @returns {number|null} The disc number as a positive integer, or null if no valid disc number is found. + */ +export function getTrackDiscNumber(track) { + const candidates = [ + track?.volumeNumber, + track?.discNumber, + track?.mediaNumber, + track?.media_number, + track?.volume, + track?.disc, + track?.volume?.number, + track?.disc?.number, + track?.media?.number, + track?.disc, + track?.disc_no, + track?.discNo, + track?.disc_number, + track?.mediaMetadata?.discNumber, + ]; + + for (const candidate of candidates) { + const parsed = toPositiveInt(candidate); + if (parsed) return parsed; + } + return null; +} From 2a01fe3227df9b116a9341e4aa304b94c8c4ad5c Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:16:03 -0500 Subject: [PATCH 4/8] No change --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d739f5..30ea8da 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

Monochrome Logo 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 { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} diff --git a/js/downloads.js b/js/downloads.js index bd05f19..718fba6 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -16,8 +16,8 @@ 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 { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -355,8 +355,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,9 +445,12 @@ 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')) { diff --git a/js/settings.js b/js/settings.js index 3182372..d18d5b9 100644 --- a/js/settings.js +++ b/js/settings.js @@ -42,6 +42,7 @@ import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; +import { customFormats } from './customFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -800,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) => { 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'; } From 7448ddce1eef81b092dbfa5020b5d099c6caa226 Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Wed, 11 Mar 2026 19:13:35 +0000 Subject: [PATCH 7/8] feat(downloads): add FLAC - Max Compression option and refactor transcoding logic --- index.html | 6 +- js/api.js | 41 ++++---- js/customFormats.ts | 161 +++---------------------------- js/downloads.js | 44 +++------ js/ffmpegFormats.ts | 229 ++++++++++++++++++++++++++++++++++++++++++++ js/settings.js | 9 +- 6 files changed, 282 insertions(+), 208 deletions(-) create mode 100644 js/ffmpegFormats.ts diff --git a/index.html b/index.html index d4c5f9a..e837995 100644 --- a/index.html +++ b/index.html @@ -5117,11 +5117,7 @@ Lossless Container Container format for lossless downloads - +

diff --git a/js/api.js b/js/api.js index 3bdfbb6..cf71f83 100644 --- a/js/api.js +++ b/js/api.js @@ -12,9 +12,15 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; import { MP3EncodingError } from './mp3-encoder.js'; -import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; -import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; +import { + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1437,33 +1443,18 @@ export class LosslessAPI { if (quality.endsWith('LOSSLESS')) { try { - switch (losslessContainerSettings.getContainer()) { - case 'flac': - if ((await getExtensionFromBlob(blob)) != 'flac') { - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - options.signal - ); - } else { - blob = await rebuildFlacWithoutMetadata(blob); - } - break; - case 'alac': - blob = await ffmpeg( + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + if (containerFmt) { + if (await containerFmt.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat( blob, - { args: ['-c:a', 'alac'] }, - 'output.m4a', - 'audio/mp4', + containerFmt, onProgress, options.signal ); - break; - default: - break; + } else if ((await getExtensionFromBlob(blob)) == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/customFormats.ts b/js/customFormats.ts index 5d9bb7c..f8d5c2e 100644 --- a/js/customFormats.ts +++ b/js/customFormats.ts @@ -1,148 +1,13 @@ -import { ffmpeg } from './ffmpeg'; - -export interface ProgressEvent { - stage?: string; - message?: string; - progress?: number; - receivedBytes?: number; - totalBytes?: number; -} - -export interface CustomFormat { - /** Human-readable label shown in the UI */ - displayName: string; - /** Internal identifier, must start with `FFMPEG_` */ - internalName: string; - /** Arguments passed to ffmpeg (excluding input/output file args) */ - ffmpegArgs: string[]; - /** Output filename used when calling ffmpeg */ - outputFilename: string; - /** MIME type of the encoded output */ - outputMime: string; - /** File extension of the encoded output */ - extension: string; - /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */ - category: string; -} - -export const customFormats: CustomFormat[] = [ - { - displayName: 'MP3 320kbps', - internalName: 'FFMPEG_MP3_320', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'MP3 256kbps', - internalName: 'FFMPEG_MP3_256', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'MP3 128kbps', - internalName: 'FFMPEG_MP3_128', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], - outputFilename: 'output.mp3', - outputMime: 'audio/mpeg', - extension: 'mp3', - category: 'MP3', - }, - { - displayName: 'OGG 320kbps', - internalName: 'FFMPEG_OGG_320', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '320k', - '-minrate', - '320k', - '-maxrate', - '320k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'OGG 256kbps', - internalName: 'FFMPEG_OGG_256', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '256k', - '-minrate', - '256k', - '-maxrate', - '256k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'OGG 128kbps', - internalName: 'FFMPEG_OGG_128', - ffmpegArgs: [ - '-map_metadata', - '-1', - '-c:a', - 'libvorbis', - '-b:a', - '128k', - '-minrate', - '128k', - '-maxrate', - '128k', - ], - outputFilename: 'output.ogg', - outputMime: 'audio/ogg', - extension: 'ogg', - category: 'OGG', - }, - { - displayName: 'AAC 256kbps', - internalName: 'FFMPEG_AAC_256', - ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], - outputFilename: 'output.m4a', - outputMime: 'audio/mp4', - extension: 'm4a', - category: 'AAC', - }, -]; - -/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ -export function isCustomFormat(quality: string): boolean { - return getCustomFormat(quality) !== undefined; -} - -/** Looks up a custom format by its internal name, or returns undefined */ -export function getCustomFormat(internalName: string): CustomFormat | undefined { - return customFormats.find((f) => f.internalName === internalName); -} - -/** - * Transcodes an audio blob using the specified custom format via ffmpeg. - * Throws if ffmpeg fails during transcoding. - */ -export async function transcodeWithCustomFormat( - audioBlob: Blob, - format: CustomFormat, - onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null -): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); -} +// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts +export { + type ProgressEvent, + type CustomFormat, + type ContainerFormat, + customFormats, + containerFormats, + isCustomFormat, + getCustomFormat, + getContainerFormat, + transcodeWithCustomFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats'; diff --git a/js/downloads.js b/js/downloads.js index 718fba6..a8f629a 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -16,8 +16,14 @@ import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { DashDownloader } from './dash-downloader.js'; import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js'; -import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { isCustomFormat, getCustomFormat, transcodeWithCustomFormat } from './customFormats.ts'; +import { loadFfmpeg } from './ffmpeg.js'; +import { + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -455,33 +461,13 @@ async function downloadTrackBlob( if (quality.endsWith('LOSSLESS')) { try { - switch (losslessContainerSettings.getContainer()) { - case 'flac': - if ((await getExtensionFromBlob(blob)) != 'flac') { - blob = await ffmpeg( - blob, - { args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] }, - 'output.flac', - 'audio/flac', - onProgress, - signal - ); - } else { - blob = await rebuildFlacWithoutMetadata(blob); - } - break; - case 'alac': - blob = await ffmpeg( - blob, - { args: ['-c:a', 'alac'] }, - 'output.m4a', - 'audio/mp4', - onProgress, - signal - ); - break; - default: - break; + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + if (containerFmt) { + if (await containerFmt.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + } else if ((await getExtensionFromBlob(blob)) == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts new file mode 100644 index 0000000..5e5a9cb --- /dev/null +++ b/js/ffmpegFormats.ts @@ -0,0 +1,229 @@ +import { ffmpeg } from './ffmpeg'; +import { getExtensionFromBlob } from './utils'; + +export interface ProgressEvent { + stage?: string; + message?: string; + progress?: number; + receivedBytes?: number; + totalBytes?: number; +} + +export interface CustomFormat { + /** Human-readable label shown in the UI */ + displayName: string; + /** Internal identifier, must start with `FFMPEG_` */ + internalName: string; + /** Arguments passed to ffmpeg (excluding input/output file args) */ + ffmpegArgs: string[]; + /** Output filename used when calling ffmpeg */ + outputFilename: string; + /** MIME type of the encoded output */ + outputMime: string; + /** File extension of the encoded output */ + extension: string; + /** Category label used for grouping in the UI (e.g. 'MP3', 'OGG', 'AAC') */ + category: string; +} + +/** + * A container format definition for lossless re-muxing/re-encoding. + * Extends CustomFormat with a callback that decides whether ffmpeg needs to run + * at all (e.g. FLAC can skip if the source is already FLAC). + */ +export interface ContainerFormat extends Omit { + /** + * Returns true when the source blob must be passed through ffmpeg to produce + * the desired container. Return false to skip the ffmpeg step (the caller + * may still apply a lightweight metadata-strip pass instead). + */ + needsTranscode: (blob: Blob) => Promise; +} + +export const customFormats: CustomFormat[] = [ + { + displayName: 'MP3 320kbps', + internalName: 'FFMPEG_MP3_320', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 256kbps', + internalName: 'FFMPEG_MP3_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'MP3 128kbps', + internalName: 'FFMPEG_MP3_128', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], + outputFilename: 'output.mp3', + outputMime: 'audio/mpeg', + extension: 'mp3', + category: 'MP3', + }, + { + displayName: 'OGG 320kbps', + internalName: 'FFMPEG_OGG_320', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '320k', + '-minrate', + '320k', + '-maxrate', + '320k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 256kbps', + internalName: 'FFMPEG_OGG_256', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '256k', + '-minrate', + '256k', + '-maxrate', + '256k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'OGG 128kbps', + internalName: 'FFMPEG_OGG_128', + ffmpegArgs: [ + '-map_metadata', + '-1', + '-c:a', + 'libvorbis', + '-b:a', + '128k', + '-minrate', + '128k', + '-maxrate', + '128k', + ], + outputFilename: 'output.ogg', + outputMime: 'audio/ogg', + extension: 'ogg', + category: 'OGG', + }, + { + displayName: 'AAC 256kbps', + internalName: 'FFMPEG_AAC_256', + ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], + outputFilename: 'output.m4a', + outputMime: 'audio/mp4', + extension: 'm4a', + category: 'AAC', + }, +]; + +/** + * Container format definitions for lossless re-muxing. Each entry describes + * the ffmpeg arguments needed to produce that container and provides a + * `needsTranscode` predicate so callers can skip the ffmpeg step when the + * source is already in the correct container. + */ +export const containerFormats: ContainerFormat[] = [ + { + displayName: 'FLAC', + internalName: 'flac', + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + outputFilename: 'output.flac', + outputMime: 'audio/flac', + extension: 'flac', + // Only transcode when the source is NOT already a FLAC file. + needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac', + }, + { + displayName: 'FLAC - Max Compression', + internalName: 'flac_max', + // `-compression_level 12` is the highest FLAC compression level; audio + // data is bit-identical to the source — only the compressed size changes. + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + outputFilename: 'output.flac', + outputMime: 'audio/flac', + extension: 'flac', + needsTranscode: async () => true, + }, + { + displayName: 'Apple Lossless', + internalName: 'alac', + ffmpegArgs: ['-c:a', 'alac'], + outputFilename: 'output.m4a', + outputMime: 'audio/mp4', + extension: 'm4a', + needsTranscode: async () => true, + }, + { + displayName: "Don't change", + internalName: 'nochange', + ffmpegArgs: [], + outputFilename: '', + outputMime: '', + extension: '', + needsTranscode: async () => false, + }, +]; + +/** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ +export function isCustomFormat(quality: string): boolean { + return getCustomFormat(quality) !== undefined; +} + +/** Looks up a custom format by its internal name, or returns undefined */ +export function getCustomFormat(internalName: string): CustomFormat | undefined { + return customFormats.find((f) => f.internalName === internalName); +} + +/** Looks up a container format by its internal name, or returns undefined */ +export function getContainerFormat(internalName: string): ContainerFormat | undefined { + return containerFormats.find((f) => f.internalName === internalName); +} + +/** + * Transcodes an audio blob using the specified custom format via ffmpeg. + * Throws if ffmpeg fails during transcoding. + */ +export async function transcodeWithCustomFormat( + audioBlob: Blob, + format: CustomFormat, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} + +/** + * Re-muxes / re-encodes an audio blob into the specified container format via ffmpeg. + * Throws if ffmpeg fails during transcoding. + */ +export async function transcodeWithContainerFormat( + audioBlob: Blob, + format: ContainerFormat, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); +} diff --git a/js/settings.js b/js/settings.js index d18d5b9..da9cd81 100644 --- a/js/settings.js +++ b/js/settings.js @@ -42,7 +42,7 @@ import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; -import { customFormats } from './customFormats.ts'; +import { containerFormats, customFormats } from './ffmpegFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { // Restore last active settings tab @@ -867,6 +867,13 @@ export function initializeSettings(scrobbler, player, api, ui) { const losslessContainerSetting = document.getElementById('lossless-container-setting'); if (losslessContainerSetting) { + for (const { internalName, displayName } of containerFormats) { + const option = document.createElement('option'); + option.value = internalName; + option.textContent = displayName; + losslessContainerSetting.appendChild(option); + } + losslessContainerSetting.value = losslessContainerSettings.getContainer(); losslessContainerSetting.addEventListener('change', (e) => { From 34c3f8dbcfcb0880cd5b75ab85952572bb3d3ebd Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 05:04:42 +0300 Subject: [PATCH 8/8] Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- js/utils.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/utils.js b/js/utils.js index e8050d0..ae22d4c 100644 --- a/js/utils.js +++ b/js/utils.js @@ -189,10 +189,10 @@ export const getExtensionFromBlob = async (blob) => { if (format) return format; if (blob.type.includes('video')) return 'mp4'; - 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'; + if (blob.type === 'audio/flac') return 'flac'; + if (blob.type === 'audio/ogg') return 'ogg'; + if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'mp4'; + if (blob.type === 'audio/mp3' || blob.type === 'audio/mpeg') return 'mp3'; return 'flac'; };