Add multi-disc ZIP folders and fix playlist extension paths

This commit is contained in:
Julien Maille 2026-02-22 00:32:45 +01:00
parent 400197aabc
commit bf346f756e
5 changed files with 287 additions and 27 deletions

View file

@ -4501,6 +4501,18 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Separate Discs in ZIP</span>
<span class="description"
>Put tracks in Disc folders when a release has multiple discs</span
>
</div>
<label class="toggle-switch">
<input type="checkbox" id="separate-discs-zip-toggle" />
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>

View file

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

View file

@ -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}`;
}
/**

View file

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

View file

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