Fix bulk download edge cases and improve robustness

- FolderPickerWriter: throw AbortError on cancel instead of returning null
- FolderPickerWriter: add try/catch with abort() to release file locks on failure
- ZipNeutralinoWriter: move writeBinaryFile after response.body validation
- bulkDownloadSettings: migrate legacy key and validate stored values
- download-utils: catch ffmpeg cancellation via signal.aborted
- downloads.js: use consistent Neutralino detection with bridge module
- download-utils: use strict equality for flac extension check
This commit is contained in:
edideaur 2026-03-12 19:35:23 +00:00
parent a776e24aee
commit b31be7dc80
4 changed files with 57 additions and 28 deletions

View file

@ -98,11 +98,11 @@ export class ZipNeutralinoWriter implements IBulkDownloadWriter {
}
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');
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
const reader = response.body.getReader();
let receivedLength = 0;
@ -138,10 +138,17 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
static async create(): Promise<FolderPickerWriter> {
// 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);
try {
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
mode: 'readwrite',
});
return new FolderPickerWriter(dirHandle);
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
throw new DOMException('User cancelled directory picker', 'AbortError');
}
}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
@ -158,23 +165,25 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
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]));
}
try {
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 {
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();
await writable.close();
} catch (error) {
await writable.abort();
throw error;
}
}
}
}

View file

@ -64,7 +64,7 @@ export async function applyAudioPostProcessing(
if (await containerFmt?.needsTranscode(blob)) {
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
} else if (extension == 'flac') {
} else if (extension === 'flac') {
blob = await rebuildFlacWithoutMetadata(blob);
} else {
blob = await ffmpegNewContainer(
@ -76,7 +76,7 @@ export async function applyAudioPostProcessing(
);
}
} catch (error) {
if ((error as Error)?.name === 'AbortError') {
if ((error as Error)?.name === 'AbortError' || signal?.aborted) {
throw error;
}

View file

@ -669,7 +669,9 @@ async function bulkDownloadToZip(
* or null when individual sequential downloads should be used.
*/
async function createBulkWriter(folderName) {
const isNeutralino = window.NL_MODE === true;
const isNeutralino =
typeof window !== 'undefined' &&
(window.NL_MODE || window.location.search.includes('mode=neutralino') || window.parent !== window);
const method = bulkDownloadSettings.getMethod();
const forceZipBlob = bulkDownloadSettings.shouldForceZipBlob();
const hasFileSystemAccess = 'showSaveFilePicker' in window && 'createWritable' in FileSystemFileHandle.prototype;
@ -679,8 +681,14 @@ async function createBulkWriter(folderName) {
return new ZipNeutralinoWriter(folderName);
}
if (method === 'folder' && hasFolderPicker) {
// FolderPickerWriter.create() throws AbortError if the user cancels
return await FolderPickerWriter.create();
try {
return await FolderPickerWriter.create();
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
return null;
}
}
if (method === 'individual') {
return null;

View file

@ -637,11 +637,23 @@ export const trackDateSettings = {
export const bulkDownloadSettings = {
METHOD_KEY: 'bulk-download-method',
FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob',
LEGACY_INDIVIDUAL_KEY: 'force-individual-downloads',
VALID_METHODS: ['zip', 'folder', 'individual'],
/** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */
getMethod() {
try {
return localStorage.getItem(this.METHOD_KEY) || 'zip';
const stored = localStorage.getItem(this.METHOD_KEY);
if (stored && this.VALID_METHODS.includes(stored)) {
return stored;
}
const legacy = localStorage.getItem(this.LEGACY_INDIVIDUAL_KEY);
if (legacy === 'true') {
localStorage.setItem(this.METHOD_KEY, 'individual');
localStorage.removeItem(this.LEGACY_INDIVIDUAL_KEY);
return 'individual';
}
return 'zip';
} catch {
return 'zip';
}