/* eslint-disable react-refresh/only-export-components */ import { useCallback, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { Ban, Cloud, Download, FolderPlus, HardDrive, History, Loader2, Pencil, Play, Plus, RefreshCw, Settings, Trash2, Tv, Upload, } from 'lucide-react' import { strmAPI } from '../api/strm' import { confirmAction } from '../components/confirmAction' import type { StrmAccount, StrmSyncPath, StrmSyncRecord } from '../types/strm' import { STRM_PROVIDER_LABELS, type StrmProvider } from '../types/strm' import { StrmAccountDialog, StrmSettingsDialog, StrmSyncPathDialog } from './StrmDialogs' type StrmDialogKey = 'account' | 'settings' | 'path' | null const STATUS_META: Record = { idle: { label: '未同步', cls: 'bg-gray-100 text-gray-500' }, running: { label: '同步中', cls: 'bg-brand-100 text-brand-600' }, ok: { label: '正常', cls: 'bg-emerald-100 text-emerald-600' }, error: { label: '失败', cls: 'bg-rose-100 text-rose-600' }, canceled: { label: '已取消', cls: 'bg-amber-100 text-amber-600' }, } const RECORD_STATUS_META: Record = { pending: { label: '排队中', cls: 'bg-gray-100 text-gray-500' }, running: { label: '进行中', cls: 'bg-brand-100 text-brand-600' }, done: { label: '完成', cls: 'bg-emerald-100 text-emerald-600' }, failed: { label: '失败', cls: 'bg-rose-100 text-rose-600' }, canceled: { label: '已取消', cls: 'bg-amber-100 text-amber-600' }, } const iconButtonCls = 'inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-ink-100 transition hover:bg-gray-50' export function providerIcon(provider: StrmProvider) { switch (provider) { case 'cloud115': return Cloud case 'clouddrive2': return HardDrive case 'openlist': return FolderPlus case 'local': return HardDrive case 'emby_remote': return Tv } } export function StrmManagePage() { const [accounts, setAccounts] = useState([]) const [paths, setPaths] = useState([]) const [records, setRecords] = useState([]) const [loading, setLoading] = useState(true) const [dialog, setDialog] = useState(null) const [editingPath, setEditingPath] = useState(null) const [editingAccount, setEditingAccount] = useState(null) const [actingPath, setActingPath] = useState(null) const refresh = useCallback(async () => { try { const [accts, pths, recs] = await Promise.all([ strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider !== 'emby_remote')), strmAPI.listPaths(), strmAPI.listRecords(), ]) setAccounts(accts) setPaths(pths) setRecords(recs) } catch { /* 保留旧数据 */ } finally { setLoading(false) } }, []) useEffect(() => { refresh().catch(() => undefined) }, [refresh]) // 有进行中的同步时轮询刷新状态 useEffect(() => { if (!paths.some((p) => p.last_sync_status === 'running')) return const timer = setInterval(() => { if (document.hidden) return refresh().catch(() => undefined) }, 4000) return () => clearInterval(timer) }, [paths, refresh]) const startSync = async (path: StrmSyncPath, mode: 'incremental' | 'full' = 'incremental') => { setActingPath(path.id) try { await strmAPI.startSync(path.id, mode) toast.success(`已开始${mode === 'full' ? '全量' : '增量'}同步「${path.name}」`) await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setActingPath(null) } } const cancelSync = async (path: StrmSyncPath) => { try { await strmAPI.cancelSync(path.id) toast.success('已请求取消同步') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const deletePath = async (path: StrmSyncPath) => { const ok = await confirmAction({ message: `确定删除同步目录「${path.name}」?本地已生成的 strm 文件不会删除。`, confirmText: '删除' }) if (!ok) return try { await strmAPI.deletePath(path.id) toast.success('已删除同步目录') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const deleteAccount = async (account: StrmAccount) => { const ok = await confirmAction({ message: `确定删除网盘账号「${account.name}」?`, confirmText: '删除' }) if (!ok) return try { await strmAPI.deleteAccount(account.id) toast.success('已删除网盘账号') await refresh() } catch (err) { toast.error(apiErrorMessage(err)) } } const testAccount = async (account: StrmAccount) => { try { const updated = await strmAPI.testAccount(account.id) setAccounts((list) => list.map((a) => (a.id === updated.id ? updated : a))) if (updated.last_test_ok) { toast.success(`「${account.name}」连接正常`) } else { toast.error(`「${account.name}」连接失败:${updated.last_test_result}`) } } catch (err) { toast.error(apiErrorMessage(err)) } } const openAddPathDialog = async () => { try { const settings = await strmAPI.getSettings() const baseUrl = settings?.['strm.base_url']?.trim() if (!baseUrl) { toast.error('未填写strm地址') setDialog('settings') return } setEditingPath(null) setDialog('path') } catch (err) { toast.error(apiErrorMessage(err)) } } return (

STRM 管理

网盘目录同步生成 .strm 文件(Emby / Jellyfin 直接刮削播放),元数据经下载 / 上传队列双向同步

{loading ? (
) : ( <> { setEditingAccount(null); setDialog('account') }} onEdit={(account) => { setEditingAccount(account); setDialog('account') }} onDelete={deleteAccount} onTest={testAccount} /> { setEditingPath(path); setDialog('path') }} onDelete={deletePath} onStart={startSync} onCancel={cancelSync} /> )} {dialog === 'account' && ( setDialog(null)} onSaved={() => { setDialog(null); refresh().catch(() => undefined) }} /> )} {dialog === 'settings' && setDialog(null)} />} {dialog === 'path' && ( setDialog(null)} onSaved={() => { setDialog(null); refresh().catch(() => undefined) }} onOpenSettings={() => setDialog('settings')} /> )}
) } // ─── 网盘账号 ──────────────────────────────────────────────────────────────── function AccountSection({ accounts, onAdd, onEdit, onDelete, onTest, }: { accounts: StrmAccount[] onAdd: () => void onEdit: (account: StrmAccount) => void onDelete: (account: StrmAccount) => void onTest: (account: StrmAccount) => void }) { return (

网盘账号

{accounts.length === 0 ? (

还没有网盘账号,点击「添加网盘账号」创建(115 网盘支持二维码登录)

) : (
{accounts.map((account) => { const Icon = providerIcon(account.provider) return (

{account.name}

{account.provider_label}

{account.enabled ? '已启用' : '已停用'}

凭据: {account.has_credential ? ( 已配置 ) : ( 未配置 )}

{account.last_test_at && (

最近测试:{account.last_test_ok ? '正常' : account.last_test_result}

)}
) })}
)}
) } // ─── STRM 同步目录(参考 QMediaSync 的 STRM 同步目录页) ───────────────────── function SyncPathSection({ paths, actingPath, onAdd, onEdit, onDelete, onStart, onCancel, }: { paths: StrmSyncPath[] actingPath: string | null onAdd: () => void onEdit: (path: StrmSyncPath) => void onDelete: (path: StrmSyncPath) => void onStart: (path: StrmSyncPath, mode?: 'incremental' | 'full') => void onCancel: (path: StrmSyncPath) => void }) { return (

STRM 同步目录

{paths.length}
{paths.length === 0 ? (

还没有同步目录。添加后系统会把网盘 / 本地目录里的视频生成 .strm 文件到本地输出目录

) : (
{paths.map((path) => { const status = STATUS_META[path.last_sync_status] ?? STATUS_META.idle const running = path.last_sync_status === 'running' const PathIcon = providerIcon(path.provider) return (

{path.name} {!path.enabled && (已停用)}

{running && } {status.label}

{STRM_PROVIDER_LABELS[path.provider]} {path.account_name ? ` · ${path.account_name}` : ''} {path.enable_cron && path.cron ? ` · 定时 ${path.cron}` : ''}

{running ? ( ) : ( <> )}
{path.remote_display_path || path.remote_path || '(根目录)'} → {path.local_path}
{path.last_sync_message && (

{path.last_sync_message}

)}
) })}
)}
) } // ─── 同步记录 ──────────────────────────────────────────────────────────────── function RecordSection({ records, onDeleted }: { records: StrmSyncRecord[]; onDeleted: () => void }) { const [deletingId, setDeletingId] = useState(null) const deleteRecord = async (record: StrmSyncRecord) => { const ok = await confirmAction({ message: '确定删除这条同步记录?', confirmText: '删除' }) if (!ok) return setDeletingId(record.id) try { await strmAPI.deleteRecord(record.id) toast.success('已删除同步记录') onDeleted() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setDeletingId(null) } } const clearRecords = async () => { const ok = await confirmAction({ message: '确定清空全部同步记录?此操作不可恢复。', confirmText: '清空' }) if (!ok) return try { const res = await strmAPI.clearRecords() toast.success(`已清空 ${res.deleted} 条同步记录`) onDeleted() } catch (err) { toast.error(apiErrorMessage(err)) } } return (

同步记录

{records.length} {records.length > 0 && ( )}
{records.length === 0 ? (

还没有同步记录

) : (
{records.map((record) => { const meta = RECORD_STATUS_META[record.status] ?? RECORD_STATUS_META.pending const isFull = record.sync_type === 'full' return ( ) })}
时间 类型 状态 扫描文件 新增/更新 跳过 下载元数据 上传元数据 清理 说明
{formatTime(record.started_at ?? record.created_at)} {isFull ? '全量' : '增量'} {meta.label} {record.total} {record.new_strm} {record.skipped} {record.new_meta} {record.uploaded ?? 0} {record.pruned} {record.message}
)}
) } export function formatTime(value?: string | null): string { if (!value) return '-' const date = new Date(value) if (Number.isNaN(date.getTime())) return '-' const pad = (n: number) => String(n).padStart(2, '0') return `${date.getMonth() + 1}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}` } export function apiErrorMessage(err: unknown): string { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error return msg ?? '操作失败' } export function formatBytes(size: number): string { if (!size) return '-' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let value = size let unit = 0 while (value >= 1024 && unit < units.length - 1) { value /= 1024 unit += 1 } return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}` } // 队列概览小组件(下载队列 / 上传队列页顶部统计) export function QueueStatPill({ icon: Icon, label, value }: { icon: typeof Download; label: string; value: number }) { return (
{label} {value}
) } // 队列状态徽章 export function taskStatusMeta(status: string): { label: string; cls: string } { switch (status) { case 'pending': return { label: '排队中', cls: 'bg-gray-100 text-gray-500' } case 'running': return { label: '进行中', cls: 'bg-brand-100 text-brand-600' } case 'done': return { label: '已完成', cls: 'bg-emerald-100 text-emerald-600' } case 'failed': return { label: '失败', cls: 'bg-rose-100 text-rose-600' } case 'canceled': return { label: '已取消', cls: 'bg-amber-100 text-amber-600' } default: return { label: status, cls: 'bg-gray-100 text-gray-500' } } } // 供下载/上传队列页复用 export { Upload as UploadIcon }