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 001/226] fix(downloads): fix m3u generation artist [object Object] bug and mismatched file extensions --- js/downloads.js | 527 ++++++++++++++++++++------------------- js/playlist-generator.js | 60 +++-- 2 files changed, 309 insertions(+), 278 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index ae947b217..bbb9469fc 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,75 +501,12 @@ 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, - }; - } - - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; - } - - if (playlistSettings.shouldGenerateNFO()) { - const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - // 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, - }; - } - // Download tracks + // 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]; @@ -595,6 +528,11 @@ async function bulkDownloadToZipStream( ); 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(), @@ -622,8 +560,71 @@ async function bulkDownloadToZipStream( } catch (err) { if (err.name === 'AbortError') throw err; console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); } } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + // 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, + }; + } + + // 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, + }; + } + + 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, + }; + } } try { @@ -657,75 +658,12 @@ 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, - }; - } - - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; - } - - if (playlistSettings.shouldGenerateNFO()) { - const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - // 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, - }; - } - - // Download tracks + // 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]; @@ -747,6 +685,11 @@ async function bulkDownloadToZipBlob( ); 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(), @@ -774,8 +717,71 @@ async function bulkDownloadToZipBlob( } catch (err) { if (err.name === 'AbortError') throw err; console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); } } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + // 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, + }; + } + + // 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, + }; + } + + 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, + }; + } } try { @@ -810,75 +816,12 @@ 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, - }; - } - - if (playlistSettings.shouldGenerateM3U8()) { - const m3u8Content = generateM3U8( - metadata || { title: folderName }, - tracks, - useRelativePaths, - playlistPathResolver, - playlistAudioExtension - ); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`, - lastModified: new Date(), - input: m3u8Content, - }; - } - - if (playlistSettings.shouldGenerateNFO()) { - const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - // 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, - }; - } - // Download tracks + // 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]; @@ -900,6 +843,11 @@ async function bulkDownloadToZipNeutralino( ); 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(), @@ -927,8 +875,71 @@ async function bulkDownloadToZipNeutralino( } catch (err) { if (err.name === 'AbortError') throw err; console.error(`Failed to download track ${trackTitle}:`, err); + trackPaths.push(null); } } + + if (playlistSettings.shouldGenerateNFO()) { + const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, + lastModified: new Date(), + input: nfoContent, + }; + } + + if (playlistSettings.shouldGenerateJSON()) { + const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); + yield { + name: `${folderName}/${sanitizeForFilename(folderName)}.json`, + lastModified: new Date(), + input: jsonContent, + }; + } + + // 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, + }; + } + + // 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, + }; + } + + 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, + }; + } } try { @@ -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 3b22b2127..a6b0e4ebc 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 3f2651633b1d9d0a459122101c9ce990d4ac9f6e 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 002/226] 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 5a253c325..d4c5f9a30 100644 --- a/index.html +++ b/index.html @@ -5108,7 +5108,6 @@

Custom Theme

diff --git a/js/api.js b/js/api.js index fd56dc2f8..8d9d7196b 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; } } @@ -1510,7 +1514,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 000000000..5d9bb7c26 --- /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 ae947b217..6494fda1c 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(); @@ -298,8 +298,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); @@ -379,9 +379,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 3182372f1..d18d5b9ed 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 dc1cbc8ff..de7c4709c 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 3b5bf8f85..728ddb3f2 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 3e24470401654a19961c9555fd1882111903f263 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 003/226] 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 d4c5f9a30..e837995f7 100644 --- a/index.html +++ b/index.html @@ -5117,11 +5117,7 @@

Custom Theme

Lossless Container Container format for lossless downloads - +
diff --git a/js/api.js b/js/api.js index 8d9d7196b..25d653247 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 5d9bb7c26..f8d5c2e9f 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 6494fda1c..dd4fce9be 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(); @@ -389,33 +395,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 000000000..5e5a9cbf0 --- /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 d18d5b9ed..da9cd81d0 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 33668ae11816e04cac8470b89316042563995220 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 004/226] 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 fd56dc2f8..30c3d9ff4 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 ae947b217..bd05f199a 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 76e24b946..93c2e945a 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 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 005/226] 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 bbb9469fc..a093fb945 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 a6b0e4ebc..59eb0115b 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 006/226] 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 5a253c325..e1c32232e 100644 --- a/index.html +++ b/index.html @@ -5141,8 +5141,8 @@

Custom Theme

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 76e24b946..d8bfb6120 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 3b5bf8f85..b9790e0bf 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 007/226] No change --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8d739f56c..30ea8da93 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 008/226] 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 fd56dc2f8..30c3d9ff4 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 ae947b217..bd05f199a 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 76e24b946..93c2e945a 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 009/226] 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 5a253c325..d4c5f9a30 100644 --- a/index.html +++ b/index.html @@ -5108,7 +5108,6 @@

Custom Theme

diff --git a/js/api.js b/js/api.js index 30c3d9ff4..3bdfbb61f 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 000000000..5d9bb7c26 --- /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 bd05f199a..718fba6ef 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 3182372f1..d18d5b9ed 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 dc1cbc8ff..de7c4709c 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 3b5bf8f85..728ddb3f2 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 010/226] 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 d4c5f9a30..e837995f7 100644 --- a/index.html +++ b/index.html @@ -5117,11 +5117,7 @@

Custom Theme

Lossless Container Container format for lossless downloads
- +
diff --git a/js/api.js b/js/api.js index 3bdfbb61f..cf71f83b2 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 5d9bb7c26..f8d5c2e9f 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 718fba6ef..a8f629a87 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 000000000..5e5a9cbf0 --- /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 d18d5b9ed..da9cd81d0 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 5e55e141daed755ebdbffc560f0a36bebb3da108 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 02:48:55 +0300 Subject: [PATCH 011/226] feat(missing-songs-import): export missing songs to CSV or copy to clipboard --- index.html | 2 ++ js/app.js | 64 ++++++++++++++++++++++++++++++++++++++++++------------ styles.css | 2 ++ 3 files changed, 54 insertions(+), 14 deletions(-) diff --git a/index.html b/index.html index 43e8baf1d..c48b4695d 100644 --- a/index.html +++ b/index.html @@ -1185,6 +1185,8 @@

Missing Tracks:

+ +
diff --git a/js/app.js b/js/app.js index 4786f72c4..480c3339a 100644 --- a/js/app.js +++ b/js/app.js @@ -1421,7 +1421,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (err) { @@ -1506,7 +1506,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { @@ -1610,7 +1610,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { @@ -1669,7 +1669,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { @@ -1728,7 +1728,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { @@ -1787,7 +1787,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (missingTracks.length > 0) { setTimeout(() => { - showMissingTracksNotification(missingTracks); + showMissingTracksNotification(missingTracks, name || 'Untitled'); }, 500); } } catch (error) { @@ -2839,10 +2839,11 @@ function escapeHtml(text) { return div.innerHTML; } -function showMissingTracksNotification(missingTracks) { +function showMissingTracksNotification(missingTracks, playlistName) { const modal = document.getElementById('missing-tracks-modal'); const listUl = document.getElementById('missing-tracks-list-ul'); const copyBtn = document.getElementById('copy-missing-tracks-btn'); + const exportCSVBtn = document.getElementById('export-missing-tracks-csv-btn'); listUl.innerHTML = missingTracks .map((track) => { @@ -2857,13 +2858,16 @@ function showMissingTracksNotification(missingTracks) { copyBtn.parentNode.replaceChild(newCopyBtn, copyBtn); newCopyBtn.addEventListener('click', () => { - const textToCopy = missingTracks - .map((track) => { - return typeof track === 'string' - ? track - : `${track.artist ? track.artist + ' - ' : ''}${track.title}`; - }) - .join('\n'); + const header = `Missing songs from ${playlistName} import:\n\n`; + const textToCopy = + header + + missingTracks + .map((track) => { + return typeof track === 'string' + ? track + : `${track.artist ? track.artist + ' - ' : ''}${track.title}`; + }) + .join('\n'); navigator.clipboard.writeText(textToCopy).then(() => { const originalText = newCopyBtn.textContent; @@ -2873,6 +2877,38 @@ function showMissingTracksNotification(missingTracks) { }); } + if (exportCSVBtn) { + const newExportBtn = exportCSVBtn.cloneNode(true); + exportCSVBtn.parentNode.replaceChild(newExportBtn, exportCSVBtn); + + newExportBtn.addEventListener('click', () => { + const headers = ['Artist', 'Title', 'Album']; + let csvContent = headers.join(',') + '\n'; + + missingTracks.forEach((track) => { + if (typeof track === 'string') { + csvContent += `"${track.replace(/"/g, '""')}","",""\n`; + } else { + const artist = (track.artist || '').replace(/"/g, '""'); + const title = (track.title || '').replace(/"/g, '""'); + const album = (track.album || '').replace(/"/g, '""'); + csvContent += `"${artist}","${title}","${album}"\n`; + } + }); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + const fileName = `${playlistName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_missing_tracks.csv`; + link.setAttribute('download', fileName); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }); + } + const closeModal = () => modal.classList.remove('active'); // Remove old listeners if any (though usually these functions are called once per instance, diff --git a/styles.css b/styles.css index e0f4a4de0..c230a8ddb 100644 --- a/styles.css +++ b/styles.css @@ -5874,6 +5874,8 @@ img[src=''] { border-top: 1px solid var(--border); display: flex; justify-content: flex-end; + gap: 0.75rem; + flex-wrap: wrap; } .missing-tracks-actions .btn-secondary { From 34c3f8dbcfcb0880cd5b75ab85952572bb3d3ebd Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 05:04:42 +0300 Subject: [PATCH 012/226] 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 e8050d0d6..ae22d4c24 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'; }; From 0ed82f586c4b9adce523dd152905833d931da525 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 05:59:20 +0300 Subject: [PATCH 013/226] didnt get it all lol --- js/downloads.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/js/downloads.js b/js/downloads.js index 524148a82..e730025e0 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -82,7 +82,7 @@ async function createDiscLayoutContext(tracks, api) { 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 explicitDiscNumbers = tracks.map((track) => getTrackDiscNumber(track)); const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean)); let resolvedDiscNumbers = explicitDiscNumbers; @@ -97,7 +97,7 @@ async function computeDiscInfo(tracks, api = null) { if (explicitDiscNumbers[index]) return explicitDiscNumbers[index]; try { const fullTrack = await api.getTrackMetadata(track.id); - return getExplicitTrackDiscNumber(fullTrack); + return getTrackDiscNumber(fullTrack); } catch { return null; } @@ -362,7 +362,7 @@ async function downloadTrackBlob( } if (albumData.tracks?.length > 0) { const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api); - const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1; + const discNumber = getTrackDiscNumber(enrichedTrack) || 1; enrichedTrack.album = { ...enrichedTrack.album, totalDiscs, @@ -1508,7 +1508,7 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag } if (albumData.tracks?.length > 0) { const { totalDiscs, tracksPerDisc } = await computeDiscInfo(albumData.tracks, api); - const discNumber = getExplicitTrackDiscNumber(enrichedTrack) || 1; + const discNumber = getTrackDiscNumber(enrichedTrack) || 1; enrichedTrack.album = { ...enrichedTrack.album, totalDiscs, From 30b2e7d4457a0753bbf5d844b2152996b8c7ed62 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:12:02 +0300 Subject: [PATCH 014/226] fix(downloading): hi-res M4A's having no embedded covers (FUCK YOU TAGLIB) --- js/api.js | 108 +++++++++++++++++++++++++++++++++++++++++--- js/downloads.js | 108 ++++++++++++++++++++++++++++++++++++++++++-- js/ffmpeg.js | 23 ++++++++-- js/ffmpeg.worker.js | 35 +++++++------- js/ffmpegFormats.ts | 26 +++++++++-- js/metadata.js | 16 +++++-- js/settings.js | 1 - js/taglib.ts | 8 +++- 8 files changed, 280 insertions(+), 45 deletions(-) diff --git a/js/api.js b/js/api.js index cf71f83b2..429c80b3d 100644 --- a/js/api.js +++ b/js/api.js @@ -5,6 +5,9 @@ import { delay, isTrackUnavailable, getExtensionFromBlob, + getTrackTitle, + getFullArtistString, + getMimeType, } from './utils.js'; import { trackDateSettings, losslessContainerSettings } from './storage.js'; import { APICache } from './cache.js'; @@ -12,7 +15,7 @@ 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 { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; import { isCustomFormat, @@ -1423,12 +1426,65 @@ export class LosslessAPI { } if (!isVideo) { + const coverBlobToEmbed = await prefetchPromises.coverFetch; + const extraFiles = []; + const ffmpegMetadataArgs = []; + + if (coverBlobToEmbed) { + const coverBuffer = await coverBlobToEmbed.arrayBuffer(); + const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; + const coverName = `cover.${coverExt}`; + extraFiles.push({ + name: coverName, + data: coverBuffer + }); + ffmpegMetadataArgs.push('-i', coverName); + } + + if (track) { + ffmpegMetadataArgs.push( + '-metadata', `title=${getTrackTitle(track)}`, + '-metadata', `artist=${getFullArtistString(track)}`, + '-metadata', `album=${track.album?.title || ''}`, + '-metadata', `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` + ); + + const trackNum = track.trackNumber; + if (trackNum) { + const totalTracks = track.album?.numberOfTracks; + ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); + } + + const discNum = track.volumeNumber || track.discNumber; + if (discNum) { + ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); + } + + const releaseDate = track.album?.releaseDate || track?.streamStartDate; + if (releaseDate) { + ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); + } + } + // Transcode to custom format if requested if (isCustomFormat(quality)) { const format = getCustomFormat(quality); if (format) { try { - blob = await transcodeWithCustomFormat(blob, format, onProgress, options.signal); + const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + format.outputFilename, + format.outputMime, + onProgress, + options.signal, + extraFiles + ); } catch (encodingError) { if (onProgress) { onProgress({ @@ -1443,18 +1499,56 @@ export class LosslessAPI { if (quality.endsWith('LOSSLESS')) { try { - const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); - if (containerFmt) { + const containerType = losslessContainerSettings.getContainer(); + const containerFmt = getContainerFormat(containerType); + + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { - blob = await transcodeWithContainerFormat( + const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( blob, - containerFmt, + { args }, + containerFmt.outputFilename, + containerFmt.outputMime, onProgress, - options.signal + options.signal, + extraFiles ); } else if ((await getExtensionFromBlob(blob)) == 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } + } else { + const actualExtension = await getExtensionFromBlob(blob); + if (actualExtension === 'm4a' || actualExtension === 'mp4') { + try { + const ffmpegArgs = [...ffmpegMetadataArgs]; + + ffmpegArgs.push('-map', '0:a'); + if (coverBlobToEmbed) { + ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); + + const remuxedBlob = await ffmpeg( + blob, + { args: ffmpegArgs }, + 'output.mp4', + 'audio/mp4', + onProgress, + options.signal, + extraFiles + ); + if (remuxedBlob) { + blob = remuxedBlob; + } + } catch (e) { + console.warn('Failed to remux hi-res M4A, proceeding with original:', e); + } + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/downloads.js b/js/downloads.js index e730025e0..cc3adea06 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -11,13 +11,15 @@ import { getExtensionFromBlob, escapeHtml, getTrackDiscNumber, + getFullArtistString, + getMimeType, } from './utils.js'; import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; 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 { loadFfmpeg } from './ffmpeg.js'; +import { ffmpeg, loadFfmpeg } from './ffmpeg.js'; import { isCustomFormat, getCustomFormat, @@ -418,23 +420,119 @@ async function downloadTrackBlob( blob = await response.blob(); } + const coverBlobToEmbed = await prefetchPromises.coverFetch; + const extraFiles = []; + const ffmpegMetadataArgs = []; + + if (coverBlobToEmbed) { + const coverBuffer = await coverBlobToEmbed.arrayBuffer(); + const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; + const coverName = `cover.${coverExt}`; + extraFiles.push({ + name: coverName, + data: coverBuffer + }); + ffmpegMetadataArgs.push('-i', coverName); + } + + if (enrichedTrack) { + ffmpegMetadataArgs.push( + '-metadata', `title=${getTrackTitle(enrichedTrack)}`, + '-metadata', `artist=${getFullArtistString(enrichedTrack)}`, + '-metadata', `album=${enrichedTrack.album?.title || ''}`, + '-metadata', `album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}` + ); + + const trackNum = enrichedTrack.trackNumber; + if (trackNum) { + const totalTracks = enrichedTrack.album?.numberOfTracks; + ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); + } + + const discNum = enrichedTrack.volumeNumber || enrichedTrack.discNumber; + if (discNum) { + ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); + } + + const releaseDate = enrichedTrack.album?.releaseDate || enrichedTrack?.streamStartDate; + if (releaseDate) { + ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); + } + } + // Transcode to custom format if requested if (isCustomFormat(quality)) { const format = getCustomFormat(quality); if (format) { - blob = await transcodeWithCustomFormat(blob, format, onProgress || (() => undefined), signal); + const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } } if (quality.endsWith('LOSSLESS')) { try { - const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); - if (containerFmt) { + const containerType = losslessContainerSettings.getContainer(); + const containerFmt = getContainerFormat(containerType); + + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { - blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; + if (coverBlobToEmbed) { + args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + + blob = await ffmpeg( + blob, + { args }, + containerFmt.outputFilename, + containerFmt.outputMime, + onProgress, + signal, + extraFiles + ); } else if ((await getExtensionFromBlob(blob)) == 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } + } else { + const actualExtension = await getExtensionFromBlob(blob); + if (actualExtension === 'm4a' || actualExtension === 'mp4') { + try { + const ffmpegArgs = [...ffmpegMetadataArgs]; + + ffmpegArgs.push('-map', '0:a'); + if (coverBlobToEmbed) { + ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + } + ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); + + const remuxedBlob = await ffmpeg( + blob, + { args: ffmpegArgs }, + 'output.mp4', + 'audio/mp4', + onProgress, + signal, + extraFiles + ); + if (remuxedBlob) { + blob = remuxedBlob; + } + } catch (e) { + console.warn('Failed to remux hi-res M4A, proceeding with original:', e); + } + } } } catch (error) { if (error?.name === 'AbortError') { diff --git a/js/ffmpeg.js b/js/ffmpeg.js index 0ea51d892..93cd1ab72 100644 --- a/js/ffmpeg.js +++ b/js/ffmpeg.js @@ -32,9 +32,10 @@ async function ffmpegWorker( outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, - signal = null + signal = null, + extraFiles = [] ) { - const audioData = await audioBlob.arrayBuffer(); + const audioData = audioBlob ? await audioBlob.arrayBuffer() : null; const assets = loadFfmpeg(); return new Promise((resolve, reject) => { @@ -79,9 +80,20 @@ async function ffmpegWorker( }; (async () => { + const transferables = []; + if (audioData) transferables.push(audioData); + for (const f of extraFiles) { + if (f.data instanceof ArrayBuffer) { + transferables.push(f.data); + } else if (f.data.buffer instanceof ArrayBuffer) { + transferables.push(f.data.buffer); + } + } + worker.postMessage( { audioData, + extraFiles, ...args, output: { name: outputName, @@ -89,7 +101,7 @@ async function ffmpegWorker( }, loadOptions: await assets, }, - [audioData] + transferables ); })(); }); @@ -101,12 +113,13 @@ export async function ffmpeg( outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, - signal = null + signal = null, + extraFiles = [] ) { try { // Use Web Worker for non-blocking FFmpeg encoding if (typeof Worker !== 'undefined') { - return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal); + return await ffmpegWorker(audioBlob, args, outputName, outputMime, onProgress, signal, extraFiles); } throw new FfmpegError('Web Workers are required for FFMPEG'); diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js index 969a12602..f4ad1055d 100644 --- a/js/ffmpeg.worker.js +++ b/js/ffmpeg.worker.js @@ -98,6 +98,7 @@ async function loadFFmpeg(loadOptions = {}) { self.onmessage = async (e) => { const { audioData, + extraFiles = [], args = [], output = { name: 'output', @@ -109,42 +110,40 @@ self.onmessage = async (e) => { } = e.data; try { - console.log(loadOptions); await loadFFmpeg(loadOptions); self.postMessage({ type: 'progress', stage: 'encoding', message: encodeStartMessage, progress: 0.0 }); try { - // Write input file to FFmpeg virtual filesystem - await ffmpeg.writeFile('input', new Uint8Array(audioData)); + if (audioData) { + await ffmpeg.writeFile('input', new Uint8Array(audioData)); + } + + for (const file of extraFiles) { + await ffmpeg.writeFile(file.name, new Uint8Array(file.data)); + } const ffmpegArgs = ['-i', 'input', ...args, output.name]; + self.postMessage({ type: 'log', message: `FFmpeg command: ffmpeg ${ffmpegArgs.join(' ')}` }); - // Log the exact FFmpeg command being run for debugging. - self.postMessage({ type: 'log', message: `Running with args: ${ffmpegArgs.join(' ')}` }); + const exitCode = await ffmpeg.exec(ffmpegArgs); - // Run FFMPEG with the provided arguments. - await ffmpeg.exec(ffmpegArgs); + if (exitCode !== 0) { + throw new Error(`FFmpeg failed with exit code ${exitCode}.`); + } self.postMessage({ type: 'progress', stage: 'finalizing', message: encodeEndMessage, progress: 100.0 }); - // Read output file - use Uint8Array directly to avoid extra bytes from ArrayBuffer const data = await ffmpeg.readFile(output.name); const outputBlob = new Blob([data], { type: output.mime }); self.postMessage({ type: 'complete', blob: outputBlob }); } finally { - // Always cleanup virtual filesystem files - try { - await ffmpeg.deleteFile('input'); - } catch { - // File may not exist if writeFile failed - } - try { - await ffmpeg.deleteFile(output.name); - } catch { - // File may not exist if exec failed + try { if (audioData) await ffmpeg.deleteFile('input'); } catch {} + for (const file of extraFiles) { + try { await ffmpeg.deleteFile(file.name); } catch {} } + try { await ffmpeg.deleteFile(output.name); } catch {} } } catch (error) { self.postMessage({ type: 'error', message: error.message }); diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index 5e5a9cbf0..5baa79cf6 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -210,9 +210,18 @@ export async function transcodeWithCustomFormat( audioBlob: Blob, format: CustomFormat, onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null + signal: AbortSignal | null = null, + extraFiles: any[] = [] ): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); + return ffmpeg( + audioBlob, + { args: format.ffmpegArgs }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } /** @@ -223,7 +232,16 @@ export async function transcodeWithContainerFormat( audioBlob: Blob, format: ContainerFormat, onProgress: ((progress: ProgressEvent) => void) | null = null, - signal: AbortSignal | null = null + signal: AbortSignal | null = null, + extraFiles: any[] = [] ): Promise { - return ffmpeg(audioBlob, { args: format.ffmpegArgs }, format.outputFilename, format.outputMime, onProgress, signal); + return ffmpeg( + audioBlob, + { args: format.ffmpegArgs }, + format.outputFilename, + format.outputMime, + onProgress, + signal, + extraFiles + ); } diff --git a/js/metadata.js b/js/metadata.js index 0440bb50d..29d6d46d3 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -5,6 +5,7 @@ import { getMimeType, getTrackCoverId, getTrackDiscNumber, + getExtensionFromBlob, } from './utils.js'; import { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts'; import { doTimed, doTimedAsync } from './doTimed.ts'; @@ -42,10 +43,18 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet const { coverFetch, lyricsFetch } = prefetchPromises; /** - * @type {import("./taglib.types.ts").TagLibMetadata} + * @type {import("./taglib.worker.ts").TagLibMetadata} */ const data = {}; + const detectedExt = await getExtensionFromBlob(audioBlob); + const isM4A = detectedExt === 'm4a' || detectedExt === 'mp4'; + + if (isM4A) { + console.log('Skipping TagLib for M4A (handled by FFmpeg)'); + return audioBlob; + } + const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer()); try { @@ -55,8 +64,7 @@ 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.numberOfTracksOnDisc ?? track.album.numberOfTracks; - data.totalDiscs = track.album.totalDiscs; + data.totalTracks = track.album.numberOfTracks; data.copyright = track.copyright; data.isrc = track.isrc; data.explicit = Boolean(track.explicit); @@ -96,9 +104,9 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet try { if (track.album?.cover) { const coverBlob = await coverFetch; - const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); if (coverBlob) { + const coverBuffer = new Uint8Array(await coverBlob.arrayBuffer()); data.cover = { data: coverBuffer, type: getMimeType(coverBuffer), diff --git a/js/settings.js b/js/settings.js index d562de443..22a48ba59 100644 --- a/js/settings.js +++ b/js/settings.js @@ -41,7 +41,6 @@ import { getButterchurnPresets } from './visualizers/butterchurn.js'; import { db } from './db.js'; import { authManager } from './accounts/auth.js'; import { syncManager } from './accounts/pocketbase.js'; -import { saveFirebaseConfig, clearFirebaseConfig } from './accounts/config.js'; import { containerFormats, customFormats } from './ffmpegFormats.ts'; export function initializeSettings(scrobbler, player, api, ui) { diff --git a/js/taglib.ts b/js/taglib.ts index c70ef5329..d4dbad814 100644 --- a/js/taglib.ts +++ b/js/taglib.ts @@ -47,7 +47,13 @@ export async function addMetadataWithTagLib( }; worker.onerror = reject; worker.onmessageerror = reject; - worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, [audioData.buffer]); + + const transferables: Transferable[] = [audioData.buffer]; + if ((data as any).cover?.data?.buffer instanceof ArrayBuffer) { + transferables.push((data as any).cover.data.buffer); + } + + worker.postMessage({ ...data, type: 'Add', wasmUrl, audioData }, transferables); }); } From 7e6375919d35757f080ffb7f376eb1e2d9e42e19 Mon Sep 17 00:00:00 2001 From: SamidyFR <168582143+SamidyFR@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:12:28 +0000 Subject: [PATCH 015/226] style: auto-fix linting issues --- js/api.js | 60 ++++++++++++++++++++++++++++++++++----------- js/downloads.js | 24 ++++++++++-------- js/ffmpeg.worker.js | 12 ++++++--- 3 files changed, 69 insertions(+), 27 deletions(-) diff --git a/js/api.js b/js/api.js index 429c80b3d..ce7434d9f 100644 --- a/js/api.js +++ b/js/api.js @@ -1429,32 +1429,39 @@ export class LosslessAPI { const coverBlobToEmbed = await prefetchPromises.coverFetch; const extraFiles = []; const ffmpegMetadataArgs = []; - + if (coverBlobToEmbed) { const coverBuffer = await coverBlobToEmbed.arrayBuffer(); const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; const coverName = `cover.${coverExt}`; extraFiles.push({ name: coverName, - data: coverBuffer + data: coverBuffer, }); ffmpegMetadataArgs.push('-i', coverName); } if (track) { ffmpegMetadataArgs.push( - '-metadata', `title=${getTrackTitle(track)}`, - '-metadata', `artist=${getFullArtistString(track)}`, - '-metadata', `album=${track.album?.title || ''}`, - '-metadata', `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` + '-metadata', + `title=${getTrackTitle(track)}`, + '-metadata', + `artist=${getFullArtistString(track)}`, + '-metadata', + `album=${track.album?.title || ''}`, + '-metadata', + `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` ); - + const trackNum = track.trackNumber; if (trackNum) { const totalTracks = track.album?.numberOfTracks; - ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); + ffmpegMetadataArgs.push( + '-metadata', + `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}` + ); } - + const discNum = track.volumeNumber || track.discNumber; if (discNum) { ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); @@ -1473,7 +1480,16 @@ export class LosslessAPI { try { const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + args.push( + '-map', + '0:a', + '-map', + '1:v', + '-c:v', + 'copy', + '-disposition:v:0', + 'attached_pic' + ); } blob = await ffmpeg( @@ -1501,12 +1517,21 @@ export class LosslessAPI { try { const containerType = losslessContainerSettings.getContainer(); const containerFmt = getContainerFormat(containerType); - + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + args.push( + '-map', + '0:a', + '-map', + '1:v', + '-c:v', + 'copy', + '-disposition:v:0', + 'attached_pic' + ); } blob = await ffmpeg( @@ -1526,10 +1551,17 @@ export class LosslessAPI { if (actualExtension === 'm4a' || actualExtension === 'mp4') { try { const ffmpegArgs = [...ffmpegMetadataArgs]; - + ffmpegArgs.push('-map', '0:a'); if (coverBlobToEmbed) { - ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); + ffmpegArgs.push( + '-map', + '1:v', + '-c:v', + 'copy', + '-disposition:v:0', + 'attached_pic' + ); } ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); diff --git a/js/downloads.js b/js/downloads.js index cc3adea06..e2dd75455 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -423,32 +423,36 @@ async function downloadTrackBlob( const coverBlobToEmbed = await prefetchPromises.coverFetch; const extraFiles = []; const ffmpegMetadataArgs = []; - + if (coverBlobToEmbed) { const coverBuffer = await coverBlobToEmbed.arrayBuffer(); const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; const coverName = `cover.${coverExt}`; extraFiles.push({ name: coverName, - data: coverBuffer + data: coverBuffer, }); ffmpegMetadataArgs.push('-i', coverName); } if (enrichedTrack) { ffmpegMetadataArgs.push( - '-metadata', `title=${getTrackTitle(enrichedTrack)}`, - '-metadata', `artist=${getFullArtistString(enrichedTrack)}`, - '-metadata', `album=${enrichedTrack.album?.title || ''}`, - '-metadata', `album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}` + '-metadata', + `title=${getTrackTitle(enrichedTrack)}`, + '-metadata', + `artist=${getFullArtistString(enrichedTrack)}`, + '-metadata', + `album=${enrichedTrack.album?.title || ''}`, + '-metadata', + `album_artist=${enrichedTrack.album?.artist?.name || enrichedTrack.artist?.name || ''}` ); - + const trackNum = enrichedTrack.trackNumber; if (trackNum) { const totalTracks = enrichedTrack.album?.numberOfTracks; ffmpegMetadataArgs.push('-metadata', `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}`); } - + const discNum = enrichedTrack.volumeNumber || enrichedTrack.discNumber; if (discNum) { ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); @@ -485,7 +489,7 @@ async function downloadTrackBlob( try { const containerType = losslessContainerSettings.getContainer(); const containerFmt = getContainerFormat(containerType); - + if (containerFmt && containerType !== 'nochange') { if (await containerFmt.needsTranscode(blob)) { const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; @@ -510,7 +514,7 @@ async function downloadTrackBlob( if (actualExtension === 'm4a' || actualExtension === 'mp4') { try { const ffmpegArgs = [...ffmpegMetadataArgs]; - + ffmpegArgs.push('-map', '0:a'); if (coverBlobToEmbed) { ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js index f4ad1055d..b90082d31 100644 --- a/js/ffmpeg.worker.js +++ b/js/ffmpeg.worker.js @@ -139,11 +139,17 @@ self.onmessage = async (e) => { self.postMessage({ type: 'complete', blob: outputBlob }); } finally { - try { if (audioData) await ffmpeg.deleteFile('input'); } catch {} + try { + if (audioData) await ffmpeg.deleteFile('input'); + } catch {} for (const file of extraFiles) { - try { await ffmpeg.deleteFile(file.name); } catch {} + try { + await ffmpeg.deleteFile(file.name); + } catch {} } - try { await ffmpeg.deleteFile(output.name); } catch {} + try { + await ffmpeg.deleteFile(output.name); + } catch {} } } catch (error) { self.postMessage({ type: 'error', message: error.message }); From 39543ee4d8903a22721d97825717ff73b16b456f Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:29:28 +0300 Subject: [PATCH 016/226] only 22+ should be recommended --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f8f1c0b6c..a2b5000e3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Thank you for your interest in contributing to Monochrome! This guide will help ### Prerequisites -- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended) +- [Node.js](https://nodejs.org/) (Version 22+ recommended) - [Bun](https://bun.sh/) (preferred) or [npm](https://www.npmjs.com/) ### Quick Start From 666f48a841d409148632d872dd459fa508a1903e Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:30:08 +0300 Subject: [PATCH 017/226] what the fuck this gotta be ai bro --- CONTRIBUTING.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a2b5000e3..3de6f50ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,9 +101,6 @@ bun run lint:css ``` monochrome/ ├── 📁 js/ # Application source code -│ ├── components/ # UI components -│ ├── utils/ # Utility functions -│ ├── api/ # API integration │ └── ... ├── 📁 public/ # Static assets │ ├── assets/ # Images, icons, fonts From 301743420aaa70ef89fa8b1fb04c92cc8349ce05 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:32:07 +0300 Subject: [PATCH 018/226] some people are dumb and + imo this looks cleaner --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3de6f50ce..ac746a514 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,7 +205,7 @@ Follow our [commit message guidelines](#commit-message-guidelines). ```bash git add . -git commit -m "feat(player): add keyboard shortcut for loop toggle" +git commit -m "feat(player): add keyboard shortcut for loop toggle" # example commit message ``` ### 5. Push and Create a Pull Request From 595a84c2ca31d30c73355093e1b034931ec95d6f Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:36:04 +0300 Subject: [PATCH 019/226] we r not vitepress dawg --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ac746a514..83417b1dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -307,7 +307,7 @@ Deployment is fully automated via **Cloudflare Pages**. 1. Push changes to the `main` branch 2. Cloudflare automatically builds and deploys -3. Changes are live within minutes +3. Changes are live a minute ### Configuration Notes From 97bff01796f65541e00aac7a7e760775fdbbe2cf Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:39:35 +0300 Subject: [PATCH 020/226] update contrib guide link --- INSTANCES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/INSTANCES.md b/INSTANCES.md index 1e92790ab..9c1df81b9 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -103,5 +103,5 @@ Want to add your instance to this list? ## Related Resources -- [Contributing Guide](CONTRIBUTE.md) - Contribute to the project +- [Contributing Guide](CONTRIBUTING.md) - Contribute to the project - [Main Repository](https://github.com/monochrome-music/monochrome) - Source code From 86df459dc151df73165dad933efd95280296fce2 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:53:51 +0300 Subject: [PATCH 021/226] chore(logs): remove accidental debug log for fetching tracks from artists (HOW DID I FORGET TO DO THIS FOR MONTHS LMFAOOO) --- js/api.js | 1 - 1 file changed, 1 deletion(-) diff --git a/js/api.js b/js/api.js index ce7434d9f..66749a574 100644 --- a/js/api.js +++ b/js/api.js @@ -1112,7 +1112,6 @@ export class LosslessAPI { const artistPromises = artistsToProcess.map(async (artist) => { try { - console.log(`Fetching tracks for artist: ${artist.name} (ID: ${artist.id})`); const artistData = await this.getArtist(artist.id, { lightweight: true, skipCache: options.refresh }); if (artistData && artistData.tracks && artistData.tracks.length > 0) { const availableTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)); From eee189aee7b8c0438eab9cd966f3a301579c64d7 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 07:58:38 +0300 Subject: [PATCH 022/226] why the fuck did this have shitty characters --- .gitignore | Bin 162 -> 128 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.gitignore b/.gitignore index db600234084f529ed4f699c72ea247fc410856a2..5c3c0678c668289e4c09ad5e963f084cef45282c 100644 GIT binary patch literal 128 zcmX}kF%E+;3`XJoKSiZ(9h^%rvv7bYB^IO&HcA|FeGmiV8@?wR*>4(K=8BlAfriOX zriJmLtaHeqBN+ql*P!#C3y9^oYUE}mK?=h#3srD^^krd=U(yvsZB>lkZnP6~V&HvMLhU?-G0FMlPnN z Date: Thu, 12 Mar 2026 08:04:16 +0300 Subject: [PATCH 023/226] why did we have a dash here idk why i added that --- license | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/license b/license index d3af583a5..424e8c754 100644 --- a/license +++ b/license @@ -187,7 +187,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2026 Monochrome-Team + Copyright 2026 Monochrome Team Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From f5ba65c6c540098d3b28b57e9269cc914c172023 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:39:01 +0300 Subject: [PATCH 024/226] kino on free and bini killed his API long ago --- INSTANCES.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/INSTANCES.md b/INSTANCES.md index 9c1df81b9..82d554452 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -61,8 +61,7 @@ These are available API endpoints that can be used with Monochrome or other Hi-F | | `https://hund.qqdl.site` | Community hosted | | **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted | | | `https://hifi-two.spotisaver.net` | Community hosted | -| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted | -| **Binimum** | `https://tidal-api.binimum.org` | Community hosted | +| **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted - [Limited/No-Sub](https://rentry.co/limitedtidalaccs) | --- From 75a3f32b3fbcd13cb064aa1282f4b36e6083f95f Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:39:15 +0300 Subject: [PATCH 025/226] spotisaver CORS block their APIs --- INSTANCES.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/INSTANCES.md b/INSTANCES.md index 82d554452..369daa75a 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -59,8 +59,6 @@ These are available API endpoints that can be used with Monochrome or other Hi-F | | `https://vogel.qqdl.site` | Community hosted | | | `https://katze.qqdl.site` | Community hosted | | | `https://hund.qqdl.site` | Community hosted | -| **Spotisaver** | `https://hifi-one.spotisaver.net` | Community hosted | -| | `https://hifi-two.spotisaver.net` | Community hosted | | **Kinoplus** | `https://tidal.kinoplus.online` | Community hosted - [Limited/No-Sub](https://rentry.co/limitedtidalaccs) | --- From 6875235da7cdcf97fab5ed50d7af9ecbd1425ee5 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:40:15 +0300 Subject: [PATCH 026/226] this isnt really needed --- INSTANCES.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/INSTANCES.md b/INSTANCES.md index 369daa75a..cd7c7e32f 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -63,18 +63,6 @@ These are available API endpoints that can be used with Monochrome or other Hi-F --- -## Instance Health - -To check the current status of instances: - -1. Visit the instance URL in your browser -2. Check if the page loads correctly -3. Try playing a track to verify API connectivity - -> **Note:** Community instances may have varying uptime and performance. If one doesn't work, try another. - ---- - ## Adding Your Instance Want to add your instance to this list? From 2d472f76f87864804a8f929bb22d5075bded1f22 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:40:47 +0300 Subject: [PATCH 027/226] bini killed his instance too --- INSTANCES.md | 1 - 1 file changed, 1 deletion(-) diff --git a/INSTANCES.md b/INSTANCES.md index cd7c7e32f..0dacb7e15 100644 --- a/INSTANCES.md +++ b/INSTANCES.md @@ -31,7 +31,6 @@ These instances provide the tidal-ui web interface, not monochrome: | Provider | URL | Status | | ------------------- | ---------------------------------------------- | --------- | -| **bini (tidal-ui)** | [music.binimum.org](https://music.binimum.org) | Community | | **squid.wtf** | [tidal.squid.wtf](https://tidal.squid.wtf) | Community | | **QQDL** | [tidal.qqdl.site](https://tidal.qqdl.site/) | Community | From 7a90c0962b811c3b50fc0908236226278e179788 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:47:17 +0300 Subject: [PATCH 028/226] why not --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 30ea8da93..f85490bb6 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,8 @@ Our Recommended way to use monochrome is through our official instance: -**[monochrome.tf](https://monochrome.tf)** +**[monochrome.tf](https://monochrome.tf)** / **[monochrome.samidy.com](https://monochrome.samidy.com)** + For alternative instances, check [INSTANCES.md](INSTANCES.md). From 6c4cfc301a1043aefb24ba573216f994b1331757 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:48:51 +0300 Subject: [PATCH 029/226] npm is alr with nodejs so dont see the point --- README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f85490bb6..57e3982d2 100644 --- a/README.md +++ b/README.md @@ -162,8 +162,7 @@ For development mode and advanced setups, see [DOCKER.md](DOCKER.md). #### Prerequisites -- [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended) -- [Bun](https://bun.sh/) or [npm](https://www.npmjs.com/) +- [Bun](https://bun.sh/) (Preferred) or [Node.js](https://nodejs.org/) (Version 20+ or 22+ recommended) #### Local Development @@ -179,7 +178,7 @@ For development mode and advanced setups, see [DOCKER.md](DOCKER.md). ```bash bun install # or - npm install + npm install # NPM is included with Node.js ``` 3. **Start the development server:** From 839cb6cc61a2f238e40e4222894bc0fec17b40f8 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 08:50:29 +0300 Subject: [PATCH 030/226] we dont even push any of these?? --- .htmlhintignore | 3 --- .prettierignore | 6 ------ 2 files changed, 9 deletions(-) delete mode 100644 .htmlhintignore delete mode 100644 .prettierignore diff --git a/.htmlhintignore b/.htmlhintignore deleted file mode 100644 index 2730e75e1..000000000 --- a/.htmlhintignore +++ /dev/null @@ -1,3 +0,0 @@ -dist/ -node_modules/ -legacy/ diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index df8a97bbc..000000000 --- a/.prettierignore +++ /dev/null @@ -1,6 +0,0 @@ -dist/ -node_modules/ -legacy/ -package-lock.json -*.min.js -.github/ \ No newline at end of file From 51b88cc5e845e14cb453e57eaa5688df43c4a942 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:00:27 +0300 Subject: [PATCH 031/226] cdn to package for am-lyrics --- bun.lock | 15 +++++++++- js/lyrics.js | 28 ++----------------- package-lock.json | 70 ++++++++++++++++++++++++++++++++++++++++++++++- package.json | 1 + 4 files changed, 87 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 7a7217b85..94267220b 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@ffmpeg/util": "^0.12.2", "@kawarp/core": "^1.1.1", "@neutralinojs/lib": "^6.5.0", + "@uimaxbai/am-lyrics": "^1.1.1", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", @@ -18,7 +19,7 @@ "fuse.js": "^7.1.0", "hls.js": "^1.6.15", "jose": "^6.2.0", - "npm": "^11.11.0", + "npm": "^11.6.0", "pocketbase": "^0.26.8", "taglib-wasm": "^1.0.5", "uuid": "^13.0.0", @@ -419,6 +420,10 @@ "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + "@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.5.1", "", {}, "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA=="], + + "@lit/reactive-element": ["@lit/reactive-element@2.1.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0" } }, "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A=="], + "@msgpack/msgpack": ["@msgpack/msgpack@3.1.3", "", {}, "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA=="], "@neutralinojs/lib": ["@neutralinojs/lib@6.5.0", "", { "optionalDependencies": { "@rollup/rollup-darwin-x64": "*", "@rollup/rollup-linux-x64-gnu": "*" } }, "sha512-ECgYh+CXAfMR1JVTvDw/kHhjL6LzNNcjk8Va1DZUSBkUwROqFTQ7zseFeuFtwGvutqvlWiwpGmU3s11rg/bdvA=="], @@ -539,6 +544,8 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.1.1", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-U4qnMK2gWqfIWUwH9Wm4r5PGeCqmvmHVNCeYOTsSUIAMMZd3LBDyCZL/dAs5A4VaHxJecCyy2dy94IN4zH5gzg=="], + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -1027,6 +1034,12 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "lit": ["lit@3.3.2", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ=="], + + "lit-element": ["lit-element@4.2.2", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.5.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w=="], + + "lit-html": ["lit-html@3.3.2", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw=="], + "localforage": ["localforage@1.10.0", "", { "dependencies": { "lie": "3.1.1" } }, "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], diff --git a/js/lyrics.js b/js/lyrics.js index 8cdf87fd2..48db23b8d 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -1,6 +1,7 @@ //js/lyrics.js import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_CLOSE } from './utils.js'; import { sidePanelManager } from './side-panel.js'; +import '@uimaxbai/am-lyrics/am-lyrics.js'; const SVG_GENIUS_ACTIVE = ``; @@ -360,33 +361,10 @@ export class LyricsManager { async ensureComponentLoaded() { if (this.componentLoaded) return; - if (typeof customElements !== 'undefined' && customElements.get('am-lyrics')) { + if (typeof customElements !== 'undefined') { + await customElements.whenDefined('am-lyrics'); this.componentLoaded = true; - return; } - - return new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.type = 'module'; - script.src = 'https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics/dist/src/am-lyrics.min.js'; - - script.onload = () => { - if (typeof customElements !== 'undefined') { - customElements - .whenDefined('am-lyrics') - .then(() => { - this.componentLoaded = true; - resolve(); - }) - .catch(reject); - } else { - resolve(); - } - }; - - script.onerror = () => reject(new Error('Failed to load lyrics component')); - document.head.appendChild(script); - }); } async fetchLyrics(trackId, track = null) { diff --git a/package-lock.json b/package-lock.json index 6b635e1b8..1ab271955 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@ffmpeg/util": "^0.12.2", "@kawarp/core": "^1.1.1", "@neutralinojs/lib": "^6.5.0", + "@uimaxbai/am-lyrics": "^1.1.1", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", @@ -3283,6 +3284,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.5.1.tgz", + "integrity": "sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==", + "license": "BSD-3-Clause" + }, + "node_modules/@lit/reactive-element": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.2.tgz", + "integrity": "sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + } + }, "node_modules/@msgpack/msgpack": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", @@ -4079,9 +4095,30 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, "license": "MIT" }, + "node_modules/@uimaxbai/am-lyrics": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@uimaxbai/am-lyrics/-/am-lyrics-1.1.1.tgz", + "integrity": "sha512-U4qnMK2gWqfIWUwH9Wm4r5PGeCqmvmHVNCeYOTsSUIAMMZd3LBDyCZL/dAs5A4VaHxJecCyy2dy94IN4zH5gzg==", + "license": "MPL-2.0", + "dependencies": { + "@babel/runtime": "^7.27.6", + "lit": "^3.1.4" + }, + "peerDependencies": { + "@lit/react": "^1.0.0", + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "@lit/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -7426,6 +7463,37 @@ "dev": true, "license": "MIT" }, + "node_modules/lit": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.2.tgz", + "integrity": "sha512-NF9zbsP79l4ao2SNrH3NkfmFgN/hBYSQo90saIVI1o5GpjAdCPVstVzO1MrLOakHoEhYkrtRjPK6Ob521aoYWQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^2.1.0", + "lit-element": "^4.2.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-element": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.2.tgz", + "integrity": "sha512-aFKhNToWxoyhkNDmWZwEva2SlQia+jfG0fjIWV//YeTaWrVnOxD89dPKfigCUspXFmjzOEUQpOkejH5Ly6sG0w==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0", + "@lit/reactive-element": "^2.1.0", + "lit-html": "^3.3.0" + } + }, + "node_modules/lit-html": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.2.tgz", + "integrity": "sha512-Qy9hU88zcmaxBXcc10ZpdK7cOLXvXpRoBxERdtqV9QOrfpMZZ6pSYP91LhpPtap3sFMUiL7Tw2RImbe0Al2/kw==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/trusted-types": "^2.0.2" + } + }, "node_modules/localforage": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", diff --git a/package.json b/package.json index 1ebbdfe44..554fb77ee 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@ffmpeg/util": "^0.12.2", "@kawarp/core": "^1.1.1", "@neutralinojs/lib": "^6.5.0", + "@uimaxbai/am-lyrics": "^1.1.1", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", From 3e83394014de9ae39dad3aed5a5809ac696d6240 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:01:47 +0300 Subject: [PATCH 032/226] update linko here --- .github/pull_request_template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a7b191901..3ecdb373a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,7 +6,7 @@ - [ ] Docs only ### Checklist -- [ ] **I have read the [Contributing Guidelines](https://github.com/monochrome-music/monochrome/blob/main/CONTRIBUTING.md).** +- [ ] **I have read the [Contributing Guidelines](../CONTRIBUTING.md).** - [ ] **I understand every line of code I am submitting.** - [ ] I have tested these changes locally, and they work as expected. From c9a1f49f23085dbf95a185c4d10b9d4648e9f36d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:13:40 +0000 Subject: [PATCH 033/226] feat: extract duplicated download utilities from api.js and downloads.js into download-utils.ts Co-authored-by: DanTheMan827 <790119+DanTheMan827@users.noreply.github.com> --- js/api.js | 192 ++----------------------------------------- js/download-utils.ts | 79 ++++++++++++++++++ js/downloads.js | 109 ++---------------------- 3 files changed, 91 insertions(+), 289 deletions(-) create mode 100644 js/download-utils.ts diff --git a/js/api.js b/js/api.js index ce7434d9f..0b8e46507 100644 --- a/js/api.js +++ b/js/api.js @@ -9,21 +9,15 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { trackDateSettings, losslessContainerSettings } from './storage.js'; +import { trackDateSettings } from './storage.js'; import { APICache } from './cache.js'; 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 { rebuildFlacWithoutMetadata } from './metadata.flac.js'; -import { - isCustomFormat, - getCustomFormat, - transcodeWithCustomFormat, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; +import { isCustomFormat } from './ffmpegFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1426,170 +1420,7 @@ export class LosslessAPI { } if (!isVideo) { - const coverBlobToEmbed = await prefetchPromises.coverFetch; - const extraFiles = []; - const ffmpegMetadataArgs = []; - - if (coverBlobToEmbed) { - const coverBuffer = await coverBlobToEmbed.arrayBuffer(); - const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; - const coverName = `cover.${coverExt}`; - extraFiles.push({ - name: coverName, - data: coverBuffer, - }); - ffmpegMetadataArgs.push('-i', coverName); - } - - if (track) { - ffmpegMetadataArgs.push( - '-metadata', - `title=${getTrackTitle(track)}`, - '-metadata', - `artist=${getFullArtistString(track)}`, - '-metadata', - `album=${track.album?.title || ''}`, - '-metadata', - `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` - ); - - const trackNum = track.trackNumber; - if (trackNum) { - const totalTracks = track.album?.numberOfTracks; - ffmpegMetadataArgs.push( - '-metadata', - `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}` - ); - } - - const discNum = track.volumeNumber || track.discNumber; - if (discNum) { - ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); - } - - const releaseDate = track.album?.releaseDate || track?.streamStartDate; - if (releaseDate) { - ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); - } - } - - // Transcode to custom format if requested - if (isCustomFormat(quality)) { - const format = getCustomFormat(quality); - if (format) { - try { - const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push( - '-map', - '0:a', - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - - blob = await ffmpeg( - blob, - { args }, - format.outputFilename, - format.outputMime, - onProgress, - options.signal, - extraFiles - ); - } catch (encodingError) { - if (onProgress) { - onProgress({ - stage: 'error', - message: `Encoding failed: ${encodingError.message}`, - }); - } - throw encodingError; - } - } - } - - if (quality.endsWith('LOSSLESS')) { - try { - const containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push( - '-map', - '0:a', - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - - blob = await ffmpeg( - blob, - { args }, - containerFmt.outputFilename, - containerFmt.outputMime, - onProgress, - options.signal, - extraFiles - ); - } else if ((await getExtensionFromBlob(blob)) == 'flac') { - blob = await rebuildFlacWithoutMetadata(blob); - } - } else { - const actualExtension = await getExtensionFromBlob(blob); - if (actualExtension === 'm4a' || actualExtension === 'mp4') { - try { - const ffmpegArgs = [...ffmpegMetadataArgs]; - - ffmpegArgs.push('-map', '0:a'); - if (coverBlobToEmbed) { - ffmpegArgs.push( - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); - - const remuxedBlob = await ffmpeg( - blob, - { args: ffmpegArgs }, - 'output.mp4', - 'audio/mp4', - onProgress, - options.signal, - extraFiles - ); - if (remuxedBlob) { - blob = remuxedBlob; - } - } catch (e) { - console.warn('Failed to remux hi-res M4A, proceeding with original:', e); - } - } - } - } catch (error) { - if (error?.name === 'AbortError') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal); // Add metadata if track information is provided if (track) { @@ -1673,7 +1504,7 @@ export class LosslessAPI { finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`); } - this.triggerDownload(blob, finalFilename); + triggerDownload(blob, finalFilename); return blob; } catch (error) { if (error.name === 'AbortError') { @@ -1694,17 +1525,6 @@ export class LosslessAPI { } } - triggerDownload(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } - getCoverUrl(id, size = '320') { if (!id) { return `https://picsum.photos/seed/${Math.random()}/${size}`; diff --git a/js/download-utils.ts b/js/download-utils.ts new file mode 100644 index 000000000..1bf62fcdd --- /dev/null +++ b/js/download-utils.ts @@ -0,0 +1,79 @@ +import { losslessContainerSettings } from './storage'; +import { rebuildFlacWithoutMetadata } from './metadata.flac'; +import { getExtensionFromBlob } from './utils'; +import { + type ProgressEvent, + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats'; + +/** + * Triggers a browser file download for the given blob. + */ +export function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Applies audio post-processing to a blob: + * 1. Transcodes to a custom ffmpeg format if `quality` identifies one. + * 2. Re-muxes to the user-selected lossless container when the quality is + * a lossless tier (quality ends with "LOSSLESS"). + * + * Returns the (possibly transformed) blob. + */ +export async function applyAudioPostProcessing( + blob: Blob, + quality: string, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + // Transcode to custom format if requested + if (isCustomFormat(quality)) { + const format = getCustomFormat(quality); + if (format) { + try { + blob = await transcodeWithCustomFormat(blob, format, onProgress, signal); + } catch (encodingError) { + if (onProgress) { + onProgress({ + stage: 'error', + message: `Encoding failed: ${(encodingError as Error).message}`, + }); + } + throw encodingError; + } + } + } + + if (quality.endsWith('LOSSLESS')) { + try { + 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 as Error)?.name === 'AbortError') { + throw error; + } + + console.error('Lossless container conversion failed:', error); + } + } + + return blob; +} diff --git a/js/downloads.js b/js/downloads.js index e2dd75455..8c77937f0 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -14,19 +14,13 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; +import { lyricsSettings, bulkDownloadSettings, playlistSettings } from './storage.js'; 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, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +import { loadFfmpeg } from './ffmpeg.js'; +import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; +import { isCustomFormat } from './ffmpegFormats.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); @@ -464,88 +458,8 @@ async function downloadTrackBlob( } } - // Transcode to custom format if requested - if (isCustomFormat(quality)) { - const format = getCustomFormat(quality); - if (format) { - const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - - blob = await ffmpeg( - blob, - { args }, - format.outputFilename, - format.outputMime, - onProgress, - signal, - extraFiles - ); - } - } - - if (quality.endsWith('LOSSLESS')) { - try { - const containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - - blob = await ffmpeg( - blob, - { args }, - containerFmt.outputFilename, - containerFmt.outputMime, - onProgress, - signal, - extraFiles - ); - } else if ((await getExtensionFromBlob(blob)) == 'flac') { - blob = await rebuildFlacWithoutMetadata(blob); - } - } else { - const actualExtension = await getExtensionFromBlob(blob); - if (actualExtension === 'm4a' || actualExtension === 'mp4') { - try { - const ffmpegArgs = [...ffmpegMetadataArgs]; - - ffmpegArgs.push('-map', '0:a'); - if (coverBlobToEmbed) { - ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); - - const remuxedBlob = await ffmpeg( - blob, - { args: ffmpegArgs }, - 'output.mp4', - 'audio/mp4', - onProgress, - signal, - extraFiles - ); - if (remuxedBlob) { - blob = remuxedBlob; - } - } catch (e) { - console.warn('Failed to remux hi-res M4A, proceeding with original:', e); - } - } - } - } catch (error) { - if (error?.name === 'AbortError') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + // Apply audio post-processing (custom format transcoding + lossless container conversion) + blob = await applyAudioPostProcessing(blob, quality, onProgress, signal); // Detect actual format from blob signature BEFORE adding metadata const extension = await getExtensionFromBlob(blob); @@ -556,17 +470,6 @@ async function downloadTrackBlob( return { blob, extension }; } -function triggerDownload(blob, filename) { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); -} - async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) { const { abortController } = bulkDownloadTasks.get(notification); const signal = abortController.signal; From c1552980eb2418eb7eea2151d869898fcd22fccd Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:57:18 +0000 Subject: [PATCH 034/226] feat: extract bulk download handlers into bulk-download-writer.ts and add folder picker + settings --- index.html | 33 ++- js/bulk-download-writer.ts | 180 +++++++++++++ js/downloads.js | 514 ++++--------------------------------- js/ffmpegFormats.ts | 9 - js/global.d.ts | 5 + js/settings.js | 68 ++++- js/storage.js | 50 +++- js/utils.js | 1 + 8 files changed, 378 insertions(+), 482 deletions(-) create mode 100644 js/bulk-download-writer.ts diff --git a/index.html b/index.html index b14849223..dfafb6da1 100644 --- a/index.html +++ b/index.html @@ -5074,14 +5074,27 @@

Custom Theme

- Zipped Bulk Downloads + Bulk Download Method Download multiple tracks as a single ZIP file (requires browser - support)Choose how multiple tracks are downloaded together +
+ +
+
+
+ Force ZIP as Blob + Download ZIP in memory instead of streaming to disk (use if ZIP streaming + causes issues)
@@ -5245,7 +5258,7 @@

Custom Theme

- Separate Discs in ZIP + Separate Discs Put tracks in Disc folders when a release has multiple discs @@ -5255,6 +5268,16 @@

Custom Theme

+
+
+ Include Cover File + Include cover.jpg in downloads +
+ +
diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts new file mode 100644 index 000000000..418002c7c --- /dev/null +++ b/js/bulk-download-writer.ts @@ -0,0 +1,180 @@ +import { triggerDownload } from './download-utils'; + +/** + * A single entry to be included in a ZIP archive or written directly to a folder. + */ +export interface WriterEntry { + name: string; + lastModified: Date; + input: Blob | string | ArrayBuffer | Uint8Array; +} + +/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */ +interface NeutralinoBridge { + os: { + showSaveDialog( + title: string, + options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> } + ): Promise; + }; + filesystem: { + writeBinaryFile(path: string, buffer: ArrayBuffer): Promise; + appendBinaryFile(path: string, buffer: ArrayBuffer): Promise; + }; +} + +async function loadClientZip() { + try { + return await import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm'); + } catch (error) { + console.error('Failed to load client-zip:', error); + throw new Error('Failed to load ZIP library'); + } +} + +/** + * Interface for writing a collection of file entries to an output destination. + * Each implementation handles its own output selection (save dialog, directory picker, etc.) + * and throws a DOMException with name 'AbortError' if the user cancels. + */ +export interface IBulkDownloadWriter { + write(files: AsyncIterable): Promise; +} + +/** + * Streams a ZIP archive to a file via the File System Access API. + * Prompts the user to choose a save location with showSaveFilePicker. + */ +export class ZipStreamWriter implements IBulkDownloadWriter { + constructor(private readonly suggestedFilename: string) {} + + async write(files: AsyncIterable): Promise { + // showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileHandle = await (window as any).showSaveFilePicker({ + suggestedName: this.suggestedFilename, + types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], + }); + const { downloadZip } = await loadClientZip(); + const writable = await fileHandle.createWritable(); + const response = downloadZip(files); + if (!response.body) throw new Error('ZIP response body is null'); + await response.body.pipeTo(writable); + } +} + +/** + * Collects a ZIP archive into a Blob and triggers a browser download. + * Works on all browsers without requiring the File System Access API. + */ +export class ZipBlobWriter implements IBulkDownloadWriter { + constructor(private readonly filename: string) {} + + async write(files: AsyncIterable): Promise { + const { downloadZip } = await loadClientZip(); + const response = downloadZip(files); + const blob = await response.blob(); + triggerDownload(blob, this.filename); + } +} + +/** + * Writes a ZIP archive to the filesystem via the Neutralino desktop bridge, + * showing a native save dialog first. + */ +export class ZipNeutralinoWriter implements IBulkDownloadWriter { + constructor(private readonly folderName: string) {} + + async write(files: AsyncIterable): Promise { + const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge; + + const savePath = await bridge.os.showSaveDialog(`Select save location for ${this.folderName}.zip`, { + defaultPath: `${this.folderName}.zip`, + filters: [{ name: 'ZIP Archive', extensions: ['zip'] }], + }); + + if (!savePath) { + throw new DOMException('User cancelled save dialog', 'AbortError'); + } + + const { downloadZip } = await loadClientZip(); + await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0)); + + const response = downloadZip(files); + if (!response.body) throw new Error('ZIP response body is null'); + + const reader = response.body.getReader(); + let receivedLength = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); + await bridge.filesystem.appendBinaryFile(savePath, chunk); + receivedLength += value.length; + } + + console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`); + } +} + +/** + * Writes files directly into a user-chosen folder using the standard browser + * File System Access API (showDirectoryPicker). Subdirectories embedded in + * file entry names are created automatically. + * + * Use the static {@link FolderPickerWriter.create} method to obtain an instance; + * the constructor is private so the directory handle is always set before use. + */ +export class FolderPickerWriter implements IBulkDownloadWriter { + private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {} + + /** + * Prompts the user to pick a writable directory. + * Returns a new {@link FolderPickerWriter} bound to the chosen directory. + * If the user dismisses the picker, the promise rejects with a DOMException + * whose name is "AbortError". + */ + static async create(): Promise { + // showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({ + mode: 'readwrite', + }); + return new FolderPickerWriter(dirHandle); + } + + async write(files: AsyncIterable): Promise { + for await (const file of files) { + const parts = file.name.split('/').filter(Boolean); + if (parts.length === 0) continue; + + let currentDir: FileSystemDirectoryHandle = this.dirHandle; + for (let i = 0; i < parts.length - 1; i++) { + currentDir = await currentDir.getDirectoryHandle(parts[i], { create: true }); + } + + const filename = parts[parts.length - 1]; + const fileHandle = await currentDir.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + + const { input } = file; + if (input instanceof Blob) { + await writable.write(input); + } else if (typeof input === 'string') { + await writable.write(new Blob([input], { type: 'text/plain' })); + } else { + // ArrayBuffer or Uint8Array – wrap in a Blob to guarantee strict typing. + // Use byteOffset/byteLength so only the view's range is included, not the + // whole backing ArrayBuffer (which may be larger due to pooling). + const buf = + input instanceof Uint8Array + ? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) + : input; + await writable.write(new Blob([buf as ArrayBuffer])); + } + + await writable.close(); + } + } +} diff --git a/js/downloads.js b/js/downloads.js index 8c77937f0..56cf9c9a3 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -21,22 +21,13 @@ import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } fro import { loadFfmpeg } from './ffmpeg.js'; import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; import { isCustomFormat } from './ffmpegFormats.ts'; +import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts'; const downloadTasks = new Map(); const bulkDownloadTasks = new Map(); const ongoingDownloads = new Set(); let downloadNotificationContainer = null; -async function loadClientZip() { - try { - const module = await import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm'); - return module; - } catch (error) { - console.error('Failed to load client-zip:', error); - throw new Error('Failed to load ZIP library'); - } -} - async function createDiscLayoutContext(tracks, api) { if (!playlistSettings.shouldSeparateDiscsInZip()) { return { separateByDisc: false, resolveDiscNumber: () => 1 }; @@ -508,27 +499,24 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not } } -async function bulkDownloadToZipStream( +async function bulkDownloadToZip( tracks, folderName, api, quality, lyricsManager, notification, - fileHandle, + writer, coverBlob = null, type = 'playlist', metadata = null ) { const { abortController } = bulkDownloadTasks.get(notification); const signal = abortController.signal; - const { downloadZip } = await loadClientZip(); - - const writable = await fileHandle.createWritable(); async function* yieldFiles() { - // Add cover if available - if (coverBlob) { + // Add cover if available and enabled + if (coverBlob && playlistSettings.shouldIncludeCover()) { yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; } @@ -613,9 +601,8 @@ async function bulkDownloadToZipStream( }; } - // For albums, generate CUE file + // For albums, generate CUE file (one per disc if multi-disc) if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - // Split tracks by volumeNumber and iterate those groups. const tracksByVolume = Object.groupBy( tracks.map((track, index) => ({ ...track, @@ -625,9 +612,14 @@ async function bulkDownloadToZipStream( ); 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); + for (const [volumeNumber, volumeTracks] of Object.entries(tracksByVolume)) { + const volumeTrackPaths = volumeTracks.map((track) => track.trackPath); + const cueContent = generateCUE( + metadata, + volumeTracks, + sanitizeForFilename(folderName), + volumeTrackPaths + ); yield { name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`, lastModified: new Date(), @@ -670,370 +662,35 @@ async function bulkDownloadToZipStream( } } - try { - const response = downloadZip(yieldFiles()); - await response.body.pipeTo(writable); - } catch (error) { - if (error.name === 'AbortError') return; - throw error; - } + await writer.write(yieldFiles()); } -// Generate ZIP as blob for browsers without File System Access API (iOS, etc.) -async function bulkDownloadToZipBlob( - tracks, - folderName, - api, - quality, - lyricsManager, - notification, - coverBlob = null, - type = 'playlist', - metadata = null -) { - const { abortController } = bulkDownloadTasks.get(notification); - const signal = abortController.signal; - const { downloadZip } = await loadClientZip(); - - async function* yieldFiles() { - // Add cover if available - if (coverBlob) { - yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; - } - - const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const discLayout = await createDiscLayoutContext(tracks, api); - const separateByDisc = discLayout.separateByDisc; - - // 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); - - 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()) { - const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - // For albums, generate CUE file - if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, - lastModified: new Date(), - input: cueContent, - }; - } - - // 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, - }; - } +/** + * Returns the appropriate bulk download writer for the current settings and environment, + * or null when individual sequential downloads should be used. + */ +async function createBulkWriter(folderName) { + const isNeutralino = window.NL_MODE === true; + const method = bulkDownloadSettings.getMethod(); + const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob(); + const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; + const hasFolderPicker = 'showDirectoryPicker' in window; - 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, - }; - } + if (isNeutralino) { + return new ZipNeutralinoWriter(folderName); } - - try { - const response = downloadZip(yieldFiles()); - const blob = await response.blob(); - triggerDownload(blob, `${folderName}.zip`); - } catch (error) { - if (error.name === 'AbortError') return; - throw error; + if (method === 'folder' && hasFolderPicker) { + // FolderPickerWriter.create() throws AbortError if the user cancels + return await FolderPickerWriter.create(); } -} - -async function bulkDownloadToZipNeutralino( - tracks, - folderName, - api, - quality, - lyricsManager, - notification, - coverBlob = null, - type = 'playlist', - metadata = null -) { - const { abortController } = bulkDownloadTasks.get(notification); - const signal = abortController.signal; - const { downloadZip } = await loadClientZip(); - - // Re-use logic for generating file entries - async function* yieldFiles() { - // Add cover if available - if (coverBlob) { - yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; - } - - const useRelativePaths = playlistSettings.shouldUseRelativePaths(); - const discLayout = await createDiscLayoutContext(tracks, api); - const separateByDisc = discLayout.separateByDisc; - - // 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); - - 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()) { - const nfoContent = generateNFO(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.nfo`, - lastModified: new Date(), - input: nfoContent, - }; - } - - if (playlistSettings.shouldGenerateJSON()) { - const jsonContent = generateJSON(metadata || { title: folderName }, tracks, type); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.json`, - lastModified: new Date(), - input: jsonContent, - }; - } - - // For albums, generate CUE file - if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - const cueContent = generateCUE(metadata, tracks, sanitizeForFilename(folderName), trackPaths); - yield { - name: `${folderName}/${sanitizeForFilename(folderName)}.cue`, - lastModified: new Date(), - input: cueContent, - }; - } - - // 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, - }; - } - - 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, - }; - } + if (method === 'individual') { + return null; } - - try { - // Load the bridge explicitly to ensure we go through the parent shell - const bridge = await import('./desktop/neutralino-bridge.js'); - - // Native Save Dialog via Bridge - const savePath = await bridge.os.showSaveDialog(`Select save location for ${folderName}.zip`, { - defaultPath: `${folderName}.zip`, - filters: [{ name: 'ZIP Archive', extensions: ['zip'] }], - }); - - if (!savePath) { - // Cancelled - removeBulkDownloadTask(notification); - return; - } - - const response = downloadZip(yieldFiles()); - - // Initialize file (empty) to ensure it exists - // We use writeBinaryFile with an empty buffer to create/overwrite - await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0)); - - // Stream the response body - if (!response.body) throw new Error('ZIP response body is null'); - - const reader = response.body.getReader(); - let receivedLength = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - // 'value' is a Uint8Array. Neutralino filesystem expects ArrayBuffer. - // value.buffer might contain the whole backing store, so we should be careful to slice if offset is non-zero - // but usually read() returns fresh chunks. - // However, neutralino bridge's appendBinaryFile takes ArrayBuffer. - const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); - - await bridge.filesystem.appendBinaryFile(savePath, chunk); - receivedLength += value.length; - - // Optional: Update granular progress if we want, but we typically update per-track in yieldFiles - } - - console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`); - - completeBulkDownload(notification, true); - } catch (error) { - if (error.name === 'AbortError') return; - throw error; + // method === 'zip' (or folder picker unavailable as fallback) + if (!forceZipBlob && hasFileSystemAccess) { + return new ZipStreamWriter(`${folderName}.zip`); } + return new ZipBlobWriter(`${folderName}.zip`); } async function startBulkDownload( @@ -1050,73 +707,32 @@ async function startBulkDownload( const notification = createBulkDownloadNotification(type, name, tracks.length); try { - const isNeutralino = window.NL_MODE === true; - const hasFileSystemAccess = - 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; - const forceIndividual = bulkDownloadSettings.shouldForceIndividual(); - const useZip = hasFileSystemAccess && !forceIndividual; - const useZipBlob = !hasFileSystemAccess && !forceIndividual; - - if (isNeutralino) { - // Neutralino Native Logic - await bulkDownloadToZipNeutralino( - tracks, - defaultName, - api, - quality, - lyricsManager, - notification, - coverBlob, - type, - metadata - ); - } else if (useZip) { - // File System Access API available - use streaming - try { - const fileHandle = await window.showSaveFilePicker({ - suggestedName: `${defaultName}.zip`, - types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], - }); - await bulkDownloadToZipStream( - tracks, - defaultName, - api, - quality, - lyricsManager, - notification, - fileHandle, - coverBlob, - type, - metadata - ); - completeBulkDownload(notification, true); - } catch (err) { - if (err.name === 'AbortError') { - removeBulkDownloadTask(notification); - return; - } - throw err; - } - } else if (useZipBlob) { - // No File System Access API (iOS, etc.) - use blob-based ZIP - await bulkDownloadToZipBlob( + const writer = await createBulkWriter(defaultName); + + if (writer) { + await bulkDownloadToZip( tracks, defaultName, api, quality, lyricsManager, notification, + writer, coverBlob, type, metadata ); - completeBulkDownload(notification, true); } else { - // Fallback or Forced: Individual sequential downloads + // Individual sequential downloads await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); - completeBulkDownload(notification, true); } + + completeBulkDownload(notification, true); } catch (error) { + if (error.name === 'AbortError') { + removeBulkDownloadTask(notification); + return; + } console.error('Bulk download failed:', error); completeBulkDownload(notification, false, error.message); } @@ -1183,10 +799,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const { abortController } = bulkDownloadTasks.get(notification); const signal = abortController.signal; - const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; - const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); - const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual(); - async function* yieldDiscography() { for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { if (signal.aborted) break; @@ -1213,7 +825,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality ); const fullFolderPath = `${rootFolder}/${albumFolder}`; - if (coverBlob) + if (coverBlob && playlistSettings.shouldIncludeCover()) yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob }; // Generate playlist files for each album @@ -1332,27 +944,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality } try { - if (useZip) { - // File System Access API available - use streaming - const fileHandle = await window.showSaveFilePicker({ - suggestedName: `${rootFolder}.zip`, - types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], - }); - const writable = await fileHandle.createWritable(); - const { downloadZip } = await loadClientZip(); - - const response = downloadZip(yieldDiscography()); - await response.body.pipeTo(writable); - completeBulkDownload(notification, true); - } else if (useZipBlob) { - // No File System Access API (iOS, etc.) - use blob-based ZIP - const { downloadZip } = await loadClientZip(); - const response = downloadZip(yieldDiscography()); - const blob = await response.blob(); - triggerDownload(blob, `${rootFolder}.zip`); - completeBulkDownload(notification, true); + const writer = await createBulkWriter(rootFolder); + + if (writer) { + await writer.write(yieldDiscography()); } else { - // Sequential individual downloads for discography + // Individual sequential downloads for discography for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { if (signal.aborted) break; const album = selectedReleases[albumIndex]; @@ -1361,8 +958,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const tracks = await annotateTracksWithDiscInfo(rawTracks, api); await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); } - completeBulkDownload(notification, true); } + + completeBulkDownload(notification, true); } catch (error) { if (error.name === 'AbortError') { removeBulkDownloadTask(notification); diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index 5baa79cf6..ea94e15bf 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -176,15 +176,6 @@ export const containerFormats: ContainerFormat[] = [ 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 */ diff --git a/js/global.d.ts b/js/global.d.ts index ed623f9ae..b64150013 100644 --- a/js/global.d.ts +++ b/js/global.d.ts @@ -2,3 +2,8 @@ declare module '*?url' { const content: string; export default content; } + +declare module 'https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm' { + /** Creates a ZIP stream from an async iterable of file entries. */ + export function downloadZip(files: AsyncIterable): Response; +} diff --git a/js/settings.js b/js/settings.js index 22a48ba59..8d7b1b020 100644 --- a/js/settings.js +++ b/js/settings.js @@ -861,10 +861,21 @@ export function initializeSettings(scrobbler, player, api, ui) { downloadQualitySetting.addEventListener('change', (e) => { downloadQualitySettings.setQuality(e.target.value); + updateLosslessContainerVisibility(); }); } const losslessContainerSetting = document.getElementById('lossless-container-setting'); + const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item'); + + /** Shows/hides the Lossless Container setting based on the selected quality */ + function updateLosslessContainerVisibility() { + if (!losslessContainerSettingItem) return; + const quality = downloadQualitySettings.getQuality(); + const isLossless = quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS'; + losslessContainerSettingItem.style.display = isLossless ? '' : 'none'; + } + if (losslessContainerSetting) { for (const { internalName, displayName } of containerFormats) { const option = document.createElement('option'); @@ -880,6 +891,8 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + updateLosslessContainerVisibility(); + // Cover Art Size setting const coverArtSizeSetting = document.getElementById('cover-art-size-setting'); if (coverArtSizeSetting) { @@ -910,11 +923,56 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } - const zippedBulkDownloadsToggle = document.getElementById('zipped-bulk-downloads-toggle'); - if (zippedBulkDownloadsToggle) { - zippedBulkDownloadsToggle.checked = !bulkDownloadSettings.shouldForceIndividual(); - zippedBulkDownloadsToggle.addEventListener('change', (e) => { - bulkDownloadSettings.setForceIndividual(!e.target.checked); + const forceZipBlobToggle = document.getElementById('force-zip-blob-toggle'); + const forceZipBlobSettingItem = forceZipBlobToggle?.closest('.setting-item'); + const hasFileSystemAccess = + 'showSaveFilePicker' in window && + typeof FileSystemFileHandle !== 'undefined' && + 'createWritable' in FileSystemFileHandle.prototype; + + /** Shows/hides the Force ZIP as Blob setting based on method and browser support */ + function updateForceZipBlobVisibility() { + if (!forceZipBlobSettingItem) return; + const method = bulkDownloadSettings.getMethod(); + // Only relevant when zip method is selected and the browser supports streaming + const visible = method === 'zip' && hasFileSystemAccess; + forceZipBlobSettingItem.style.display = visible ? '' : 'none'; + } + + const bulkDownloadMethod = document.getElementById('bulk-download-method'); + if (bulkDownloadMethod) { + // Remove the folder picker option if the browser doesn't support it + if (!('showDirectoryPicker' in window)) { + const folderOption = bulkDownloadMethod.querySelector('option[value="folder"]'); + if (folderOption) { + folderOption.remove(); + } + // If the stored method is 'folder', fall back to 'zip' + if (bulkDownloadSettings.getMethod() === 'folder') { + bulkDownloadSettings.setMethod('zip'); + } + } + bulkDownloadMethod.value = bulkDownloadSettings.getMethod(); + bulkDownloadMethod.addEventListener('change', (e) => { + bulkDownloadSettings.setMethod(e.target.value); + updateForceZipBlobVisibility(); + }); + } + + if (forceZipBlobToggle) { + forceZipBlobToggle.checked = bulkDownloadSettings.shouldForceZipBlob(); + forceZipBlobToggle.addEventListener('change', (e) => { + bulkDownloadSettings.setForceZipBlob(e.target.checked); + }); + } + + updateForceZipBlobVisibility(); + + const includeCoverToggle = document.getElementById('include-cover-toggle'); + if (includeCoverToggle) { + includeCoverToggle.checked = playlistSettings.shouldIncludeCover(); + includeCoverToggle.addEventListener('change', (e) => { + playlistSettings.setIncludeCover(e.target.checked); }); } diff --git a/js/storage.js b/js/storage.js index de7c4709c..db28d429e 100644 --- a/js/storage.js +++ b/js/storage.js @@ -559,7 +559,9 @@ export const losslessContainerSettings = { STORAGE_KEY: 'lossless-container', getContainer() { try { - return localStorage.getItem(this.STORAGE_KEY) || 'flac'; + const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac'; + // 'nochange' was removed as an option; fall back to FLAC + return stored === 'nochange' ? 'flac' : stored; } catch { return 'flac'; } @@ -650,18 +652,42 @@ export const trackDateSettings = { }; export const bulkDownloadSettings = { - STORAGE_KEY: 'force-individual-downloads', + METHOD_KEY: 'bulk-download-method', + FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob', - shouldForceIndividual() { + /** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */ + getMethod() { try { - return localStorage.getItem(this.STORAGE_KEY) === 'true'; + return localStorage.getItem(this.METHOD_KEY) || 'zip'; + } catch { + return 'zip'; + } + }, + + setMethod(method) { + localStorage.setItem(this.METHOD_KEY, method); + }, + + /** When using ZIP mode, force in-memory blob download instead of streaming to disk */ + shouldForceZipBlob() { + try { + return localStorage.getItem(this.FORCE_ZIP_BLOB_KEY) === 'true'; } catch { return false; } }, + setForceZipBlob(enabled) { + localStorage.setItem(this.FORCE_ZIP_BLOB_KEY, enabled ? 'true' : 'false'); + }, + + // Kept for backward compatibility + shouldForceIndividual() { + return this.getMethod() === 'individual'; + }, + setForceIndividual(enabled) { - localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); + this.setMethod(enabled ? 'individual' : 'zip'); }, }; @@ -673,6 +699,7 @@ export const playlistSettings = { JSON_KEY: 'playlist-generate-json', RELATIVE_PATHS_KEY: 'playlist-relative-paths', SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip', + INCLUDE_COVER_KEY: 'playlist-include-cover', shouldGenerateM3U() { try { @@ -760,6 +787,19 @@ export const playlistSettings = { setSeparateDiscsInZip(enabled) { localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false'); }, + + shouldIncludeCover() { + try { + const val = localStorage.getItem(this.INCLUDE_COVER_KEY); + return val === null ? true : val === 'true'; + } catch { + return true; + } + }, + + setIncludeCover(enabled) { + localStorage.setItem(this.INCLUDE_COVER_KEY, enabled ? 'true' : 'false'); + }, }; export const visualizerSettings = { diff --git a/js/utils.js b/js/utils.js index ae22d4c24..5c55dfa0c 100644 --- a/js/utils.js +++ b/js/utils.js @@ -373,6 +373,7 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } export const formatTemplate = (template, data) => { let result = template; + result = result.replace(/\{discNumber\}/g, data.discNumber ? String(data.discNumber).padStart(2, '0') : '01'); result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00'); result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist')); result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title')); From 489b606654a79e9297cab171c7be35dd498abacc Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:08:18 +0300 Subject: [PATCH 035/226] why the fuck is this here --- index.html | 7 ------- 1 file changed, 7 deletions(-) diff --git a/index.html b/index.html index b14849223..a26c4a040 100644 --- a/index.html +++ b/index.html @@ -5544,13 +5544,6 @@

Sign Up / Sign In

Sync your library across devices

-

We only store music data and a randomized ID to find out which Google/Email account is which. From 6ff010d098e1c89f6efc3ba7483ff0d0f6b1cc36 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:13:20 +0300 Subject: [PATCH 036/226] consistency shi ykwim --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index a26c4a040..fb24770f6 100644 --- a/index.html +++ b/index.html @@ -1756,7 +1756,7 @@

Support Monochrome

- An alternative way to support us is by starring us on GitHub, and it's completely free! + If you cannot financially support us, please consider starring the project on GitHub and sharing with friends!

Date: Thu, 12 Mar 2026 09:16:58 +0300 Subject: [PATCH 037/226] we dont need to pre-connect to any hifi API --- index.html | 1 - 1 file changed, 1 deletion(-) diff --git a/index.html b/index.html index fb24770f6..9ec2385d2 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,6 @@ - From 401c2687fb1ee709e18b1e184aaf3bb4bdc382be Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:17:22 +0300 Subject: [PATCH 038/226] both arent needed anymore and practically worse for privacy --- index.html | 2 -- 1 file changed, 2 deletions(-) diff --git a/index.html b/index.html index 9ec2385d2..2a9c31d8b 100644 --- a/index.html +++ b/index.html @@ -14,8 +14,6 @@ - - From 5dca0421c020ae8d0a255c5c75a84f140e5c71f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:18:39 +0000 Subject: [PATCH 039/226] chore(deps): bump tar and npm Removes [tar](https://github.com/isaacs/node-tar). It's no longer used after updating ancestor dependency [npm](https://github.com/npm/cli). These dependencies need to be updated together. Removes `tar` Updates `npm` from 11.11.0 to 11.11.1 - [Release notes](https://github.com/npm/cli/releases) - [Changelog](https://github.com/npm/cli/blob/latest/CHANGELOG.md) - [Commits](https://github.com/npm/cli/compare/v11.11.0...v11.11.1) --- updated-dependencies: - dependency-name: tar dependency-version: dependency-type: indirect - dependency-name: npm dependency-version: 11.11.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package-lock.json | 61 +++++++++++++++++++++++------------------------ package.json | 2 +- 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ab271955..fb9706bfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "fuse.js": "^7.1.0", "hls.js": "^1.6.15", "jose": "^6.2.0", - "npm": "^11.6.0", + "npm": "^11.11.1", "pocketbase": "^0.26.8", "taglib-wasm": "^1.0.5", "uuid": "^13.0.0" @@ -7789,9 +7789,9 @@ } }, "node_modules/npm": { - "version": "11.11.0", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.11.0.tgz", - "integrity": "sha512-82gRxKrh/eY5UnNorkTFcdBQAGpgjWehkfGVqAGlJjejEtJZGGJUqjo3mbBTNbc5BTnPKGVtGPBZGhElujX5cw==", + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.11.1.tgz", + "integrity": "sha512-asazCodkFdz1ReQzukyzS/DD77uGCIqUFeRG3gtaT8b9UR0ne1m9QOBuMgT72ij1rt7TRrOox4A1WzntMWIuEg==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -7869,7 +7869,7 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.4.0", + "@npmcli/arborist": "^9.4.1", "@npmcli/config": "^10.7.1", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", @@ -7877,7 +7877,7 @@ "@npmcli/package-json": "^7.0.5", "@npmcli/promise-spawn": "^9.0.1", "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.3", + "@npmcli/run-script": "^10.0.4", "@sigstore/tuf": "^4.0.1", "abbrev": "^4.0.0", "archy": "~1.0.0", @@ -7894,17 +7894,17 @@ "is-cidr": "^6.0.3", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.1.3", - "libnpmexec": "^10.2.3", - "libnpmfund": "^7.0.17", + "libnpmdiff": "^8.1.4", + "libnpmexec": "^10.2.4", + "libnpmfund": "^7.0.18", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.1.3", + "libnpmpack": "^9.1.4", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", "libnpmversion": "^8.0.3", "make-fetch-happen": "^15.0.4", - "minimatch": "^10.2.2", + "minimatch": "^10.2.4", "minipass": "^7.1.3", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", @@ -7918,7 +7918,7 @@ "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", - "pacote": "^21.4.0", + "pacote": "^21.5.0", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", @@ -7927,7 +7927,7 @@ "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.1", "supports-color": "^10.2.2", - "tar": "^7.5.9", + "tar": "^7.5.11", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", @@ -7993,10 +7993,11 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.4.0", + "version": "9.4.1", "inBundle": true, "license": "ISC", "dependencies": { + "@gar/promise-retry": "^1.0.0", "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", @@ -8193,7 +8194,7 @@ } }, "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "10.0.3", + "version": "10.0.4", "inBundle": true, "license": "ISC", "dependencies": { @@ -8201,8 +8202,7 @@ "@npmcli/package-json": "^7.0.0", "@npmcli/promise-spawn": "^9.0.0", "node-gyp": "^12.1.0", - "proc-log": "^6.0.0", - "which": "^6.0.0" + "proc-log": "^6.0.0" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8357,7 +8357,7 @@ } }, "node_modules/npm/node_modules/brace-expansion": { - "version": "5.0.3", + "version": "5.0.4", "inBundle": true, "license": "MIT", "dependencies": { @@ -8711,11 +8711,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.1.3", + "version": "8.1.4", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.0", + "@npmcli/arborist": "^9.4.1", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -8729,12 +8729,12 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.2.3", + "version": "10.2.4", "inBundle": true, "license": "ISC", "dependencies": { "@gar/promise-retry": "^1.0.0", - "@npmcli/arborist": "^9.4.0", + "@npmcli/arborist": "^9.4.1", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -8751,11 +8751,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.17", + "version": "7.0.18", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.0" + "@npmcli/arborist": "^9.4.1" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -8774,11 +8774,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.1.3", + "version": "9.1.4", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.4.0", + "@npmcli/arborist": "^9.4.1", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -8873,7 +8873,7 @@ } }, "node_modules/npm/node_modules/minimatch": { - "version": "10.2.2", + "version": "10.2.4", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9183,7 +9183,7 @@ } }, "node_modules/npm/node_modules/pacote": { - "version": "21.4.0", + "version": "21.5.0", "inBundle": true, "license": "ISC", "dependencies": { @@ -9462,7 +9462,7 @@ } }, "node_modules/npm/node_modules/tar": { - "version": "7.5.9", + "version": "7.5.11", "inBundle": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -9607,11 +9607,10 @@ } }, "node_modules/npm/node_modules/write-file-atomic": { - "version": "7.0.0", + "version": "7.0.1", "inBundle": true, "license": "ISC", "dependencies": { - "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" }, "engines": { diff --git a/package.json b/package.json index 554fb77ee..32dd856bf 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "fuse.js": "^7.1.0", "hls.js": "^1.6.15", "jose": "^6.2.0", - "npm": "^11.6.0", + "npm": "^11.11.1", "pocketbase": "^0.26.8", "taglib-wasm": "^1.0.5", "uuid": "^13.0.0" From b7c57a06fe884aa9609d81bae85f3d682d8e5948 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:27:13 +0300 Subject: [PATCH 040/226] outdated --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 32dd856bf..4f9201003 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "type": "module", "version": "2.5.0", "description": "[\"Monochrome](https://monochrome.samidy.com)", - "main": "sw.js", "scripts": { "dev": "vite", "dev:desktop": "start npm run dev & node scripts/dev-runner.js", From dfa6e4038500bfa4e3322f6b90db8c5345813fc6 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:27:27 +0300 Subject: [PATCH 041/226] we dont even have tests --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 4f9201003..7496fa531 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,7 @@ "lint:css": "stylelint \"**/*.css\"", "lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", "lint": "npm run lint:js && npm run lint:css && npm run lint:html", - "format": "prettier --write .", - "test": "echo \"Error: no test specified\" && exit 1" + "format": "prettier --write ." }, "repository": { "type": "git", From 46d09af6d7657a9501e5c3a3e6d4ea9683252c11 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:27:58 +0300 Subject: [PATCH 042/226] outdated github links --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 7496fa531..075c10f6c 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,15 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/SamidyFR/monochrome.git" + "url": "git+https://github.com/monochrome-music/monochrome.git" }, "keywords": [], "author": "", "license": "ISC", "bugs": { - "url": "https://github.com/SamidyFR/monochrome/issues" + "url": "https://github.com/monochrome-music/monochrome/issues" }, - "homepage": "https://github.com/SamidyFR/monochrome#readme", + "homepage": "https://github.com/monochrome-music/monochrome#readme", "devDependencies": { "@neutralinojs/neu": "^11.7.0", "@types/node": "^25.3.5", From cb7141341ad73e8da93b8543ff2b35fb26175152 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:31:02 +0300 Subject: [PATCH 043/226] use bun for everything --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 075c10f6c..43b3214a9 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,14 @@ "description": "[\"Monochrome](https://monochrome.samidy.com)", "scripts": { "dev": "vite", - "dev:desktop": "start npm run dev & node scripts/dev-runner.js", + "dev:desktop": "start bun run dev & node scripts/dev-runner.js", "build": "vite build --mode neutralino && bun x neu build", "postbuild": "node -e \"const fs = require('fs'); const path = require('path'); const src = 'extensions'; const dest = path.join('dist', 'Monochrome', 'extensions'); if (fs.existsSync(src)) { fs.mkdirSync(dest, { recursive: true }); fs.cpSync(src, dest, { recursive: true }); console.log('Extensions manually copied to ' + dest); }\"", "preview": "vite preview", "lint:js": "eslint .", "lint:css": "stylelint \"**/*.css\"", "lint:html": "htmlhint \"**/*.html\" --ignore=\"dist/**,legacy/**,node_modules/**\"", - "lint": "npm run lint:js && npm run lint:css && npm run lint:html", + "lint": "bun run lint:js && bun run lint:css && bun run lint:html", "format": "prettier --write ." }, "repository": { From c165e979f1dfb8e86c7d74389a6d694a9482df7f Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:37:35 +0300 Subject: [PATCH 044/226] shitty ai slop ass dashes --- .env.example | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index da95031de..47a579be9 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ # Monochrome Docker Configuration # Copy to .env and edit: cp .env.example .env -# --- Monochrome --- +# Monochrome MONOCHROME_PORT=3000 MONOCHROME_DEV_PORT=5173 -# --- Auth Gate (server-side authentication) --- +# Auth Gate (server-side authentication) # Set AUTH_ENABLED=true to enable the auth gate entirely (login required) AUTH_ENABLED=false AUTH_SECRET=change-me-to-a-random-string @@ -18,7 +18,7 @@ APPWRITE_PROJECT_ID=auth-for-monochrome # POCKETBASE_URL=https://monodb.samidy.com # SESSION_MAX_AGE=604800000 # 7 days in ms (default) -# --- PocketBase (only used with --profile pocketbase) --- +# PocketBase (only used with --profile pocketbase) POCKETBASE_PORT=8090 PB_ADMIN_EMAIL=admin@example.com PB_ADMIN_PASSWORD=changeme From edffc9566a45ae9610d3d161f7e5a686df6794ea Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:37:54 +0300 Subject: [PATCH 045/226] update example --- .env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 47a579be9..1c0b4570b 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ APPWRITE_PROJECT_ID=auth-for-monochrome # AUTH_GOOGLE_ENABLED=true # AUTH_EMAIL_ENABLED=true # Optional: set PocketBase URL (hides the field in settings when set) -# POCKETBASE_URL=https://monodb.samidy.com +# POCKETBASE_URL=https://data.samidy.xyz # SESSION_MAX_AGE=604800000 # 7 days in ms (default) # PocketBase (only used with --profile pocketbase) From 898c9c62e5badf117c8ab3a354d8d8ce11678f2c Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:40:21 +0300 Subject: [PATCH 046/226] no need for .vscode --- .gitignore | 1 + .vscode/tasks.json | 23 ----------------------- 2 files changed, 1 insertion(+), 23 deletions(-) delete mode 100644 .vscode/tasks.json diff --git a/.gitignore b/.gitignore index 5c3c0678c..15e834060 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ dist .DS_Store *.local .vite +.vscode # Docker .env diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 693875458..000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "build", - "group": { - "kind": "build", - "isDefault": true - }, - "problemMatcher": [], - "label": "npm: build", - "detail": "vite build" - }, - { - "type": "npm", - "script": "dev", - "problemMatcher": [], - "label": "npm: dev", - "detail": "vite" - } - ] -} From b04019f2823489fef0d17d3098f2e457c951791a Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:57:42 +0000 Subject: [PATCH 047/226] fix(downloads): mp4 files with flac audio are now tagged This is resolved by using ffmpeg to copy the audio data into a new mp4 container file before passing it to taglib. --- bun.lock | 2 +- js/download-utils.ts | 3 ++- js/downloads.js | 2 -- js/ffmpegFormats.ts | 22 ++++++++++------------ js/metadata.js | 11 ++--------- js/storage.js | 3 +-- 6 files changed, 16 insertions(+), 27 deletions(-) diff --git a/bun.lock b/bun.lock index 7a7217b85..8223c9999 100644 --- a/bun.lock +++ b/bun.lock @@ -18,7 +18,7 @@ "fuse.js": "^7.1.0", "hls.js": "^1.6.15", "jose": "^6.2.0", - "npm": "^11.11.0", + "npm": "^11.6.0", "pocketbase": "^0.26.8", "taglib-wasm": "^1.0.5", "uuid": "^13.0.0", diff --git a/js/download-utils.ts b/js/download-utils.ts index 1bf62fcdd..763a78c36 100644 --- a/js/download-utils.ts +++ b/js/download-utils.ts @@ -9,6 +9,7 @@ import { getContainerFormat, transcodeWithContainerFormat, } from './ffmpegFormats'; +import { ffmpeg } from './ffmpeg'; /** * Triggers a browser file download for the given blob. @@ -62,7 +63,7 @@ export async function applyAudioPostProcessing( if (containerFmt) { if (await containerFmt.needsTranscode(blob)) { blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); - } else if ((await getExtensionFromBlob(blob)) == 'flac') { + } else if ((await getExtensionFromBlob(blob)) === 'flac') { blob = await rebuildFlacWithoutMetadata(blob); } } diff --git a/js/downloads.js b/js/downloads.js index 56cf9c9a3..29821fe05 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -549,7 +549,6 @@ async function bulkDownloadToZip( const discNumber = discLayout.resolveDiscNumber(i); const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; - console.log(`[Playlist] Track ${i + 1}: ${discPath}`); trackPaths.push(discPath); yield { @@ -852,7 +851,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const discNumber = discLayout.resolveDiscNumber(i); const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; - console.log(`[Playlist] Track ${i + 1}: ${discPath}`); trackPaths.push(discPath); yield { diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index ea94e15bf..049399350 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -149,24 +149,13 @@ export const containerFormats: ContainerFormat[] = [ { displayName: 'FLAC', internalName: 'flac', - ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'], 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', @@ -176,6 +165,15 @@ export const containerFormats: ContainerFormat[] = [ extension: 'm4a', needsTranscode: async () => true, }, + { + displayName: "Don't change", + internalName: 'nochange', + ffmpegArgs: ['-c:a', 'copy', '-strict', '-2'], + outputFilename: 'output.mp4', + outputMime: 'audio/mp4', + extension: 'mp4', + needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) == 'm4a', + }, ]; /** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ diff --git a/js/metadata.js b/js/metadata.js index 29d6d46d3..330aa3009 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -47,14 +47,6 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet */ const data = {}; - const detectedExt = await getExtensionFromBlob(audioBlob); - const isM4A = detectedExt === 'm4a' || detectedExt === 'mp4'; - - if (isM4A) { - console.log('Skipping TagLib for M4A (handled by FFmpeg)'); - return audioBlob; - } - const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer()); try { @@ -64,7 +56,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); diff --git a/js/storage.js b/js/storage.js index db28d429e..e03b18d4f 100644 --- a/js/storage.js +++ b/js/storage.js @@ -560,8 +560,7 @@ export const losslessContainerSettings = { getContainer() { try { const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac'; - // 'nochange' was removed as an option; fall back to FLAC - return stored === 'nochange' ? 'flac' : stored; + return stored; } catch { return 'flac'; } From 9ccd6cf514a854627e3332a4ce6d802270c53537 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:44:46 +0300 Subject: [PATCH 048/226] this section doesnt exist anymore --- CONTRIBUTING.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83417b1dc..e3fe8780f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,7 +13,6 @@ Thank you for your interest in contributing to Monochrome! This guide will help - [Contributing Workflow](#contributing-workflow) - [Commit Message Guidelines](#commit-message-guidelines) - [Deployment](#deployment) -- [Questions?](#questions) --- From 079198c59e9a26fd61e8b5edc12831a6ff1c904e Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 12 Mar 2026 06:48:05 +0000 Subject: [PATCH 049/226] fix: improve discNumber formatting in formatTemplate function --- js/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/utils.js b/js/utils.js index 5c55dfa0c..75588d79b 100644 --- a/js/utils.js +++ b/js/utils.js @@ -373,7 +373,7 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } export const formatTemplate = (template, data) => { let result = template; - result = result.replace(/\{discNumber\}/g, data.discNumber ? String(data.discNumber).padStart(2, '0') : '01'); + result = result.replace(/\{discNumber\}/g, String(Number(data.discNumber || 1))); result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00'); result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist')); result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title')); From ea46d7c0217d26fd7ce6fd391d1b7db0d3a0ec60 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 09:49:33 +0300 Subject: [PATCH 050/226] woopsie daisys --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 2a9c31d8b..0df789b6a 100644 --- a/index.html +++ b/index.html @@ -2046,7 +2046,7 @@

Pinned

From fe7a9e54979f625d9a859f089c3c03099038874a Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 10:10:50 +0300 Subject: [PATCH 053/226] replace discord link here --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 697a3f46f..0b01b4d83 100644 --- a/index.html +++ b/index.html @@ -5651,7 +5651,7 @@

Monochrome Official App

The App is still in Beta. Please report any issues in our - Discord server.

From df7ba22fa1a05b6a8f9abd80bc451921e98465d4 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 10:12:06 +0300 Subject: [PATCH 054/226] didnt see these --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43b3214a9..3c9fa509d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "monochrome", "type": "module", "version": "2.5.0", - "description": "[\"Monochrome](https://monochrome.samidy.com)", + "description": "[\"Monochrome](https://monochrome.tf)", "scripts": { "dev": "vite", "dev:desktop": "start bun run dev & node scripts/dev-runner.js", From 3fc74738a50356bda46ce78b552343ccc582c56c Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 10:16:38 +0300 Subject: [PATCH 055/226] this barely ever worked anyways lets just remove smooth scrolling --- index.html | 12 ----- js/app.js | 1 - js/settings.js | 12 ----- js/smooth-scrolling.js | 100 ----------------------------------------- js/storage.js | 16 ------- 5 files changed, 141 deletions(-) delete mode 100644 js/smooth-scrolling.js diff --git a/index.html b/index.html index 0b01b4d83..72614e6d0 100644 --- a/index.html +++ b/index.html @@ -3746,18 +3746,6 @@

Custom Theme

-
-
- Smooth Scrolling - Provides a smoother scrolling experience with Lenis (Experimental) -
- -
Album Cover Background diff --git a/js/app.js b/js/app.js index 480c3339a..742f33c4f 100644 --- a/js/app.js +++ b/js/app.js @@ -25,7 +25,6 @@ import { showNotification } from './downloads.js'; import { syncManager } from './accounts/pocketbase.js'; import { authManager } from './accounts/auth.js'; import { registerSW } from 'virtual:pwa-register'; -import './smooth-scrolling.js'; import { openEditProfile } from './profile.js'; import { ThemeStore } from './themeStore.js'; import './commandPalette.js'; diff --git a/js/settings.js b/js/settings.js index 22a48ba59..24a1cf381 100644 --- a/js/settings.js +++ b/js/settings.js @@ -10,7 +10,6 @@ import { cardSettings, waveformSettings, replayGainSettings, - smoothScrollingSettings, downloadQualitySettings, losslessContainerSettings, coverArtSizeSettings, @@ -2170,17 +2169,6 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } - // Smooth Scrolling Toggle - const smoothScrollingToggle = document.getElementById('smooth-scrolling-toggle'); - if (smoothScrollingToggle) { - smoothScrollingToggle.checked = smoothScrollingSettings.isEnabled(); - smoothScrollingToggle.addEventListener('change', (e) => { - smoothScrollingSettings.setEnabled(e.target.checked); - - window.dispatchEvent(new CustomEvent('smooth-scrolling-toggle', { detail: { enabled: e.target.checked } })); - }); - } - // Visualizer Sensitivity const visualizerSensitivitySlider = document.getElementById('visualizer-sensitivity-slider'); const visualizerSensitivityValue = document.getElementById('visualizer-sensitivity-value'); diff --git a/js/smooth-scrolling.js b/js/smooth-scrolling.js deleted file mode 100644 index 8407c7830..000000000 --- a/js/smooth-scrolling.js +++ /dev/null @@ -1,100 +0,0 @@ -//js/smooth-scrolling.js -import { smoothScrollingSettings } from './storage.js'; - -let lenis = null; -let lenisLoaded = false; -let lenisLoading = false; - -async function loadLenisScript() { - if (lenisLoaded) return true; - if (lenisLoading) { - return new Promise((resolve) => { - const checkLoaded = setInterval(() => { - if (!lenisLoading) { - clearInterval(checkLoaded); - resolve(lenisLoaded); - } - }, 100); - }); - } - - lenisLoading = true; - - try { - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://unpkg.com/@studio-freight/lenis'; - script.onload = resolve; - script.onerror = reject; - document.head.appendChild(script); - }); - - lenisLoaded = true; - lenisLoading = false; - console.log('✓ Lenis loaded successfully'); - return true; - } catch (error) { - console.error('✗ Failed to load Lenis:', error); - lenisLoaded = false; - lenisLoading = false; - return false; - } -} - -async function initializeSmoothScrolling() { - if (lenis) return; // Already initialized - - const loaded = await loadLenisScript(); - if (!loaded) return; - - lenis = new window.Lenis({ - wrapper: document.querySelector('.main-content'), - content: document.querySelector('.main-content'), - lerp: 0.1, - smoothWheel: true, - smoothTouch: false, - normalizeWheel: true, - wheelMultiplier: 0.8, - }); - - function raf(time) { - if (lenis) { - lenis.raf(time); - requestAnimationFrame(raf); - } - } - - requestAnimationFrame(raf); -} - -function destroySmoothScrolling() { - if (lenis) { - lenis.destroy(); - lenis = null; - } -} - -async function setupSmoothScrolling() { - // Check if smooth scrolling is enabled - const smoothScrollingEnabled = smoothScrollingSettings.isEnabled(); - - if (smoothScrollingEnabled) { - await initializeSmoothScrolling(); - } - - // Listen for toggle changes - window.addEventListener('smooth-scrolling-toggle', async function (e) { - if (e.detail.enabled) { - await initializeSmoothScrolling(); - } else { - destroySmoothScrolling(); - } - }); -} - -// Initialize when DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', setupSmoothScrolling); -} else { - setupSmoothScrolling(); -} diff --git a/js/storage.js b/js/storage.js index de7c4709c..556be078f 100644 --- a/js/storage.js +++ b/js/storage.js @@ -599,22 +599,6 @@ export const waveformSettings = { }, }; -export const smoothScrollingSettings = { - STORAGE_KEY: 'smooth-scrolling-enabled', - - isEnabled() { - try { - return localStorage.getItem(this.STORAGE_KEY) === 'true'; - } catch { - return false; - } - }, - - setEnabled(enabled) { - localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); - }, -}; - export const qualityBadgeSettings = { STORAGE_KEY: 'show-quality-badges', From 3b088d169221b1ebb608bd84c7da69706a848c15 Mon Sep 17 00:00:00 2001 From: Samidy Date: Thu, 12 Mar 2026 10:24:08 +0300 Subject: [PATCH 056/226] unecessary comments --- index.html | 4 ---- 1 file changed, 4 deletions(-) diff --git a/index.html b/index.html index 72614e6d0..98da4e6dd 100644 --- a/index.html +++ b/index.html @@ -3876,7 +3876,6 @@

Custom Theme

>
-