mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
834 lines
32 KiB
TypeScript
834 lines
32 KiB
TypeScript
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<string, string> = {
|
|
alist: 'ALIST',
|
|
openlist: 'OpenList',
|
|
webdav: 'WEBDAV',
|
|
s3: 'S3',
|
|
cloud115: '115网盘',
|
|
quark: '夸克网盘',
|
|
clouddrive2: 'CloudDrive2',
|
|
}
|
|
const PATH_BASED_CLOUD = new Set<StorageType>(['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<StorageType>('openlist')
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-blue-400/10 text-blue-300">
|
|
<Cloud size={20} />
|
|
</div>
|
|
<div>
|
|
<h1 className="font-display text-3xl font-bold text-ink-600">外部存储</h1>
|
|
<p className="text-sm text-ink-50">
|
|
配置 OpenList / Alist / S3 / WebDAV / CloudDrive2 / 网盘(115 / 夸克)后端,支持本地转存、网盘挂载和 302/反代播放
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 border-b border-gray-200">
|
|
{(['openlist', 'alist', 'webdav', 'clouddrive2', 's3', 'cloud115', 'quark'] as StorageType[]).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setActive(t)}
|
|
className={
|
|
'border-b-2 px-4 py-2 text-sm ' +
|
|
(active === t
|
|
? 'border-primary-400 text-brand-500'
|
|
: 'border-transparent text-ink-50 hover:text-white')
|
|
}
|
|
>
|
|
{TYPE_LABEL[t] ?? t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<StorageForm key={active} type={active} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const FIELD_DEFS: Record<StorageType, { key: string; label: string; secret?: boolean; placeholder?: string }[]> = {
|
|
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<Record<string, string>>({})
|
|
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<string, string> = {}
|
|
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<string, string> = {}
|
|
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 (
|
|
<div className="flex justify-center py-12 text-ink-50">
|
|
<Loader2 className="animate-spin" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={onSave} className="glass-panel space-y-4">
|
|
{fields.map((f) => (
|
|
<label key={f.key} className="block">
|
|
<span className="mb-1 block text-sm text-ink-100">
|
|
{f.label}
|
|
<span className="ml-2 font-mono text-[10px] text-gray-500">{f.key}</span>
|
|
</span>
|
|
<input
|
|
type={f.secret ? 'password' : 'text'}
|
|
className="input-base"
|
|
placeholder={f.placeholder}
|
|
value={config[f.key] ?? ''}
|
|
onChange={(e) => setConfig((c) => ({ ...c, [f.key]: e.target.value }))}
|
|
/>
|
|
</label>
|
|
))}
|
|
{type === 'cloud115' && (
|
|
<QRLoginPanel
|
|
type={type}
|
|
onCookie={(c) => setConfig((cfg) => ({ ...cfg, cookie: c }))}
|
|
/>
|
|
)}
|
|
<label className="flex items-center gap-2 text-sm text-ink-100">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 accent-primary-400"
|
|
checked={enabled}
|
|
onChange={(e) => setEnabled(e.target.checked)}
|
|
/>
|
|
启用
|
|
</label>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
{isCloud(type) && (
|
|
<button
|
|
type="button"
|
|
onClick={onLogout}
|
|
disabled={loggingOut}
|
|
className="rounded-lg border border-red-200 px-4 py-2 text-sm text-red-500 hover:bg-red-50 disabled:opacity-50"
|
|
>
|
|
{loggingOut ? <Loader2 size={14} className="inline animate-spin" /> : <LogOut size={14} className="inline" />}
|
|
{' '}退出登录
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={onTest}
|
|
disabled={testing}
|
|
className="rounded-lg border border-gray-200 px-4 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
|
>
|
|
{testing ? <Loader2 size={14} className="inline animate-spin" /> : <Send size={14} className="inline" />}
|
|
{' '}测试
|
|
</button>
|
|
<button type="submit" disabled={saving} className="neon-button">
|
|
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
|
保存
|
|
</button>
|
|
</div>
|
|
<StorageUploadPanel type={type} />
|
|
{isCloud(type) && <CloudBrowser type={type} />}
|
|
</form>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="rounded-xl border border-gray-200 bg-gray-50/70 p-4">
|
|
<div className="mb-3 flex items-start justify-between gap-3">
|
|
<div>
|
|
<h3 className="flex items-center gap-2 font-display text-base font-semibold text-ink-600">
|
|
<Upload size={16} /> 本地媒体转存到此存储
|
|
</h3>
|
|
<p className="mt-1 text-xs text-ink-50">
|
|
复制本地媒体文件到外部存储,保留本地源文件;自动跳过远端已存在文件。
|
|
</p>
|
|
</div>
|
|
{!supported && (
|
|
<span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">
|
|
直传待接
|
|
</span>
|
|
)}
|
|
</div>
|
|
{!supported && (
|
|
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
|
115 / 夸克原生上传需要私有分片上传协议。推荐把 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,在这里选择 OpenList / CloudDrive2 / Alist 转存。
|
|
</p>
|
|
)}
|
|
{type === 'openlist' && (
|
|
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
|
OpenList 优先使用服务地址 + 用户名密码/Token 调用 API 进行浏览、挂载、转存和获取播放直链;WebDAV URL 只是兼容备用。默认端口常见为 5244,未配置 HTTPS 反代时请填写 http://。
|
|
</p>
|
|
)}
|
|
{type === 'clouddrive2' && (
|
|
<p className="mb-3 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-800">
|
|
CloudDrive2 已经对接 115、123、阿里、夸克等网盘;这里通过它的 WebDAV 入口浏览、挂载和上传,播放默认走服务端反代以携带认证头。
|
|
</p>
|
|
)}
|
|
<div className="grid gap-3 lg:grid-cols-2">
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm text-ink-100">本地源目录 / 文件</span>
|
|
<input
|
|
className="input-base"
|
|
placeholder="例如 /media/电影 或 F:\\media\\Movies"
|
|
value={sourcePath}
|
|
onChange={(event) => setSourcePath(event.target.value)}
|
|
/>
|
|
</label>
|
|
<label className="block">
|
|
<span className="mb-1 block text-sm text-ink-100">网盘目标目录</span>
|
|
<input
|
|
className="input-base"
|
|
placeholder="/MediaStationGo"
|
|
value={destPath}
|
|
onChange={(event) => setDestPath(event.target.value)}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm text-ink-100">
|
|
<label className="flex items-center gap-2">
|
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} />
|
|
递归目录
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={includeSidecars} onChange={(event) => setIncludeSidecars(event.target.checked)} />
|
|
同步 NFO / 海报 / 字幕
|
|
</label>
|
|
<label className="flex items-center gap-2">
|
|
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={overwrite} onChange={(event) => setOverwrite(event.target.checked)} />
|
|
覆盖远端同名文件
|
|
</label>
|
|
<button type="button" className="neon-button ml-auto" disabled={busy || !supported} onClick={submit}>
|
|
{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}
|
|
{busy ? '转存中…' : '开始转存'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<QRSession | null>(null)
|
|
const [state, setState] = useState<string>('')
|
|
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 (
|
|
<div className="rounded-lg border border-gray-200 p-3">
|
|
<button
|
|
type="button"
|
|
onClick={start}
|
|
disabled={busy}
|
|
className="flex items-center gap-2 rounded-lg border border-gray-200 px-3 py-2 text-sm text-ink-100 hover:bg-gray-50"
|
|
>
|
|
{busy ? <Loader2 size={14} className="animate-spin" /> : <QrCode size={14} />}
|
|
使用 115 App 扫码登录
|
|
</button>
|
|
{sess && (
|
|
<div className="mt-3 flex items-center gap-3">
|
|
<img src={sess.qr_image_url} alt="115 QR" className="h-40 w-40 rounded bg-white p-1" />
|
|
<span className="text-sm text-ink-50">
|
|
{state === 'scanned' ? '已扫描,请在手机上确认登录…' : '请使用 115 手机 App 扫描二维码…'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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<CloudEntry[]>([])
|
|
const [mounts, setMounts] = useState<Library[]>([])
|
|
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<CloudScanStatus[]>([])
|
|
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 (
|
|
<div className="mt-2 rounded-lg border border-gray-200 p-3">
|
|
<div className="mb-3 rounded border border-emerald-100 bg-emerald-50/60 p-2">
|
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
<div>
|
|
<div className="text-xs font-semibold text-ink-100">网盘媒体库扫描</div>
|
|
<p className="text-xs text-ink-50">
|
|
只需在系统设置填写公开域名,扫描会自动为网盘媒体生成 STRM/302 播放入口;中断后再次扫描会去重补齐。
|
|
</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded border border-emerald-300 px-2 py-1 text-xs text-emerald-700 hover:bg-emerald-50"
|
|
disabled={scanBusy}
|
|
onClick={scanAllCloudLibraries}
|
|
>
|
|
{scanBusy ? <Loader2 size={13} className="inline animate-spin" /> : <RefreshCw size={13} className="inline" />}
|
|
{' '}一键扫描全部网盘库
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-red-200 px-2 py-1 text-xs text-red-500 hover:bg-red-50"
|
|
disabled={cancelBusy}
|
|
onClick={cancelCloudScans}
|
|
>
|
|
{cancelBusy ? <Loader2 size={13} className="inline animate-spin" /> : <PauseCircle size={13} className="inline" />}
|
|
{' '}中断当前网盘扫描
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{scanStatuses.length > 0 && (
|
|
<div className="grid gap-1 text-xs text-ink-50 md:grid-cols-2">
|
|
{scanStatuses.slice(0, 6).map((item) => (
|
|
<div key={item.library_id} className="rounded bg-white/80 px-2 py-1">
|
|
<span className="font-mono text-ink-100">{item.state}</span>
|
|
{' · '}
|
|
{item.provider}
|
|
{' · 目录 '}
|
|
{item.dirs}
|
|
{' · 发现 '}
|
|
{item.discovered}
|
|
{' · 入库 '}
|
|
{item.added + item.updated}
|
|
{item.error ? <span className="text-red-500"> · {item.error}</span> : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{mounts.length > 0 && (
|
|
<div className="mb-3 rounded border border-blue-100 bg-blue-50/60 p-2">
|
|
<div className="mb-1 text-xs font-semibold text-ink-100">已挂载目录</div>
|
|
<div className="space-y-1">
|
|
{mounts.map((lib) => (
|
|
<div key={lib.id} className="flex items-center gap-2 rounded bg-white/80 px-2 py-1 text-xs">
|
|
<span className="min-w-0 flex-1 truncate text-ink-100">
|
|
{lib.name} · {cloudLibraryLabel(lib.path)}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-red-200 px-1.5 py-0.5 text-red-500 hover:bg-red-50"
|
|
onClick={() => removeMount(lib)}
|
|
title="移除挂载"
|
|
>
|
|
<Trash2 size={13} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
<div className="flex flex-wrap items-center gap-1 text-xs text-ink-50">
|
|
<span className="text-ink-100">网盘资源:</span>
|
|
{stack.map((s, i) => (
|
|
<span key={i}>
|
|
<button type="button" className="hover:text-brand-500" onClick={() => goTo(i)}>
|
|
{s.name}
|
|
</button>
|
|
{i < stack.length - 1 && <span className="mx-1">/</span>}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<p className="basis-full text-xs text-ink-50">
|
|
挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
|
|
如果已有同名同类型媒体库,会在首页和 Emby/SenPlayer 中自动归并显示。
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<select
|
|
className="rounded border border-gray-200 bg-white px-2 py-0.5 text-xs text-ink-100"
|
|
value={mountMediaType}
|
|
onChange={(event) => setMountMediaType(event.target.value)}
|
|
>
|
|
<option value="auto">自动识别</option>
|
|
<option value="movie">电影</option>
|
|
<option value="tv">剧集</option>
|
|
<option value="anime">动漫</option>
|
|
<option value="variety">综艺</option>
|
|
<option value="adult">成人</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-brand-400/40 px-2 py-0.5 text-xs text-brand-500 hover:bg-brand-400/10"
|
|
disabled={mounting || batchMounting || loading}
|
|
onClick={mountCurrent}
|
|
>
|
|
{mounting ? '挂载中…' : '挂载当前目录并归并到媒体库'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-blue-300 px-2 py-0.5 text-xs text-blue-600 hover:bg-blue-50"
|
|
disabled={mounting || batchMounting || loading || items.every((item) => !item.is_dir)}
|
|
onClick={mountVisibleDirectories}
|
|
>
|
|
{batchMounting ? '批量挂载中…' : '一键挂载当前目录下所有文件夹'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{loading ? (
|
|
<div className="flex justify-center py-4 text-ink-50">
|
|
<Loader2 className="animate-spin" size={16} />
|
|
</div>
|
|
) : error ? (
|
|
<p className="py-2 text-sm text-red-400">{error}</p>
|
|
) : items.length === 0 ? (
|
|
<p className="py-2 text-sm text-ink-50">该目录为空</p>
|
|
) : (
|
|
<ul className="divide-y divide-gray-100">
|
|
{items.map((e) => (
|
|
<li key={e.id} className="flex items-center gap-2 py-1.5 text-sm">
|
|
{e.is_dir ? <Folder size={15} className="text-amber-400" /> : <FileVideo size={15} className="text-blue-300" />}
|
|
{e.is_dir ? (
|
|
<button type="button" className="flex-1 text-left text-ink-100 hover:text-brand-500" onClick={() => enter(e)}>
|
|
{e.name}
|
|
</button>
|
|
) : (
|
|
<>
|
|
<span className="flex-1 truncate text-ink-100">{e.name}</span>
|
|
<button
|
|
type="button"
|
|
className="rounded border border-gray-200 px-2 py-0.5 text-xs text-ink-100 hover:bg-gray-50"
|
|
onClick={() => doImport(e)}
|
|
>
|
|
导入
|
|
</button>
|
|
</>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|