- Zipped Bulk Downloads
+ Bulk Download MethodDownload 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 ContainerContainer format for lossless downloads
-
+
@@ -5225,7 +5240,7 @@
- Separate Discs in ZIP
+ Separate DiscsPut 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