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:
commit
b120a70b66
6 changed files with 395 additions and 337 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
571
js/downloads.js
571
js/downloads.js
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
49
js/utils.js
49
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue