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(); const { downloadZip } = await loadClientZip();
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
const response = downloadZip(files); const response = downloadZip(files);
if (!response.body) throw new Error('ZIP response body is null'); if (!response.body) throw new Error('ZIP response body is null');
await bridge.filesystem.writeBinaryFile(savePath, new ArrayBuffer(0));
const reader = response.body.getReader(); const reader = response.body.getReader();
let receivedLength = 0; let receivedLength = 0;
@ -138,10 +138,17 @@ export class FolderPickerWriter implements IBulkDownloadWriter {
static async create(): Promise<FolderPickerWriter> { static async create(): Promise<FolderPickerWriter> {
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs) // 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({ try {
mode: 'readwrite', const dirHandle: FileSystemDirectoryHandle = await (window as any).showDirectoryPicker({
}); mode: 'readwrite',
return new FolderPickerWriter(dirHandle); });
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> { 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 fileHandle = await currentDir.getFileHandle(filename, { create: true });
const writable = await fileHandle.createWritable(); const writable = await fileHandle.createWritable();
const { input } = file; try {
if (input instanceof Blob) { const { input } = file;
await writable.write(input); if (input instanceof Blob) {
} else if (typeof input === 'string') { await writable.write(input);
await writable.write(new Blob([input], { type: 'text/plain' })); } else if (typeof input === 'string') {
} else { await writable.write(new Blob([input], { type: 'text/plain' }));
// ArrayBuffer or Uint8Array wrap in a Blob to guarantee strict typing. } else {
// Use byteOffset/byteLength so only the view's range is included, not the const buf =
// whole backing ArrayBuffer (which may be larger due to pooling). input instanceof Uint8Array
const buf = ? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength)
input instanceof Uint8Array : input;
? input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength) await writable.write(new Blob([buf as ArrayBuffer]));
: 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)) { if (await containerFmt?.needsTranscode(blob)) {
blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal); blob = await transcodeWithContainerFormat(blob, containerFmt, onProgress, signal);
} else if (extension == 'flac') { } else if (extension === 'flac') {
blob = await rebuildFlacWithoutMetadata(blob); blob = await rebuildFlacWithoutMetadata(blob);
} else { } else {
blob = await ffmpegNewContainer( blob = await ffmpegNewContainer(
@ -76,7 +76,7 @@ export async function applyAudioPostProcessing(
); );
} }
} catch (error) { } catch (error) {
if ((error as Error)?.name === 'AbortError') { if ((error as Error)?.name === 'AbortError' || signal?.aborted) {
throw error; throw error;
} }

View file

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

View file

@ -637,11 +637,23 @@ export const trackDateSettings = {
export const bulkDownloadSettings = { export const bulkDownloadSettings = {
METHOD_KEY: 'bulk-download-method', METHOD_KEY: 'bulk-download-method',
FORCE_ZIP_BLOB_KEY: 'bulk-download-force-zip-blob', 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' */ /** Returns the selected bulk download method: 'zip' | 'folder' | 'individual' */
getMethod() { getMethod() {
try { 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 { } catch {
return 'zip'; return 'zip';
} }