Merge pull request #299 from DanTheMan827/copilot/fix-m3u-generation-logic

Fix m3u generation: artist renders as [object Object], file extensions mismatch actual downloads
This commit is contained in:
Samidy 2026-03-12 05:05:22 +03:00 committed by GitHub
commit b120a70b66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 395 additions and 337 deletions

View file

@ -1,4 +1,4 @@
<p align="center">
<p align="center">
<a href="https://monochrome.tf">
<img src="https://github.com/monochrome-music/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo" width="150px">
</a>

View file

@ -5145,8 +5145,8 @@
<div class="info">
<span class="label">Filename Template</span>
<span class="description"
>Customize download filenames. Available: {trackNumber}, {artist}, {title},
{album}</span
>Customize download filenames. Available: {discNumber}, {trackNumber},
{artist}, {title}, {album}</span
>
</div>
<input

View file

@ -10,6 +10,7 @@ import {
getCoverBlob,
getExtensionFromBlob,
escapeHtml,
getTrackDiscNumber,
} from './utils.js';
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
@ -40,42 +41,12 @@ async function loadClientZip() {
}
}
function toPositiveInt(value) {
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 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);

View file

@ -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 = {};

View file

@ -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;

View file

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