diff --git a/index.html b/index.html
index 48c0ec1..9a8c9aa 100644
--- a/index.html
+++ b/index.html
@@ -4501,6 +4501,18 @@
+
+
+ Separate Discs in ZIP
+ Put tracks in Disc folders when a release has multiple discs
+
+
+
diff --git a/js/downloads.js b/js/downloads.js
index 559fb72..11f073f 100644
--- a/js/downloads.js
+++ b/js/downloads.js
@@ -31,6 +31,88 @@ 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 explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
+
+ if (explicitDistinct.size > 1) {
+ return {
+ separateByDisc: true,
+ resolveDiscNumber: (index) => explicitDiscNumbers[index] || 1,
+ };
+ }
+
+ // Some providers omit disc fields in album payload but include them in full track metadata.
+ const hydratedDiscNumbers = await Promise.all(
+ tracks.map(async (track, index) => {
+ if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
+ try {
+ const fullTrack = await api.getTrackMetadata(track.id);
+ return getExplicitTrackDiscNumber(fullTrack);
+ } catch {
+ return null;
+ }
+ })
+ );
+
+ const hydratedDistinct = new Set(hydratedDiscNumbers.filter(Boolean));
+ if (hydratedDistinct.size > 1) {
+ return {
+ separateByDisc: true,
+ resolveDiscNumber: (index) => hydratedDiscNumbers[index] || explicitDiscNumbers[index] || 1,
+ };
+ }
+
+ return { separateByDisc: false, resolveDiscNumber: () => 1 };
+}
+
+function getDiscFolderName(discNumber) {
+ return `Disc ${discNumber}`;
+}
+
+function buildZipTrackPath(rootFolder, filename, separateByDisc, discNumber = 1) {
+ if (!separateByDisc) return `${rootFolder}/${filename}`;
+ return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`;
+}
+
+function getPlaylistAudioExtension(quality) {
+ return quality === 'LOW' || quality === 'HIGH' ? 'm4a' : 'flac';
+}
+
function createDownloadNotification() {
if (!downloadNotificationContainer) {
downloadNotificationContainer = document.createElement('div');
@@ -190,6 +272,26 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null, sign
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
+ try {
+ const fullTrack = await api.getTrackMetadata(track.id);
+ if (fullTrack) {
+ enrichedTrack = {
+ ...fullTrack,
+ ...enrichedTrack,
+ artist: enrichedTrack.artist || fullTrack.artist,
+ album: {
+ ...(fullTrack.album || {}),
+ ...(enrichedTrack.album || {}),
+ },
+ // Preserve explicit disc fields from either source
+ discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
+ volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
+ };
+ }
+ } catch {
+ // Non-fatal: continue with best available track payload
+ }
+
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
@@ -323,9 +425,21 @@ async function bulkDownloadToZipStream(
// 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);
+ const m3uContent = generateM3U(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@@ -334,7 +448,13 @@ async function bulkDownloadToZipStream(
}
if (playlistSettings.shouldGenerateM3U8()) {
- const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
+ const m3u8Content = generateM3U8(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@@ -382,7 +502,12 @@ async function bulkDownloadToZipStream(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
- yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
+ const discNumber = discLayout.resolveDiscNumber(i);
+ yield {
+ name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
+ lastModified: new Date(),
+ input: blob,
+ };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@@ -392,7 +517,7 @@ async function bulkDownloadToZipStream(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
- name: `${folderName}/${lrcFilename}`,
+ name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@@ -442,9 +567,21 @@ async function bulkDownloadToZipBlob(
// 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);
+ const m3uContent = generateM3U(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@@ -453,7 +590,13 @@ async function bulkDownloadToZipBlob(
}
if (playlistSettings.shouldGenerateM3U8()) {
- const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
+ const m3u8Content = generateM3U8(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@@ -501,7 +644,12 @@ async function bulkDownloadToZipBlob(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
- yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
+ const discNumber = discLayout.resolveDiscNumber(i);
+ yield {
+ name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
+ lastModified: new Date(),
+ input: blob,
+ };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@@ -511,7 +659,7 @@ async function bulkDownloadToZipBlob(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
- name: `${folderName}/${lrcFilename}`,
+ name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@@ -562,9 +710,21 @@ async function bulkDownloadToZipNeutralino(
// 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);
+ const m3uContent = generateM3U(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u`,
lastModified: new Date(),
@@ -573,7 +733,13 @@ async function bulkDownloadToZipNeutralino(
}
if (playlistSettings.shouldGenerateM3U8()) {
- const m3u8Content = generateM3U8(metadata || { title: folderName }, tracks, useRelativePaths);
+ const m3u8Content = generateM3U8(
+ metadata || { title: folderName },
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${folderName}/${sanitizeForFilename(folderName)}.m3u8`,
lastModified: new Date(),
@@ -621,7 +787,12 @@ async function bulkDownloadToZipNeutralino(
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
- yield { name: `${folderName}/${filename}`, lastModified: new Date(), input: blob };
+ const discNumber = discLayout.resolveDiscNumber(i);
+ yield {
+ name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
+ lastModified: new Date(),
+ input: blob,
+ };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@@ -631,7 +802,7 @@ async function bulkDownloadToZipNeutralino(
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
- name: `${folderName}/${lrcFilename}`,
+ name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
lastModified: new Date(),
input: lrcContent,
};
@@ -718,8 +889,9 @@ async function startBulkDownload(
const isNeutralino = window.NL_MODE === true;
const hasFileSystemAccess =
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
- const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
- const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
+ const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
+ const useZip = hasFileSystemAccess && !forceIndividual;
+ const useZipBlob = !hasFileSystemAccess && !forceIndividual;
if (isNeutralino) {
// Neutralino Native Logic
@@ -871,9 +1043,21 @@ 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);
+ const m3uContent = generateM3U(
+ fullAlbum,
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u`,
lastModified: new Date(),
@@ -882,7 +1066,13 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
}
if (playlistSettings.shouldGenerateM3U8()) {
- const m3u8Content = generateM3U8(fullAlbum, tracks, useRelativePaths);
+ const m3u8Content = generateM3U8(
+ fullAlbum,
+ tracks,
+ useRelativePaths,
+ playlistPathResolver,
+ playlistAudioExtension
+ );
yield {
name: `${fullFolderPath}/${sanitizeForFilename(fullAlbum.title)}.m3u8`,
lastModified: new Date(),
@@ -918,12 +1108,18 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
};
}
- for (const track of tracks) {
+ for (let i = 0; i < tracks.length; i++) {
+ const track = tracks[i];
if (signal.aborted) break;
try {
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal);
const filename = buildTrackFilename(track, quality, extension);
- yield { name: `${fullFolderPath}/${filename}`, lastModified: new Date(), input: blob };
+ const discNumber = discLayout.resolveDiscNumber(i);
+ yield {
+ name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber),
+ lastModified: new Date(),
+ input: blob,
+ };
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
try {
@@ -933,7 +1129,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
if (lrcContent) {
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
yield {
- name: `${fullFolderPath}/${lrcFilename}`,
+ name: buildZipTrackPath(
+ fullFolderPath,
+ lrcFilename,
+ separateByDisc,
+ discNumber
+ ),
lastModified: new Date(),
input: lrcContent,
};
@@ -1097,6 +1298,25 @@ export async function downloadTrackWithMetadata(track, quality, api, lyricsManag
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
};
+ try {
+ const fullTrack = await api.getTrackMetadata(track.id);
+ if (fullTrack) {
+ enrichedTrack = {
+ ...fullTrack,
+ ...enrichedTrack,
+ artist: enrichedTrack.artist || fullTrack.artist,
+ album: {
+ ...(fullTrack.album || {}),
+ ...(enrichedTrack.album || {}),
+ },
+ discNumber: enrichedTrack.discNumber ?? fullTrack.discNumber,
+ volumeNumber: enrichedTrack.volumeNumber ?? fullTrack.volumeNumber,
+ };
+ }
+ } catch {
+ // Continue with available track payload
+ }
+
if (enrichedTrack.album && (!enrichedTrack.album.title || !enrichedTrack.album.artist) && enrichedTrack.album.id) {
try {
const albumData = await api.getAlbum(enrichedTrack.album.id);
diff --git a/js/playlist-generator.js b/js/playlist-generator.js
index 731a193..3b22b21 100644
--- a/js/playlist-generator.js
+++ b/js/playlist-generator.js
@@ -5,9 +5,11 @@ import { sanitizeForFilename } from './utils.js';
* @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
* @returns {string} M3U content
*/
-export function generateM3U(playlist, tracks, useRelativePaths = true) {
+export function generateM3U(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
let content = '#EXTM3U\n';
if (playlist.title) {
@@ -29,8 +31,9 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
content += `#EXTINF:${duration},${displayName}\n`;
- const filename = getTrackFilename(track, index + 1);
- const path = useRelativePaths ? filename : filename;
+ const filename = getTrackFilename(track, index + 1, audioExtension);
+ const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
+ const path = useRelativePaths ? relativePath : relativePath;
content += `${path}\n\n`;
});
@@ -43,9 +46,11 @@ export function generateM3U(playlist, tracks, useRelativePaths = true) {
* @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
* @returns {string} M3U8 content
*/
-export function generateM3U8(playlist, tracks, useRelativePaths = true) {
+export function generateM3U8(playlist, tracks, useRelativePaths = true, pathResolver = null, audioExtension = 'flac') {
let content = '#EXTM3U\n';
content += '#EXT-X-VERSION:3\n';
content += '#EXT-X-PLAYLIST-TYPE:VOD\n';
@@ -72,8 +77,9 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true) {
content += `#EXTINF:${duration}.000,${displayName}\n`;
- const filename = getTrackFilename(track, index + 1);
- const path = useRelativePaths ? filename : filename;
+ const filename = getTrackFilename(track, index + 1, audioExtension);
+ const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
+ const path = useRelativePaths ? relativePath : relativePath;
content += `${path}\n\n`;
});
@@ -242,7 +248,7 @@ function getTrackArtists(track) {
/**
* Helper function to get track filename
*/
-function getTrackFilename(track, trackNumber = 1) {
+function getTrackFilename(track, trackNumber = 1, audioExtension = 'flac') {
const paddedNumber = String(trackNumber).padStart(2, '0');
const artists = getTrackArtists(track);
const title = track.title || 'Unknown Title';
@@ -250,7 +256,7 @@ function getTrackFilename(track, trackNumber = 1) {
const sanitizedArtists = sanitizeForFilename(artists);
const sanitizedTitle = sanitizeForFilename(title);
- return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.flac`;
+ return `${paddedNumber} - ${sanitizedArtists} - ${sanitizedTitle}.${audioExtension}`;
}
/**
diff --git a/js/settings.js b/js/settings.js
index 8dbbdf2..a69abfa 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -2533,6 +2533,14 @@ export function initializeSettings(scrobbler, player, api, ui) {
});
}
+ const separateDiscsZipToggle = document.getElementById('separate-discs-zip-toggle');
+ if (separateDiscsZipToggle) {
+ separateDiscsZipToggle.checked = playlistSettings.shouldSeparateDiscsInZip();
+ separateDiscsZipToggle.addEventListener('change', (e) => {
+ playlistSettings.setSeparateDiscsInZip(e.target.checked);
+ });
+ }
+
// API settings
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('refresh-speed-test-btn');
diff --git a/js/storage.js b/js/storage.js
index 536133c..e590063 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -638,6 +638,7 @@ export const playlistSettings = {
NFO_KEY: 'playlist-generate-nfo',
JSON_KEY: 'playlist-generate-json',
RELATIVE_PATHS_KEY: 'playlist-relative-paths',
+ SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip',
shouldGenerateM3U() {
try {
@@ -689,6 +690,15 @@ export const playlistSettings = {
}
},
+ shouldSeparateDiscsInZip() {
+ try {
+ const val = localStorage.getItem(this.SEPARATE_DISCS_KEY);
+ return val === null ? true : val === 'true';
+ } catch {
+ return true;
+ }
+ },
+
setGenerateM3U(enabled) {
localStorage.setItem(this.M3U_KEY, enabled ? 'true' : 'false');
},
@@ -712,6 +722,10 @@ export const playlistSettings = {
setUseRelativePaths(enabled) {
localStorage.setItem(this.RELATIVE_PATHS_KEY, enabled ? 'true' : 'false');
},
+
+ setSeparateDiscsInZip(enabled) {
+ localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false');
+ },
};
export const visualizerSettings = {