diff --git a/README.md b/README.md index 8d739f5..30ea8da 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

+

Monochrome Logo diff --git a/index.html b/index.html index 3be4032..b148492 100644 --- a/index.html +++ b/index.html @@ -5145,8 +5145,8 @@

Filename Template Customize download filenames. Available: {trackNumber}, {artist}, {title}, - {album}Customize download filenames. Available: {discNumber}, {trackNumber}, + {artist}, {title}, {album}
0 ? parsed : null; -} - -function getExplicitTrackDiscNumber(track) { - const candidates = [ - track?.volumeNumber, - track?.discNumber, - track?.mediaNumber, - track?.media_number, - track?.volume, - track?.disc, - track?.volume?.number, - track?.disc?.number, - track?.media?.number, - track?.disc, - track?.disc_no, - track?.discNo, - track?.disc_number, - track?.mediaMetadata?.discNumber, - ]; - - for (const candidate of candidates) { - const parsed = toPositiveInt(candidate); - if (parsed) return parsed; - } - return null; -} - async function createDiscLayoutContext(tracks, api) { if (!playlistSettings.shouldSeparateDiscsInZip()) { return { separateByDisc: false, resolveDiscNumber: () => 1 }; } - const explicitDiscNumbers = tracks.map((track) => getExplicitTrackDiscNumber(track)); + const explicitDiscNumbers = tracks.map((track) => getTrackDiscNumber(track)); const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean)); if (explicitDistinct.size > 1) { @@ -91,7 +62,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; } @@ -175,10 +146,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'); @@ -560,75 +527,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]; @@ -650,6 +554,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(), @@ -677,8 +586,83 @@ 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()) { + // Split tracks by volumeNumber and iterate those groups. + const tracksByVolume = Object.groupBy( + tracks.map((track, index) => ({ + ...track, + trackPath: trackPaths[index], + })), + (track) => String(getTrackDiscNumber(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 + 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 { @@ -712,75 +696,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]; @@ -802,6 +723,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(), @@ -829,8 +755,70 @@ 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 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, + }; + } } try { @@ -865,75 +853,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]; @@ -955,6 +880,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(), @@ -982,8 +912,70 @@ 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 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, + }; + } } try { @@ -1221,72 +1213,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; @@ -1302,6 +1233,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(), @@ -1334,8 +1270,55 @@ 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 cueContent = generateCUE(fullAlbum, tracks, sanitizeForFilename(fullAlbum.title), trackPaths); + 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/metadata.js b/js/metadata.js index 93c2e94..0440bb5 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 = {}; diff --git a/js/playlist-generator.js b/js/playlist-generator.js index 3b22b21..59eb011 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`; }); @@ -92,40 +120,39 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso * 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; diff --git a/js/utils.js b/js/utils.js index 728ddb3..ae22d4c 100644 --- a/js/utils.js +++ b/js/utils.js @@ -189,10 +189,10 @@ export const getExtensionFromBlob = async (blob) => { if (format) return format; if (blob.type.includes('video')) return 'mp4'; - if (mimeType === 'audio/flac') return 'flac'; - if (mimeType === 'audio/ogg') return 'ogg'; - if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4'; - if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3'; + if (blob.type === 'audio/flac') return 'flac'; + if (blob.type === 'audio/ogg') return 'ogg'; + if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'mp4'; + if (blob.type === 'audio/mp3' || blob.type === 'audio/mpeg') return 'mp3'; return 'flac'; }; @@ -214,6 +214,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), @@ -641,3 +642,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; +}