import { FormEvent, useEffect, useMemo, useState } from 'react' import { Cloud, FileVideo, Folder, Loader2, LogOut, PauseCircle, QrCode, RefreshCw, Save, Send, Trash2, Upload } from 'lucide-react' import toast from 'react-hot-toast' import { libraryAPI } from '../api/library' import { cloudAPI, storageAPI, type CloudScanStatus, type CloudEntry, type QRSession, type StorageType, } from '../api/storage_config' import { confirmAction } from '../components/ConfirmDialog' import type { Library } from '../types' const CLOUD_TYPES: StorageType[] = ['cloud115', 'quark', 'clouddrive2', 'openlist'] const isCloud = (t: StorageType) => CLOUD_TYPES.includes(t) const TYPE_LABEL: Record = { alist: 'ALIST', openlist: 'OpenList', webdav: 'WEBDAV', s3: 'S3', cloud115: '115网盘', quark: '夸克网盘', clouddrive2: 'CloudDrive2', } const PATH_BASED_CLOUD = new Set(['openlist', 'clouddrive2']) function normalizeCloudDisplayPath(value: string) { let text = value.trim() try { text = decodeURIComponent(text) } catch { // Keep the original value if it is not URI-encoded. } return text.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') } function cloudLibraryProvider(path: string) { if (!path.toLowerCase().startsWith('cloud://')) return '' try { return new URL(path).host } catch { return '' } } function cloudLibraryLabel(path: string) { try { const parsed = new URL(path) const display = normalizeCloudDisplayPath(parsed.pathname) const scanDir = normalizeCloudDisplayPath(parsed.searchParams.get('dir') ?? '') return display || scanDir || '根目录' } catch { return path } } function cloudMountDisplayPath(type: StorageType, stack: { id: string; name: string }[], child?: CloudEntry) { if (PATH_BASED_CLOUD.has(type)) { return normalizeCloudDisplayPath(child?.id ?? stack[stack.length - 1]?.id ?? '') } const parts = stack.slice(1).map((item) => item.name).filter(Boolean) if (child?.name) parts.push(child.name) return parts.map(normalizeCloudDisplayPath).filter(Boolean).join('/') } // StorageConfigPage manages the Alist / S3 / WebDAV adapters used by // the import / playback / STRM subsystems. Mirrors the Vue UI's // `admin/storage/*` tabs in a tabbed React surface. export function StorageConfigPage() { const [active, setActive] = useState('openlist') return (

外部存储

配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放

{(['openlist', 'alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => ( ))}
) } const FIELD_DEFS: Record = { alist: [ { key: 'server', label: 'Server URL', placeholder: 'https://alist.example.com' }, { key: 'token', label: 'Token', secret: true }, ], openlist: [ { key: 'server', label: 'OpenList 服务地址(API / 浏览 / 挂载 / 转存)', placeholder: 'http://NAS-IP:5244' }, { key: 'username', label: 'OpenList 用户名' }, { key: 'password', label: 'OpenList 密码', secret: true }, { key: 'token', label: 'OpenList Token(可选,优先于用户名密码)', secret: true }, { key: 'url', label: 'WebDAV URL(可选兼容备用)', placeholder: '通常不用填;需要备用时填 http://NAS-IP:5244/dav/' }, { key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' }, ], webdav: [ { key: 'url', label: 'URL', placeholder: 'https://example.com/dav/' }, { key: 'username', label: '用户名' }, { key: 'password', label: '密码', secret: true }, { key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' }, ], s3: [ { key: 'endpoint', label: 'Endpoint', placeholder: 'https://s3.amazonaws.com' }, { key: 'region', label: 'Region', placeholder: 'us-east-1' }, { key: 'bucket', label: 'Bucket' }, { key: 'access_key', label: 'Access Key', secret: true }, { key: 'secret_key', label: 'Secret Key', secret: true }, { key: 'force_path_style', label: 'force_path_style (true/false)' }, ], cloud115: [ { key: 'cookie', label: 'Cookie(UID / CID / SEID,或扫码登录自动填充)', secret: true, placeholder: 'UID=...; CID=...; SEID=...' }, ], quark: [ { key: 'cookie', label: 'Cookie(从 pan.quark.cn 复制整段)', secret: true, placeholder: '__pus=...; __kp=...; kps=...' }, ], clouddrive2: [ { key: 'url', label: 'CloudDrive2 WebDAV URL', placeholder: 'http://host.docker.internal:19798/dav 或 http://NAS-IP:19798/dav' }, { key: 'username', label: '用户名' }, { key: 'password', label: '密码 / Token', secret: true }, { key: 'token', label: 'Authorization Token(可选)', secret: true, placeholder: 'Bearer ... 或 Basic ...' }, { key: 'timeout_seconds', label: '请求超时秒数', placeholder: '120' }, ], } function StorageForm({ type }: { type: StorageType }) { const fields = useMemo(() => FIELD_DEFS[type], [type]) const [config, setConfig] = useState>({}) const [enabled, setEnabled] = useState(true) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [testing, setTesting] = useState(false) const [loggingOut, setLoggingOut] = useState(false) const refresh = async () => { setLoading(true) try { const r = await storageAPI.get(type) const next: Record = {} for (const f of fields) { const v = r.config?.[f.key] // List() redacts secrets to "********"; show empty so the user // doesn't accidentally save the placeholder. next[f.key] = v === '********' ? '' : v ?? '' } setConfig(next) setEnabled(r.enabled ?? true) } catch { const next: Record = {} for (const f of fields) next[f.key] = '' setConfig(next) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]) const onSave = async (e: FormEvent) => { e.preventDefault() setSaving(true) try { await storageAPI.save(type, config, enabled) toast.success('已保存') await refresh() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } finally { setSaving(false) } } const onTest = async () => { setTesting(true) try { const r = await storageAPI.test(type, config) if (r.ok) toast.success('连接成功') else toast.error(r.error ?? '连接失败') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '测试失败' toast.error(msg) } finally { setTesting(false) } } const onLogout = async () => { const ok = await confirmAction({ title: '退出云盘登录', message: `将清空「${TYPE_LABEL[type] ?? type}」保存的 Cookie / Token / 密码并停用该外部存储;不会删除网盘文件,也不会删除已挂载媒体库。`, confirmText: '退出登录', }) if (!ok) return setLoggingOut(true) try { await storageAPI.logout(type) toast.success('已退出云盘登录并停用该存储') await refresh() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '退出登录失败' toast.error(msg) } finally { setLoggingOut(false) } } if (loading) { return (
) } return (
{fields.map((f) => ( ))} {type === 'cloud115' && ( setConfig((cfg) => ({ ...cfg, cookie: c }))} /> )}
{isCloud(type) && ( )}
{isCloud(type) && } ) } function StorageUploadPanel({ type }: { type: StorageType }) { const [sourcePath, setSourcePath] = useState('') const [destPath, setDestPath] = useState('/MediaStationGo') const [recursive, setRecursive] = useState(true) const [includeSidecars, setIncludeSidecars] = useState(true) const [overwrite, setOverwrite] = useState(false) const [busy, setBusy] = useState(false) const supported = type === 'alist' || type === 'openlist' || type === 'webdav' || type === 'clouddrive2' const submit = async () => { if (!supported) { toast.error('本地直传目前支持 OpenList / Alist / WebDAV / CloudDrive2。115/123/夸克建议通过 OpenList、CloudDrive2 或 Alist 桥接后转存。') return } if (!sourcePath.trim()) { toast.error('请填写本地源目录或文件路径') return } setBusy(true) try { const { result, error } = await storageAPI.uploadLocal(type, { source_path: sourcePath.trim(), dest_path: destPath.trim() || '/', recursive, include_sidecars: includeSidecars, overwrite, }) const errText = error || (result.errors && result.errors.length > 0 ? ` · 错误 ${result.errors.length}` : '') toast.success(`转存完成:上传 ${result.uploaded} · 跳过 ${result.skipped} · ${fmtBytes(result.bytes)}${errText}`) } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转存失败') } finally { setBusy(false) } } return (

本地媒体转存到此存储

复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。

{!supported && ( 直传待接 )}
{!supported && (

115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。

)} {type === 'openlist' && (

OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。

)} {type === 'clouddrive2' && (

CloudDrive2 已经对接 115、123、阿里、夸克等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。

)}
) } function fmtBytes(value: number): string { if (!value) return '0 B' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let size = value let idx = 0 while (size >= 1024 && idx < units.length - 1) { size /= 1024 idx++ } return `${size.toFixed(size >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}` } // QRLoginPanel drives the 115 QR-code login: start → render image → poll → // fill the cookie field on confirmation. function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: string) => void }) { const [sess, setSess] = useState(null) const [state, setState] = useState('') const [busy, setBusy] = useState(false) useEffect(() => { if (!sess) return let alive = true const timer = setInterval(async () => { try { const st = await cloudAPI.qrPoll(type, sess) if (!alive) return setState(st.state) if (st.state === 'confirmed' && st.cookie) { onCookie(st.cookie) toast.success('扫码登录成功,Cookie 已填入,请点击保存') setSess(null) } else if (st.state === 'expired') { toast.error('二维码已过期,请重新获取') setSess(null) } } catch { /* keep polling */ } }, 2000) return () => { alive = false clearInterval(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [sess]) const start = async () => { setBusy(true) try { const s = await cloudAPI.qrStart(type) setSess(s) setState('waiting') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '获取二维码失败' toast.error(msg) } finally { setBusy(false) } } return (
{sess && (
115 QR {state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
)}
) } // CloudBrowser lists 网盘 directories and imports a file as a 302-backed media. function CloudBrowser({ type }: { type: StorageType }) { const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }]) const [items, setItems] = useState([]) const [mounts, setMounts] = useState([]) const [loading, setLoading] = useState(false) const [mounting, setMounting] = useState(false) const [batchMounting, setBatchMounting] = useState(false) const [scanBusy, setScanBusy] = useState(false) const [cancelBusy, setCancelBusy] = useState(false) const [scanStatuses, setScanStatuses] = useState([]) const [mountMediaType, setMountMediaType] = useState('auto') const [error, setError] = useState('') const cur = stack[stack.length - 1] const load = async (dir: string) => { setLoading(true) setError('') try { const r = await cloudAPI.list(type, dir) setItems(r.items ?? []) if (r.error) setError(r.error) } catch (err: unknown) { setError((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败') setItems([]) } finally { setLoading(false) } } const loadMounts = async () => { const libs = await libraryAPI.list({ includeHidden: true }) setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type)) } const loadScanStatus = async () => { const r = await storageAPI.cloudScanStatus() setScanStatuses((r.items ?? []).filter((item) => !type || item.provider === type)) } useEffect(() => { load(cur.id).catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [stack.length, type]) useEffect(() => { loadMounts().catch(() => undefined) loadScanStatus().catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]) useEffect(() => { const timer = window.setInterval(() => { loadScanStatus().catch(() => undefined) }, 3000) return () => window.clearInterval(timer) // eslint-disable-next-line react-hooks/exhaustive-deps }, [type]) const enter = (e: CloudEntry) => setStack((s) => [...s, { id: e.id, name: e.name }]) const goTo = (i: number) => setStack((s) => s.slice(0, i + 1)) const currentMountPath = () => cloudMountDisplayPath(type, stack) const childMountPath = (child: CloudEntry) => cloudMountDisplayPath(type, stack, child) const handleMountResult = (res: unknown, label: string) => { const out = res as { already_mounted?: boolean; skipped?: boolean; reason?: string; library?: Library; message?: string; estimate_message?: string } if (out.skipped) { toast(`已跳过「${label}」:和已挂载目录重叠`) return 'skipped' } if (out.already_mounted) { toast(`「${label}」已经挂载,后台会刷新扫描并自动入库`) return 'mounted' } toast.success(`已挂载「${label}」,${out.message ?? '后台会递归扫描并自动加入媒体库'}。${out.estimate_message ?? ''}`) return 'mounted' } const doImport = async (e: CloudEntry) => { const ref = type === 'cloud115' ? e.pick_code || e.id : e.id try { await cloudAPI.import(type, ref, e.name, e.size) toast.success(`已导入「${e.name}」,可在媒体库中 302 播放`) } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '导入失败') } } const mountCurrent = async () => { setMounting(true) try { const label = TYPE_LABEL[type] ?? type const name = cur.id ? cur.name : label const res = await cloudAPI.mount(type, cur.id, name, mountMediaType, currentMountPath()) handleMountResult(res, cur.name) await loadMounts() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败') } finally { setMounting(false) } } const mountVisibleDirectories = async () => { const dirs = items.filter((item) => item.is_dir) if (dirs.length === 0) { toast.error('当前目录下没有可挂载的子目录') return } setBatchMounting(true) let ok = 0 let skipped = 0 let failed = 0 for (const dir of dirs) { try { const result = await cloudAPI.mount(type, dir.id, dir.name, 'auto', childMountPath(dir)) const state = handleMountResult(result, dir.name) if (state === 'skipped') skipped += 1 else ok += 1 } catch { failed += 1 } } if (failed > 0) { toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`) } else { toast.success(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,后台会自动生成 302/STRM 播放入口`) } await loadMounts() setBatchMounting(false) } const removeMount = async (lib: Library) => { const ok = await confirmAction({ title: '移除网盘挂载', message: `仅移除「${lib.name}」在本项目中的媒体库和媒体记录,不会删除网盘文件。`, confirmText: '移除', }) if (!ok) return await libraryAPI.remove(lib.id) toast.success('已移除挂载') await loadMounts() } const scanAllCloudLibraries = async () => { setScanBusy(true) try { const r = await storageAPI.scanAllCloud() setScanStatuses(r.items ?? []) toast.success(r.message ?? '已开始扫描所有启用的网盘媒体库') } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '启动扫描失败') } finally { setScanBusy(false) } } const cancelCloudScans = async () => { setCancelBusy(true) try { const r = await storageAPI.cancelCloudScan('', type) toast.success(r.message ?? `已中断 ${r.cancelled} 个扫描任务`) await loadScanStatus() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '中断扫描失败') } finally { setCancelBusy(false) } } return (
网盘媒体库扫描

只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。

{scanStatuses.length > 0 && (
{scanStatuses.slice(0, 6).map((item) => (
{item.state} {' · '} {item.provider} {' · 目录 '} {item.dirs} {' · 发现 '} {item.discovered} {' · 入库 '} {item.added + item.updated} {item.error ? · {item.error} : null}
))}
)}
{mounts.length > 0 && (
已挂载目录
{mounts.map((lib) => (
{lib.name} · {cloudLibraryLabel(lib.path)}
))}
)}
网盘资源: {stack.map((s, i) => ( {i < stack.length - 1 && /} ))}

挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。 如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。

{loading ? (
) : error ? (

{error}

) : items.length === 0 ? (

该目录为空

) : (
    {items.map((e) => (
  • {e.is_dir ? : } {e.is_dir ? ( ) : ( <> {e.name} )}
  • ))}
)}
) }