Feat: Implement memory-efficient sequential streaming ZIP downloads using zip.js and StreamSaver.js
This commit is contained in:
parent
f7a0da1934
commit
67a97a34a8
1 changed files with 107 additions and 107 deletions
214
js/downloads.js
214
js/downloads.js
|
|
@ -6,24 +6,24 @@ import { addMetadataToAudio } from './metadata.js';
|
||||||
const downloadTasks = new Map();
|
const downloadTasks = new Map();
|
||||||
let downloadNotificationContainer = null;
|
let downloadNotificationContainer = null;
|
||||||
|
|
||||||
/**
|
async function loadZipJS() {
|
||||||
* Adds a cover blob to a JSZip instance
|
try {
|
||||||
*/
|
// Load zip.js from CDN (ES Module)
|
||||||
function addCoverBlobToZip(zip, folderPath, blob) {
|
const module = await import('https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.34/index.js');
|
||||||
if (!blob) return;
|
return module;
|
||||||
const path = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg';
|
} catch (error) {
|
||||||
if (!zip.file(path)) {
|
console.error('Failed to load zip.js:', error);
|
||||||
zip.file(path, blob);
|
throw new Error('Failed to load ZIP library');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadJSZip() {
|
async function loadStreamSaver() {
|
||||||
try {
|
try {
|
||||||
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
|
const module = await import('https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.js');
|
||||||
return module.default;
|
return module.default;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load JSZip:', error);
|
console.error('Failed to load StreamSaver:', error);
|
||||||
throw new Error('Failed to load ZIP library');
|
throw new Error('Failed to load StreamSaver library');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,7 +167,7 @@ function removeDownloadTask(trackId) {
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
|
async function downloadTrackBlob(track, quality, api) {
|
||||||
const lookup = await api.getTrack(track.id, quality);
|
const lookup = await api.getTrack(track.id, quality);
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
|
|
||||||
|
|
@ -193,81 +193,49 @@ async function downloadTrackBlob(track, quality, api, lyricsManager = null) {
|
||||||
return blob;
|
return blob;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generateAndDownloadZip(zip, filename, notification, progressTotal, fileHandle = null) {
|
/**
|
||||||
updateBulkDownloadProgress(notification, progressTotal, progressTotal, 'Creating ZIP...');
|
* Initializes the download stream (using File System Access API or StreamSaver)
|
||||||
|
* and returns a ZipWriter instance that pipes to it.
|
||||||
|
*/
|
||||||
|
async function createZipStreamWriter(filename) {
|
||||||
|
const zip = await loadZipJS();
|
||||||
|
let writable;
|
||||||
|
let abortFn = null;
|
||||||
|
|
||||||
try {
|
// 1. Try File System Access API (Chrome/Edge/Opera)
|
||||||
// Use the pre-acquired file handle for streaming (Chrome/Edge/Opera)
|
if (window.showSaveFilePicker) {
|
||||||
if (fileHandle) {
|
|
||||||
const writable = await fileHandle.createWritable();
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
zip.generateInternalStream({
|
|
||||||
type: 'uint8array',
|
|
||||||
compression: 'STORE',
|
|
||||||
streamFiles: true
|
|
||||||
})
|
|
||||||
.on('data', (chunk, metadata) => {
|
|
||||||
writable.write(chunk);
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
writable.close();
|
|
||||||
reject(err);
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
writable.close();
|
|
||||||
resolve();
|
|
||||||
})
|
|
||||||
.resume();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback for Firefox/Safari or if user cancelled/API not available
|
|
||||||
const zipBlob = await zip.generateAsync({
|
|
||||||
type: 'blob',
|
|
||||||
compression: 'STORE',
|
|
||||||
streamFiles: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(zipBlob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${filename}.zip`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
completeBulkDownload(notification, true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('ZIP generation failed:', error);
|
|
||||||
completeBulkDownload(notification, false, 'ZIP creation failed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeZipDownload(defaultName, useFilePicker = false) {
|
|
||||||
const JSZip = await loadJSZip();
|
|
||||||
const zip = new JSZip();
|
|
||||||
|
|
||||||
let fileHandle = null;
|
|
||||||
if (useFilePicker && window.showSaveFilePicker) {
|
|
||||||
try {
|
try {
|
||||||
fileHandle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
suggestedName: `${defaultName}.zip`,
|
suggestedName: `${filename}.zip`,
|
||||||
types: [{
|
types: [{
|
||||||
description: 'ZIP Archive',
|
description: 'ZIP Archive',
|
||||||
accept: { 'application/zip': ['.zip'] }
|
accept: { 'application/zip': ['.zip'] }
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
writable = await handle.createWritable();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'AbortError') return null; // User cancelled
|
if (err.name === 'AbortError') return null; // User cancelled
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// 2. Fallback to StreamSaver.js (Firefox/Safari)
|
||||||
|
else {
|
||||||
|
const streamSaver = await loadStreamSaver();
|
||||||
|
writable = streamSaver.createWriteStream(`${filename}.zip`);
|
||||||
|
// StreamSaver doesn't support aborting via API easily in this flow,
|
||||||
|
// but closing the writer effectively ends it.
|
||||||
}
|
}
|
||||||
return { zip, fileHandle };
|
|
||||||
|
// Create zip.js writer
|
||||||
|
// zip.js requires a specific Writer interface for WritableStream
|
||||||
|
const zipWriter = new zip.ZipWriter(new zip.WritableStreamWriter(writable));
|
||||||
|
|
||||||
|
return { zipWriter, zipModule: zip };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) {
|
async function streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) {
|
||||||
|
const { BlobReader, TextReader } = zipModule;
|
||||||
|
|
||||||
for (let i = 0; i < tracks.length; i++) {
|
for (let i = 0; i < tracks.length; i++) {
|
||||||
const track = tracks[i];
|
const track = tracks[i];
|
||||||
const currentGlobalIndex = startProgressIndex + i;
|
const currentGlobalIndex = startProgressIndex + i;
|
||||||
|
|
@ -277,9 +245,15 @@ async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyrics
|
||||||
updateBulkDownloadProgress(notification, currentGlobalIndex, totalTracks, trackTitle);
|
updateBulkDownloadProgress(notification, currentGlobalIndex, totalTracks, trackTitle);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Download track (into memory blob)
|
||||||
const blob = await downloadTrackBlob(track, quality, api);
|
const blob = await downloadTrackBlob(track, quality, api);
|
||||||
zip.file(`${folderName}/${filename}`, blob);
|
|
||||||
|
// Write to ZIP stream (and flush to disk immediately)
|
||||||
|
await zipWriter.add(`${folderName}/${filename}`, new BlobReader(blob));
|
||||||
|
|
||||||
|
// Blob is now eligible for GC (as we await the write)
|
||||||
|
|
||||||
|
// Lyrics
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
try {
|
try {
|
||||||
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
const lyricsData = await lyricsManager.fetchLyrics(track.id, track);
|
||||||
|
|
@ -287,11 +261,11 @@ async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyrics
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||||
if (lrcContent) {
|
if (lrcContent) {
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||||
zip.file(`${folderName}/${lrcFilename}`, lrcContent);
|
await zipWriter.add(`${folderName}/${lrcFilename}`, new TextReader(lrcContent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Could not add lyrics for:', trackTitle);
|
// Ignore lyrics error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -311,21 +285,25 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana
|
||||||
year: year
|
year: year
|
||||||
});
|
});
|
||||||
|
|
||||||
// Only prompt for save location if we have >= 20 tracks (to capture user gesture early)
|
const streamResult = await createZipStreamWriter(folderName);
|
||||||
// Otherwise, we'll auto-download the blob at the end
|
if (!streamResult) return; // Cancelled
|
||||||
const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
|
const { zipWriter, zipModule } = streamResult;
|
||||||
if (!initResult) return; // User cancelled
|
|
||||||
const { zip, fileHandle } = initResult;
|
|
||||||
|
|
||||||
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
|
||||||
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
|
const notification = createBulkDownloadNotification('album', album.title, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
addCoverBlobToZip(zip, folderName, coverBlob);
|
const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId);
|
||||||
await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification);
|
if (coverBlob) {
|
||||||
await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle);
|
await zipWriter.add(`${folderName}/cover.jpg`, new zipModule.BlobReader(coverBlob));
|
||||||
|
}
|
||||||
|
|
||||||
|
await streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, quality, lyricsManager, notification);
|
||||||
|
|
||||||
|
await zipWriter.close();
|
||||||
|
completeBulkDownload(notification, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
|
try { await zipWriter.close(); } catch (e) {} // Try to close anyway
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -337,22 +315,27 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
|
||||||
year: new Date().getFullYear()
|
year: new Date().getFullYear()
|
||||||
});
|
});
|
||||||
|
|
||||||
const initResult = await initializeZipDownload(folderName, tracks.length >= 20);
|
const streamResult = await createZipStreamWriter(folderName);
|
||||||
if (!initResult) return; // User cancelled
|
if (!streamResult) return; // Cancelled
|
||||||
const { zip, fileHandle } = initResult;
|
const { zipWriter, zipModule } = streamResult;
|
||||||
|
|
||||||
const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length);
|
const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find a representative cover for the playlist (first track with cover)
|
// Cover
|
||||||
const representativeTrack = tracks.find(t => t.album?.cover);
|
const representativeTrack = tracks.find(t => t.album?.cover);
|
||||||
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover);
|
||||||
addCoverBlobToZip(zip, folderName, coverBlob);
|
if (coverBlob) {
|
||||||
|
await zipWriter.add(`${folderName}/cover.jpg`, new zipModule.BlobReader(coverBlob));
|
||||||
|
}
|
||||||
|
|
||||||
await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification);
|
await streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, quality, lyricsManager, notification);
|
||||||
await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle);
|
|
||||||
|
await zipWriter.close();
|
||||||
|
completeBulkDownload(notification, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
|
try { await zipWriter.close(); } catch (e) {}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -360,23 +343,25 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri
|
||||||
export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
|
export async function downloadDiscography(artist, api, quality, lyricsManager = null) {
|
||||||
const rootFolder = `${sanitizeForFilename(artist.name)} discography`;
|
const rootFolder = `${sanitizeForFilename(artist.name)} discography`;
|
||||||
|
|
||||||
// Always use file picker for discography as it's likely large
|
const streamResult = await createZipStreamWriter(rootFolder);
|
||||||
const initResult = await initializeZipDownload(rootFolder, true);
|
if (!streamResult) return;
|
||||||
if (!initResult) return; // User cancelled
|
const { zipWriter, zipModule } = streamResult;
|
||||||
const { zip, fileHandle } = initResult;
|
|
||||||
|
|
||||||
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
const allReleases = [...(artist.albums || []), ...(artist.eps || [])];
|
||||||
const notification = createBulkDownloadNotification('discography', artist.name, allReleases.length);
|
const notification = createBulkDownloadNotification('discography', artist.name, allReleases.length); // Total is approx tracks, but showing albums for now in text
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Calculate total tracks for better progress?
|
||||||
|
// It's expensive to fetch all album details first. We'll just update text.
|
||||||
|
|
||||||
|
let totalTracksDownloaded = 0;
|
||||||
|
|
||||||
for (let albumIndex = 0; albumIndex < allReleases.length; albumIndex++) {
|
for (let albumIndex = 0; albumIndex < allReleases.length; albumIndex++) {
|
||||||
const album = allReleases[albumIndex];
|
const album = allReleases[albumIndex];
|
||||||
|
|
||||||
updateBulkDownloadProgress(notification, albumIndex, allReleases.length, album.title);
|
updateBulkDownloadProgress(notification, albumIndex, allReleases.length, album.title);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { album: fullAlbum, tracks } = await api.getAlbum(album.id);
|
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 releaseDateStr = fullAlbum.releaseDate || (tracks[0]?.streamStartDate ? tracks[0].streamStartDate.split('T')[0] : '');
|
||||||
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
const releaseDate = releaseDateStr ? new Date(releaseDateStr) : null;
|
||||||
|
|
@ -389,13 +374,28 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
|
||||||
});
|
});
|
||||||
|
|
||||||
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
const fullFolderPath = `${rootFolder}/${albumFolder}`;
|
||||||
addCoverBlobToZip(zip, fullFolderPath, coverBlob);
|
|
||||||
|
// Cover
|
||||||
|
const coverBlob = await getCoverBlob(api, fullAlbum.cover || album.cover);
|
||||||
|
if (coverBlob) {
|
||||||
|
await zipWriter.add(`${fullFolderPath}/cover.jpg`, new zipModule.BlobReader(coverBlob));
|
||||||
|
}
|
||||||
|
|
||||||
|
// We reuse the streamTracksToZip logic but we need to pass just this album's tracks
|
||||||
|
// and careful with progress bar. streamTracksToZip resets progress?
|
||||||
|
// Let's call the logic manually to control progress bar or adapt streamTracksToZip.
|
||||||
|
|
||||||
|
// Actually, streamTracksToZip updates progress based on its inputs.
|
||||||
|
// For discography, we might want to keep the "Album X/Y" progress.
|
||||||
|
// Let's just inline the loop here or simple helper.
|
||||||
|
|
||||||
|
const { BlobReader, TextReader } = zipModule;
|
||||||
|
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
const filename = buildTrackFilename(track, quality);
|
const filename = buildTrackFilename(track, quality);
|
||||||
try {
|
try {
|
||||||
const blob = await downloadTrackBlob(track, quality, api);
|
const blob = await downloadTrackBlob(track, quality, api);
|
||||||
zip.file(`${fullFolderPath}/${filename}`, blob);
|
await zipWriter.add(`${fullFolderPath}/${filename}`, new BlobReader(blob));
|
||||||
|
|
||||||
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -404,12 +404,10 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
|
||||||
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
const lrcContent = lyricsManager.generateLRCContent(lyricsData, track);
|
||||||
if (lrcContent) {
|
if (lrcContent) {
|
||||||
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc');
|
||||||
zip.file(`${fullFolderPath}/${lrcFilename}`, lrcContent);
|
await zipWriter.add(`${fullFolderPath}/${lrcFilename}`, new TextReader(lrcContent));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (e) {}
|
||||||
// Silent fail for lyrics in bulk
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to download track ${track.title}:`, err);
|
console.error(`Failed to download track ${track.title}:`, err);
|
||||||
|
|
@ -421,9 +419,11 @@ export async function downloadDiscography(artist, api, quality, lyricsManager =
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await generateAndDownloadZip(zip, rootFolder, notification, allReleases.length, fileHandle);
|
await zipWriter.close();
|
||||||
|
completeBulkDownload(notification, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
completeBulkDownload(notification, false, error.message);
|
completeBulkDownload(notification, false, error.message);
|
||||||
|
try { await zipWriter.close(); } catch (e) {}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue