diff --git a/README.md b/README.md
index 8d739f5..30ea8da 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
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;
+}