diff --git a/js/downloads.js b/js/downloads.js index a40f5b4..15c76b6 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -6,24 +6,24 @@ import { addMetadataToAudio } from './metadata.js'; const downloadTasks = new Map(); let downloadNotificationContainer = null; -async function loadZipJS() { - try { - // Load zip.js from CDN (ES Module) - const module = await import('https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.34/index.js'); - return module; - } catch (error) { - console.error('Failed to load zip.js:', error); - throw new Error('Failed to load ZIP library'); +/** + * Adds a cover blob to a JSZip instance + */ +function addCoverBlobToZip(zip, folderPath, blob) { + if (!blob) return; + const path = folderPath ? `${folderPath}/cover.jpg` : 'cover.jpg'; + if (!zip.file(path)) { + zip.file(path, blob); } } -async function loadStreamSaver() { +async function loadJSZip() { try { - const module = await import('https://cdn.jsdelivr.net/npm/streamsaver@2.0.6/StreamSaver.js'); + const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm'); return module.default; } catch (error) { - console.error('Failed to load StreamSaver:', error); - throw new Error('Failed to load StreamSaver library'); + console.error('Failed to load JSZip:', error); + throw new Error('Failed to load ZIP library'); } } @@ -167,7 +167,7 @@ function removeDownloadTask(trackId) { }, 300); } -async function downloadTrackBlob(track, quality, api) { +async function downloadTrackBlob(track, quality, api, lyricsManager = null) { const lookup = await api.getTrack(track.id, quality); let streamUrl; @@ -193,49 +193,81 @@ async function downloadTrackBlob(track, quality, api) { return blob; } -/** - * 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; +async function generateAndDownloadZip(zip, filename, notification, progressTotal, fileHandle = null) { + updateBulkDownloadProgress(notification, progressTotal, progressTotal, 'Creating ZIP...'); - // 1. Try File System Access API (Chrome/Edge/Opera) - if (window.showSaveFilePicker) { + try { + // Use the pre-acquired file handle for streaming (Chrome/Edge/Opera) + 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 { - const handle = await window.showSaveFilePicker({ - suggestedName: `${filename}.zip`, + fileHandle = await window.showSaveFilePicker({ + suggestedName: `${defaultName}.zip`, types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }] }); - writable = await handle.createWritable(); } catch (err) { if (err.name === 'AbortError') return null; // User cancelled 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. } - - // 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 }; + return { zip, fileHandle }; } -async function streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) { - const { BlobReader, TextReader } = zipModule; - +async function downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification, startProgressIndex = 0, totalTracks = tracks.length) { for (let i = 0; i < tracks.length; i++) { const track = tracks[i]; const currentGlobalIndex = startProgressIndex + i; @@ -245,15 +277,9 @@ async function streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, updateBulkDownloadProgress(notification, currentGlobalIndex, totalTracks, trackTitle); try { - // Download track (into memory blob) const blob = await downloadTrackBlob(track, quality, api); - - // 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) + zip.file(`${folderName}/${filename}`, blob); - // Lyrics if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { const lyricsData = await lyricsManager.fetchLyrics(track.id, track); @@ -261,11 +287,11 @@ async function streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); if (lrcContent) { const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - await zipWriter.add(`${folderName}/${lrcFilename}`, new TextReader(lrcContent)); + zip.file(`${folderName}/${lrcFilename}`, lrcContent); } } } catch (error) { - // Ignore lyrics error + console.log('Could not add lyrics for:', trackTitle); } } } catch (err) { @@ -285,25 +311,21 @@ export async function downloadAlbumAsZip(album, tracks, api, quality, lyricsMana year: year }); - const streamResult = await createZipStreamWriter(folderName); - if (!streamResult) return; // Cancelled - const { zipWriter, zipModule } = streamResult; + // Only prompt for save location if we have >= 20 tracks (to capture user gesture early) + // Otherwise, we'll auto-download the blob at the end + const initResult = await initializeZipDownload(folderName, tracks.length >= 20); + 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); try { - const coverBlob = await getCoverBlob(api, album.cover || album.album?.cover || album.coverId); - if (coverBlob) { - 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); + addCoverBlobToZip(zip, folderName, coverBlob); + await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification); + await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle); } catch (error) { completeBulkDownload(notification, false, error.message); - try { await zipWriter.close(); } catch (e) {} // Try to close anyway throw error; } } @@ -315,27 +337,22 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri year: new Date().getFullYear() }); - const streamResult = await createZipStreamWriter(folderName); - if (!streamResult) return; // Cancelled - const { zipWriter, zipModule } = streamResult; + const initResult = await initializeZipDownload(folderName, tracks.length >= 20); + if (!initResult) return; // User cancelled + const { zip, fileHandle } = initResult; const notification = createBulkDownloadNotification('playlist', playlist.title, tracks.length); try { - // Cover + // Find a representative cover for the playlist (first track with cover) const representativeTrack = tracks.find(t => t.album?.cover); const coverBlob = await getCoverBlob(api, representativeTrack?.album?.cover); - if (coverBlob) { - await zipWriter.add(`${folderName}/cover.jpg`, new zipModule.BlobReader(coverBlob)); - } + addCoverBlobToZip(zip, folderName, coverBlob); - await streamTracksToZip(zipWriter, zipModule, tracks, folderName, api, quality, lyricsManager, notification); - - await zipWriter.close(); - completeBulkDownload(notification, true); + await downloadTracksToZip(zip, tracks, folderName, api, quality, lyricsManager, notification); + await generateAndDownloadZip(zip, folderName, notification, tracks.length, fileHandle); } catch (error) { completeBulkDownload(notification, false, error.message); - try { await zipWriter.close(); } catch (e) {} throw error; } } @@ -343,25 +360,23 @@ export async function downloadPlaylistAsZip(playlist, tracks, api, quality, lyri export async function downloadDiscography(artist, api, quality, lyricsManager = null) { const rootFolder = `${sanitizeForFilename(artist.name)} discography`; - const streamResult = await createZipStreamWriter(rootFolder); - if (!streamResult) return; - const { zipWriter, zipModule } = streamResult; + // Always use file picker for discography as it's likely large + const initResult = await initializeZipDownload(rootFolder, true); + if (!initResult) return; // User cancelled + const { zip, fileHandle } = initResult; const allReleases = [...(artist.albums || []), ...(artist.eps || [])]; - const notification = createBulkDownloadNotification('discography', artist.name, allReleases.length); // Total is approx tracks, but showing albums for now in text + const notification = createBulkDownloadNotification('discography', artist.name, allReleases.length); 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++) { const album = allReleases[albumIndex]; + updateBulkDownloadProgress(notification, albumIndex, allReleases.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; @@ -374,28 +389,13 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = }); const fullFolderPath = `${rootFolder}/${albumFolder}`; - - // 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; + addCoverBlobToZip(zip, fullFolderPath, coverBlob); for (const track of tracks) { const filename = buildTrackFilename(track, quality); try { const blob = await downloadTrackBlob(track, quality, api); - await zipWriter.add(`${fullFolderPath}/${filename}`, new BlobReader(blob)); + zip.file(`${fullFolderPath}/${filename}`, blob); if (lyricsManager && lyricsSettings.shouldDownloadLyrics()) { try { @@ -404,10 +404,12 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = const lrcContent = lyricsManager.generateLRCContent(lyricsData, track); if (lrcContent) { const lrcFilename = filename.replace(/\.[^.]+$/, '.lrc'); - await zipWriter.add(`${fullFolderPath}/${lrcFilename}`, new TextReader(lrcContent)); + zip.file(`${fullFolderPath}/${lrcFilename}`, lrcContent); } } - } catch (e) {} + } catch (error) { + // Silent fail for lyrics in bulk + } } } catch (err) { console.error(`Failed to download track ${track.title}:`, err); @@ -419,11 +421,9 @@ export async function downloadDiscography(artist, api, quality, lyricsManager = } } - await zipWriter.close(); - completeBulkDownload(notification, true); + await generateAndDownloadZip(zip, rootFolder, notification, allReleases.length, fileHandle); } catch (error) { completeBulkDownload(notification, false, error.message); - try { await zipWriter.close(); } catch (e) {} throw error; } }