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">
|
<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">
|
<img src="https://github.com/monochrome-music/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo" width="150px">
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -5145,8 +5145,8 @@
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span class="label">Filename Template</span>
|
<span class="label">Filename Template</span>
|
||||||
<span class="description"
|
<span class="description"
|
||||||
>Customize download filenames. Available: {trackNumber}, {artist}, {title},
|
>Customize download filenames. Available: {discNumber}, {trackNumber},
|
||||||
{album}</span
|
{artist}, {title}, {album}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
571
js/downloads.js
571
js/downloads.js
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getCoverBlob,
|
getCoverBlob,
|
||||||
getExtensionFromBlob,
|
getExtensionFromBlob,
|
||||||
escapeHtml,
|
escapeHtml,
|
||||||
|
getTrackDiscNumber,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
|
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
|
||||||
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.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) {
|
async function createDiscLayoutContext(tracks, api) {
|
||||||
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
if (!playlistSettings.shouldSeparateDiscsInZip()) {
|
||||||
return { separateByDisc: false, resolveDiscNumber: () => 1 };
|
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));
|
const explicitDistinct = new Set(explicitDiscNumbers.filter(Boolean));
|
||||||
|
|
||||||
if (explicitDistinct.size > 1) {
|
if (explicitDistinct.size > 1) {
|
||||||
|
|
@ -91,7 +62,7 @@ async function createDiscLayoutContext(tracks, api) {
|
||||||
if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
|
if (explicitDiscNumbers[index]) return explicitDiscNumbers[index];
|
||||||
try {
|
try {
|
||||||
const fullTrack = await api.getTrackMetadata(track.id);
|
const fullTrack = await api.getTrackMetadata(track.id);
|
||||||
return getExplicitTrackDiscNumber(fullTrack);
|
return getTrackDiscNumber(fullTrack);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -175,10 +146,6 @@ function buildZipTrackPath(rootFolder, filename, separateByDisc, discNumber = 1)
|
||||||
return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`;
|
return `${rootFolder}/${getDiscFolderName(discNumber)}/${filename}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPlaylistAudioExtension(quality) {
|
|
||||||
return quality === 'LOW' || quality === 'HIGH' ? 'm4a' : 'flac';
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDownloadNotification() {
|
function createDownloadNotification() {
|
||||||
if (!downloadNotificationContainer) {
|
if (!downloadNotificationContainer) {
|
||||||
downloadNotificationContainer = document.createElement('div');
|
downloadNotificationContainer = document.createElement('div');
|
||||||
|
|
@ -560,75 +527,12 @@ async function bulkDownloadToZipStream(
|
||||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate playlist files first
|
|
||||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
|
||||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||||
const separateByDisc = discLayout.separateByDisc;
|
const separateByDisc = discLayout.separateByDisc;
|
||||||
const playlistPathResolver = separateByDisc
|
|
||||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (playlistSettings.shouldGenerateM3U()) {
|
// Download tracks, yielding each immediately and collecting actual paths for playlist generation
|
||||||
const m3uContent = generateM3U(
|
const trackPaths = [];
|
||||||
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
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
|
|
@ -650,6 +554,11 @@ async function bulkDownloadToZipStream(
|
||||||
);
|
);
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
||||||
|
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||||
|
trackPaths.push(discPath);
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -677,8 +586,83 @@ async function bulkDownloadToZipStream(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw err;
|
if (err.name === 'AbortError') throw err;
|
||||||
console.error(`Failed to download track ${trackTitle}:`, 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 {
|
try {
|
||||||
|
|
@ -712,75 +696,12 @@ async function bulkDownloadToZipBlob(
|
||||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate playlist files first
|
|
||||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
|
||||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||||
const separateByDisc = discLayout.separateByDisc;
|
const separateByDisc = discLayout.separateByDisc;
|
||||||
const playlistPathResolver = separateByDisc
|
|
||||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (playlistSettings.shouldGenerateM3U()) {
|
// Download tracks, yielding each immediately and collecting actual paths for playlist generation
|
||||||
const m3uContent = generateM3U(
|
const trackPaths = [];
|
||||||
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
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
|
|
@ -802,6 +723,11 @@ async function bulkDownloadToZipBlob(
|
||||||
);
|
);
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
||||||
|
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||||
|
trackPaths.push(discPath);
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -829,8 +755,70 @@ async function bulkDownloadToZipBlob(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw err;
|
if (err.name === 'AbortError') throw err;
|
||||||
console.error(`Failed to download track ${trackTitle}:`, 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 {
|
try {
|
||||||
|
|
@ -865,75 +853,12 @@ async function bulkDownloadToZipNeutralino(
|
||||||
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate playlist files first
|
|
||||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
|
||||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||||
const separateByDisc = discLayout.separateByDisc;
|
const separateByDisc = discLayout.separateByDisc;
|
||||||
const playlistPathResolver = separateByDisc
|
|
||||||
? (_track, filename, index) => `${getDiscFolderName(discLayout.resolveDiscNumber(index))}/${filename}`
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (playlistSettings.shouldGenerateM3U()) {
|
// Download tracks, yielding each immediately and collecting actual paths for playlist generation
|
||||||
const m3uContent = generateM3U(
|
const trackPaths = [];
|
||||||
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
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
|
|
@ -955,6 +880,11 @@ async function bulkDownloadToZipNeutralino(
|
||||||
);
|
);
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
||||||
|
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||||
|
trackPaths.push(discPath);
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
name: buildZipTrackPath(folderName, filename, separateByDisc, discNumber),
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -982,8 +912,70 @@ async function bulkDownloadToZipNeutralino(
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw err;
|
if (err.name === 'AbortError') throw err;
|
||||||
console.error(`Failed to download track ${trackTitle}:`, 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 {
|
try {
|
||||||
|
|
@ -1221,72 +1213,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
|
|
||||||
// Generate playlist files for each album
|
// Generate playlist files for each album
|
||||||
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
||||||
const playlistAudioExtension = getPlaylistAudioExtension(quality);
|
|
||||||
const discLayout = await createDiscLayoutContext(tracks, api);
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
||||||
const separateByDisc = discLayout.separateByDisc;
|
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++) {
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
|
|
@ -1302,6 +1233,11 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
);
|
);
|
||||||
const filename = buildTrackFilename(track, quality, extension);
|
const filename = buildTrackFilename(track, quality, extension);
|
||||||
const discNumber = discLayout.resolveDiscNumber(i);
|
const discNumber = discLayout.resolveDiscNumber(i);
|
||||||
|
const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename;
|
||||||
|
|
||||||
|
console.log(`[Playlist] Track ${i + 1}: ${discPath}`);
|
||||||
|
trackPaths.push(discPath);
|
||||||
|
|
||||||
yield {
|
yield {
|
||||||
name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber),
|
name: buildZipTrackPath(fullFolderPath, filename, separateByDisc, discNumber),
|
||||||
lastModified: new Date(),
|
lastModified: new Date(),
|
||||||
|
|
@ -1334,8 +1270,55 @@ export async function downloadDiscography(artist, selectedReleases, api, quality
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') throw err;
|
if (err.name === 'AbortError') throw err;
|
||||||
console.error(`Failed to download track ${track.title}:`, 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) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') throw error;
|
if (error.name === 'AbortError') throw error;
|
||||||
console.error(`Failed to download album ${album.title}:`, 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 { fetchTagLib, addMetadataWithTagLib, getMetadataWithTagLib } from './taglib.ts';
|
||||||
import { doTimed, doTimedAsync } from './doTimed.ts';
|
import { doTimed, doTimedAsync } from './doTimed.ts';
|
||||||
import { managers } from './app.js';
|
import { managers } from './app.js';
|
||||||
|
|
@ -35,7 +42,7 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet
|
||||||
const { coverFetch, lyricsFetch } = prefetchPromises;
|
const { coverFetch, lyricsFetch } = prefetchPromises;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {import("./taglib.worker.ts").TagLibMetadata}
|
* @type {import("./taglib.types.ts").TagLibMetadata}
|
||||||
*/
|
*/
|
||||||
const data = {};
|
const data = {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,20 @@ import { sanitizeForFilename } from './utils.js';
|
||||||
* Generates M3U playlist content
|
* Generates M3U playlist content
|
||||||
* @param {Object} playlist - Playlist metadata (title, artist, etc.)
|
* @param {Object} playlist - Playlist metadata (title, artist, etc.)
|
||||||
* @param {Array} tracks - Array of track objects
|
* @param {Array} tracks - Array of track objects
|
||||||
* @param {boolean} useRelativePaths - Whether to use relative paths
|
* @param {boolean} _useRelativePaths - Unused; kept for API compatibility
|
||||||
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
|
* @param {Function|null} pathResolver - Optional resolver for per-track relative path (used when trackPaths is null)
|
||||||
* @param {string} audioExtension - Audio file extension used in generated paths
|
* @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
|
* @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';
|
let content = '#EXTM3U\n';
|
||||||
|
|
||||||
if (playlist.title) {
|
if (playlist.title) {
|
||||||
|
|
@ -17,13 +25,16 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.artist) {
|
if (playlist.artist) {
|
||||||
content += `#ARTIST:${playlist.artist}\n`;
|
content += `#ARTIST:${playlist.artist?.name || playlist.artist}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
content += `#DATE:${date}\n\n`;
|
content += `#DATE:${date}\n\n`;
|
||||||
|
|
||||||
tracks.forEach((track, index) => {
|
tracks.forEach((track, index) => {
|
||||||
|
const resolvedPath = trackPaths ? trackPaths[index] : null;
|
||||||
|
if (trackPaths && !resolvedPath) return;
|
||||||
|
|
||||||
const duration = Math.round(track.duration || 0);
|
const duration = Math.round(track.duration || 0);
|
||||||
const artists = getTrackArtists(track);
|
const artists = getTrackArtists(track);
|
||||||
const title = track.title || 'Unknown Title';
|
const title = track.title || 'Unknown Title';
|
||||||
|
|
@ -31,9 +42,12 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol
|
||||||
|
|
||||||
content += `#EXTINF:${duration},${displayName}\n`;
|
content += `#EXTINF:${duration},${displayName}\n`;
|
||||||
|
|
||||||
const filename = getTrackFilename(track, index + 1, audioExtension);
|
const path =
|
||||||
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
resolvedPath ??
|
||||||
const path = useRelativePaths ? relativePath : relativePath;
|
(() => {
|
||||||
|
const filename = getTrackFilename(track, index + 1, audioExtension);
|
||||||
|
return typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
||||||
|
})();
|
||||||
|
|
||||||
content += `${path}\n\n`;
|
content += `${path}\n\n`;
|
||||||
});
|
});
|
||||||
|
|
@ -45,12 +59,20 @@ export function generateM3U(playlist, tracks, useRelativePaths = true, pathResol
|
||||||
* Generates M3U8 playlist content (UTF-8 extended)
|
* Generates M3U8 playlist content (UTF-8 extended)
|
||||||
* @param {Object} playlist - Playlist metadata
|
* @param {Object} playlist - Playlist metadata
|
||||||
* @param {Array} tracks - Array of track objects
|
* @param {Array} tracks - Array of track objects
|
||||||
* @param {boolean} useRelativePaths - Whether to use relative paths
|
* @param {boolean} _useRelativePaths - Unused; kept for API compatibility
|
||||||
* @param {Function|null} pathResolver - Optional resolver for per-track relative path
|
* @param {Function|null} pathResolver - Optional resolver for per-track relative path (used when trackPaths is null)
|
||||||
* @param {string} audioExtension - Audio file extension used in generated paths
|
* @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
|
* @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';
|
let content = '#EXTM3U\n';
|
||||||
content += '#EXT-X-VERSION:3\n';
|
content += '#EXT-X-VERSION:3\n';
|
||||||
content += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
content += '#EXT-X-PLAYLIST-TYPE:VOD\n';
|
||||||
|
|
@ -63,13 +85,16 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playlist.artist) {
|
if (playlist.artist) {
|
||||||
content += `#ARTIST:${playlist.artist}\n`;
|
content += `#ARTIST:${playlist.artist?.name || playlist.artist}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
content += `#DATE:${date}\n\n`;
|
content += `#DATE:${date}\n\n`;
|
||||||
|
|
||||||
tracks.forEach((track, index) => {
|
tracks.forEach((track, index) => {
|
||||||
|
const resolvedPath = trackPaths ? trackPaths[index] : null;
|
||||||
|
if (trackPaths && !resolvedPath) return;
|
||||||
|
|
||||||
const duration = Math.round(track.duration || 0);
|
const duration = Math.round(track.duration || 0);
|
||||||
const artists = getTrackArtists(track);
|
const artists = getTrackArtists(track);
|
||||||
const title = track.title || 'Unknown Title';
|
const title = track.title || 'Unknown Title';
|
||||||
|
|
@ -77,9 +102,12 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso
|
||||||
|
|
||||||
content += `#EXTINF:${duration}.000,${displayName}\n`;
|
content += `#EXTINF:${duration}.000,${displayName}\n`;
|
||||||
|
|
||||||
const filename = getTrackFilename(track, index + 1, audioExtension);
|
const path =
|
||||||
const relativePath = typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
resolvedPath ??
|
||||||
const path = useRelativePaths ? relativePath : relativePath;
|
(() => {
|
||||||
|
const filename = getTrackFilename(track, index + 1, audioExtension);
|
||||||
|
return typeof pathResolver === 'function' ? pathResolver(track, filename, index) : filename;
|
||||||
|
})();
|
||||||
|
|
||||||
content += `${path}\n\n`;
|
content += `${path}\n\n`;
|
||||||
});
|
});
|
||||||
|
|
@ -92,40 +120,39 @@ export function generateM3U8(playlist, tracks, useRelativePaths = true, pathReso
|
||||||
* Generates CUE sheet content for albums
|
* Generates CUE sheet content for albums
|
||||||
* @param {Object} album - Album metadata
|
* @param {Object} album - Album metadata
|
||||||
* @param {Array} tracks - Array of track objects
|
* @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
|
* @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 performer = album.artist?.name || album.artist || 'Unknown Artist';
|
||||||
const title = album.title || 'Unknown Album';
|
const title = album.title || 'Unknown Album';
|
||||||
|
|
||||||
let content = `PERFORMER "${performer}"\n`;
|
let content = `PERFORMER "${performer}"\n`;
|
||||||
content += `TITLE "${title}"\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) => {
|
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 trackNumber = String(track.trackNumber || index + 1).padStart(2, '0');
|
||||||
const trackTitle = track.title || 'Unknown Track';
|
const trackTitle = track.title || 'Unknown Track';
|
||||||
const trackPerformer = track.artist?.name || getTrackArtists(track) || performer;
|
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 += ` TRACK ${trackNumber} AUDIO\n`;
|
||||||
content += ` TITLE "${trackTitle}"\n`;
|
content += ` TITLE "${trackTitle}"\n`;
|
||||||
content += ` PERFORMER "${trackPerformer}"\n`;
|
content += ` PERFORMER "${trackPerformer}"\n`;
|
||||||
|
content += ` INDEX 01 00:00:00\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;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return content;
|
return content;
|
||||||
|
|
|
||||||
49
js/utils.js
49
js/utils.js
|
|
@ -189,10 +189,10 @@ export const getExtensionFromBlob = async (blob) => {
|
||||||
if (format) return format;
|
if (format) return format;
|
||||||
|
|
||||||
if (blob.type.includes('video')) return 'mp4';
|
if (blob.type.includes('video')) return 'mp4';
|
||||||
if (mimeType === 'audio/flac') return 'flac';
|
if (blob.type === 'audio/flac') return 'flac';
|
||||||
if (mimeType === 'audio/ogg') return 'ogg';
|
if (blob.type === 'audio/ogg') return 'ogg';
|
||||||
if (mimeType === 'audio/mp4' || mimeType === 'audio/x-m4a') return 'mp4';
|
if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') return 'mp4';
|
||||||
if (mimeType === 'audio/mp3' || mimeType === 'audio/mpeg') return 'mp3';
|
if (blob.type === 'audio/mp3' || blob.type === 'audio/mpeg') return 'mp3';
|
||||||
|
|
||||||
return 'flac';
|
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 artistName = track.artist?.name || track.artists?.[0]?.name || 'Unknown Artist';
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
discNumber: getTrackDiscNumber(track) || 1,
|
||||||
trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
artist: artistName,
|
artist: artistName,
|
||||||
title: getTrackTitle(track),
|
title: getTrackTitle(track),
|
||||||
|
|
@ -641,3 +642,43 @@ export function getTrackCoverId(track) {
|
||||||
null
|
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