import { FormEvent, useEffect, useRef, useState } from 'react' import toast from 'react-hot-toast' import { ArrowDown, ArrowUp, ChevronRight, Cloud, FolderPlus, HardDrive, Loader2, Plus, QrCode, Trash2, Tv, X, } from 'lucide-react' import { strmAPI, type StrmRemoteEntry } from '../api/strm' import type { SettingDef } from './SettingsRow' import { SettingRow } from './SettingsRow' import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from '../types/strm' import { STRM_PROVIDER_LABELS } from '../types/strm' import { apiErrorMessage } from './StrmManagePage' import { Strm115AuthPanel } from './strm-dialogs/Strm115AuthPanel' import { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import { defaultEmbyRemoteLines, encodeEmbyRemoteConfigLines, normalizeEmbyRemoteLines, type EmbyRemoteLine, } from '../utils/embyRemoteLines' import { lastPathSegment, remoteTailNameOf, syncLocalPathWithRemote } from '../utils/strmPaths' // ─── 弹框外壳 ──────────────────────────────────────────────────────────────── function DialogShell({ title, subtitle, children, onClose, wide, }: { title: string subtitle?: string children: React.ReactNode onClose: () => void wide?: boolean }) { return (
event.stopPropagation()} >

{title}

{subtitle &&

{subtitle}

}
{children}
) } function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return ( ) } const inputCls = 'input-base w-full' const SECRET_PLACEHOLDER = '已配置,留空则不修改' function accountDialogDefaults(existing: StrmAccount | null) { const preview = existing?.config_preview return { provider: (existing?.provider ?? 'cloud115') as StrmProvider, name: existing?.name ?? '', enabled: existing?.enabled ?? true, url: preview?.url ?? '', server: preview?.server ?? '', username: preview?.username ?? '', password: '', token: '', proxyPlay: existing?.proxy_play ?? false, embyLines: defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined), hasPassword: Boolean(preview?.has_password), hasToken: Boolean(preview?.has_token || preview?.has_api_key), } } // ─── 添加/编辑网盘账号 ──────────────────────────────────────────────────────── const PROVIDER_OPTIONS: { provider: StrmProvider; label: string; desc: string }[] = [ { provider: 'cloud115', label: '115 网盘', desc: '开放平台 OAuth 授权(扫码/网页)' }, { provider: 'clouddrive2', label: 'CloudDrive2', desc: 'WebDAV 桥接多种网盘' }, { provider: 'openlist', label: 'OpenList', desc: 'OpenList / AList 兼容服务' }, ] export function StrmAccountDialog({ existing, onClose, onSaved, }: { existing: StrmAccount | null onClose: () => void onSaved: () => void }) { const defaults = accountDialogDefaults(existing) const [provider, setProvider] = useState(defaults.provider) const [name, setName] = useState(defaults.name) const [enabled, setEnabled] = useState(defaults.enabled) const [url, setUrl] = useState(defaults.url) const [embyLines, setEmbyLines] = useState(defaults.embyLines) const [server, setServer] = useState(defaults.server) const [username, setUsername] = useState(defaults.username) const [password, setPassword] = useState(defaults.password) const [token, setToken] = useState(defaults.token) const [proxyPlay, setProxyPlay] = useState(defaults.proxyPlay) const [hasPassword, setHasPassword] = useState(defaults.hasPassword) const [hasToken, setHasToken] = useState(defaults.hasToken) const [saving, setSaving] = useState(false) const buildConfig = (): Record => { switch (provider) { case 'clouddrive2': return { ...(url ? { url } : {}), ...(username ? { username } : {}), ...(password ? { password } : {}), } case 'openlist': return { ...(server ? { server } : {}), ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), } case 'emby_remote': { const lineConfig = encodeEmbyRemoteConfigLines(embyLines) return { ...lineConfig, ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), proxy_play: proxyPlay ? 'true' : 'false', } } default: return {} } } const canSave = () => { switch (provider) { case 'clouddrive2': return url !== '' case 'openlist': return server !== '' && (token !== '' || password !== '') case 'emby_remote': return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '') default: return false } } const submit = async (event: FormEvent) => { event.preventDefault() setSaving(true) try { if (existing) { await strmAPI.updateAccount(existing.id, { name, provider: existing.provider, enabled, config: buildConfig(), }) toast.success('网盘账号已更新') } else { if (!canSave()) { toast.error('请先完成凭据配置') return } await strmAPI.createAccount({ name, provider, config: buildConfig() }) toast.success('网盘账号已添加') } onSaved() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setSaving(false) } } return (
{/* 提供方选择 */} {!existing && (
{PROVIDER_OPTIONS.map((option) => { const Icon = option.provider === 'cloud115' ? QrCode : option.provider === 'clouddrive2' ? HardDrive : option.provider === 'emby_remote' ? Tv : FolderPlus const active = provider === option.provider return ( ) })}
)} setName(e.target.value)} /> {provider === 'cloud115' && ( { toast.success(`「${account.name}」授权成功`) onSaved() }} /> )} {provider === 'clouddrive2' && ( setUrl(e.target.value)} /> )} {provider === 'openlist' && ( setServer(e.target.value)} /> )} {provider === 'emby_remote' && (

接入线路

可配置内网、外网等多条线路,连接失败时按顺序自动切换

{embyLines.map((line, index) => (
线路 {index + 1} {existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item))) } /> setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item))) } />
))}
)} {(provider === 'clouddrive2' || provider === 'emby_remote') && (
setUsername(e.target.value)} /> { setPassword(e.target.value) if (e.target.value) setHasPassword(false) }} />
)} {provider === 'openlist' && (
setUsername(e.target.value)} /> { setPassword(e.target.value) if (e.target.value) setHasPassword(false) }} />
)} {provider === 'emby_remote' && ( { setToken(e.target.value) if (e.target.value) setHasToken(false) }} /> )} {provider === 'openlist' && ( { setToken(e.target.value) if (e.target.value) setHasToken(false) }} /> )} {provider === 'emby_remote' && ( )} {existing && provider !== 'cloud115' && ( )} {provider !== 'cloud115' && (
)}
) } // ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ─────────── // QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊) const SETTING_DEFS: SettingDef[] = [ { key: 'strm.base_url', label: 'STRM 链接基础地址', type: 'text', hint: '生成的 strm 文件指向的播放地址;留空依次自动使用 app.server_url,最后回退到本机地址(http://127.0.0.1:端口)。Emby 在其他设备时请配置局域网/公网地址' }, { key: 'strm.video_ext', label: '视频扩展名', type: 'text', hint: '逗号分隔,命中即生成 .strm' }, { key: 'strm.meta_ext', label: '元数据扩展名', type: 'text', hint: '逗号分隔,进入下载/上传队列(nfo/图片/字幕)' }, { key: 'strm.exclude_name', label: '排除文件名', type: 'text', hint: '逗号分隔,文件名包含任一关键词即跳过' }, { key: 'strm.min_video_size_mb', label: '最小视频大小(MB)', type: 'number', hint: '小于该大小的视频不生成 STRM,0 表示不限' }, { key: 'strm.add_path', label: 'STRM 链接 path 参数', type: 'select', hint: '1=附带完整远端路径 2=仅文件名 3=不带 path', options: [ { value: '1', label: '完整路径' }, { value: '2', label: '仅文件名' }, { value: '3', label: '不带 path' }, ], }, { key: 'strm.keep_ext', label: '保留视频扩展名(多版本)', type: 'toggle', hint: '关闭(默认):同名不同扩展(如 竞女01.mkv / 竞女01.mp4)择优生成一条 name.strm;开启:分别生成 name.mkv.strm / name.mp4.strm,保留全部版本供播放切换', }, { key: 'strm.115_relay_key', label: '115 中继授权共享密钥', type: 'text', hint: 'QMediaSync/MQFamily 中继授权的共享 AES 密钥;不配置则中继授权不可用' }, { key: 'strm.download_threads', label: '下载队列线程数', type: 'number', hint: '元数据下载并发数' }, { key: 'strm.upload_threads', label: '上传队列线程数', type: 'number', hint: '元数据上传并发数' }, ] export function StrmSettingsDialog({ onClose }: { onClose: () => void }) { const [values, setValues] = useState | null>(null) const [saving, setSaving] = useState(false) useEffect(() => { strmAPI .getSettings() .then(setValues) .catch((err) => toast.error(apiErrorMessage(err))) }, []) const submit = async (event: FormEvent) => { event.preventDefault() if (!values) return setSaving(true) try { await strmAPI.updateSettings(values) toast.success('STRM 设置已保存') onClose() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setSaving(false) } } return ( {!values ? (
) : (
{SETTING_DEFS.map((def) => ( setValues((v) => ({ ...v!, [def.key]: value }))} /> ))}
)}
) } // ─── 添加/编辑同步目录 ──────────────────────────────────────────────────────── /** 推断已有配置当前实际拼在本地输出目录末尾的尾段(目录名或 115 目录 ID)。 */ function initRemoteTail(existing: StrmSyncPath): string { const cidTail = lastPathSegment(existing.remote_path) const nameTail = existing.remote_display_path ? lastPathSegment(existing.remote_display_path) : '' if (nameTail && lastPathSegment(existing.local_path) === nameTail) return nameTail return cidTail } export function StrmSyncPathDialog({ accounts, existing, onClose, onSaved, onOpenSettings, }: { accounts: StrmAccount[] existing: StrmSyncPath | null onClose: () => void onSaved: () => void onOpenSettings?: () => void }) { const [form, setForm] = useState(() => ({ name: existing?.name ?? '', provider: existing?.provider ?? 'cloud115', account_id: existing?.account_id ?? '', remote_path: existing?.remote_path ?? '', remote_display_path: existing?.remote_display_path ?? '', local_path: existing?.local_path ?? '', strm_base_url: existing?.strm_base_url ?? '', video_ext: existing?.video_ext ?? '', meta_ext: existing?.meta_ext ?? '', exclude_name: existing?.exclude_name ?? '', min_video_size_mb: existing?.min_video_size_mb ?? 0, add_path: existing?.add_path ?? 1, download_meta: existing?.download_meta ?? true, upload_meta: existing?.upload_meta ?? false, delete_dir: existing?.delete_dir ?? false, keep_ext: existing?.keep_ext ?? false, cron: existing?.cron ?? '', enable_cron: existing?.enable_cron ?? false, sync_mode: existing?.sync_mode ?? 'incremental', enabled: existing?.enabled ?? true, })) const [saving, setSaving] = useState(false) const [browsing, setBrowsing] = useState(false) const [browsingLocal, setBrowsingLocal] = useState(null) // 输入框显示的远端目录文本:对于 115 优先显示完整路径,若尚未反查到则显示 ID const [remoteInputValue, setRemoteInputValue] = useState( () => existing?.remote_display_path || existing?.remote_path || '', ) // 反查展示路径的序号守卫:远端目录快速连续变更时丢弃过期响应 const resolveSeqRef = useRef(0) // prevRemoteTailRef 记录当前拼在本地输出目录末尾、由本弹窗管理的尾段。 // 兼容两类历史数据:新版保存的 local_path 末段是目录名,旧版是目录 ID。 const prevRemoteTailRef = useRef(existing ? initRemoteTail(existing) : '') useEffect(() => { if (existing) return strmAPI .getSettings() .then((settings) => { const keepExt = settings['strm.keep_ext'] if (keepExt === 'true' || keepExt === '1') { setForm((f) => ({ ...f, keep_ext: true })) } }) .catch(() => undefined) }, [existing]) const set = (key: K, value: StrmSyncPathInput[K]) => setForm((f) => ({ ...f, [key]: value })) const updateRemotePath = (remotePath: string, displayPath?: string) => { const shown = (displayPath || remotePath).trim() setRemoteInputValue(shown) setForm((f) => { const tail = remoteTailNameOf(remotePath, displayPath) const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail) prevRemoteTailRef.current = synced.remoteTail return { ...f, remote_path: remotePath, remote_display_path: displayPath ?? '', local_path: synced.localPath, } }) } // 按 ID 反查完整展示路径。默认仅补展示、不改动已配置的本地输出目录; // resyncTail 用于手动输入 ID 的新配置:把刚拼上的 ID 尾段替换为目录名。 const resolveDisplayPath = (accountId: string, remotePath: string, resyncTail = false) => { const seq = ++resolveSeqRef.current strmAPI .resolveRemoteDirPath(accountId, remotePath) .then((fullPath) => { if (seq !== resolveSeqRef.current) return if (fullPath) setRemoteInputValue(fullPath) setForm((f) => { if (f.remote_path !== remotePath) return f if (resyncTail) { const tail = remoteTailNameOf(remotePath, fullPath) const synced = syncLocalPathWithRemote(f.local_path, remotePath, prevRemoteTailRef.current, tail) prevRemoteTailRef.current = synced.remoteTail return { ...f, remote_display_path: fullPath, local_path: synced.localPath } } // 旧配置的 local_path 末段若恰好就是目录名,把它认作受管尾段, // 后续重新选择目录时才能正确替换而不是叠加 const tail = lastPathSegment(fullPath) if (tail && prevRemoteTailRef.current !== tail && lastPathSegment(f.local_path) === tail) { prevRemoteTailRef.current = tail } return { ...f, remote_display_path: fullPath } }) }) .catch(() => undefined) } const commitLocalPath = (localPath: string) => { setForm((f) => { const tail = remoteTailNameOf(f.remote_path, f.remote_display_path) const synced = syncLocalPathWithRemote(localPath, f.remote_path, prevRemoteTailRef.current, tail) prevRemoteTailRef.current = synced.remoteTail return { ...f, local_path: synced.localPath } }) } const isLocal = form.provider === 'local' const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential) const remoteDisplayPath = isLocal ? '' : form.remote_display_path?.trim() ?? '' const remoteTail = isLocal ? '' : remoteTailNameOf(form.remote_path, remoteDisplayPath) // 编辑旧配置时若尚未存展示路径,按 ID 反查补齐并更新输入框展示 useEffect(() => { if (isLocal || form.provider !== 'cloud115') return if (!form.account_id || !form.remote_path || form.remote_display_path) return resolveDisplayPath(form.account_id, form.remote_path) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const submit = async (event: FormEvent) => { event.preventDefault() setSaving(true) try { if (!existing) { const settings = await strmAPI.getSettings() if (!settings?.['strm.base_url']?.trim()) { toast.error('未填写strm地址') onClose() onOpenSettings?.() return } } if (existing) { await strmAPI.updatePath(existing.id, form) toast.success('同步目录已更新') } else { await strmAPI.createPath(form) toast.success('同步目录已添加') } onSaved() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setSaving(false) } } return (
set('name', e.target.value)} />
{!isLocal && ( )}
setRemoteInputValue(e.target.value)} onBlur={(e) => { const value = e.target.value.trim() if (isLocal) { set('remote_path', value) return } if (form.provider === 'cloud115') { if (!value) { updateRemotePath('', '') return } // 若用户直接输入/粘贴纯数字目录 ID,触发反查并自动替换为完整路径 if (/^\d+$/.test(value)) { updateRemotePath(value, '') if (form.account_id) resolveDisplayPath(form.account_id, value, true) return } // 若当前展示的是完整路径且未变动,不做处理 if (value === form.remote_display_path) return } else { updateRemotePath(value) } }} />
{isLocal && ( )} {!isLocal && form.account_id && ( )}
set('local_path', e.target.value)} onBlur={(e) => { if (!isLocal) commitLocalPath(e.target.value) }} />
高级选项
set('video_ext', e.target.value)} /> set('meta_ext', e.target.value)} /> set('exclude_name', e.target.value)} />
set('min_video_size_mb', Number(e.target.value))} />
set('cron', e.target.value)} />
set('download_meta', v)} /> set('upload_meta', v)} /> set('delete_dir', v)} /> set('keep_ext', v)} /> set('enable_cron', v)} /> set('enabled', v)} />
{browsing && form.account_id && ( { updateRemotePath(id, form.provider === 'cloud115' ? fullPath : undefined) setBrowsing(false) }} onClose={() => setBrowsing(false)} /> )} {browsingLocal && ( { if (browsingLocal === 'local_path') { commitLocalPath(path) } else { set(browsingLocal, path) } setBrowsingLocal(null) }} onClose={() => setBrowsingLocal(null)} /> )}
) } function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return ( ) } /** 把浏览时经过的目录名称链拼成以 / 开头的完整展示路径。 */ function chainFullPath(chain: { id: string; name: string }[]): string { const names = chain.map((item) => item.name.trim()).filter(Boolean) return names.length > 0 ? '/' + names.join('/') : '' } // ─── 远端目录浏览选择器 ─────────────────────────────────────────────────────── function StrmDirBrowserDialog({ accountId, initialDir, onSelect, onClose, }: { accountId: string initialDir?: string onSelect: (id: string, name?: string, fullPath?: string) => void onClose: () => void }) { const [dir, setDir] = useState(initialDir ?? '') const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) // 已进入目录的名称链:面包屑按名称展示,「选择当前目录」时回传末级名称 const [dirChain, setDirChain] = useState<{ id: string; name: string }[]>([]) const dirChainRef = useRef<{ id: string; name: string }[]>([]) // 递增序号守卫:快速连续进入目录时丢弃过期目录响应 const loadSeqRef = useRef(0) const load = async (target: string, nextChain?: { id: string; name: string }[]) => { const seq = ++loadSeqRef.current setLoading(true) try { const list = await strmAPI.listRemoteDir(accountId, target) if (seq !== loadSeqRef.current) return if (nextChain) dirChainRef.current = nextChain setEntries(list) setDir(target) setDirChain([...dirChainRef.current]) } catch (err) { if (seq !== loadSeqRef.current) return toast.error(apiErrorMessage(err)) } finally { if (seq === loadSeqRef.current) setLoading(false) } } useEffect(() => { load(initialDir ?? '').catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId]) const enterDir = (entry: StrmRemoteEntry) => { const chain = [...dirChainRef.current] const existingIdx = chain.findIndex((item) => item.id === entry.id) if (existingIdx >= 0) chain.splice(existingIdx + 1) else chain.push({ id: entry.id, name: entry.name }) load(entry.id, chain).catch(() => undefined) } return (
event.stopPropagation()} >

选择远端目录

{(dirChain.length > 0 ? dirChain.map((item) => item.name) : dir.split('/').filter(Boolean)).map((label, index) => ( {label} ))} {dir || '(根目录 / 0)'}
{loading ? (
) : entries.length === 0 ? (

该目录为空

) : (
{entries.map((entry) => { const Icon = entry.is_dir ? FolderPlus : HardDrive return ( ) : ( {formatEntrySize(entry.size)} )} ) })}
)}
双击进入目录,点击「选择此目录」使用该目录 ID / 路径
) } function formatEntrySize(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]}` }