import { useCallback, useEffect, useMemo, useState } from 'react' import toast from 'react-hot-toast' import { ChevronUp, Copy, FileVideo, Folder, FolderOpen, GitBranch, HardDrive, Home, Move, Pencil, Plus, RefreshCw, Trash2, } from 'lucide-react' import { filesAPI, type FileEntry, type FileListing } from '../api/files' import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' import { schedulerAPI } from '../api/scheduler' import { toolsAPI } from '../api/tools' import { confirmAction } from '../components/ConfirmDialog' import type { Library, Setting } from '../types' function fmtBytes(n: number): string { if (!n) return '0 B' const u = ['B', 'KB', 'MB', 'GB', 'TB'] let v = n let i = 0 while (v >= 1024 && i < u.length - 1) { v /= 1024 i++ } return `${v.toFixed(1)} ${u[i]}` } function formatScanSummary(scans: Array<{ name: string; added: number; updated: number; visited: number; error?: string }>): string { if (scans.length === 0) return ' · 未扫描:没有匹配的媒体库' const ok = scans.filter((scan) => !scan.error) const added = ok.reduce((sum, scan) => sum + (scan.added ?? 0), 0) const updated = ok.reduce((sum, scan) => sum + (scan.updated ?? 0), 0) const visited = ok.reduce((sum, scan) => sum + (scan.visited ?? 0), 0) return ` · 扫描 ${ok.length}/${scans.length} 个库 · 新入库 ${added} · 更新 ${updated} · 访问 ${visited}` } function formatScrapeSummary(scrapes: Array<{ name: string; matched: number; skipped?: boolean; reason?: string; error?: string }>): string { if (scrapes.length === 0) return '' const ok = scrapes.filter((scrape) => !scrape.error && !scrape.skipped) const skipped = scrapes.filter((scrape) => scrape.skipped).length const matched = ok.reduce((sum, scrape) => sum + (scrape.matched ?? 0), 0) if (ok.length === 0 && skipped > 0) return ` · 刮削跳过 ${skipped} 个库` return ` · 刮削 ${ok.length}/${scrapes.length} 个库 · 匹配 ${matched}${skipped ? ` · 跳过 ${skipped}` : ''}` } type AutoOrganizeConfig = { enabled: string afterDownload: string scrapeAfter: string sourceDir: string targetDir: string transferMode: string intervalSeconds: string } const AUTO_ORGANIZE_DEFAULTS: AutoOrganizeConfig = { enabled: 'false', afterDownload: 'false', scrapeAfter: 'false', sourceDir: '', targetDir: '', transferMode: 'hardlink', intervalSeconds: '300', } const AUTO_ORGANIZE_KEYS: Record = { enabled: 'organize.auto', afterDownload: 'organizer.auto_after_download', scrapeAfter: 'organize.scrape_after', sourceDir: 'organize.source_dir', targetDir: 'organize.target_dir', transferMode: 'organize.transfer_mode', intervalSeconds: 'organize.interval_seconds', } function settingIndex(rows: Setting[]): Record { const out: Record = {} for (const row of rows) out[row.key] = row.value return out } function mergeAutoOrganizeSettings(rows: Setting[]): AutoOrganizeConfig { const idx = settingIndex(rows) return { enabled: idx[AUTO_ORGANIZE_KEYS.enabled] ?? AUTO_ORGANIZE_DEFAULTS.enabled, afterDownload: idx[AUTO_ORGANIZE_KEYS.afterDownload] ?? AUTO_ORGANIZE_DEFAULTS.afterDownload, scrapeAfter: idx[AUTO_ORGANIZE_KEYS.scrapeAfter] ?? AUTO_ORGANIZE_DEFAULTS.scrapeAfter, sourceDir: idx[AUTO_ORGANIZE_KEYS.sourceDir] ?? AUTO_ORGANIZE_DEFAULTS.sourceDir, targetDir: idx[AUTO_ORGANIZE_KEYS.targetDir] ?? AUTO_ORGANIZE_DEFAULTS.targetDir, transferMode: idx[AUTO_ORGANIZE_KEYS.transferMode] ?? AUTO_ORGANIZE_DEFAULTS.transferMode, intervalSeconds: idx[AUTO_ORGANIZE_KEYS.intervalSeconds] ?? AUTO_ORGANIZE_DEFAULTS.intervalSeconds, } } function settingOn(value: string): boolean { return ['1', 'true', 'yes', 'on', 'enabled', '启用', '开启'].includes(value.trim().toLowerCase()) } // FileManagerPage mirrors MoviePilot's local Storage view at a smaller scope: // browse allowed roots, optionally recurse, and perform safe local operations. export function FileManagerPage() { const [libraries, setLibraries] = useState([]) const [path, setPath] = useState('') const [data, setData] = useState(null) const [error, setError] = useState('') const [loading, setLoading] = useState(true) const [recursive, setRecursive] = useState(false) const [selected, setSelected] = useState(null) const [folderName, setFolderName] = useState('') const [renameTo, setRenameTo] = useState('') const [destPath, setDestPath] = useState('') const [transferMode, setTransferMode] = useState('copy') const [busy, setBusy] = useState('') const [organizeLibraryID, setOrganizeLibraryID] = useState('') const [organizeDestPath, setOrganizeDestPath] = useState('') const [organizeTransferMode, setOrganizeTransferMode] = useState('hardlink') const [organizeMediaType, setOrganizeMediaType] = useState('auto') const [scanAfter, setScanAfter] = useState(true) const [scrapeAfter, setScrapeAfter] = useState(false) const [organizeBusy, setOrganizeBusy] = useState('') const [previewItems, setPreviewItems] = useState>([]) const [autoConfig, setAutoConfig] = useState(AUTO_ORGANIZE_DEFAULTS) const [autoDirty, setAutoDirty] = useState(false) const [autoSaving, setAutoSaving] = useState(false) const [autoRunning, setAutoRunning] = useState(false) const [autoLoading, setAutoLoading] = useState(true) const currentDir = useMemo(() => { if (data?.path) return data.path return path }, [data?.path, path]) const refresh = useCallback(() => { setLoading(true) setError('') filesAPI .list(path, recursive ? 5000 : 1000, recursive) .then(setData) .catch((err: unknown) => { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加载失败' setError(msg) }) .finally(() => setLoading(false)) }, [path, recursive]) useEffect(() => { refresh() }, [refresh]) useEffect(() => { libraryAPI.list({ includeHidden: true }).then(setLibraries).catch(() => undefined) }, []) const refreshAutoConfig = useCallback(() => { setAutoLoading(true) adminAPI .listSettings() .then((rows) => { const nextConfig = mergeAutoOrganizeSettings(rows) setAutoConfig(nextConfig) setScrapeAfter(settingOn(nextConfig.scrapeAfter)) setAutoDirty(false) }) .catch(() => undefined) .finally(() => setAutoLoading(false)) }, []) useEffect(() => { refreshAutoConfig() }, [refreshAutoConfig]) useEffect(() => { setSelected(null) setRenameTo('') }, [path]) useEffect(() => { const lib = libraries.find((item) => item.id === organizeLibraryID) if (!lib) return setOrganizeDestPath(lib.path) setOrganizeMediaType(lib.type || 'auto') }, [libraries, organizeLibraryID]) const changeAutoConfig = (key: keyof AutoOrganizeConfig, value: string) => { setAutoConfig((current) => ({ ...current, [key]: value })) setAutoDirty(true) } const saveAutoConfig = async (): Promise => { setAutoSaving(true) try { for (const key of Object.keys(AUTO_ORGANIZE_KEYS) as Array) { await adminAPI.updateSetting(AUTO_ORGANIZE_KEYS[key], autoConfig[key] ?? '') } setAutoDirty(false) toast.success('自动整理设置已保存') return true } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存自动整理设置失败') return false } finally { setAutoSaving(false) } } const runAutoOrganizeNow = async () => { if (autoDirty) { const saved = await saveAutoConfig() if (!saved) return } setAutoRunning(true) try { await schedulerAPI.run('organize_source') toast.success('已触发自动整理任务,请稍后刷新媒体库查看入库结果') } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '触发自动整理失败') } finally { setAutoRunning(false) } } const enter = (e: FileEntry) => { if (e.is_dir) setPath(e.path) } const choose = (e: FileEntry) => { setSelected(e) setRenameTo(e.name) if (e.is_dir) setDestPath(e.path) } const createFolder = async () => { if (!currentDir || !folderName.trim()) return setBusy('mkdir') try { const res = await filesAPI.createFolder(currentDir, folderName.trim()) toast.success(`已创建目录:${res.path}`) setFolderName('') refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '创建失败') } finally { setBusy('') } } const renameSelected = async () => { if (!selected || !renameTo.trim()) return setBusy('rename') try { const res = await filesAPI.rename(selected.path, renameTo.trim()) toast.success(`已重命名:${res.path}`) setSelected(null) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '重命名失败') } finally { setBusy('') } } const deleteSelected = async () => { if (!selected) return const ok = await confirmAction({ title: '确认删除文件', message: `将删除:${selected.path}。此操作不可恢复,请确认不是媒体库根目录。`, confirmText: '确认删除', danger: true, }) if (!ok) return setBusy('delete') try { await filesAPI.remove(selected.path) toast.success('已删除') setSelected(null) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败') } finally { setBusy('') } } const transferSelected = async () => { if (!selected || selected.is_dir || !destPath.trim()) return setBusy('transfer') try { const res = await filesAPI.transfer(selected.path, destPath.trim(), transferMode) toast.success(`已完成转移:${res.path}`) if (transferMode === 'move') setSelected(null) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '转移失败') } finally { setBusy('') } } const organizeSource = selected?.path || currentDir const organizeReady = Boolean(organizeSource && organizeDestPath.trim()) const runManualOrganize = async (dryRun: boolean) => { if (!organizeReady) { toast.error('请选择来源文件/文件夹,并设置目标媒体库或目的路径') return } if (!dryRun) { const ok = await confirmAction({ title: '确认整理入库', message: `来源:${organizeSource}\n目标:${organizeDestPath}\n方式:${organizeTransferMode}${scanAfter ? '\n整理完成后会扫描入库。' : ''}${scanAfter && scrapeAfter ? '\n扫描后会自动刮削。' : ''}`, confirmText: '开始整理', }) if (!ok) return } setOrganizeBusy(dryRun ? 'preview' : 'run') try { const result = await toolsAPI.organizeDirectory({ source_path: organizeSource, dest_path: organizeDestPath.trim(), transfer_mode: organizeTransferMode, media_type: organizeMediaType === 'auto' ? undefined : organizeMediaType, scan_after: !dryRun && scanAfter, scrape_after: !dryRun && scanAfter && scrapeAfter, library_id: !dryRun && scanAfter && organizeLibraryID ? organizeLibraryID : undefined, dry_run: dryRun, }) setPreviewItems(result.items ?? []) const replaced = result.replaced ?? 0 const total = (result.organized ?? 0) + replaced + (result.skipped ?? 0) + (result.errors?.length ?? 0) if (total === 0) { toast(`未发现可整理视频:${result.source_path || organizeSource}`, { icon: '⚠️', duration: 6000, }) return } if (dryRun) { toast.success(`预览完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}`) return } const scanText = scanAfter ? formatScanSummary(result.scans ?? []) : '' const scrapeText = scanAfter && scrapeAfter ? formatScrapeSummary(result.scrapes ?? []) : '' toast.success(`整理完成:新增 ${result.organized} · 替换 ${replaced} · 跳过 ${result.skipped}${scanText}${scrapeText}`) refresh() } catch (err: unknown) { toast.error((err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '整理失败') } finally { setOrganizeBusy('') } } return (

文件管理

像 MoviePilot 一样:在下载目录中选择文件夹或视频,直接设置目标并整理入库。

{data?.parent && ( )} {data?.path && ( {data.path} )}

自动整理设置

设置后可自动递归扫描下载/待整理目录,整理到媒体库目录;也可以在这里立即执行一次。

{autoDirty ? '有未保存设置' : '设置已同步'} · 定时任务名:organize_source
{data?.path && (

手动整理入库

来源优先使用选中项;未选中时使用当前目录。

来源:{organizeSource || '未选择'}
{previewItems.length > 0 && (
{previewItems.map((item, index) => ( ))}
动作 来源 目标 原因
{item.action} {item.source} {item.target || '—'} {item.reason || '—'}
)}
)} {data?.path && (
文件操作 新建目录 / 重命名 / 删除 / 转移

新建目录

setFolderName(e.target.value)} />

选中项

{selected ? (

{selected.path}

setRenameTo(e.target.value)} />
{!selected.is_dir && (
setDestPath(e.target.value)} />
)}
) : (

先在下方列表点击“操作”选择文件或目录。

)}
)} {loading &&

加载中…

} {error &&
{error}
} {!loading && data && !data.path && data.roots && (
{data.roots.map((r) => ( ))}
)} {!loading && data?.entries && data.entries.length > 0 && (
{data.entries.map((entry) => ( ))}
名称 大小 修改时间 选择
{entry.is_dir ? '—' : fmtBytes(entry.size)} {new Date(entry.modified * 1000).toLocaleString()}
)} {!loading && data?.entries && data.entries.length === 0 &&

空目录。

}
) }