mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
feat: add cloud transfer and cache optimizations
This commit is contained in:
@@ -32,6 +32,22 @@ export interface StorageConfig {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CloudUploadResult {
|
||||
source_path: string
|
||||
dest_path: string
|
||||
uploaded: number
|
||||
skipped: number
|
||||
bytes: number
|
||||
errors?: string[]
|
||||
items?: Array<{
|
||||
source: string
|
||||
target: string
|
||||
action: 'upload' | 'skip' | 'error'
|
||||
size?: number
|
||||
reason?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export const storageAPI = {
|
||||
status: () =>
|
||||
api
|
||||
@@ -53,6 +69,20 @@ export const storageAPI = {
|
||||
config,
|
||||
})
|
||||
.then((r) => r.data),
|
||||
|
||||
uploadLocal: (
|
||||
type: StorageType,
|
||||
input: {
|
||||
source_path: string
|
||||
dest_path: string
|
||||
recursive: boolean
|
||||
include_sidecars: boolean
|
||||
overwrite: boolean
|
||||
},
|
||||
) =>
|
||||
api
|
||||
.post<{ result: CloudUploadResult; error?: string }>(`/admin/storage/${type}/upload-local`, input)
|
||||
.then((r) => r.data),
|
||||
}
|
||||
|
||||
// cloudAPI drives 网盘 browsing, QR login and 302 import.
|
||||
@@ -69,6 +99,11 @@ export const cloudAPI = {
|
||||
.post(`/admin/cloud/${type}/import`, { ref, name, size })
|
||||
.then((r) => r.data),
|
||||
|
||||
mount: (type: StorageType, dir = '', name = '', media_type = 'movie') =>
|
||||
api
|
||||
.post(`/admin/cloud/${type}/mount`, { dir, name, media_type })
|
||||
.then((r) => r.data),
|
||||
|
||||
qrStart: (type: StorageType) =>
|
||||
api.post<QRSession>(`/admin/cloud/${type}/qr/start`).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -281,6 +281,70 @@ const GROUPS: SettingGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud-upload',
|
||||
label: '网盘转存',
|
||||
description: '把本地媒体复制上传到外部存储。推荐:将 115/夸克挂载到 Alist 后,使用 Alist 作为转存目标。',
|
||||
items: [
|
||||
{
|
||||
key: 'cloud.upload_auto_enabled',
|
||||
label: '启用自动转存',
|
||||
type: 'toggle',
|
||||
hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕复制到目标存储;不会删除本地源文件。',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_provider',
|
||||
label: '转存目标',
|
||||
type: 'select',
|
||||
defaultValue: 'alist',
|
||||
options: [
|
||||
{ value: 'alist', label: 'Alist(推荐,可桥接 115/夸克)' },
|
||||
{ value: 'webdav', label: 'WebDAV' },
|
||||
{ value: 'cloud115', label: '115 原生(待接分片上传)' },
|
||||
{ value: 'quark', label: '夸克原生(待接分片上传)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_source_dir',
|
||||
label: '本地源目录',
|
||||
type: 'text',
|
||||
placeholder: '/media/电影 或 F:\\media\\Movies',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_dest_path',
|
||||
label: '网盘目标目录',
|
||||
type: 'text',
|
||||
defaultValue: '/MediaStationGo',
|
||||
placeholder: '/MediaStationGo',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_recursive',
|
||||
label: '递归扫描源目录',
|
||||
type: 'toggle',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_sidecars',
|
||||
label: '同步 NFO / 海报 / 字幕',
|
||||
type: 'toggle',
|
||||
defaultValue: 'true',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_overwrite',
|
||||
label: '覆盖远端同名文件',
|
||||
type: 'toggle',
|
||||
defaultValue: 'false',
|
||||
},
|
||||
{
|
||||
key: 'cloud.upload_interval_seconds',
|
||||
label: '自动转存间隔秒数',
|
||||
type: 'number',
|
||||
hint: '最小 300 秒,建议 3600 秒或更高,避免频繁读盘和触发网盘风控。',
|
||||
defaultValue: '3600',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'adult',
|
||||
label: 'Adult / NSFW',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send } from 'lucide-react'
|
||||
import { Cloud, FileVideo, Folder, Loader2, QrCode, Save, Send, Upload } from 'lucide-react'
|
||||
import toast from 'react-hot-toast'
|
||||
|
||||
import {
|
||||
@@ -212,11 +212,125 @@ function StorageForm({ type }: { type: StorageType }) {
|
||||
保存
|
||||
</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 === 'webdav'
|
||||
|
||||
const submit = async () => {
|
||||
if (!supported) {
|
||||
toast.error('本地直传目前支持 Alist / WebDAV。115/夸克建议先挂载到 Alist,再通过 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 / 夸克原生上传需要私有分片上传协议。本版本先支持 Alist / WebDAV:把 115 或夸克挂到 Alist 后,选择 Alist 即可把本地文件转存到对应网盘。
|
||||
</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 }) {
|
||||
@@ -293,6 +407,8 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
||||
const [stack, setStack] = useState<{ id: string; name: string }[]>([{ id: '', name: '根目录' }])
|
||||
const [items, setItems] = useState<CloudEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mounting, setMounting] = useState(false)
|
||||
const [mountMediaType, setMountMediaType] = useState('movie')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const cur = stack[stack.length - 1]
|
||||
@@ -329,18 +445,56 @@ function CloudBrowser({ type }: { type: StorageType }) {
|
||||
}
|
||||
}
|
||||
|
||||
const mountCurrent = async () => {
|
||||
setMounting(true)
|
||||
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}`)
|
||||
} catch (err: unknown) {
|
||||
toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '挂载失败')
|
||||
} finally {
|
||||
setMounting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-gray-200 p-3" onClick={(e) => e.preventDefault()}>
|
||||
<div className="mb-2 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 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>
|
||||
<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="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 || loading}
|
||||
onClick={mountCurrent}
|
||||
>
|
||||
{mounting ? '挂载扫描中…' : '挂载当前目录为媒体库'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-4 text-ink-50">
|
||||
|
||||
Reference in New Issue
Block a user