diff --git a/bun.lock b/bun.lock index 9426722..0701e68 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", + "client-zip": "^2.5.0", "cookie-session": "^2.1.1", "dashjs": "^5.1.1", "fuse.js": "^7.1.0", @@ -630,6 +631,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "client-zip": ["client-zip@2.5.0", "", {}, "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ=="], + "codem-isoboxer": ["codem-isoboxer@0.3.10", "", {}, "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], diff --git a/index.html b/index.html index 98da4e6..1251b3e 100644 --- a/index.html +++ b/index.html @@ -5054,14 +5054,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)
@@ -5106,7 +5119,9 @@ Lossless Container Container format for lossless downloads
- +
@@ -5225,7 +5240,7 @@
- Separate Discs in ZIP + Separate Discs Put tracks in Disc folders when a release has multiple discs @@ -5235,6 +5250,16 @@
+
+
+ Include Cover File + Include cover.jpg in downloads +
+ +
diff --git a/js/api.js b/js/api.js index 66749a5..2cecebc 100644 --- a/js/api.js +++ b/js/api.js @@ -9,21 +9,15 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { trackDateSettings, losslessContainerSettings } from './storage.js'; +import { trackDateSettings } from './storage.js'; import { APICache } from './cache.js'; import { addMetadataToAudio, prefetchMetadataObjects } from './metadata.js'; import { DashDownloader } from './dash-downloader.js'; import { HlsDownloader } from './hls-downloader.js'; import { MP3EncodingError } from './mp3-encoder.js'; -import { ffmpeg, loadFfmpeg, FfmpegError } from './ffmpeg.js'; -import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; -import { - isCustomFormat, - getCustomFormat, - transcodeWithCustomFormat, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +import { loadFfmpeg, FfmpegError } from './ffmpeg.js'; +import { triggerDownload, applyAudioPostProcessing } from './download-utils.ts'; +import { isCustomFormat } from './ffmpegFormats.ts'; export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE'; const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25'; @@ -1425,170 +1419,7 @@ export class LosslessAPI { } if (!isVideo) { - const coverBlobToEmbed = await prefetchPromises.coverFetch; - const extraFiles = []; - const ffmpegMetadataArgs = []; - - if (coverBlobToEmbed) { - const coverBuffer = await coverBlobToEmbed.arrayBuffer(); - const coverExt = getMimeType(new Uint8Array(coverBuffer)) === 'image/png' ? 'png' : 'jpg'; - const coverName = `cover.${coverExt}`; - extraFiles.push({ - name: coverName, - data: coverBuffer, - }); - ffmpegMetadataArgs.push('-i', coverName); - } - - if (track) { - ffmpegMetadataArgs.push( - '-metadata', - `title=${getTrackTitle(track)}`, - '-metadata', - `artist=${getFullArtistString(track)}`, - '-metadata', - `album=${track.album?.title || ''}`, - '-metadata', - `album_artist=${track.album?.artist?.name || track.artist?.name || ''}` - ); - - const trackNum = track.trackNumber; - if (trackNum) { - const totalTracks = track.album?.numberOfTracks; - ffmpegMetadataArgs.push( - '-metadata', - `track=${trackNum}${totalTracks ? `/${totalTracks}` : ''}` - ); - } - - const discNum = track.volumeNumber || track.discNumber; - if (discNum) { - ffmpegMetadataArgs.push('-metadata', `disc=${discNum}`); - } - - const releaseDate = track.album?.releaseDate || track?.streamStartDate; - if (releaseDate) { - ffmpegMetadataArgs.push('-metadata', `date=${releaseDate.split('-')[0]}`); - } - } - - // Transcode to custom format if requested - if (isCustomFormat(quality)) { - const format = getCustomFormat(quality); - if (format) { - try { - const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push( - '-map', - '0:a', - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - - blob = await ffmpeg( - blob, - { args }, - format.outputFilename, - format.outputMime, - onProgress, - options.signal, - extraFiles - ); - } catch (encodingError) { - if (onProgress) { - onProgress({ - stage: 'error', - message: `Encoding failed: ${encodingError.message}`, - }); - } - throw encodingError; - } - } - } - - if (quality.endsWith('LOSSLESS')) { - try { - const containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push( - '-map', - '0:a', - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - - blob = await ffmpeg( - blob, - { args }, - containerFmt.outputFilename, - containerFmt.outputMime, - onProgress, - options.signal, - extraFiles - ); - } else if ((await getExtensionFromBlob(blob)) == 'flac') { - blob = await rebuildFlacWithoutMetadata(blob); - } - } else { - const actualExtension = await getExtensionFromBlob(blob); - if (actualExtension === 'm4a' || actualExtension === 'mp4') { - try { - const ffmpegArgs = [...ffmpegMetadataArgs]; - - ffmpegArgs.push('-map', '0:a'); - if (coverBlobToEmbed) { - ffmpegArgs.push( - '-map', - '1:v', - '-c:v', - 'copy', - '-disposition:v:0', - 'attached_pic' - ); - } - ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); - - const remuxedBlob = await ffmpeg( - blob, - { args: ffmpegArgs }, - 'output.mp4', - 'audio/mp4', - onProgress, - options.signal, - extraFiles - ); - if (remuxedBlob) { - blob = remuxedBlob; - } - } catch (e) { - console.warn('Failed to remux hi-res M4A, proceeding with original:', e); - } - } - } - } catch (error) { - if (error?.name === 'AbortError') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + blob = await applyAudioPostProcessing(blob, quality, onProgress, options.signal); // Add metadata if track information is provided if (track) { @@ -1672,7 +1503,7 @@ export class LosslessAPI { finalFilename = filename.replace(/\.[^.]+$/, `.${detectedExtension}`); } - this.triggerDownload(blob, finalFilename); + triggerDownload(blob, finalFilename); return blob; } catch (error) { if (error.name === 'AbortError') { @@ -1693,17 +1524,6 @@ export class LosslessAPI { } } - 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); - } - getCoverUrl(id, size = '320') { if (!id) { return `https://picsum.photos/seed/${Math.random()}/${size}`; diff --git a/js/bulk-download-writer.ts b/js/bulk-download-writer.ts new file mode 100644 index 0000000..49d8413 --- /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('client-zip'); + } 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/customFormats.ts b/js/customFormats.ts deleted file mode 100644 index f8d5c2e..0000000 --- a/js/customFormats.ts +++ /dev/null @@ -1,13 +0,0 @@ -// Re-exports for backwards compatibility – canonical source is ffmpegFormats.ts -export { - type ProgressEvent, - type CustomFormat, - type ContainerFormat, - customFormats, - containerFormats, - isCustomFormat, - getCustomFormat, - getContainerFormat, - transcodeWithCustomFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats'; diff --git a/js/download-utils.ts b/js/download-utils.ts new file mode 100644 index 0000000..c5b17da --- /dev/null +++ b/js/download-utils.ts @@ -0,0 +1,88 @@ +import { losslessContainerSettings } from './storage'; +import { getExtensionFromBlob } from './utils'; +import { rebuildFlacWithoutMetadata } from './metadata.flac.js'; +import { + type ProgressEvent, + isCustomFormat, + getCustomFormat, + transcodeWithCustomFormat, + getContainerFormat, + transcodeWithContainerFormat, +} from './ffmpegFormats'; +import { ffmpegNewContainer } from './ffmpeg'; + +/** + * Triggers a browser file download for the given blob. + */ +export function triggerDownload(blob: Blob, filename: string): void { + 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); +} + +/** + * Applies audio post-processing to a blob: + * 1. Transcodes to a custom ffmpeg format if `quality` identifies one. + * 2. Re-muxes to the user-selected lossless container when the quality is + * a lossless tier (quality ends with "LOSSLESS"). + * + * Returns the (possibly transformed) blob. + */ +export async function applyAudioPostProcessing( + blob: Blob, + quality: string, + onProgress: ((progress: ProgressEvent) => void) | null = null, + signal: AbortSignal | null = null +): Promise { + // Transcode to custom format if requested + if (isCustomFormat(quality)) { + const format = getCustomFormat(quality); + if (format) { + try { + blob = await transcodeWithCustomFormat(blob, format, onProgress, signal); + } catch (encodingError) { + if (onProgress) { + onProgress({ + stage: 'error', + message: `Encoding failed: ${(encodingError as Error).message}`, + }); + } + throw encodingError; + } + } + } + + if (quality.endsWith('LOSSLESS')) { + try { + const containerFmt = getContainerFormat(losslessContainerSettings.getContainer()); + const extension = await getExtensionFromBlob(blob); + + if (await containerFmt?.needsTranscode(blob)) { + blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); + } else if (extension == 'flac') { + blob = await rebuildFlacWithoutMetadata(blob); + } else { + blob = await ffmpegNewContainer( + blob, + extension == 'm4a' ? 'mp4' : extension, + blob.type, + onProgress, + signal + ); + } + } catch (error) { + if ((error as Error)?.name === 'AbortError') { + throw error; + } + + console.error('Lossless container conversion failed:', error); + } + } + + return blob; +} diff --git a/js/downloads.js b/js/downloads.js index e2dd754..29821fe 100644 --- a/js/downloads.js +++ b/js/downloads.js @@ -14,35 +14,20 @@ import { getFullArtistString, getMimeType, } from './utils.js'; -import { lyricsSettings, bulkDownloadSettings, losslessContainerSettings, playlistSettings } from './storage.js'; +import { lyricsSettings, bulkDownloadSettings, 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 { ffmpeg, loadFfmpeg } from './ffmpeg.js'; -import { - isCustomFormat, - getCustomFormat, - transcodeWithCustomFormat, - getContainerFormat, - transcodeWithContainerFormat, -} from './ffmpegFormats.ts'; +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 }; @@ -464,88 +449,8 @@ async function downloadTrackBlob( } } - // Transcode to custom format if requested - if (isCustomFormat(quality)) { - const format = getCustomFormat(quality); - if (format) { - const args = [...ffmpegMetadataArgs, ...format.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - - blob = await ffmpeg( - blob, - { args }, - format.outputFilename, - format.outputMime, - onProgress, - signal, - extraFiles - ); - } - } - - if (quality.endsWith('LOSSLESS')) { - try { - const containerType = losslessContainerSettings.getContainer(); - const containerFmt = getContainerFormat(containerType); - - if (containerFmt && containerType !== 'nochange') { - if (await containerFmt.needsTranscode(blob)) { - const args = [...ffmpegMetadataArgs, ...containerFmt.ffmpegArgs]; - if (coverBlobToEmbed) { - args.push('-map', '0:a', '-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - - blob = await ffmpeg( - blob, - { args }, - containerFmt.outputFilename, - containerFmt.outputMime, - onProgress, - signal, - extraFiles - ); - } else if ((await getExtensionFromBlob(blob)) == 'flac') { - blob = await rebuildFlacWithoutMetadata(blob); - } - } else { - const actualExtension = await getExtensionFromBlob(blob); - if (actualExtension === 'm4a' || actualExtension === 'mp4') { - try { - const ffmpegArgs = [...ffmpegMetadataArgs]; - - ffmpegArgs.push('-map', '0:a'); - if (coverBlobToEmbed) { - ffmpegArgs.push('-map', '1:v', '-c:v', 'copy', '-disposition:v:0', 'attached_pic'); - } - ffmpegArgs.push('-c:a', 'copy', '-movflags', '+faststart', '-strict', '-2'); - - const remuxedBlob = await ffmpeg( - blob, - { args: ffmpegArgs }, - 'output.mp4', - 'audio/mp4', - onProgress, - signal, - extraFiles - ); - if (remuxedBlob) { - blob = remuxedBlob; - } - } catch (e) { - console.warn('Failed to remux hi-res M4A, proceeding with original:', e); - } - } - } - } catch (error) { - if (error?.name === 'AbortError') { - throw error; - } - - console.error('Lossless container conversion failed:', error); - } - } + // Apply audio post-processing (custom format transcoding + lossless container conversion) + blob = await applyAudioPostProcessing(blob, quality, onProgress, signal); // Detect actual format from blob signature BEFORE adding metadata const extension = await getExtensionFromBlob(blob); @@ -556,17 +461,6 @@ async function downloadTrackBlob( 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; @@ -605,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 }; } @@ -658,7 +549,6 @@ async function bulkDownloadToZipStream( const discNumber = discLayout.resolveDiscNumber(i); const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; - console.log(`[Playlist] Track ${i + 1}: ${discPath}`); trackPaths.push(discPath); yield { @@ -710,9 +600,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, @@ -722,9 +611,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(), @@ -767,370 +661,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( @@ -1147,73 +706,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); } @@ -1280,10 +798,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; @@ -1310,7 +824,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 @@ -1337,7 +851,6 @@ export async function downloadDiscography(artist, selectedReleases, api, quality const discNumber = discLayout.resolveDiscNumber(i); const discPath = separateByDisc ? `${getDiscFolderName(discNumber)}/${filename}` : filename; - console.log(`[Playlist] Track ${i + 1}: ${discPath}`); trackPaths.push(discPath); yield { @@ -1429,27 +942,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]; @@ -1458,8 +956,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/ffmpeg.js b/js/ffmpeg.js index 93cd1ab..28193fd 100644 --- a/js/ffmpeg.js +++ b/js/ffmpeg.js @@ -1,8 +1,7 @@ import { fetchBlobURL } from './utils'; import FfmpegWorker from './ffmpeg.worker.js?worker'; -const ffmpegBase = 'https://unpkg.com/@ffmpeg/core/dist/esm'; -const coreJs = `${ffmpegBase}/ffmpeg-core.js`; -const coreWasm = `${ffmpegBase}/ffmpeg-core.wasm`; +import coreJs from '!/@ffmpeg/core/dist/esm/ffmpeg-core.js?url'; +import coreWasm from '!/@ffmpeg/core/dist/esm/ffmpeg-core.wasm?url'; class FfmpegError extends Error { constructor(message) { @@ -28,7 +27,7 @@ export function loadFfmpeg() { async function ffmpegWorker( audioBlob, - args = {}, + args = [], outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, @@ -94,7 +93,7 @@ async function ffmpegWorker( { audioData, extraFiles, - ...args, + args, output: { name: outputName, mime: outputMime, @@ -109,7 +108,7 @@ async function ffmpegWorker( export async function ffmpeg( audioBlob, - args = {}, + args = [], outputName = 'output', outputMime = 'application/octet-stream', onProgress = null, @@ -129,4 +128,24 @@ export async function ffmpeg( } } +/** + * Creates a new FFmpeg container with copied codec and stripped metadata. + * @param {Blob} audioBlob - The audio blob to process + * @param {string} outputExtension - The extension for the output file + * @param {string} outputMime - The MIME type for the output blob + * @param {Function} onProgress - Callback function to track conversion progress + * @param {AbortSignal} signal - AbortSignal for cancelling the operation + * @returns {Promise} A promise that resolves to the processed data blob + */ +export async function ffmpegNewContainer(audioBlob, outputExtension, outputMime, onProgress, signal) { + return await ffmpeg( + audioBlob, + ['-map_metadata', '-1', '-c', 'copy', '-strict', '-2'], + `output.${outputExtension}`, + outputMime, + onProgress, + signal + ); +} + export { FfmpegError }; diff --git a/js/ffmpeg.worker.js b/js/ffmpeg.worker.js index b90082d..e331ec6 100644 --- a/js/ffmpeg.worker.js +++ b/js/ffmpeg.worker.js @@ -141,15 +141,21 @@ self.onmessage = async (e) => { } finally { try { if (audioData) await ffmpeg.deleteFile('input'); - } catch {} + } catch { + self.postMessage({ type: 'log', message: 'Failed to delete input file from FFmpeg FS.' }); + } for (const file of extraFiles) { try { await ffmpeg.deleteFile(file.name); - } catch {} + } catch { + self.postMessage({ type: 'log', message: `Failed to delete ${file.name} from FFmpeg FS.` }); + } } try { await ffmpeg.deleteFile(output.name); - } catch {} + } catch { + self.postMessage({ type: 'log', message: `Failed to delete ${output.name} from FFmpeg FS.` }); + } } } catch (error) { self.postMessage({ type: 'error', message: error.message }); diff --git a/js/ffmpegFormats.ts b/js/ffmpegFormats.ts index 5baa79c..ef69277 100644 --- a/js/ffmpegFormats.ts +++ b/js/ffmpegFormats.ts @@ -12,8 +12,6 @@ export interface ProgressEvent { export interface CustomFormat { /** Human-readable label shown in the UI */ displayName: string; - /** Internal identifier, must start with `FFMPEG_` */ - internalName: string; /** Arguments passed to ffmpeg (excluding input/output file args) */ ffmpegArgs: string[]; /** Output filename used when calling ffmpeg */ @@ -40,37 +38,33 @@ export interface ContainerFormat extends Omit { needsTranscode: (blob: Blob) => Promise; } -export const customFormats: CustomFormat[] = [ - { +export const customFormats: Record = { + FFMPEG_MP3_320: { displayName: 'MP3 320kbps', - internalName: 'FFMPEG_MP3_320', ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '320k', '-ar', '44100'], outputFilename: 'output.mp3', outputMime: 'audio/mpeg', extension: 'mp3', category: 'MP3', }, - { + FFMPEG_MP3_256: { displayName: 'MP3 256kbps', - internalName: 'FFMPEG_MP3_256', ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '44100'], outputFilename: 'output.mp3', outputMime: 'audio/mpeg', extension: 'mp3', category: 'MP3', }, - { + FFMPEG_MP3_128: { displayName: 'MP3 128kbps', - internalName: 'FFMPEG_MP3_128', ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'libmp3lame', '-b:a', '128k', '-ar', '44100'], outputFilename: 'output.mp3', outputMime: 'audio/mpeg', extension: 'mp3', category: 'MP3', }, - { + FFMPEG_OGG_320: { displayName: 'OGG 320kbps', - internalName: 'FFMPEG_OGG_320', ffmpegArgs: [ '-map_metadata', '-1', @@ -88,9 +82,8 @@ export const customFormats: CustomFormat[] = [ extension: 'ogg', category: 'OGG', }, - { + FFMPEG_OGG_256: { displayName: 'OGG 256kbps', - internalName: 'FFMPEG_OGG_256', ffmpegArgs: [ '-map_metadata', '-1', @@ -108,9 +101,8 @@ export const customFormats: CustomFormat[] = [ extension: 'ogg', category: 'OGG', }, - { + FFMPEG_OGG_128: { displayName: 'OGG 128kbps', - internalName: 'FFMPEG_OGG_128', ffmpegArgs: [ '-map_metadata', '-1', @@ -128,16 +120,15 @@ export const customFormats: CustomFormat[] = [ extension: 'ogg', category: 'OGG', }, - { + FFMPEG_AAC_256: { displayName: 'AAC 256kbps', - internalName: 'FFMPEG_AAC_256', ffmpegArgs: ['-map_metadata', '-1', '-c:a', 'aac', '-b:a', '256k'], outputFilename: 'output.m4a', outputMime: 'audio/mp4', extension: 'm4a', category: 'AAC', }, -]; +}; /** * Container format definitions for lossless re-muxing. Each entry describes @@ -145,47 +136,25 @@ export const customFormats: CustomFormat[] = [ * `needsTranscode` predicate so callers can skip the ffmpeg step when the * source is already in the correct container. */ -export const containerFormats: ContainerFormat[] = [ - { +export const containerFormats: Record = { + flac: { displayName: 'FLAC', - internalName: 'flac', - ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], + ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac'], outputFilename: 'output.flac', outputMime: 'audio/flac', extension: 'flac', // Only transcode when the source is NOT already a FLAC file. needsTranscode: async (blob) => (await getExtensionFromBlob(blob)) !== 'flac', }, - { - displayName: 'FLAC - Max Compression', - internalName: 'flac_max', - // `-compression_level 12` is the highest FLAC compression level; audio - // data is bit-identical to the source — only the compressed size changes. - ffmpegArgs: ['-vn', '-map_metadata', '-1', '-map', '0:a', '-c:a', 'flac', '-compression_level', '12'], - outputFilename: 'output.flac', - outputMime: 'audio/flac', - extension: 'flac', - needsTranscode: async () => true, - }, - { + alac: { displayName: 'Apple Lossless', - internalName: 'alac', ffmpegArgs: ['-c:a', 'alac'], outputFilename: 'output.m4a', outputMime: 'audio/mp4', 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 */ export function isCustomFormat(quality: string): boolean { @@ -194,12 +163,12 @@ export function isCustomFormat(quality: string): boolean { /** Looks up a custom format by its internal name, or returns undefined */ export function getCustomFormat(internalName: string): CustomFormat | undefined { - return customFormats.find((f) => f.internalName === internalName); + return customFormats[internalName]; } /** Looks up a container format by its internal name, or returns undefined */ export function getContainerFormat(internalName: string): ContainerFormat | undefined { - return containerFormats.find((f) => f.internalName === internalName); + return containerFormats[internalName]; } /** @@ -215,7 +184,7 @@ export async function transcodeWithCustomFormat( ): Promise { return ffmpeg( audioBlob, - { args: format.ffmpegArgs }, + format.ffmpegArgs, format.outputFilename, format.outputMime, onProgress, @@ -237,7 +206,7 @@ export async function transcodeWithContainerFormat( ): Promise { return ffmpeg( audioBlob, - { args: format.ffmpegArgs }, + format.ffmpegArgs, format.outputFilename, format.outputMime, onProgress, 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/metadata.js b/js/metadata.js index 29d6d46..330aa30 100644 --- a/js/metadata.js +++ b/js/metadata.js @@ -47,14 +47,6 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet */ const data = {}; - const detectedExt = await getExtensionFromBlob(audioBlob); - const isM4A = detectedExt === 'm4a' || detectedExt === 'mp4'; - - if (isM4A) { - console.log('Skipping TagLib for M4A (handled by FFmpeg)'); - return audioBlob; - } - const audioBuffer = await doTimedAsync('Get audio array buffer', () => audioBlob.arrayBuffer()); try { @@ -64,7 +56,8 @@ export async function addMetadataToAudio(audioBlob, track, api, _quality, prefet data.albumArtist = track.album?.artist?.name || track.artist?.name; data.trackNumber = track.trackNumber; data.discNumber = track.volumeNumber ?? track.discNumber; - data.totalTracks = track.album.numberOfTracks; + data.totalTracks = track.album.numberOfTracksOnDisc ?? track.album.numberOfTracks; + data.totalDiscs = track.album.totalDiscs; data.copyright = track.copyright; data.isrc = track.isrc; data.explicit = Boolean(track.explicit); diff --git a/js/settings.js b/js/settings.js index 24a1cf3..abc0734 100644 --- a/js/settings.js +++ b/js/settings.js @@ -815,8 +815,8 @@ export function initializeSettings(scrobbler, player, api, ui) { })); // Append custom (ffmpeg-transcoded) format options - for (const fmt of customFormats) { - allOptions.push({ value: fmt.internalName, text: fmt.displayName, category: fmt.category }); + for (const [key, fmt] of Object.entries(customFormats)) { + allOptions.push({ value: key, text: fmt.displayName, category: fmt.category }); } // Sort by category order first, then by bitrate descending within each category @@ -860,18 +860,34 @@ 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 noChangeOption = losslessContainerSetting.querySelector('option:last-child'); + noChangeOption.remove(); + + for (const [internalName, { displayName }] of Object.entries(containerFormats)) { const option = document.createElement('option'); option.value = internalName; option.textContent = displayName; losslessContainerSetting.appendChild(option); } + losslessContainerSetting.append(noChangeOption); + losslessContainerSetting.value = losslessContainerSettings.getContainer(); losslessContainerSetting.addEventListener('change', (e) => { @@ -879,6 +895,8 @@ export function initializeSettings(scrobbler, player, api, ui) { }); } + updateLosslessContainerVisibility(); + // Cover Art Size setting const coverArtSizeSetting = document.getElementById('cover-art-size-setting'); if (coverArtSizeSetting) { @@ -909,11 +927,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 556be07..62e594c 100644 --- a/js/storage.js +++ b/js/storage.js @@ -559,7 +559,8 @@ export const losslessContainerSettings = { STORAGE_KEY: 'lossless-container', getContainer() { try { - return localStorage.getItem(this.STORAGE_KEY) || 'flac'; + const stored = localStorage.getItem(this.STORAGE_KEY) || 'flac'; + return stored; } catch { return 'flac'; } @@ -634,18 +635,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'); }, }; @@ -657,6 +682,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 { @@ -744,6 +770,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..75588d7 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, String(Number(data.discNumber || 1))); 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')); diff --git a/package.json b/package.json index 3c9fa50..912e909 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "appwrite": "^23.0.0", "butterchurn": "^2.6.7", "butterchurn-presets": "^2.4.7", + "client-zip": "^2.5.0", "cookie-session": "^2.1.1", "dashjs": "^5.1.1", "fuse.js": "^7.1.0",