From c1552980eb2418eb7eea2151d869898fcd22fccd Mon Sep 17 00:00:00 2001 From: Daniel <790119+DanTheMan827@users.noreply.github.com> Date: Thu, 12 Mar 2026 05:57:18 +0000 Subject: [PATCH] feat: extract bulk download handlers into bulk-download-writer.ts and add folder picker + settings --- index.html | 33 ++- js/bulk-download-writer.ts | 180 +++++++++++++ js/downloads.js | 510 ++++--------------------------------- js/ffmpegFormats.ts | 9 - js/global.d.ts | 5 + js/settings.js | 68 ++++- js/storage.js | 50 +++- js/utils.js | 1 + 8 files changed, 376 insertions(+), 480 deletions(-) create mode 100644 js/bulk-download-writer.ts diff --git a/index.html b/index.html index b148492..dfafb6d 100644 --- a/index.html +++ b/index.html @@ -5074,14 +5074,27 @@
- Zipped Bulk Downloads + Bulk Download Method Download multiple tracks as a single ZIP file (requires browser - support)Choose how multiple tracks are downloaded together +
+ +
+
+
+ Force ZIP as Blob + Download ZIP in memory instead of streaming to disk (use if ZIP streaming + causes issues)
@@ -5245,7 +5258,7 @@
- Separate Discs in ZIP + Separate Discs Put tracks in Disc folders when a release has multiple discs @@ -5255,6 +5268,16 @@
+
+
+ Include Cover File + Include cover.jpg in downloads +
+ +
diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts new file mode 100644 index 0000000..418002c --- /dev/null +++ b/js/bulk-download-writer.ts @@ -0,0 +1,180 @@ +import { triggerDownload } from './download-utils'; + +/** + * A single entry to be included in a ZIP archive or written directly to a folder. + */ +export interface WriterEntry { + name: string; + lastModified: Date; + input: Blob | string | ArrayBuffer | Uint8Array; +} + +/** Minimal interface for the Neutralino bridge used by ZipNeutralinoWriter */ +interface NeutralinoBridge { + os: { + showSaveDialog( + title: string, + options: { defaultPath: string; filters: Array<{ name: string; extensions: string[] }> } + ): Promise; + }; + filesystem: { + writeBinaryFile(path: string, buffer: ArrayBuffer): Promise; + appendBinaryFile(path: string, buffer: ArrayBuffer): Promise; + }; +} + +async function loadClientZip() { + try { + return await import('https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm'); + } catch (error) { + console.error('Failed to load client-zip:', error); + throw new Error('Failed to load ZIP library'); + } +} + +/** + * Interface for writing a collection of file entries to an output destination. + * Each implementation handles its own output selection (save dialog, directory picker, etc.) + * and throws a DOMException with name 'AbortError' if the user cancels. + */ +export interface IBulkDownloadWriter { + write(files: AsyncIterable): Promise; +} + +/** + * Streams a ZIP archive to a file via the File System Access API. + * Prompts the user to choose a save location with showSaveFilePicker. + */ +export class ZipStreamWriter implements IBulkDownloadWriter { + constructor(private readonly suggestedFilename: string) {} + + async write(files: AsyncIterable): Promise { + // showSaveFilePicker is part of the File System Access API (not yet in all TS DOM libs) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileHandle = await (window as any).showSaveFilePicker({ + suggestedName: this.suggestedFilename, + types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }], + }); + const { downloadZip } = await loadClientZip(); + const writable = await fileHandle.createWritable(); + const response = downloadZip(files); + if (!response.body) throw new Error('ZIP response body is null'); + await response.body.pipeTo(writable); + } +} + +/** + * Collects a ZIP archive into a Blob and triggers a browser download. + * Works on all browsers without requiring the File System Access API. + */ +export class ZipBlobWriter implements IBulkDownloadWriter { + constructor(private readonly filename: string) {} + + async write(files: AsyncIterable): Promise { + const { downloadZip } = await loadClientZip(); + const response = downloadZip(files); + const blob = await response.blob(); + triggerDownload(blob, this.filename); + } +} + +/** + * Writes a ZIP archive to the filesystem via the Neutralino desktop bridge, + * showing a native save dialog first. + */ +export class ZipNeutralinoWriter implements IBulkDownloadWriter { + constructor(private readonly folderName: string) {} + + async write(files: AsyncIterable): Promise { + const bridge = (await import('./desktop/neutralino-bridge.js')) as unknown as NeutralinoBridge; + + const savePath = await bridge.os.showSaveDialog(`Select save location for ${this.folderName}.zip`, { + defaultPath: `${this.folderName}.zip`, + filters: [{ name: 'ZIP Archive', extensions: ['zip'] }], + }); + + if (!savePath) { + throw new DOMException('User cancelled save dialog', 'AbortError'); + } + + const { downloadZip } = await loadClientZip(); + await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0)); + + const response = downloadZip(files); + 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; + const chunk = value.buffer.slice(value.byteOffset, value.byteOffset + value.byteLength); + await bridge.filesystem.appendBinaryFile(savePath, chunk); + receivedLength += value.length; + } + + console.log(`[ZIP] Download complete. Total size: ${receivedLength} bytes.`); + } +} + +/** + * Writes files directly into a user-chosen folder using the standard browser + * File System Access API (showDirectoryPicker). Subdirectories embedded in + * file entry names are created automatically. + * + * Use the static {@link FolderPickerWriter.create} method to obtain an instance; + * the constructor is private so the directory handle is always set before use. + */ +export class FolderPickerWriter implements IBulkDownloadWriter { + private constructor(private readonly dirHandle: FileSystemDirectoryHandle) {} + + /** + * Prompts the user to pick a writable directory. + * Returns a new {@link FolderPickerWriter} bound to the chosen directory. + * If the user dismisses the picker, the promise rejects with a DOMException + * whose name is "AbortError". + */ + static async create(): Promise { + // showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({ + mode: 'readwrite', + }); + return new FolderPickerWriter(dirHandle); + } + + async write(files: AsyncIterable): Promise { + for await (const file of files) { + const parts = file.name.split('/').filter(Boolean); + if (parts.length === 0) continue; + + let currentDir: FileSystemDirectoryHandle = this.dirHandle; + for (let i = 0; i < parts.length - 1; i++) { + currentDir = await currentDir.getDirectoryHandle(parts[i], { create: true }); + } + + const filename = parts[parts.length - 1]; + const fileHandle = await currentDir.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + + const { input } = file; + if (input instanceof Blob) { + await writable.write(input); + } else if (typeof input === 'string') { + await writable.write(new Blob([input], { type: 'text/plain' })); + } else { + // ArrayBuffer or Uint8Array – wrap in a Blob to guarantee strict typing. + // Use byteOffset/byteLength so only the view's range is included, not the + // whole backing ArrayBuffer (which may be larger due to pooling). + const buf = + input instanceof Uint8Array + ? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) + : input; + await writable.write(new Blob([buf as ArrayBuffer])); + } + + await writable.close(); + } + } +} diff --git a/js/downloads.js b/js/downloads.js index 8c77937..56cf9c9 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -21,22 +21,13 @@ import { generateM3U, generateM3U8, generateCUE, generateNFO, generateJSON } fro import { loadFfmpeg } from './ffmpeg.js'; import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; import { isCustomFormat } from './ffmpegFormats.ts'; +import { ZipStreamWriter, ZipBlobWriter, ZipNeutralinoWriter, FolderPickerWriter } from './bulk-download-writer.ts'; 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'); - } -} - async function createDiscLayoutContext(tracks, api) { if (!playlistSettings.shouldSeparateDiscsInZip()) { return { separateByDisc: false, resolveDiscNumber: () => 1 }; @@ -508,27 +499,24 @@ async function bulkDownloadSequentially(tracks, api, quality, lyricsManager, not } } -async function bulkDownloadToZipStream( +async function bulkDownloadToZip( tracks, folderName, api, quality, lyricsManager, notification, - fileHandle, + writer, 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) { + // Add cover if available and enabled + if (coverBlob && playlistSettings.shouldIncludeCover()) { yield { name: `${folderName}/cover.jpg`, lastModified: new Date(), input: coverBlob }; } @@ -613,9 +601,8 @@ async function bulkDownloadToZipStream( }; } - // For albums, generate CUE file + // For albums, generate CUE file (one per disc if multi-disc) if (type === 'album' && playlistSettings.shouldGenerateCUE()) { - // Split tracks by volumeNumber and iterate those groups. const tracksByVolume = Object.groupBy( tracks.map((track, index) => ({ ...track, @@ -625,9 +612,14 @@ async function bulkDownloadToZipStream( ); 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); + for (const [volumeNumber, volumeTracks] of Object.entries(tracksByVolume)) { + const volumeTrackPaths = volumeTracks.map((track) => track.trackPath); + const cueContent = generateCUE( + metadata, + volumeTracks, + sanitizeForFilename(folderName), + volumeTrackPaths + ); yield { name: `${folderName}/${sanitizeForFilename(folderName)}${multiDisc ? ` - Disc ${volumeNumber}` : ''}.cue`, lastModified: new Date(), @@ -670,370 +662,35 @@ async function bulkDownloadToZipStream( } } - try { - const response = downloadZip(yieldFiles()); - await response.body.pipeTo(writable); - } catch (error) { - if (error.name === 'AbortError') return; - throw error; - } + await writer.write(yieldFiles()); } -// 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(); +/** + * Returns the appropriate bulk download writer for the current settings and environment, + * or null when individual sequential downloads should be used. + */ +async function createBulkWriter(folderName) { + const isNeutralino = window.NL_MODE === true; + const method = bulkDownloadSettings.getMethod(); + const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob(); + const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype; + const hasFolderPicker = 'showDirectoryPicker' in window; - 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, - }; - } + if (isNeutralino) { + return new ZipNeutralinoWriter(folderName); } - - try { - const response = downloadZip(yieldFiles()); - const blob = await response.blob(); - triggerDownload(blob, `${folderName}.zip`); - } catch (error) { - if (error.name === 'AbortError') return; - throw error; + if (method === 'folder' && hasFolderPicker) { + // FolderPickerWriter.create() throws AbortError if the user cancels + return await FolderPickerWriter.create(); } -} - -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, - }; - } + if (method === 'individual') { + return null; } - - 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; + // method === 'zip' (or folder picker unavailable as fallback) + if (!forceZipBlob && hasFileSystemAccess) { + return new ZipStreamWriter(`${folderName}.zip`); } + return new ZipBlobWriter(`${folderName}.zip`); } async function startBulkDownload( @@ -1050,73 +707,32 @@ async function startBulkDownload( 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; + const writer = await createBulkWriter(defaultName); - if (isNeutralino) { - // Neutralino Native Logic - await bulkDownloadToZipNeutralino( + if (writer) { + await bulkDownloadToZip( tracks, defaultName, api, quality, lyricsManager, notification, + writer, 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 + // Individual sequential downloads await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); - completeBulkDownload(notification, true); } + + completeBulkDownload(notification, true); } catch (error) { + if (error.name === 'AbortError') { + removeBulkDownloadTask(notification); + return; + } console.error('Bulk download failed:', error); completeBulkDownload(notification, false, error.message); } @@ -1183,10 +799,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality 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; @@ -1213,7 +825,7 @@ export async function downloadDiscography(artist, selectedReleases, api, quality ); const fullFolderPath = `${rootFolder}/${albumFolder}`; - if (coverBlob) + if (coverBlob && playlistSettings.shouldIncludeCover()) yield { name: `${fullFolderPath}/cover.jpg`, lastModified: new Date(), input: coverBlob }; // Generate playlist files for each album @@ -1332,27 +944,12 @@ export async function downloadDiscography(artist, selectedReleases, api, quality } 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 writer = await createBulkWriter(rootFolder); - 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); + if (writer) { + await writer.write(yieldDiscography()); } else { - // Sequential individual downloads for discography + // Individual sequential downloads for discography for (let albumIndex = 0; albumIndex < selectedReleases.length; albumIndex++) { if (signal.aborted) break; const album = selectedReleases[albumIndex]; @@ -1361,8 +958,9 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const tracks = await annotateTracksWithDiscInfo(rawTracks, api); await bulkDownloadSequentially(tracks, api, quality, lyricsManager, notification); } - completeBulkDownload(notification, true); } + + completeBulkDownload(notification, true); } catch (error) { if (error.name === 'AbortError') { removeBulkDownloadTask(notification); diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index 5baa79c..ea94e15 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -176,15 +176,6 @@ export const containerFormats: ContainerFormat[] = [ extension: 'm4a', needsTranscode: async () => true, }, - { - displayName: "Don't change", - internalName: 'nochange', - ffmpegArgs: [], - outputFilename: '', - outputMime: '', - extension: '', - needsTranscode: async () => false, - }, ]; /** Returns true if the quality string identifies a known custom ffmpeg-transcoded format */ diff --git a/js/global.d.ts b/js/global.d.ts index ed623f9..b641500 100644 --- a/js/global.d.ts +++ b/js/global.d.ts @@ -2,3 +2,8 @@ declare module '*?url' { const content: string; export default content; } + +declare module 'https://cdn.jsdelivr.net/npm/client-zip@2.4.5/+esm' { + /** Creates a ZIP stream from an async iterable of file entries. */ + export function downloadZip(files: AsyncIterable): Response; +} diff --git a/js/settings.js b/js/settings.js index 22a48ba..8d7b1b0 100644 --- a/js/settings.js +++ b/js/settings.js @@ -861,10 +861,21 @@ export function initializeSettings(scrobbler, player, api, ui) { downloadQualitySetting.addEventListener('change', (e) => { downloadQualitySettings.setQuality(e.target.value); + updateLosslessContainerVisibility(); }); } const losslessContainerSetting = document.getElementById('lossless-container-setting'); + const losslessContainerSettingItem = losslessContainerSetting?.closest('.setting-item'); + + /** Shows/hides the Lossless Container setting based on the selected quality */ + function updateLosslessContainerVisibility() { + if (!losslessContainerSettingItem) return; + const quality = downloadQualitySettings.getQuality(); + const isLossless = quality === 'LOSSLESS' || quality === 'HI_RES_LOSSLESS'; + losslessContainerSettingItem.style.display = isLossless ? '' : 'none'; + } + if (losslessContainerSetting) { for (const { internalName, displayName } of containerFormats) { const option = document.createElement('option'); @@ -880,6 +891,8 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + updateLosslessContainerVisibility(); + // Cover Art Size setting const coverArtSizeSetting = document.getElementById('cover-art-size-setting'); if (coverArtSizeSetting) { @@ -910,11 +923,56 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } - const zippedBulkDownloadsToggle = document.getElementById('zipped-bulk-downloads-toggle'); - if (zippedBulkDownloadsToggle) { - zippedBulkDownloadsToggle.checked = !bulkDownloadSettings.shouldForceIndividual(); - zippedBulkDownloadsToggle.addEventListener('change', (e) => { - bulkDownloadSettings.setForceIndividual(!e.target.checked); + const forceZipBlobToggle = document.getElementById('force-zip-blob-toggle'); + const forceZipBlobSettingItem = forceZipBlobToggle?.closest('.setting-item'); + const hasFileSystemAccess = + 'showSaveFilePicker' in window && + typeof FileSystemFileHandle !== 'undefined' && + 'createWritable' in FileSystemFileHandle.prototype; + + /** Shows/hides the Force ZIP as Blob setting based on method and browser support */ + function updateForceZipBlobVisibility() { + if (!forceZipBlobSettingItem) return; + const method = bulkDownloadSettings.getMethod(); + // Only relevant when zip method is selected and the browser supports streaming + const visible = method === 'zip' && hasFileSystemAccess; + forceZipBlobSettingItem.style.display = visible ? '' : 'none'; + } + + const bulkDownloadMethod = document.getElementById('bulk-download-method'); + if (bulkDownloadMethod) { + // Remove the folder picker option if the browser doesn't support it + if (!('showDirectoryPicker' in window)) { + const folderOption = bulkDownloadMethod.querySelector('option[value="folder"]'); + if (folderOption) { + folderOption.remove(); + } + // If the stored method is 'folder', fall back to 'zip' + if (bulkDownloadSettings.getMethod() === 'folder') { + bulkDownloadSettings.setMethod('zip'); + } + } + bulkDownloadMethod.value = bulkDownloadSettings.getMethod(); + bulkDownloadMethod.addEventListener('change', (e) => { + bulkDownloadSettings.setMethod(e.target.value); + updateForceZipBlobVisibility(); + }); + } + + if (forceZipBlobToggle) { + forceZipBlobToggle.checked = bulkDownloadSettings.shouldForceZipBlob(); + forceZipBlobToggle.addEventListener('change', (e) => { + bulkDownloadSettings.setForceZipBlob(e.target.checked); + }); + } + + updateForceZipBlobVisibility(); + + const includeCoverToggle = document.getElementById('include-cover-toggle'); + if (includeCoverToggle) { + includeCoverToggle.checked = playlistSettings.shouldIncludeCover(); + includeCoverToggle.addEventListener('change', (e) => { + playlistSettings.setIncludeCover(e.target.checked); }); } diff --git a/js/storage.js b/js/storage.js index de7c470..db28d42 100644 --- a/js/storage.js +++ b/js/storage.js @@ -559,7 +559,9 @@ export const losslessContainerSettings = { STORAGE_KEY: 'lossless-container', getContainer() { try { - return localStorage.getItem(this.STORAGE_KEY) || 'flac'; + const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac'; + // 'nochange' was removed as an option; fall back to FLAC + return stored === 'nochange' ? 'flac' : stored; } catch { return 'flac'; } @@ -650,18 +652,42 @@ export const trackDateSettings = { }; export const bulkDownloadSettings = { - STORAGE_KEY: 'force-individual-downloads', + METHOD_KEY: 'bulk-download-method', + FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob', - shouldForceIndividual() { + /** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */ + getMethod() { try { - return localStorage.getItem(this.STORAGE_KEY) === 'true'; + return localStorage.getItem(this.METHOD_KEY) || 'zip'; + } catch { + return 'zip'; + } + }, + + setMethod(method) { + localStorage.setItem(this.METHOD_KEY, method); + }, + + /** When using ZIP mode, force in-memory blob download instead of streaming to disk */ + shouldForceZipBlob() { + try { + return localStorage.getItem(this.FORCE_ZIP_BLOB_KEY) === 'true'; } catch { return false; } }, + setForceZipBlob(enabled) { + localStorage.setItem(this.FORCE_ZIP_BLOB_KEY, enabled ? 'true' : 'false'); + }, + + // Kept for backward compatibility + shouldForceIndividual() { + return this.getMethod() === 'individual'; + }, + setForceIndividual(enabled) { - localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false'); + this.setMethod(enabled ? 'individual' : 'zip'); }, }; @@ -673,6 +699,7 @@ export const playlistSettings = { JSON_KEY: 'playlist-generate-json', RELATIVE_PATHS_KEY: 'playlist-relative-paths', SEPARATE_DISCS_KEY: 'playlist-separate-discs-in-zip', + INCLUDE_COVER_KEY: 'playlist-include-cover', shouldGenerateM3U() { try { @@ -760,6 +787,19 @@ export const playlistSettings = { setSeparateDiscsInZip(enabled) { localStorage.setItem(this.SEPARATE_DISCS_KEY, enabled ? 'true' : 'false'); }, + + shouldIncludeCover() { + try { + const val = localStorage.getItem(this.INCLUDE_COVER_KEY); + return val === null ? true : val === 'true'; + } catch { + return true; + } + }, + + setIncludeCover(enabled) { + localStorage.setItem(this.INCLUDE_COVER_KEY, enabled ? 'true' : 'false'); + }, }; export const visualizerSettings = { diff --git a/js/utils.js b/js/utils.js index ae22d4c..5c55dfa 100644 --- a/js/utils.js +++ b/js/utils.js @@ -373,6 +373,7 @@ export const getTrackArtistsHTML = (track = {}, { fallback = 'Unknown Artist' } export const formatTemplate = (template, data) => { let result = template; + result = result.replace(/\{discNumber\}/g, data.discNumber ? String(data.discNumber).padStart(2, '0') : '01'); result = result.replace(/\{trackNumber\}/g, data.trackNumber ? String(data.trackNumber).padStart(2, '0') : '00'); result = result.replace(/\{artist\}/g, sanitizeForFilename(data.artist || 'Unknown Artist')); result = result.replace(/\{title\}/g, sanitizeForFilename(data.title || 'Unknown Title'));