mirror of
https://git.um-react.app/um/um-react.git
synced 2026-10-21 14:03:01 +00:00
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:
@@ -71,6 +71,18 @@
|
||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||

|
||||
|
||||
### 批量下载约十首后失败 / 无法全部下载
|
||||
|
||||
Chromium 系浏览器会限制页面在短时间内通过程序触发的**多个**文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
|
||||
|
||||
本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:
|
||||
|
||||
1. 选择输出目录,直接写入文件(推荐,Chrome / Edge)
|
||||
2. 流式生成单个 ZIP 并保存
|
||||
3. 在内存中生成 ZIP 后触发一次下载(兼容性最好)
|
||||
|
||||
若全部失败会弹出提示。大批量转换也可使用 [命令行版本](https://git.um-react.app/um/cli)。
|
||||
|
||||
### Via 等浏览器无法正常解密/下载
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@unlock-music/crypto": "^0.1.12",
|
||||
"classnames": "^2.5.1",
|
||||
"fflate": "^0.8.3",
|
||||
"idb-keyval": "^6.3.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"radash": "^12.1.1",
|
||||
"react": "^19.2.0",
|
||||
|
||||
Generated
+15
-4
@@ -25,6 +25,12 @@ importers:
|
||||
classnames:
|
||||
specifier: ^2.5.1
|
||||
version: 2.5.1
|
||||
fflate:
|
||||
specifier: ^0.8.3
|
||||
version: 0.8.3
|
||||
idb-keyval:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
nanoid:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
@@ -2212,8 +2218,8 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
[email protected].2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
[email protected].3:
|
||||
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
@@ -2409,6 +2415,9 @@ packages:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==}
|
||||
|
||||
@@ -5380,7 +5389,7 @@ snapshots:
|
||||
'@vitest/[email protected]([email protected])':
|
||||
dependencies:
|
||||
'@vitest/utils': 3.2.4
|
||||
fflate: 0.8.2
|
||||
fflate: 0.8.3
|
||||
flatted: 3.3.3
|
||||
pathe: 2.0.3
|
||||
sirv: 3.0.2
|
||||
@@ -5986,7 +5995,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
[email protected].2: {}
|
||||
[email protected].3: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -6193,6 +6202,8 @@ snapshots:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
+156
-48
@@ -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} 首)`);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@ export function OtherFAQ() {
|
||||
<p>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</p>
|
||||
<p>请使用第三方工具进行编辑或管理元信息。</p>
|
||||
|
||||
<Header3 id="batch-download">批量下载约十首后失败 / 无法全部下载</Header3>
|
||||
<p>
|
||||
Chromium 系浏览器会限制页面在短时间内通过程序触发的<strong>多个</strong>
|
||||
文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。
|
||||
</p>
|
||||
<p className="my-2">本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:</p>
|
||||
<ol className="list-decimal pl-8 my-2">
|
||||
<li>选择输出目录,直接写入文件(推荐,Chrome / Edge)</li>
|
||||
<li>流式生成单个 ZIP 并保存</li>
|
||||
<li>在内存中生成 ZIP 后触发一次下载(兼容性最好)</li>
|
||||
</ol>
|
||||
<p>
|
||||
若全部失败会弹出提示。大批量转换也可使用
|
||||
<ExtLink href="https://git.um-react.app/um/cli">命令行版本</ExtLink>。
|
||||
</p>
|
||||
|
||||
<Header3 id="android-browsers">安卓: 浏览器支持说明</Header3>
|
||||
<p>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</p>
|
||||
<div className="flex flex-col md:flex-row gap-2 md:gap-8">
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useId, useState, type ReactNode } from 'react';
|
||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||
import { selectStagingBatchDownloadSettings } from '../settingsSelector';
|
||||
import { BatchDownloadSettings, updateBatchDownloadSetting } from '../settingsSlice';
|
||||
import { supportsDirectoryPicker, supportsStreamingZipDownload } from '~/util/batchDownloadCapabilities';
|
||||
import { clearRememberedDirectoryHandle, hasRememberedDirectoryHandle } from '~/util/downloadDirectoryHandle';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export function PanelGeneral() {
|
||||
const dispatch = useAppDispatch();
|
||||
const batch = useAppSelector(selectStagingBatchDownloadSettings);
|
||||
const canDirectoryPicker = supportsDirectoryPicker();
|
||||
const canStreamingZip = supportsStreamingZipDownload();
|
||||
const [hasRememberedDir, setHasRememberedDir] = useState(false);
|
||||
|
||||
const refreshRemembered = useCallback(() => {
|
||||
hasRememberedDirectoryHandle()
|
||||
.then(setHasRememberedDir)
|
||||
.catch(() => setHasRememberedDir(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRemembered();
|
||||
}, [refreshRemembered]);
|
||||
|
||||
const setBatch = (key: keyof BatchDownloadSettings, value: boolean) => {
|
||||
dispatch(updateBatchDownloadSetting({ key, value }));
|
||||
};
|
||||
|
||||
const handleClearDirectory = async () => {
|
||||
await clearRememberedDirectoryHandle();
|
||||
refreshRemembered();
|
||||
toast.info('已清除记住的输出目录');
|
||||
};
|
||||
|
||||
const idDir = useId();
|
||||
const idRemember = useId();
|
||||
const idStreamZip = useId();
|
||||
const idBlobZip = useId();
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-col grow px-1">
|
||||
<h2 className="text-2xl font-bold mb-4">通用设置</h2>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">批量下载</h3>
|
||||
<p className="text-sm text-base-content/80 mb-4">
|
||||
点击「下载全部」时,将按下方勾选项的顺序依次尝试。若选中的模式均不可用或全部失败,会弹出提示。
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<BatchOption
|
||||
id={idDir}
|
||||
checked={batch.directoryPicker && canDirectoryPicker}
|
||||
disabled={!canDirectoryPicker}
|
||||
dimmed={!canDirectoryPicker}
|
||||
title="使用选择目录批量下载"
|
||||
description={
|
||||
canDirectoryPicker
|
||||
? '通过 showDirectoryPicker 将解密文件直接写入所选目录(推荐,Chrome / Edge)。'
|
||||
: '当前浏览器不支持 showDirectoryPicker。'
|
||||
}
|
||||
onChange={(checked) => setBatch('directoryPicker', checked)}
|
||||
/>
|
||||
|
||||
<div className={`ml-6 flex flex-col gap-2 ${batch.directoryPicker && canDirectoryPicker ? '' : 'opacity-50'}`}>
|
||||
<BatchOption
|
||||
id={idRemember}
|
||||
checked={batch.rememberDirectory}
|
||||
disabled={!batch.directoryPicker || !canDirectoryPicker}
|
||||
title="记住输出目录"
|
||||
description="将目录句柄保存在本机;下次下载时可能仍需确认写入权限。"
|
||||
onChange={(checked) => setBatch('rememberDirectory', checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-base-content/70">{hasRememberedDir ? '已记住一个输出目录' : '尚未记住输出目录'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost"
|
||||
disabled={!hasRememberedDir}
|
||||
onClick={() => void handleClearDirectory()}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchOption
|
||||
id={idStreamZip}
|
||||
checked={batch.streamingZip && canStreamingZip}
|
||||
disabled={!canStreamingZip}
|
||||
dimmed={!canStreamingZip}
|
||||
title="流式生成 ZIP"
|
||||
description={
|
||||
canStreamingZip
|
||||
? '边打包边写入单个 ZIP 文件(showSaveFilePicker),内存占用较低。'
|
||||
: '当前浏览器不支持 showSaveFilePicker,无法流式写出 ZIP。'
|
||||
}
|
||||
onChange={(checked) => setBatch('streamingZip', checked)}
|
||||
/>
|
||||
|
||||
<BatchOption
|
||||
id={idBlobZip}
|
||||
checked={batch.blobZip}
|
||||
title="生成单个 ZIP 并触发下载"
|
||||
description={
|
||||
<>
|
||||
在内存中生成 ZIP 后触发一次浏览器下载。兼容性最好;ZIP 需整包驻留内存,建议合计不超过约
|
||||
500MB~1GB(视设备可用内存而定,过大可能失败或卡顿)。
|
||||
</>
|
||||
}
|
||||
onChange={(checked) => setBatch('blobZip', checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BatchOptionProps = {
|
||||
id: string;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
dimmed?: boolean;
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
function BatchOption({ id, checked, disabled, dimmed, title, description, onChange }: BatchOptionProps) {
|
||||
return (
|
||||
<label className={`flex items-start gap-2 ${dimmed ? 'opacity-50' : ''}`}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="checkbox checkbox-sm mt-0.5"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{title}</span>
|
||||
<span className="block text-sm text-base-content/70">{description}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import { debounce } from 'radash';
|
||||
|
||||
import type { AppStore } from '~/store';
|
||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
||||
import {
|
||||
settingsSlice,
|
||||
setProductionChanges,
|
||||
ProductionSettings,
|
||||
DEFAULT_BATCH_DOWNLOAD_SETTINGS,
|
||||
BatchDownloadSettings,
|
||||
} from './settingsSlice';
|
||||
import { enumObject } from '~/util/objects';
|
||||
import { getLogger } from '~/util/logUtils';
|
||||
import { parseKwm2ProductionKey } from './keyFormats';
|
||||
@@ -9,8 +15,24 @@ import { deepClone } from '~/util/deepClone';
|
||||
|
||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||
|
||||
function mergeBatchDownload(source: Partial<BatchDownloadSettings> | undefined): BatchDownloadSettings {
|
||||
const result = { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS };
|
||||
if (!source) {
|
||||
return result;
|
||||
}
|
||||
for (const key of Object.keys(DEFAULT_BATCH_DOWNLOAD_SETTINGS) as (keyof BatchDownloadSettings)[]) {
|
||||
if (typeof source[key] === 'boolean') {
|
||||
result[key] = source[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||
const draft = deepClone(settingsSlice.getInitialState().production);
|
||||
|
||||
draft.general.batchDownload = mergeBatchDownload(settings?.general?.batchDownload);
|
||||
|
||||
if (settings?.qmc2) {
|
||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
|
||||
@@ -66,3 +66,6 @@ export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse):
|
||||
|
||||
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
||||
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
||||
|
||||
export const selectStagingBatchDownloadSettings = (state: RootState) => state.settings.staging.general.batchDownload;
|
||||
export const selectFinalBatchDownloadSettings = (state: RootState) => state.settings.production.general.batchDownload;
|
||||
|
||||
@@ -21,7 +21,17 @@ import {
|
||||
StagingKugouKey,
|
||||
} from './keyFormats';
|
||||
|
||||
export interface BatchDownloadSettings {
|
||||
directoryPicker: boolean;
|
||||
rememberDirectory: boolean;
|
||||
streamingZip: boolean;
|
||||
blobZip: boolean;
|
||||
}
|
||||
|
||||
export interface StagingSettings {
|
||||
general: {
|
||||
batchDownload: BatchDownloadSettings;
|
||||
};
|
||||
qmc2: {
|
||||
keys: StagingQMCv2Key[];
|
||||
allowFuzzyNameSearch: boolean;
|
||||
@@ -38,6 +48,9 @@ export interface StagingSettings {
|
||||
}
|
||||
|
||||
export interface ProductionSettings {
|
||||
general: {
|
||||
batchDownload: BatchDownloadSettings;
|
||||
};
|
||||
qmc2: {
|
||||
keys: ProductionQMCv2Keys; // { [fileName]: ekey }
|
||||
allowFuzzyNameSearch: boolean;
|
||||
@@ -53,6 +66,13 @@ export interface ProductionSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_BATCH_DOWNLOAD_SETTINGS: BatchDownloadSettings = {
|
||||
directoryPicker: true,
|
||||
rememberDirectory: true,
|
||||
streamingZip: true,
|
||||
blobZip: true,
|
||||
};
|
||||
|
||||
export interface SettingsState {
|
||||
dirty: boolean;
|
||||
staging: StagingSettings;
|
||||
@@ -62,12 +82,14 @@ export interface SettingsState {
|
||||
const initialState: SettingsState = {
|
||||
dirty: false,
|
||||
staging: {
|
||||
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||
kwm2: { keys: [] },
|
||||
qtfm: { android: '' },
|
||||
kugou: { keys: [] },
|
||||
},
|
||||
production: {
|
||||
general: { batchDownload: { ...DEFAULT_BATCH_DOWNLOAD_SETTINGS } },
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||
kwm2: { keys: {} },
|
||||
kugou: { keys: {} },
|
||||
@@ -76,6 +98,9 @@ const initialState: SettingsState = {
|
||||
};
|
||||
|
||||
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
general: {
|
||||
batchDownload: { ...staging.general.batchDownload },
|
||||
},
|
||||
qmc2: {
|
||||
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
|
||||
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
||||
@@ -90,6 +115,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
});
|
||||
|
||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||
general: {
|
||||
batchDownload: { ...production.general.batchDownload },
|
||||
},
|
||||
qmc2: {
|
||||
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
|
||||
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
||||
@@ -208,6 +236,13 @@ export const settingsSlice = createSlice({
|
||||
state.staging.qtfm.android = deviceKey;
|
||||
state.dirty = true;
|
||||
},
|
||||
updateBatchDownloadSetting(
|
||||
state,
|
||||
{ payload: { key, value } }: PayloadAction<{ key: keyof BatchDownloadSettings; value: boolean }>,
|
||||
) {
|
||||
state.staging.general.batchDownload[key] = value;
|
||||
state.dirty = true;
|
||||
},
|
||||
//
|
||||
discardStagingChanges: (state) => {
|
||||
state.dirty = false;
|
||||
@@ -252,6 +287,7 @@ export const {
|
||||
kugouImportKeys,
|
||||
|
||||
qtfmAndroidUpdateKey,
|
||||
updateBatchDownloadSetting,
|
||||
|
||||
commitStagingChange,
|
||||
discardStagingChanges,
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import type { FC } from 'react';
|
||||
import { PanelGeneral } from '~/features/settings/panels/PanelGeneral.tsx';
|
||||
import { PanelQMCv2Key } from '~/features/settings/panels/PanelQMCv2Key.tsx';
|
||||
import { PanelKWMv2Key } from '~/features/settings/panels/PanelKWMv2Key.tsx';
|
||||
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
||||
import { PanelQingTing } from '~/features/settings/panels/PanelQingTing.tsx';
|
||||
|
||||
export const SETTINGS_TABS: Record<string, { name: string; Tab: FC }> = {
|
||||
general: { name: '通用设置', Tab: PanelGeneral },
|
||||
qmc: { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||
kwm: { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||
kgg: { name: 'KGG 密钥', Tab: PanelKGGKey },
|
||||
qtfm: { name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||
// misc: { name: '其它/待定', Tab: () => <p>这里空空如也~</p> },
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export function supportsDirectoryPicker(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||
}
|
||||
|
||||
export function supportsSaveFilePicker(): boolean {
|
||||
return typeof window !== 'undefined' && typeof window.showSaveFilePicker === 'function';
|
||||
}
|
||||
|
||||
export function supportsStreamingZipDownload(): boolean {
|
||||
return supportsSaveFilePicker();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { get, set, del } from 'idb-keyval';
|
||||
import { isAbortError } from '~/util/fsPicker';
|
||||
|
||||
const DIRECTORY_HANDLE_KEY = 'um-react-batch-download-dir';
|
||||
|
||||
export async function getRememberedDirectoryHandle(): Promise<FileSystemDirectoryHandle | null> {
|
||||
try {
|
||||
const handle = await get<FileSystemDirectoryHandle>(DIRECTORY_HANDLE_KEY);
|
||||
return handle ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setRememberedDirectoryHandle(handle: FileSystemDirectoryHandle): Promise<void> {
|
||||
await set(DIRECTORY_HANDLE_KEY, handle);
|
||||
}
|
||||
|
||||
export async function clearRememberedDirectoryHandle(): Promise<void> {
|
||||
await del(DIRECTORY_HANDLE_KEY);
|
||||
}
|
||||
|
||||
export async function hasRememberedDirectoryHandle(): Promise<boolean> {
|
||||
return (await getRememberedDirectoryHandle()) !== null;
|
||||
}
|
||||
|
||||
export async function verifyDirectoryPermission(
|
||||
handle: FileSystemDirectoryHandle,
|
||||
mode: FileSystemPermissionMode = 'readwrite',
|
||||
): Promise<boolean> {
|
||||
const opts: FileSystemHandlePermissionDescriptor = { mode };
|
||||
if ((await handle.queryPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
if ((await handle.requestPermission(opts)) === 'granted') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a writable directory: remembered handle (if allowed) → picker.
|
||||
* Returns null when the user cancels the picker.
|
||||
*/
|
||||
export async function resolveWritableDirectory(remember: boolean): Promise<FileSystemDirectoryHandle | null> {
|
||||
if (remember) {
|
||||
const remembered = await getRememberedDirectoryHandle();
|
||||
if (remembered && (await verifyDirectoryPermission(remembered))) {
|
||||
return remembered;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = await window.showDirectoryPicker({ mode: 'readwrite' });
|
||||
if (remember) {
|
||||
await setRememberedDirectoryHandle(dir);
|
||||
}
|
||||
return dir;
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** True when the user cancelled a File System Access picker (or similar AbortError). */
|
||||
export function isAbortError(e: unknown): boolean {
|
||||
return e instanceof DOMException && e.name === 'AbortError';
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Zip, ZipPassThrough } from 'fflate';
|
||||
import { isAbortError } from '~/util/fsPicker';
|
||||
import { triggerAnchorDownload } from '~/util/triggerDownload';
|
||||
|
||||
export interface ZipSourceFile {
|
||||
/** Path inside the ZIP (UTF-8; non-ASCII gets Language Encoding Flag). */
|
||||
name: string;
|
||||
/** Blob URL or other fetchable URL of file contents. */
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a streaming ZIP (STORE / no recompression) from fetchable URLs.
|
||||
* Filenames with non-ASCII characters get ZIP UTF-8 (LEF) via fflate.
|
||||
*/
|
||||
export function createZipReadableStream(files: ZipSourceFile[]): ReadableStream<Uint8Array> {
|
||||
const zip = new Zip();
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
zip.ondata = (err, data, final) => {
|
||||
if (err) {
|
||||
controller.error(err);
|
||||
return;
|
||||
}
|
||||
controller.enqueue(data);
|
||||
if (final) {
|
||||
controller.close();
|
||||
}
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const usedNames = new Set<string>();
|
||||
|
||||
for (const file of files) {
|
||||
const name = uniqueZipName(file.name, usedNames);
|
||||
const entry = new ZipPassThrough(name);
|
||||
zip.add(entry);
|
||||
|
||||
const res = await fetch(file.url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`无法读取文件: ${file.name} (${res.status})`);
|
||||
}
|
||||
if (!res.body) {
|
||||
const buf = new Uint8Array(await res.arrayBuffer());
|
||||
entry.push(buf, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
entry.push(new Uint8Array(0), true);
|
||||
break;
|
||||
}
|
||||
entry.push(value, false);
|
||||
}
|
||||
}
|
||||
|
||||
zip.end();
|
||||
} catch (e) {
|
||||
zip.terminate();
|
||||
controller.error(e);
|
||||
}
|
||||
})();
|
||||
},
|
||||
cancel() {
|
||||
zip.terminate();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Split `name.ext` → base + ext (ext includes the leading dot). */
|
||||
function splitBaseExt(name: string): { base: string; ext: string } {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot > 0) {
|
||||
return { base: name.slice(0, dot), ext: name.slice(dot) };
|
||||
}
|
||||
return { base: name, ext: '' };
|
||||
}
|
||||
|
||||
function uniqueZipName(name: string, used: Set<string>): string {
|
||||
if (!used.has(name)) {
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
|
||||
const { base, ext } = splitBaseExt(name);
|
||||
let i = 2;
|
||||
let candidate: string;
|
||||
do {
|
||||
candidate = `${base} (${i})${ext}`;
|
||||
i += 1;
|
||||
} while (used.has(candidate));
|
||||
used.add(candidate);
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export function defaultZipFileName(files: ZipSourceFile[]): string {
|
||||
const now = new Date();
|
||||
const date = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
const first = files[0]?.name ?? '已解密的音乐';
|
||||
const stem = splitBaseExt(first).base || '已解密的音乐';
|
||||
return `${date} - ${stem} 等.zip`;
|
||||
}
|
||||
|
||||
export async function downloadZipViaSavePicker(
|
||||
files: ZipSourceFile[],
|
||||
suggestedName = defaultZipFileName(files),
|
||||
): Promise<'ok' | 'cancelled'> {
|
||||
let handle: FileSystemFileHandle;
|
||||
try {
|
||||
handle = await window.showSaveFilePicker({
|
||||
suggestedName,
|
||||
types: [
|
||||
{
|
||||
description: 'ZIP archive',
|
||||
accept: { 'application/zip': ['.zip'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
return 'cancelled';
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
const writable = await handle.createWritable();
|
||||
try {
|
||||
await createZipReadableStream(files).pipeTo(writable);
|
||||
} catch (e) {
|
||||
try {
|
||||
await writable.abort();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
export async function downloadZipAsBlob(files: ZipSourceFile[], filename = defaultZipFileName(files)): Promise<void> {
|
||||
const blob = await new Response(createZipReadableStream(files), {
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
}).blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
triggerAnchorDownload(objectUrl, filename);
|
||||
} finally {
|
||||
// Revoke after a short delay so the download can start.
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Programmatically trigger a browser file download via a temporary anchor. */
|
||||
export function triggerAnchorDownload(href: string, filename: string): void {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = href;
|
||||
anchor.download = filename;
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const macCommandLoader: Plugin = {
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of tarball) {
|
||||
chunks.push(chunk as Buffer);
|
||||
chunks.push(chunk);
|
||||
}
|
||||
const dataBuffer = Buffer.concat(chunks);
|
||||
const base64 = dataBuffer.toString('base64');
|
||||
|
||||
Reference in New Issue
Block a user