@@ -327,6 +399,16 @@ export function LibraryPage() {
)
}
+function formatDuration(seconds: number): string {
+ if (!Number.isFinite(seconds) || seconds <= 0) return ''
+ if (seconds < 60) return `${Math.round(seconds)}秒`
+ const minutes = Math.floor(seconds / 60)
+ const rest = Math.round(seconds % 60)
+ if (minutes < 60) return `${minutes}分${rest}秒`
+ const hours = Math.floor(minutes / 60)
+ return `${hours}小时${minutes % 60}分`
+}
+
function formatSize(bytes: number): string {
if (!bytes || bytes <= 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
diff --git a/web/src/pages/StorageConfigPage.tsx b/web/src/pages/StorageConfigPage.tsx
index bdfff8d..aa99018 100644
--- a/web/src/pages/StorageConfigPage.tsx
+++ b/web/src/pages/StorageConfigPage.tsx
@@ -1,7 +1,8 @@
import { FormEvent, useEffect, useMemo, useState } from 'react'
-import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Upload } from 'lucide-react'
+import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Trash2, Upload } from 'lucide-react'
import toast from 'react-hot-toast'
+import { libraryAPI } from '../api/library'
import {
cloudAPI,
storageAPI,
@@ -9,6 +10,8 @@ import {
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)
@@ -21,6 +24,46 @@ const TYPE_LABEL: Record
= {
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
@@ -433,6 +476,7 @@ function QRLoginPanel({ type, onCookie }: { type: StorageType; onCookie: (c: str
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)
@@ -455,13 +499,39 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
}
+ const loadMounts = async () => {
+ const libs = await libraryAPI.list({ includeHidden: true })
+ setMounts(libs.filter((lib) => cloudLibraryProvider(lib.path) === type))
+ }
+
useEffect(() => {
load(cur.id).catch(() => undefined)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stack.length, type])
+ useEffect(() => {
+ loadMounts().catch(() => undefined)
+ // 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
@@ -478,9 +548,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
try {
const label = TYPE_LABEL[type] ?? type
const name = cur.id ? `${label} · ${cur.name}` : label
- const res = await cloudAPI.mount(type, cur.id, name, mountMediaType)
- const scan = (res as { scan?: { added?: number; updated?: number; removed?: number; visited?: number } }).scan
- toast.success(`已挂载为媒体库并扫描:新增 ${scan?.added ?? 0} · 更新 ${scan?.updated ?? 0} · 访问 ${scan?.visited ?? 0}`)
+ 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 {
@@ -496,26 +566,64 @@ function CloudBrowser({ type }: { type: StorageType }) {
}
setBatchMounting(true)
let ok = 0
+ let skipped = 0
let failed = 0
const label = TYPE_LABEL[type] ?? type
for (const dir of dirs) {
try {
- await cloudAPI.mount(type, dir.id, `${label} · ${dir.name}`, 'auto')
- ok += 1
+ const result = await cloudAPI.mount(type, dir.id, `${label} · ${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} 个目录,失败 ${failed} 个;请查看日志或逐个挂载定位`)
+ toast.error(`已挂载 ${ok} 个目录,跳过 ${skipped} 个重叠目录,失败 ${failed} 个`)
} else {
- toast.success(`已挂载 ${ok} 个目录为媒体库,后台会自动生成 302/STRM 播放入口`)
+ 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()
+ }
+
return (
e.preventDefault()}>
+ {mounts.length > 0 && (
+
+
已挂载目录
+
+ {mounts.map((lib) => (
+
+
+ {lib.name} · {cloudLibraryLabel(lib.path)}
+
+
+
+ ))}
+
+
+ )}
网盘资源:
@@ -528,6 +636,9 @@ function CloudBrowser({ type }: { type: StorageType }) {
))}
+
+ 挂载后不会复制网盘文件;后台会递归读取该目录里的子文件夹和媒体文件,扫描到的影片会自动加入对应媒体库。小目录通常几十秒,大目录取决于网盘接口速度。
+
diff --git a/web/src/types/index.ts b/web/src/types/index.ts
index 3a582e0..1bd6876 100644
--- a/web/src/types/index.ts
+++ b/web/src/types/index.ts
@@ -152,6 +152,12 @@ export interface ScanResult {
probed: number
local_metadata?: number
removed?: number
+ skipped?: number
+ discovered?: number
+ queued?: boolean
+ cloud?: boolean
+ message?: string
+ estimate_message?: string
}
export interface Setting {