1518 lines
58 KiB
JavaScript
1518 lines
58 KiB
JavaScript
//js/downloads.js
|
|
import {
|
|
buildTrackFilename,
|
|
sanitizeForFilename,
|
|
RATE_LIMIT_ERROR_MESSAGE,
|
|
getTrackArtists,
|
|
getTrackTitle,
|
|
formatTemplate,
|
|
SVG_CLOSE,
|
|
getCoverBlob,
|
|
getExtensionFromBlob,
|
|
escapeHtml,
|
|
} from './utils.js';
|
|
import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js';
|
|
import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js';
|
|
import { rebuildFlacWithoutMetadata } from './metadata.flac.js';
|
|
import { DashDownloader } from './dash-downloader.js';
|
|
import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } from './playlist-generator.js';
|
|
import { encodeToMp3 } from './mp3-encoder.js';
|
|
import { ffmpeg, loadFfmpeg } from './ffmpeg.js';
|
|
|
|
const downloadTasks = new Map();
|
|
const bulkDownloadTasks = new Map();
|
|
const ongoingDownloads = new Set();
|
|
let downloadNotificationContainer = null;
|
|
|
|
async function loadClientZip() {
|
|
try {
|
|
const module = await import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm');
|
|
return module;
|
|
} catch (error) {
|
|
console.error('Failed to load client-zip:', error);
|
|
throw new Error('Failed to load ZIP library');
|
|
}
|
|
}
|
|
|
|
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 createDownloadNotification() {
|
|
if (!downloadNotificationContainer) {
|
|
downloadNotificationContainer = document.createElement('div');
|
|
downloadNotificationContainer.id = 'download-notifications';
|
|
document.body.appendChild(downloadNotificationContainer);
|
|
}
|
|
return downloadNotificationContainer;
|
|
}
|
|
|
|
export function showNotification(message) {
|
|
const container = createDownloadNotification();
|
|
|
|
const notifEl = document.createElement('div');
|
|
notifEl.className = 'download-task';
|
|
|
|
const innerDiv = document.createElement('div');
|
|
innerDiv.style.display = 'flex';
|
|
innerDiv.style.alignItems = 'start';
|
|
innerDiv.textContent = message;
|
|
notifEl.appendChild(innerDiv);
|
|
|
|
container.appendChild(notifEl);
|
|
|
|
// Auto remove
|
|
setTimeout(() => {
|
|
notifEl.style.animation = 'slide-out 0.3s ease forwards';
|
|
setTimeout(() => notifEl.remove(), 300);
|
|
}, 1500);
|
|
}
|
|
|
|
export function addDownloadTask(trackId, track, filename, api, abortController) {
|
|
const container = createDownloadNotification();
|
|
|
|
const taskEl = document.createElement('div');
|
|
taskEl.className = 'download-task';
|
|
taskEl.dataset.trackId = trackId;
|
|
const trackTitle = getTrackTitle(track);
|
|
const trackArtists = getTrackArtists(track);
|
|
taskEl.innerHTML = `
|
|
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
|
<img src="${api.getCoverUrl(track.album?.cover)}"
|
|
style="width: 40px; height: 40px; border-radius: 4px; flex-shrink: 0;">
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="font-weight: 500; font-size: 0.9rem; margin-bottom: 0.25rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${trackTitle}</div>
|
|
<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-bottom: 0.5rem;">${trackArtists}</div>
|
|
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
|
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
|
</div>
|
|
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Starting...</div>
|
|
</div>
|
|
<button class="download-cancel" style="background: transparent; border: none; color: var(--muted-foreground); cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
|
|
${SVG_CLOSE}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(taskEl);
|
|
|
|
downloadTasks.set(trackId, { taskEl, abortController });
|
|
|
|
taskEl.querySelector('.download-cancel').addEventListener('click', () => {
|
|
abortController.abort();
|
|
removeDownloadTask(trackId);
|
|
});
|
|
|
|
return { taskEl, abortController };
|
|
}
|
|
|
|
export function updateDownloadProgress(trackId, progress) {
|
|
const task = downloadTasks.get(trackId);
|
|
if (!task) return;
|
|
|
|
const { taskEl } = task;
|
|
const progressFill = taskEl.querySelector('.download-progress-fill');
|
|
const statusEl = taskEl.querySelector('.download-status');
|
|
|
|
if (progress.stage === 'downloading') {
|
|
const percent = progress.totalBytes ? Math.round((progress.receivedBytes / progress.totalBytes) * 100) : 0;
|
|
|
|
progressFill.style.width = `${percent}%`;
|
|
progressFill.style.background = 'var(--highlight)';
|
|
|
|
const receivedMB = (progress.receivedBytes / (1024 * 1024)).toFixed(1);
|
|
const totalMB = progress.totalBytes ? (progress.totalBytes / (1024 * 1024)).toFixed(1) : '?';
|
|
|
|
statusEl.textContent = `Downloading: ${receivedMB}MB / ${totalMB}MB (${percent}%)`;
|
|
} else if (progress.stage === 'encoding') {
|
|
const percent = progress.progress ? Math.round(progress.progress) : 0;
|
|
progressFill.style.width = `${percent}%`;
|
|
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
|
statusEl.textContent = `Converting: ${percent}%`;
|
|
} else if (progress.stage === 'finalizing' || progress.stage === 'processing') {
|
|
progressFill.style.width = '100%';
|
|
progressFill.style.background = '#3b82f6';
|
|
statusEl.textContent = progress.message || 'Processing...';
|
|
}
|
|
}
|
|
|
|
export function completeDownloadTask(trackId, success = true, message = null) {
|
|
const task = downloadTasks.get(trackId);
|
|
if (!task) return;
|
|
|
|
const { taskEl } = task;
|
|
const progressFill = taskEl.querySelector('.download-progress-fill');
|
|
const statusEl = taskEl.querySelector('.download-status');
|
|
const cancelBtn = taskEl.querySelector('.download-cancel');
|
|
|
|
if (success) {
|
|
progressFill.style.width = '100%';
|
|
progressFill.style.background = '#10b981';
|
|
statusEl.textContent = '✓ Downloaded';
|
|
statusEl.style.color = '#10b981';
|
|
cancelBtn.remove();
|
|
|
|
setTimeout(() => removeDownloadTask(trackId), 3000);
|
|
} else {
|
|
progressFill.style.background = '#ef4444';
|
|
statusEl.textContent = message || '✗ Download failed';
|
|
statusEl.style.color = '#ef4444';
|
|
cancelBtn.innerHTML = `
|
|
${SVG_CLOSE}
|
|
`;
|
|
cancelBtn.onclick = () => removeDownloadTask(trackId);
|
|
|
|
setTimeout(() => removeDownloadTask(trackId), 5000);
|
|
}
|
|
}
|
|
|
|
function removeDownloadTask(trackId) {
|
|
const task = downloadTasks.get(trackId);
|
|
if (!task) return;
|
|
|
|
const { taskEl } = task;
|
|
taskEl.style.animation = 'slide-out 0.3s ease forwards';
|
|
|
|
setTimeout(() => {
|
|
taskEl.remove();
|
|
downloadTasks.delete(trackId);
|
|
|
|
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
|
downloadNotificationContainer.remove();
|
|
downloadNotificationContainer = null;
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
function removeBulkDownloadTask(notifEl) {
|
|
const task = bulkDownloadTasks.get(notifEl);
|
|
if (!task) return;
|
|
|
|
notifEl.style.animation = 'slide-out 0.3s ease forwards';
|
|
|
|
setTimeout(() => {
|
|
notifEl.remove();
|
|
bulkDownloadTasks.delete(notifEl);
|
|
|
|
if (downloadNotificationContainer && downloadNotificationContainer.children.length === 0) {
|
|
downloadNotificationContainer.remove();
|
|
downloadNotificationContainer = null;
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
async function downloadTrackBlob(
|
|
track,
|
|
quality,
|
|
api,
|
|
lyricsManager = null,
|
|
signal = null,
|
|
onProgress = null,
|
|
coverBlob = null
|
|
) {
|
|
// Load ffmpeg in the background.
|
|
loadFfmpeg().catch(console.error);
|
|
|
|
const prefetchPromises = prefetchMetadataObjects(track, api, coverBlob);
|
|
|
|
let enrichedTrack = {
|
|
...track,
|
|
artist: track.artist || (track.artists && track.artists.length > 0 ? track.artists[0] : null),
|
|
};
|
|
|
|
// MP3_320 is not a native TIDAL quality, we download LOSSLESS and convert
|
|
const downloadQuality = quality === 'MP3_320' ? 'LOSSLESS' : quality;
|
|
|
|
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);
|
|
if (albumData.album) {
|
|
enrichedTrack.album = {
|
|
...enrichedTrack.album,
|
|
...albumData.album,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch album data for metadata:', error);
|
|
}
|
|
}
|
|
|
|
const lookup = await api.getTrack(track.id, downloadQuality);
|
|
let streamUrl;
|
|
|
|
if (lookup.originalTrackUrl) {
|
|
streamUrl = lookup.originalTrackUrl;
|
|
} else {
|
|
streamUrl = api.extractStreamUrlFromManifest(lookup.info.manifest);
|
|
if (!streamUrl) {
|
|
throw new Error('Could not resolve stream URL');
|
|
}
|
|
}
|
|
|
|
if (lookup.info) {
|
|
enrichedTrack.replayGain = {
|
|
trackReplayGain: lookup.info.trackReplayGain,
|
|
trackPeakAmplitude: lookup.info.trackPeakAmplitude,
|
|
albumReplayGain: lookup.info.albumReplayGain,
|
|
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
};
|
|
}
|
|
|
|
// Handle DASH streams (blob URLs)
|
|
let blob;
|
|
if (streamUrl.startsWith('blob:')) {
|
|
try {
|
|
const downloader = new DashDownloader();
|
|
blob = await downloader.downloadDashStream(streamUrl, { signal });
|
|
} catch (dashError) {
|
|
console.error('DASH download failed:', dashError);
|
|
// Fallback
|
|
if (downloadQuality !== 'LOSSLESS') {
|
|
console.warn('Falling back to LOSSLESS (16-bit) download.');
|
|
return downloadTrackBlob(track, 'LOSSLESS', api, lyricsManager, signal, onProgress, coverBlob);
|
|
}
|
|
throw dashError;
|
|
}
|
|
} else {
|
|
const response = await fetch(streamUrl, { signal });
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch track: ${response.status}`);
|
|
}
|
|
blob = await response.blob();
|
|
}
|
|
|
|
// Convert to MP3 320kbps if requested
|
|
if (quality === 'MP3_320') {
|
|
blob = await encodeToMp3(blob, onProgress || (() => undefined), signal);
|
|
}
|
|
|
|
if (quality.endsWith('LOSSLESS')) {
|
|
try {
|
|
switch (losslessContainerSettings.getContainer()) {
|
|
case 'flac':
|
|
if ((await getExtensionFromBlob(blob)) != 'flac') {
|
|
blob = await ffmpeg(
|
|
blob,
|
|
{ args: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'] },
|
|
'output.flac',
|
|
'audio/flac',
|
|
onProgress,
|
|
signal
|
|
);
|
|
} else {
|
|
blob = await rebuildFlacWithoutMetadata(blob);
|
|
}
|
|
break;
|
|
case 'alac':
|
|
blob = await ffmpeg(
|
|
blob,
|
|
{ args: ['-c:a', 'alac'] },
|
|
'output.m4a',
|
|
'audio/mp4',
|
|
onProgress,
|
|
signal
|
|
);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
if (error?.name === 'AbortError') {
|
|
throw error;
|
|
}
|
|
|
|
console.error('Lossless container conversion failed:', error);
|
|
}
|
|
}
|
|
|
|
// Detect actual format from blob signature BEFORE adding metadata
|
|
const extension = await getExtensionFromBlob(blob);
|
|
|
|
// Add metadata to the blob
|
|
blob = await addMetadataToAudio(blob, enrichedTrack, api, quality, prefetchPromises);
|
|
|
|
return { blob, extension };
|
|
}
|
|
|
|
function triggerDownload(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification, coverBlob = null) {
|
|
const { abortController } = bulkDownloadTasks.get(notification);
|
|
const signal = abortController.signal;
|
|
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
if (signal.aborted) break;
|
|
const track = tracks[i];
|
|
const trackTitle = getTrackTitle(track);
|
|
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
|
|
try {
|
|
const { blob, extension } = await downloadTrackBlob(track, quality, api, null, signal, null, coverBlob);
|
|
const filename = buildTrackFilename(track, quality, extension);
|
|
triggerDownload(blob, filename);
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
if (lrcContent) {
|
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
const lrcBlob = new Blob([lrcContent], { type: 'text/plain' });
|
|
triggerDownload(lrcBlob, lrcFilename);
|
|
}
|
|
}
|
|
} catch {
|
|
// Silent fail for lyrics
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') throw err;
|
|
console.error(`Failed to download track ${trackTitle}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function bulkDownloadToZipStream(
|
|
tracks,
|
|
folderName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
fileHandle,
|
|
coverBlob = null,
|
|
type = 'playlist',
|
|
metadata = null
|
|
) {
|
|
const { abortController } = bulkDownloadTasks.get(notification);
|
|
const signal = abortController.signal;
|
|
const { downloadZip } = await loadClientZip();
|
|
|
|
const writable = await fileHandle.createWritable();
|
|
|
|
async function* yieldFiles() {
|
|
// Add cover if available
|
|
if (coverBlob) {
|
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
|
}
|
|
|
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
|
const separateByDisc = discLayout.separateByDisc;
|
|
|
|
// 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];
|
|
const trackTitle = getTrackTitle(track);
|
|
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
|
|
try {
|
|
const { blob, extension } = await downloadTrackBlob(
|
|
track,
|
|
quality,
|
|
api,
|
|
null,
|
|
signal,
|
|
(p) => {
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
|
},
|
|
coverBlob
|
|
);
|
|
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(),
|
|
input: blob,
|
|
};
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
if (lrcContent) {
|
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
yield {
|
|
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
|
lastModified: new Date(),
|
|
input: lrcContent,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
} 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(getExplicitTrackDiscNumber(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 {
|
|
const response = downloadZip(yieldFiles());
|
|
await response.body.pipeTo(writable);
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Generate ZIP as blob for browsers without File System Access API (iOS, etc.)
|
|
async function bulkDownloadToZipBlob(
|
|
tracks,
|
|
folderName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
coverBlob = null,
|
|
type = 'playlist',
|
|
metadata = null
|
|
) {
|
|
const { abortController } = bulkDownloadTasks.get(notification);
|
|
const signal = abortController.signal;
|
|
const { downloadZip } = await loadClientZip();
|
|
|
|
async function* yieldFiles() {
|
|
// Add cover if available
|
|
if (coverBlob) {
|
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
|
}
|
|
|
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
|
const separateByDisc = discLayout.separateByDisc;
|
|
|
|
// 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];
|
|
const trackTitle = getTrackTitle(track);
|
|
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
|
|
try {
|
|
const { blob, extension } = await downloadTrackBlob(
|
|
track,
|
|
quality,
|
|
api,
|
|
null,
|
|
signal,
|
|
(p) => {
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
|
},
|
|
coverBlob
|
|
);
|
|
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(),
|
|
input: blob,
|
|
};
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
if (lrcContent) {
|
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
yield {
|
|
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
|
lastModified: new Date(),
|
|
input: lrcContent,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
} 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 {
|
|
const response = downloadZip(yieldFiles());
|
|
const blob = await response.blob();
|
|
triggerDownload(blob, `${folderName}.zip`);
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bulkDownloadToZipNeutralino(
|
|
tracks,
|
|
folderName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
coverBlob = null,
|
|
type = 'playlist',
|
|
metadata = null
|
|
) {
|
|
const { abortController } = bulkDownloadTasks.get(notification);
|
|
const signal = abortController.signal;
|
|
const { downloadZip } = await loadClientZip();
|
|
|
|
// Re-use logic for generating file entries
|
|
async function* yieldFiles() {
|
|
// Add cover if available
|
|
if (coverBlob) {
|
|
yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
|
}
|
|
|
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
|
const separateByDisc = discLayout.separateByDisc;
|
|
|
|
// 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];
|
|
const trackTitle = getTrackTitle(track);
|
|
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle);
|
|
|
|
try {
|
|
const { blob, extension } = await downloadTrackBlob(
|
|
track,
|
|
quality,
|
|
api,
|
|
null,
|
|
signal,
|
|
(p) => {
|
|
updateBulkDownloadProgress(notification, i, tracks.length, trackTitle, p);
|
|
},
|
|
coverBlob
|
|
);
|
|
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(),
|
|
input: blob,
|
|
};
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
if (lrcContent) {
|
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
yield {
|
|
name: buildZipTrackPath(folderName, lrcFilename, separateByDisc, discNumber),
|
|
lastModified: new Date(),
|
|
input: lrcContent,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
} 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 {
|
|
// Load the bridge explicitly to ensure we go through the parent shell
|
|
const bridge = await import('./desktop/neutralino-bridge.js');
|
|
|
|
// Native Save Dialog via Bridge
|
|
const savePath = await bridge.os.showSaveDialog(`Select save location for ${folderName}.zip`, {
|
|
defaultPath: `${folderName}.zip`,
|
|
filters: [{ name: 'ZIP Archive', extensions: ['zip'] }],
|
|
});
|
|
|
|
if (!savePath) {
|
|
// Cancelled
|
|
removeBulkDownloadTask(notification);
|
|
return;
|
|
}
|
|
|
|
const response = downloadZip(yieldFiles());
|
|
|
|
// Initialize file (empty) to ensure it exists
|
|
// We use writeBinaryFile with an empty buffer to create/overwrite
|
|
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
|
|
|
|
// Stream the response body
|
|
if (!response.body) throw new Error('ZIP response body is null');
|
|
|
|
const reader = response.body.getReader();
|
|
let receivedLength = 0;
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
|
|
// 'value' is a Uint8Array. Neutralino filesystem expects ArrayBuffer.
|
|
// value.buffer might contain the whole backing store, so we should be careful to slice if offset is non-zero
|
|
// but usually read() returns fresh chunks.
|
|
// However, neutralino bridge's appendBinaryFile takes ArrayBuffer.
|
|
const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength);
|
|
|
|
await bridge.filesystem.appendBinaryFile(savePath, chunk);
|
|
receivedLength += value.length;
|
|
|
|
// Optional: Update granular progress if we want, but we typically update per-track in yieldFiles
|
|
}
|
|
|
|
console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`);
|
|
|
|
completeBulkDownload(notification, true);
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') return;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function startBulkDownload(
|
|
tracks,
|
|
defaultName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
type,
|
|
name,
|
|
coverBlob = null,
|
|
metadata = null
|
|
) {
|
|
const notification = createBulkDownloadNotification(type, name, tracks.length);
|
|
|
|
try {
|
|
const isNeutralino = window.NL_MODE === true;
|
|
const hasFileSystemAccess =
|
|
'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
|
const forceIndividual = bulkDownloadSettings.shouldForceIndividual();
|
|
const useZip = hasFileSystemAccess && !forceIndividual;
|
|
const useZipBlob = !hasFileSystemAccess && !forceIndividual;
|
|
|
|
if (isNeutralino) {
|
|
// Neutralino Native Logic
|
|
await bulkDownloadToZipNeutralino(
|
|
tracks,
|
|
defaultName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
coverBlob,
|
|
type,
|
|
metadata
|
|
);
|
|
} else if (useZip) {
|
|
// File System Access API available - use streaming
|
|
try {
|
|
const fileHandle = await window.showSaveFilePicker({
|
|
suggestedName: `${defaultName}.zip`,
|
|
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
|
});
|
|
await bulkDownloadToZipStream(
|
|
tracks,
|
|
defaultName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
fileHandle,
|
|
coverBlob,
|
|
type,
|
|
metadata
|
|
);
|
|
completeBulkDownload(notification, true);
|
|
} catch (err) {
|
|
if (err.name === 'AbortError') {
|
|
removeBulkDownloadTask(notification);
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
} else if (useZipBlob) {
|
|
// No File System Access API (iOS, etc.) - use blob-based ZIP
|
|
await bulkDownloadToZipBlob(
|
|
tracks,
|
|
defaultName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
notification,
|
|
coverBlob,
|
|
type,
|
|
metadata
|
|
);
|
|
completeBulkDownload(notification, true);
|
|
} else {
|
|
// Fallback or Forced: Individual sequential downloads
|
|
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
|
completeBulkDownload(notification, true);
|
|
}
|
|
} catch (error) {
|
|
console.error('Bulk download failed:', error);
|
|
completeBulkDownload(notification, false, error.message);
|
|
}
|
|
}
|
|
|
|
export async function downloadTracks(tracks, api, quality, lyricsManager = null) {
|
|
const folderName = `Queue - ${new Date().toISOString().slice(0, 10)}`;
|
|
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'queue', 'Queue', null, {
|
|
title: 'Queue',
|
|
});
|
|
}
|
|
|
|
export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsManager = null) {
|
|
const releaseDateStr =
|
|
album.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
|
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
|
|
|
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
|
albumTitle: album.title,
|
|
albumArtist: album.artist?.name,
|
|
year: year,
|
|
});
|
|
|
|
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
|
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'album', album.title, coverBlob, album);
|
|
}
|
|
|
|
export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyricsManager = null) {
|
|
const folderName = formatTemplate(localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}', {
|
|
albumTitle: playlist.title,
|
|
albumArtist: 'Playlist',
|
|
year: new Date().getFullYear(),
|
|
});
|
|
|
|
const representativeTrack = tracks.find((t) => t.album?.cover);
|
|
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
|
await startBulkDownload(
|
|
tracks,
|
|
folderName,
|
|
api,
|
|
quality,
|
|
lyricsManager,
|
|
'playlist',
|
|
playlist.title,
|
|
coverBlob,
|
|
playlist
|
|
);
|
|
}
|
|
|
|
export async function downloadDiscography(artist, selectedReleases, api, quality, lyricsManager = null) {
|
|
const rootFolder = `${sanitizeForFilename(artist.name)} discography`;
|
|
const notification = createBulkDownloadNotification('discography', artist.name, selectedReleases.length);
|
|
const { abortController } = bulkDownloadTasks.get(notification);
|
|
const signal = abortController.signal;
|
|
|
|
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
|
|
const useZip = hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
|
const useZipBlob = !hasFileSystemAccess && !bulkDownloadSettings.shouldForceIndividual();
|
|
|
|
async function* yieldDiscography() {
|
|
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
|
if (signal.aborted) break;
|
|
const album = selectedReleases[albumIndex];
|
|
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
|
|
|
try {
|
|
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
|
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
|
const releaseDateStr =
|
|
fullAlbum.releaseDate ||
|
|
(tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
|
const year = releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate.getFullYear() : '';
|
|
|
|
const albumFolder = formatTemplate(
|
|
localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}',
|
|
{
|
|
albumTitle: fullAlbum.title,
|
|
albumArtist: fullAlbum.artist?.name,
|
|
year: year,
|
|
}
|
|
);
|
|
|
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
|
if (coverBlob)
|
|
yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob };
|
|
|
|
// Generate playlist files for each album
|
|
const useRelativePaths = playlistSettings.shouldUseRelativePaths();
|
|
const discLayout = await createDiscLayoutContext(tracks, api);
|
|
const separateByDisc = discLayout.separateByDisc;
|
|
|
|
// 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;
|
|
try {
|
|
const { blob, extension } = await downloadTrackBlob(
|
|
track,
|
|
quality,
|
|
api,
|
|
null,
|
|
signal,
|
|
null,
|
|
coverBlob
|
|
);
|
|
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(),
|
|
input: blob,
|
|
};
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
|
if (lrcContent) {
|
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
|
yield {
|
|
name: buildZipTrackPath(
|
|
fullFolderPath,
|
|
lrcFilename,
|
|
separateByDisc,
|
|
discNumber
|
|
),
|
|
lastModified: new Date(),
|
|
input: lrcContent,
|
|
};
|
|
}
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
} 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (useZip) {
|
|
// File System Access API available - use streaming
|
|
const fileHandle = await window.showSaveFilePicker({
|
|
suggestedName: `${rootFolder}.zip`,
|
|
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
|
|
});
|
|
const writable = await fileHandle.createWritable();
|
|
const { downloadZip } = await loadClientZip();
|
|
|
|
const response = downloadZip(yieldDiscography());
|
|
await response.body.pipeTo(writable);
|
|
completeBulkDownload(notification, true);
|
|
} else if (useZipBlob) {
|
|
// No File System Access API (iOS, etc.) - use blob-based ZIP
|
|
const { downloadZip } = await loadClientZip();
|
|
const response = downloadZip(yieldDiscography());
|
|
const blob = await response.blob();
|
|
triggerDownload(blob, `${rootFolder}.zip`);
|
|
completeBulkDownload(notification, true);
|
|
} else {
|
|
// Sequential individual downloads for discography
|
|
for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) {
|
|
if (signal.aborted) break;
|
|
const album = selectedReleases[albumIndex];
|
|
updateBulkDownloadProgress(notification, albumIndex, selectedReleases.length, album.title);
|
|
const { tracks } = await api.getAlbum(album.id);
|
|
await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification);
|
|
}
|
|
completeBulkDownload(notification, true);
|
|
}
|
|
} catch (error) {
|
|
if (error.name === 'AbortError') {
|
|
removeBulkDownloadTask(notification);
|
|
return;
|
|
}
|
|
completeBulkDownload(notification, false, error.message);
|
|
}
|
|
}
|
|
|
|
function createBulkDownloadNotification(type, name, _totalItems) {
|
|
const container = createDownloadNotification();
|
|
|
|
const notifEl = document.createElement('div');
|
|
notifEl.className = 'download-task bulk-download';
|
|
notifEl.dataset.bulkType = type;
|
|
notifEl.dataset.bulkName = name;
|
|
|
|
const typeLabel =
|
|
type === 'album'
|
|
? 'Album'
|
|
: type === 'playlist'
|
|
? 'Playlist'
|
|
: type === 'liked'
|
|
? 'Liked Tracks'
|
|
: type === 'queue'
|
|
? 'Queue'
|
|
: 'Discography';
|
|
|
|
notifEl.innerHTML = `
|
|
<div style="display: flex; align-items: start; gap: 0.75rem;">
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="font-weight: 600; font-size: 0.95rem; margin-bottom: 0.25rem;">
|
|
Downloading ${typeLabel}
|
|
</div>
|
|
<div style="font-size: 0.85rem; color: var(--muted-foreground); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${escapeHtml(name)}</div>
|
|
<div class="download-progress-bar" style="height: 4px; background: var(--secondary); border-radius: 2px; overflow: hidden;">
|
|
<div class="download-progress-fill" style="width: 0%; height: 100%; background: var(--highlight); transition: width 0.2s;"></div>
|
|
</div>
|
|
<div class="download-status" style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.25rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">Starting...</div>
|
|
</div>
|
|
<button class="download-cancel" style="background: transparent; border: none; color: var(--muted-foreground); cursor: pointer; padding: 4px; border-radius: 4px; transition: all 0.2s;">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(notifEl);
|
|
|
|
const abortController = new AbortController();
|
|
bulkDownloadTasks.set(notifEl, { abortController });
|
|
|
|
notifEl.querySelector('.download-cancel').addEventListener('click', () => {
|
|
abortController.abort();
|
|
removeBulkDownloadTask(notifEl);
|
|
});
|
|
|
|
return notifEl;
|
|
}
|
|
|
|
function updateBulkDownloadProgress(notifEl, current, total, currentItem, ffmpegProgress = null) {
|
|
const progressFill = notifEl.querySelector('.download-progress-fill');
|
|
const statusEl = notifEl.querySelector('.download-status');
|
|
|
|
if (ffmpegProgress && (ffmpegProgress.stage === 'encoding' || ffmpegProgress.stage === 'finalizing')) {
|
|
const percent = ffmpegProgress.progress ? Math.round(ffmpegProgress.progress) : 100;
|
|
progressFill.style.width = `${percent}%`;
|
|
progressFill.style.background = '#3b82f6'; // Blue for encoding
|
|
statusEl.textContent = `Converting ${current}/${total}: ${percent}%`;
|
|
return;
|
|
}
|
|
|
|
const percent = total > 0 ? Math.round((current / total) * 100) : 0;
|
|
progressFill.style.width = `${percent}%`;
|
|
progressFill.style.background = 'var(--highlight)';
|
|
statusEl.textContent = `${current}/${total} - ${currentItem}`;
|
|
}
|
|
|
|
function completeBulkDownload(notifEl, success = true, message = null) {
|
|
const progressFill = notifEl.querySelector('.download-progress-fill');
|
|
const statusEl = notifEl.querySelector('.download-status');
|
|
|
|
if (success) {
|
|
progressFill.style.width = '100%';
|
|
progressFill.style.background = '#10b981';
|
|
statusEl.textContent = '✓ Download complete';
|
|
statusEl.style.color = '#10b981';
|
|
|
|
setTimeout(() => {
|
|
notifEl.style.animation = 'slide-out 0.3s ease forwards';
|
|
setTimeout(() => notifEl.remove(), 300);
|
|
}, 3000);
|
|
} else {
|
|
progressFill.style.background = '#ef4444';
|
|
statusEl.textContent = message || '✗ Download failed';
|
|
statusEl.style.color = '#ef4444';
|
|
|
|
setTimeout(() => {
|
|
notifEl.style.animation = 'slide-out 0.3s ease forwards';
|
|
setTimeout(() => notifEl.remove(), 300);
|
|
}, 5000);
|
|
}
|
|
}
|
|
|
|
export async function downloadTrackWithMetadata(track, quality, api, lyricsManager = null, abortController = null) {
|
|
if (!track) {
|
|
alert('No track is currently playing');
|
|
return;
|
|
}
|
|
|
|
const downloadKey = `track-${track.id}`;
|
|
if (ongoingDownloads.has(downloadKey)) {
|
|
showNotification('This track is already being downloaded');
|
|
return;
|
|
}
|
|
|
|
let enrichedTrack = {
|
|
...track,
|
|
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);
|
|
if (albumData.album) {
|
|
enrichedTrack.album = {
|
|
...enrichedTrack.album,
|
|
...albumData.album,
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch album data for metadata:', error);
|
|
}
|
|
}
|
|
|
|
const filename = buildTrackFilename(enrichedTrack, quality);
|
|
|
|
const controller = abortController || new AbortController();
|
|
ongoingDownloads.add(downloadKey);
|
|
|
|
try {
|
|
addDownloadTask(track.id, enrichedTrack, filename, api, controller);
|
|
|
|
await api.downloadTrack(track.id, quality, filename, {
|
|
signal: controller.signal,
|
|
track: enrichedTrack,
|
|
onProgress: (progress) => {
|
|
updateDownloadProgress(track.id, progress);
|
|
},
|
|
});
|
|
|
|
completeDownloadTask(track.id, true);
|
|
|
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
|
try {
|
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
|
if (lyricsData) {
|
|
lyricsManager.downloadLRC(lyricsData, track);
|
|
}
|
|
} catch {
|
|
console.log('Could not download lyrics for track');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error.name !== 'AbortError') {
|
|
const errorMsg =
|
|
error.message === RATE_LIMIT_ERROR_MESSAGE ? error.message : 'Download failed. Please try again.';
|
|
completeDownloadTask(track.id, false, errorMsg);
|
|
}
|
|
} finally {
|
|
ongoingDownloads.delete(downloadKey);
|
|
}
|
|
}
|
|
|
|
export async function downloadLikedTracks(tracks, api, quality, lyricsManager = null) {
|
|
const folderName = `Liked Tracks - ${new Date().toISOString().slice(0, 10)}`;
|
|
await startBulkDownload(tracks, folderName, api, quality, lyricsManager, 'liked', 'Liked Tracks');
|
|
}
|