feat(#10): add batch download settings and capabilities

- Introduced new batch download settings in the settings slice, allowing users to configure options for directory picking, streaming ZIP downloads, and blob ZIP downloads.
- Implemented a new general settings panel for managing batch download preferences.
- Added utility functions to handle directory selection and ZIP file creation.
- Updated the download logic to support multiple download modes based on user preferences.
- Enhanced documentation to reflect changes in batch download functionality.
This commit is contained in:
鲁树人
2026-10-21 10:24:21 +09:00
parent 3059f495ab
commit 07d160c547
16 changed files with 660 additions and 55 deletions
+156 -48
View File
@@ -3,9 +3,17 @@ import { FaDownload } from 'react-icons/fa';
import { useAppSelector } from '~/hooks';
import { toast } from 'react-toastify';
import { SimpleQueue } from '~/util/SimpleQueue';
import { selectFinalBatchDownloadSettings } from '~/features/settings/settingsSelector';
import type { BatchDownloadSettings } from '~/features/settings/settingsSlice';
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
import { resolveWritableDirectory } from '~/util/downloadDirectoryHandle';
import { downloadZipAsBlob, downloadZipViaSavePicker, ZipSourceFile } from '~/util/streamZip';
import { triggerAnchorDownload } from '~/util/triggerDownload';
export function DownloadAll() {
const files = useAppSelector(selectFiles);
const batchSettings = useAppSelector(selectFinalBatchDownloadSettings);
const downloadAllAsync = async () => {
const fileList = Object.values(files);
const fileCount = fileList.length;
@@ -14,51 +22,53 @@ export function DownloadAll() {
return;
}
// 判断所有文件是否处理完成
const allComplete = fileList.every((file) => file.state !== ProcessState.PROCESSING);
if (!allComplete) {
toast.warning('请等待所有文件解密完成');
return;
}
// 过滤处理失败的文件
const completeFiles = fileList.filter((file) => file.state === ProcessState.COMPLETE);
// 准备下载
let dir: FileSystemDirectoryHandle | null = null;
try {
dir = await window.showDirectoryPicker({ mode: 'readwrite' });
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') {
return; // user cancelled
}
console.error(e);
if (completeFiles.length === 0) {
toast.warning('没有可下载的已解密文件');
return;
}
toast.warning('开始下载,请稍候');
const queue = new SimpleQueue(8);
const promises = Object.values(completeFiles).map(async (file) => {
try {
await queue.enter();
await downloadFile(file, dir);
} catch (e) {
console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e as Error}`);
throw e;
} finally {
queue.leave();
}
});
const promiseResults = await Promise.allSettled(promises);
const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
toast[level](`成功下载: ${success}/${fileCount}首`);
// Single file: direct blob download, no ZIP / directory picker.
if (completeFiles.length === 1) {
downloadSingleBlob(completeFiles[0]);
toast.success('已开始下载');
return;
}
const modes = buildEnabledModes(batchSettings);
if (modes.length === 0) {
toast.error('批量下载失败:未启用任何可用的下载方式,请在「设定 → 通用设置」中勾选。');
return;
}
toast.warning('开始下载,请稍候');
for (const mode of modes) {
try {
const result = await runMode(mode, completeFiles, batchSettings.rememberDirectory);
if (result === 'ok') {
return;
}
// cancelled / skipped → try next mode
} catch (e) {
console.error(`批量下载模式 ${mode} 失败`, e);
toast.error(`下载方式失败,尝试下一种: ${e instanceof Error ? e.message : String(e)}`);
}
}
toast.error('批量下载失败:当前浏览器不支持或您取消了所有可用方式。可在「设定 → 通用设置」调整选项。');
};
function onDownloadAll() {
downloadAllAsync().catch((e) => {
// this should not happen
console.error('下载全部出现错误', e);
toast.error(`批量下载出现错误: ${e instanceof Error ? e.message : String(e)}`);
});
}
@@ -69,22 +79,120 @@ export function DownloadAll() {
);
}
async function downloadFile(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle | null) {
if (dir) {
const fileHandle = await dir.getFileHandle(file.cleanName + '.' + file.ext, { create: true });
const fileStream = await fileHandle.createWritable();
try {
const res = await fetch(file.decrypted);
await res.body?.pipeTo(fileStream);
} catch {
await fileStream.abort();
}
} else {
const anchor = document.createElement('a');
anchor.href = file.decrypted;
anchor.download = file.cleanName + '.' + file.ext;
document.body.append(anchor);
anchor.click();
anchor.remove();
type BatchMode = 'directory' | 'streamingZip' | 'blobZip';
function buildEnabledModes(
settings: Pick<BatchDownloadSettings, 'directoryPicker' | 'streamingZip' | 'blobZip'>,
): BatchMode[] {
const modes: BatchMode[] = [];
if (settings.directoryPicker && supportsDirectoryPicker()) {
modes.push('directory');
}
if (settings.streamingZip && supportsStreamingZipDownload()) {
modes.push('streamingZip');
}
if (settings.blobZip) {
modes.push('blobZip');
}
return modes;
}
async function runMode(
mode: BatchMode,
completeFiles: DecryptedAudioFile[],
rememberDirectory: boolean,
): Promise<'ok' | 'cancelled'> {
switch (mode) {
case 'directory':
return downloadToDirectory(completeFiles, rememberDirectory);
case 'streamingZip':
return downloadStreamingZip(completeFiles);
case 'blobZip':
await downloadBlobZip(completeFiles);
return 'ok';
}
}
async function downloadToDirectory(
completeFiles: DecryptedAudioFile[],
remember: boolean,
): Promise<'ok' | 'cancelled'> {
const dir = await resolveWritableDirectory(remember);
if (!dir) {
return 'cancelled';
}
const fileCount = completeFiles.length;
const queue = new SimpleQueue(8);
const promises = completeFiles.map(async (file) => {
try {
await queue.enter();
await writeFileToDirectory(file, dir);
} catch (e) {
console.error(`下载失败: ${file.fileName}`, e);
toast.error(`出现错误: ${e instanceof Error ? e.message : String(e)}`);
throw e;
} finally {
queue.leave();
}
});
const promiseResults = await Promise.allSettled(promises);
const success = promiseResults.filter((result) => result.status === 'fulfilled').length;
const level = success === fileCount ? 'success' : success === 0 ? 'error' : 'warning';
toast[level](`成功下载: ${success}/${fileCount}首`);
if (success === 0) {
throw new Error('未能写入任何文件到所选目录');
}
return 'ok';
}
function decryptedDownloadName(file: DecryptedAudioFile): string {
return `${file.cleanName}.${file.ext}`;
}
async function writeFileToDirectory(file: DecryptedAudioFile, dir: FileSystemDirectoryHandle) {
const fileHandle = await dir.getFileHandle(decryptedDownloadName(file), { create: true });
const fileStream = await fileHandle.createWritable();
try {
const res = await fetch(file.decrypted);
if (!res.ok) {
throw new Error(`无法读取文件: ${decryptedDownloadName(file)} (${res.status})`);
}
if (!res.body) {
await fileStream.write(await res.arrayBuffer());
await fileStream.close();
return;
}
await res.body.pipeTo(fileStream);
} catch (e) {
await fileStream.abort();
throw e;
}
}
function toZipSources(files: DecryptedAudioFile[]): ZipSourceFile[] {
return files.map((file) => ({
name: decryptedDownloadName(file),
url: file.decrypted,
}));
}
function downloadSingleBlob(file: DecryptedAudioFile) {
triggerAnchorDownload(file.decrypted, decryptedDownloadName(file));
}
async function downloadStreamingZip(completeFiles: DecryptedAudioFile[]): Promise<'ok' | 'cancelled'> {
const sources = toZipSources(completeFiles);
const result = await downloadZipViaSavePicker(sources);
if (result === 'ok') {
toast.success(`已导出 ZIP(${completeFiles.length} 首)`);
}
return result;
}
async function downloadBlobZip(completeFiles: DecryptedAudioFile[]): Promise<void> {
await downloadZipAsBlob(toZipSources(completeFiles));
toast.success(`已开始下载 ZIP(${completeFiles.length} 首)`);
}