kv-music/js/bulk-download-writer.ts
2026-04-04 01:37:47 +03:00

193 lines
7.2 KiB
TypeScript

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 | File | string | ArrayBuffer | Uint8Array;
}
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<WriterEntry>): Promise<void>;
}
/**
* Triggers individual downloads for each file entry, one after another.
*/
class SequentialFileWriter implements IBulkDownloadWriter {
constructor() {}
async write(files: AsyncIterable<WriterEntry>): Promise<void> {
for await (const file of files) {
const name = file.name?.split('/').pop();
const ext = name?.split('.').pop().toLowerCase();
if (!name) {
console.warn('No name for file entry.', file);
continue;
}
if (['m3u', 'm3u8', 'cue', 'jpg', 'png', 'nfo', 'json'].includes(ext)) {
continue;
}
if (file.input instanceof Blob || file.input instanceof File) {
triggerDownload(file.input, name);
} else {
triggerDownload(new Blob([file.input as BlobPart]), name);
}
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
const sequentialFileWriter = new SequentialFileWriter();
export { sequentialFileWriter as SequentialFileWriter };
/**
* 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<WriterEntry>): Promise<void> {
const fileHandle = await window.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<WriterEntry>): Promise<void> {
const { downloadZip } = await loadClientZip();
const response = downloadZip(files);
const blob = await response.blob();
triggerDownload(blob, this.filename);
}
}
/**
* 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) {}
/** Returns the underlying directory handle (e.g. to persist it for later re-use). */
getDirHandle(): FileSystemDirectoryHandle {
return this.dirHandle;
}
/**
* Creates a {@link FolderPickerWriter} from an already-obtained handle
* without showing a directory picker. Useful when re-using a stored handle
* whose permission has already been verified by the caller.
*/
static fromHandle(handle: FileSystemDirectoryHandle): FolderPickerWriter {
return new FolderPickerWriter(handle);
}
/**
* Prompts the user to pick a writable directory, or re-uses a previously
* saved handle when one is supplied and write permission can be obtained.
* 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(savedHandle?: FileSystemDirectoryHandle | null): Promise<FolderPickerWriter> {
// Try to re-use a saved handle first
if (savedHandle) {
try {
const permission = await savedHandle.requestPermission({ mode: 'readwrite' });
if (permission === 'granted') {
return new FolderPickerWriter(savedHandle);
}
} catch {
// Fall through to show the picker
}
}
// showDirectoryPicker is part of the File System Access API (not yet in all TS DOM libs)
try {
const dirHandle: FileSystemDirectoryHandle = await window.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> {
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();
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();
} catch (error) {
await writable.abort();
throw error;
}
}
}
}