import { FormEvent, useEffect, useState } from 'react' import { Loader2, Save, SettingsIcon } from 'lucide-react' import toast from 'react-hot-toast' import { adminAPI } from '../api/admin' import { libraryAPI } from '../api/library' import type { Library, Setting } from '../types' // SettingsPage replaces the Vue SettingsView's curated runtime settings. // The Go backend stores settings as a single // key/value table; we group the most useful keys client-side and let // the operator edit them with typed widgets (select / toggle / input). // // Any key not in the curated list still works via the AdminPage generic // key/value editor — this page is the curated UX over the same store. interface SettingDef { key: string label: string type: 'text' | 'select' | 'toggle' | 'number' | 'textarea' | 'library-multiselect' hint?: string defaultValue?: string options?: { value: string; label: string }[] placeholder?: string } interface SettingGroup { key: string label: string description?: string items: SettingDef[] } const GROUPS: SettingGroup[] = [ { key: 'general', label: '常规', description: '语言 / 转码引擎参数(API 密钥请在管理后台 → 外部API 配置)', items: [ { key: 'tmdb.language', label: 'TMDb 元数据语言', type: 'select', options: [ { value: 'zh-CN', label: '简体中文' }, { value: 'zh-TW', label: '繁体中文' }, { value: 'en-US', label: 'English' }, { value: 'ja-JP', label: '日本語' }, ], }, { key: 'app.server_url', label: '公开访问域名 / STRM 域名', type: 'text', hint: '例如 http://NAS-IP:18080 或 https://media.example.com。填写后网盘媒体扫描会自动生成完整 STRM/302 播放入口;不填则使用同源相对路径。', placeholder: 'http://192.168.1.125:18080', }, { key: 'playback.direct_only', label: '客户端直连解码(释放宿主机资源)', type: 'toggle', hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。', defaultValue: 'false', }, { key: 'transcode.enabled', label: '启用转码', type: 'toggle', hint: '关闭后所有视频直连播放(「客户端直连解码」开启时本项自动失效)', defaultValue: 'true', }, { key: 'transcode.hw_accel', label: '硬件编码器', type: 'select', hint: '只有开启下方「启用硬件加速」后才会使用;未开启时强制软件转码', defaultValue: 'none', options: [ { value: 'none', label: '软件转码' }, { value: 'nvenc', label: 'NVIDIA NVENC' }, { value: 'qsv', label: 'Intel QSV' }, { value: 'vaapi', label: 'VAAPI (Linux)' }, ], }, { key: 'transcode.hw_enabled', label: '启用硬件加速', type: 'toggle', hint: '关闭时即使选择了 NVENC/QSV/VAAPI,也不会调用硬件编码参数', defaultValue: 'false', }, { key: 'transcode.max_jobs', label: '最大并发转码任务', type: 'number', hint: 'NAS 建议 1', defaultValue: '1', }, { key: 'transcode.realtime', label: '按播放速度转码', type: 'toggle', hint: '开启后 ffmpeg 不会抢跑压完整片,可显著降低 CPU 峰值', defaultValue: 'true', }, { key: 'transcode.threads', label: '软件转码线程数', type: 'number', hint: 'NAS 建议 1-2;仅软件转码生效', defaultValue: '2', }, { key: 'transcode.idle_timeout_seconds', label: '转码空闲停止秒数', type: 'number', hint: '播放器关闭或停止请求分片后自动结束 ffmpeg', defaultValue: '120', }, { key: 'ffmpeg.path', label: 'FFmpeg 路径', type: 'text', placeholder: 'ffmpeg', }, { key: 'ffprobe.path', label: 'FFprobe 路径', type: 'text', placeholder: 'ffprobe', }, { key: 'ffprobe.max_concurrent', label: 'FFprobe 最大并发', type: 'number', hint: 'NAS 建议 1;用于扫描、整理洗版和手动探测,避免同时启动多个 ffprobe 进程', defaultValue: '1', }, ], }, { key: 'license', label: '授权服务', description: '连接私有 MediaStationGo 授权服务;开源版默认最多 20 个用户,激活后按授权策略提升额度。', items: [ { key: 'license.server_url', label: 'License Server 地址', type: 'text', placeholder: 'http://127.0.0.1:8001', }, { key: 'license.hmac_secret', label: 'HMAC 签名密钥', type: 'text', hint: '必须与 License Server 的 LICENSE_HMAC_SECRET 保持一致;留空则跳过响应签名校验。', }, ], }, { key: 'cloud-upload', label: '网盘转存', description: '把本地媒体复制上传到外部存储。推荐:将 115、123、夸克等挂载到 OpenList、CloudDrive2 或 Alist 后,使用桥接目标转存。', items: [ { key: 'cloud.auto_sync_enabled', label: '夜间自动同步网盘媒体库', type: 'toggle', hint: '默认关闭。开启后仅在每天 23:00-05:00 按检查间隔触发;每次完整扫描所有启用网盘库一次后自动停止。手动扫描仍可随时执行。', defaultValue: 'false', }, { key: 'cloud.sync_interval_seconds', label: '夜间窗口检查间隔秒数', type: 'number', hint: '最小 300 秒,建议 1800 秒;同一个夜间窗口成功同步后不会重复全量扫,避免大型网盘反复递归。', defaultValue: '1800', }, { key: 'cloud.boot_scan_enabled', label: '启动后立即扫描网盘', type: 'toggle', hint: '默认关闭。仅排障或小型网盘建议开启;大型库请使用手动扫描或夜间自动同步。', defaultValue: 'false', }, { key: 'cloud.upload_auto_enabled', label: '启用自动转存', type: 'toggle', hint: '开启后后台会按间隔扫描本地源目录,把视频、NFO、海报、字幕转存到目标存储;还需要在外部存储页开启该目标的“允许转存写入”。', defaultValue: 'false', }, { key: 'cloud.upload_provider', label: '转存目标', type: 'select', defaultValue: 'alist', options: [ { value: 'openlist', label: 'OpenList(推荐,可桥接 115/123/阿里/夸克)' }, { value: 'clouddrive2', label: 'CloudDrive2(推荐,可桥接 115/123/阿里/夸克)' }, { value: 'alist', label: 'Alist(可桥接多网盘)' }, { 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_transfer_mode', label: '自动转存方式', type: 'select', defaultValue: 'copy', hint: '复制会保留本地源文件;移动只在上传成功后删除本地文件。', options: [ { value: 'copy', label: '复制' }, { value: 'move', label: '移动' }, ], }, { key: 'cloud.upload_interval_seconds', label: '自动转存间隔秒数', type: 'number', hint: '最小 300 秒,建议 3600 秒或更高,避免频繁读盘和触发网盘风控。', defaultValue: '3600', }, ], }, { key: 'adult', label: 'Adult / NSFW', description: '成人内容隔离开关 (默认隐藏)', items: [ { key: 'adult.enabled', label: '启用成人内容', type: 'toggle', hint: '全局开关。关闭后所有人都无法显示成人库;开启后用户仍默认隐藏,可在个人资料或 Bot 中自行显示。', defaultValue: 'true', }, { key: 'adult.library_ids', label: '指定成人媒体库', type: 'library-multiselect', hint: '管理员指定哪些媒体库目录属于成人影视库。指定后网页、搜索和第三方客户端都会统一隐藏。', defaultValue: '[]', }, { key: 'adult.require_pin', label: '访问需要 PIN', type: 'toggle', }, { key: 'adult.pin', label: 'PIN 码', type: 'text', hint: '4-8 位数字', }, ], }, // qBittorrent 配置已迁移到独立的「下载器」页面(侧边栏 → 下载器), // 该页面支持多客户端 + 连接测试。这里不再重复暴露入口,避免与 // /api/admin/download/clients 写入的数据来源冲突。 ] const ALL_KEYS = new Set(GROUPS.flatMap((g) => g.items.map((i) => i.key))) export function SettingsPage() { const [activeGroup, setActiveGroup] = useState(GROUPS[0].key) const [values, setValues] = useState>({}) const [dirty, setDirty] = useState>(new Set()) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [libraries, setLibraries] = useState([]) const refresh = async () => { setLoading(true) try { const [all, libs] = await Promise.all([ adminAPI.listSettings(), libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]), ]) const idx: Record = {} for (const s of all as Setting[]) { if (ALL_KEYS.has(s.key)) idx[s.key] = s.value } setValues(idx) setLibraries(libs as Library[]) setDirty(new Set()) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) }, []) const onChange = (key: string, value: string) => { setValues((v) => ({ ...v, [key]: value })) setDirty((d) => new Set(d).add(key)) } const onSave = async (e: FormEvent) => { e.preventDefault() if (dirty.size === 0) return setSaving(true) try { // Backend exposes a single-key updater; loop through dirty keys. for (const key of dirty) { await adminAPI.updateSetting(key, values[key] ?? '') } toast.success(`已保存 ${dirty.size} 项配置`) setDirty(new Set()) } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } finally { setSaving(false) } } const group = GROUPS.find((g) => g.key === activeGroup)! return (

系统设置

按分组编辑转码 / 网盘转存 / Adult 等关键配置

{GROUPS.map((g) => ( ))}
{loading && (
)} {!loading && (
{group.description &&

{group.description}

} {group.items.map((it) => ( onChange(it.key, v)} libraries={libraries} /> ))}
{dirty.size > 0 ? `有 ${dirty.size} 项未保存` : '所有更改已保存'}
)}
) } function SettingRow({ def, value, onChange, libraries = [], }: { def: SettingDef value: string onChange: (v: string) => void libraries?: Library[] }) { const toggleOn = value === 'true' || value === '1' || value === 'on' const selectedLibraryIDs = parseLibraryIDs(value) const toggleLibrary = (id: string, checked: boolean) => { const next = checked ? Array.from(new Set([...selectedLibraryIDs, id])) : selectedLibraryIDs.filter((item) => item !== id) onChange(JSON.stringify(next)) } return (
{def.type === 'text' && ( onChange(e.target.value)} /> )} {def.type === 'number' && ( onChange(e.target.value)} /> )} {def.type === 'textarea' && (