From 07d160c5479b254f9cf496af931d881e6f6300e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=B2=81=E6=A0=91=E4=BA=BA?= Date: Wed, 21 Oct 2026 10:24:21 +0900 Subject: [PATCH] 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. --- docs/faq_zh-hans.md | 12 ++ package.json | 2 + pnpm-lock.yaml | 19 +- src/components/DownloadAll.tsx | 204 +++++++++++++----- src/faq/OtherFAQ.tsx | 16 ++ src/features/settings/panels/PanelGeneral.tsx | 145 +++++++++++++ src/features/settings/persistSettings.ts | 24 ++- src/features/settings/settingsSelector.ts | 3 + src/features/settings/settingsSlice.ts | 36 ++++ src/features/settings/settingsTabs.tsx | 3 +- src/util/batchDownloadCapabilities.ts | 11 + src/util/downloadDirectoryHandle.ts | 65 ++++++ src/util/fsPicker.ts | 4 + src/util/streamZip.ts | 160 ++++++++++++++ src/util/triggerDownload.ts | 9 + support/mac-command-loader.ts | 2 +- 16 files changed, 660 insertions(+), 55 deletions(-) create mode 100644 src/features/settings/panels/PanelGeneral.tsx create mode 100644 src/util/batchDownloadCapabilities.ts create mode 100644 src/util/downloadDirectoryHandle.ts create mode 100644 src/util/fsPicker.ts create mode 100644 src/util/streamZip.ts create mode 100644 src/util/triggerDownload.ts diff --git a/docs/faq_zh-hans.md b/docs/faq_zh-hans.md index 97d5dbd..9002cbc 100644 --- a/docs/faq_zh-hans.md +++ b/docs/faq_zh-hans.md @@ -71,6 +71,18 @@ - 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。 ![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc@2x.webp) +### 批量下载约十首后失败 / 无法全部下载 + +Chromium 系浏览器会限制页面在短时间内通过程序触发的**多个**文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。 + +本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试: + +1. 选择输出目录,直接写入文件(推荐,Chrome / Edge) +2. 流式生成单个 ZIP 并保存 +3. 在内存中生成 ZIP 后触发一次下载(兼容性最好) + +若全部失败会弹出提示。大批量转换也可使用 [命令行版本](https://git.um-react.app/um/cli)。 + ### Via 等浏览器无法正常解密/下载 ⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。 diff --git a/package.json b/package.json index a05af6a..084f895 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a8bcfe1..9d30aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 - fflate@0.8.2: - resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} @@ -2409,6 +2415,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + idb-keyval@6.3.0: + resolution: {integrity: sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -5380,7 +5389,7 @@ snapshots: '@vitest/ui@3.2.4(vitest@3.2.4)': 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 - fflate@0.8.2: {} + fflate@0.8.3: {} file-entry-cache@8.0.0: dependencies: @@ -6193,6 +6202,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.3.0: {} + idb@7.1.1: {} ignore@5.3.2: {} diff --git a/src/components/DownloadAll.tsx b/src/components/DownloadAll.tsx index 94b6873..3694d3a 100644 --- a/src/components/DownloadAll.tsx +++ b/src/components/DownloadAll.tsx @@ -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, +): 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 { + await downloadZipAsBlob(toZipSources(completeFiles)); + toast.success(`已开始下载 ZIP(${completeFiles.length} 首)`); +} diff --git a/src/faq/OtherFAQ.tsx b/src/faq/OtherFAQ.tsx index eb33408..a8c6413 100644 --- a/src/faq/OtherFAQ.tsx +++ b/src/faq/OtherFAQ.tsx @@ -11,6 +11,22 @@ export function OtherFAQ() {

该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。

请使用第三方工具进行编辑或管理元信息。

+ 批量下载约十首后失败 / 无法全部下载 +

+ Chromium 系浏览器会限制页面在短时间内通过程序触发的多个 + 文件下载(常见表现为大约十个文件后静默中断,或提示「此网站正在尝试下载多个文件」)。 +

+

本站的「下载全部」会按「设定 → 通用设置 → 批量下载」中勾选的顺序尝试:

+
    +
  1. 选择输出目录,直接写入文件(推荐,Chrome / Edge)
  2. +
  3. 流式生成单个 ZIP 并保存
  4. +
  5. 在内存中生成 ZIP 后触发一次下载(兼容性最好)
  6. +
+

+ 若全部失败会弹出提示。大批量转换也可使用 + 命令行版本。 +

+ 安卓: 浏览器支持说明

⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。

diff --git a/src/features/settings/panels/PanelGeneral.tsx b/src/features/settings/panels/PanelGeneral.tsx new file mode 100644 index 0000000..a91c81b --- /dev/null +++ b/src/features/settings/panels/PanelGeneral.tsx @@ -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 ( +
+

通用设置

+ +

批量下载

+

+ 点击「下载全部」时,将按下方勾选项的顺序依次尝试。若选中的模式均不可用或全部失败,会弹出提示。 +

+ +
+ setBatch('directoryPicker', checked)} + /> + +
+ setBatch('rememberDirectory', checked)} + /> +
+ {hasRememberedDir ? '已记住一个输出目录' : '尚未记住输出目录'} + +
+
+ + setBatch('streamingZip', checked)} + /> + + + 在内存中生成 ZIP 后触发一次浏览器下载。兼容性最好;ZIP 需整包驻留内存,建议合计不超过约 + 500MB~1GB(视设备可用内存而定,过大可能失败或卡顿)。 + + } + onChange={(checked) => setBatch('blobZip', checked)} + /> +
+
+ ); +} + +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 ( + + ); +} diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts index 314aaeb..b3e32a3 100644 --- a/src/features/settings/persistSettings.ts +++ b/src/features/settings/persistSettings.ts @@ -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 | 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)) { diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index 991ba3e..a05ff10 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -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; diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts index 852e969..c638b9a 100644 --- a/src/features/settings/settingsSlice.ts +++ b/src/features/settings/settingsSlice.ts @@ -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, diff --git a/src/features/settings/settingsTabs.tsx b/src/features/settings/settingsTabs.tsx index fdc6c61..c4417de 100644 --- a/src/features/settings/settingsTabs.tsx +++ b/src/features/settings/settingsTabs.tsx @@ -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 = { + 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: () =>

这里空空如也~

}, } as const; diff --git a/src/util/batchDownloadCapabilities.ts b/src/util/batchDownloadCapabilities.ts new file mode 100644 index 0000000..c40dd10 --- /dev/null +++ b/src/util/batchDownloadCapabilities.ts @@ -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(); +} diff --git a/src/util/downloadDirectoryHandle.ts b/src/util/downloadDirectoryHandle.ts new file mode 100644 index 0000000..b4d7f1f --- /dev/null +++ b/src/util/downloadDirectoryHandle.ts @@ -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 { + try { + const handle = await get(DIRECTORY_HANDLE_KEY); + return handle ?? null; + } catch { + return null; + } +} + +export async function setRememberedDirectoryHandle(handle: FileSystemDirectoryHandle): Promise { + await set(DIRECTORY_HANDLE_KEY, handle); +} + +export async function clearRememberedDirectoryHandle(): Promise { + await del(DIRECTORY_HANDLE_KEY); +} + +export async function hasRememberedDirectoryHandle(): Promise { + return (await getRememberedDirectoryHandle()) !== null; +} + +export async function verifyDirectoryPermission( + handle: FileSystemDirectoryHandle, + mode: FileSystemPermissionMode = 'readwrite', +): Promise { + 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 { + 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; + } +} diff --git a/src/util/fsPicker.ts b/src/util/fsPicker.ts new file mode 100644 index 0000000..360a531 --- /dev/null +++ b/src/util/fsPicker.ts @@ -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'; +} diff --git a/src/util/streamZip.ts b/src/util/streamZip.ts new file mode 100644 index 0000000..505f501 --- /dev/null +++ b/src/util/streamZip.ts @@ -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 { + const zip = new Zip(); + + return new ReadableStream({ + 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(); + + 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 { + 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 { + 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); + } +} diff --git a/src/util/triggerDownload.ts b/src/util/triggerDownload.ts new file mode 100644 index 0000000..daa7aba --- /dev/null +++ b/src/util/triggerDownload.ts @@ -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(); +} diff --git a/support/mac-command-loader.ts b/support/mac-command-loader.ts index 31047e7..51e10b4 100644 --- a/support/mac-command-loader.ts +++ b/support/mac-command-loader.ts @@ -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');