import { FormEvent, useEffect, useMemo, useRef, useState } from 'react' import toast from 'react-hot-toast' import { ArrowDown, ArrowUp, ChevronRight, Cloud, ExternalLink, FolderPlus, HardDrive, Loader2, Plus, QrCode, RefreshCw, Search, Trash2, Tv, X, } from 'lucide-react' import QRCode from 'qrcode' import { strmAPI, type Strm115Source, 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 { LocalDirBrowserDialog } from '../components/LocalDirBrowserDialog' import { defaultEmbyRemoteLines, encodeEmbyRemoteConfigLines, normalizeEmbyRemoteLines, type EmbyRemoteLine, } from '../utils/embyRemoteLines' // ─── 弹框外壳 ──────────────────────────────────────────────────────────────── 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 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 [provider, setProvider] = useState(existing?.provider ?? 'cloud115') const [name, setName] = useState(existing?.name ?? '') const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [url, setUrl] = useState('') const [embyLines, setEmbyLines] = useState(() => defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined), ) const [server, setServer] = useState('') const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [token, setToken] = useState('') const [proxyPlay, setProxyPlay] = useState(existing?.proxy_play ?? false) 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: existing.has_credential && !Object.keys(buildConfig()).length ? {} : 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)} />
)} {provider === 'openlist' && (
setUsername(e.target.value)} /> setPassword(e.target.value)} />
)} {provider === 'emby_remote' && ( setToken(e.target.value)} /> )} {provider === 'openlist' && ( setToken(e.target.value)} /> )} {provider === 'emby_remote' && ( )} {existing && provider !== 'cloud115' && ( )} {provider !== 'cloud115' && (
)}
) } // ─── 115 登录二维码(canvas 渲染;115 返回的是扫码页面链接而非图片) ─────────── // QR 尺寸:物理像素 = CSS 像素(避免 canvas 被 CSS 缩放导致裁切/模糊) const QR_SIZE = 176 function QrCanvas({ content }: { content: string }) { const canvasRef = useRef(null) const [error, setError] = useState('') useEffect(() => { const canvas = canvasRef.current if (!canvas || !content) return QRCode.toCanvas(canvas, content, { width: QR_SIZE, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#1f2937', light: '#ffffff' }, }) .then(() => setError('')) .catch((err: unknown) => setError(err instanceof Error ? err.message : '二维码生成失败')) }, [content]) return (
{error && (
{error}
)}
) } // ─── 115 开放平台授权面板 ───────────────────────────────────────────────────── type AuthSourceKey = 'built_in_appid' | 'custom_appid' | 'built_in_relay' | 'third_party_service' const AUTH_SOURCE_OPTIONS: { key: AuthSourceKey; label: string; desc: string; needsRelayKey?: boolean }[] = [ { key: 'built_in_appid', label: '官方应用目录', desc: '设备码扫码授权,无需回跳' }, { key: 'custom_appid', label: '自定义 APP ID', desc: '使用自己申请的开放平台应用' }, { key: 'built_in_relay', label: '中继授权', desc: 'QMediaSync / MQFamily 中继', needsRelayKey: true }, { key: 'third_party_service', label: '第三方服务', desc: 'MoviePilot / CloudDrive' }, ] function Strm115AuthPanel({ existing, accountName, onAuthed, }: { existing: StrmAccount | null accountName: string onAuthed: (account: StrmAccount) => void }) { const [sources, setSources] = useState<{ built_in: Strm115Source[]; relay: Strm115Source[]; third_party: Strm115Source[] } | null>(null) const [relayKeyConfigured, setRelayKeyConfigured] = useState(false) const [authSource, setAuthSource] = useState('built_in_appid') const [thirdParty, setThirdParty] = useState<'moviepilot' | 'clouddrive'>('moviepilot') const [relayProvider, setRelayProvider] = useState<'qmediasync' | 'mqfamily'>('qmediasync') const [appID, setAppID] = useState('100195125') const [appKeyword, setAppKeyword] = useState('') const [starting, setStarting] = useState(false) const [authUI, setAuthUI] = useState<{ sessionId: string; accountId: string; mode: 'qrcode' | 'url'; authUrl?: string; qrcodeUrl?: string } | null>(null) const [authStatus, setAuthStatus] = useState('') const pollRef = useRef | null>(null) const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current) pollRef.current = null } } useEffect(() => stopPolling, []) useEffect(() => { ;(async () => { try { const data = await strmAPI.list115Sources() setSources(data) const settings = await strmAPI.getSettings().catch(() => null) const key = settings?.['strm.115_relay_key'] setRelayKeyConfigured(Boolean(key && key.trim())) } catch (err) { toast.error(apiErrorMessage(err)) } })() }, []) const filteredApps = useMemo( () => (sources?.built_in ?? []).filter( (s) => !appKeyword || s.app_name.toLowerCase().includes(appKeyword.toLowerCase()) || s.app_id.includes(appKeyword), ), [appKeyword, sources], ) useEffect(() => { if (filteredApps.length > 0 && !filteredApps.some((source) => source.app_id === appID)) { setAppID(filteredApps[0].app_id) } }, [appID, filteredApps]) const buildStartPayload = () => { switch (authSource) { case 'built_in_appid': return { auth_source: 'built_in_appid', app_id: appID } case 'custom_appid': return { auth_source: 'custom_appid', app_id: appID } case 'built_in_relay': return { auth_source: 'built_in_relay', provider: relayProvider } case 'third_party_service': return { auth_source: 'third_party_service', provider: thirdParty } } } const startAuth = async () => { if (authSource === 'built_in_appid' && filteredApps.length === 0) { toast.error('未找到匹配的官方应用') return } setStarting(true) setAuthStatus('') try { // 115 授权需要账号 ID:没有则先创建空凭据账号 let account = existing if (!account) { account = await strmAPI.createAccount({ name: accountName || '115 网盘', provider: 'cloud115', config: {} }) } const result = await strmAPI.start115OAuth(account.id, buildStartPayload()) setAuthUI( result.mode === 'qrcode' ? { sessionId: result.session_id, accountId: account.id, mode: 'qrcode', qrcodeUrl: result.qrcode?.qrcode } : { sessionId: result.session_id, accountId: account.id, mode: 'url', authUrl: result.auth_url }, ) stopPolling() pollRef.current = setInterval(async () => { try { const status = await strmAPI.poll115OAuth(account.id, result.session_id) setAuthStatus(status.tip) if (status.status === 'confirmed') { stopPolling() const updated = await strmAPI.testAccount(account.id) onAuthed(updated) } if (status.status === 'expired') { stopPolling() setAuthUI(null) toast.error('授权已过期,请重新发起') } } catch { /* 轮询失败等下一次 */ } }, 3000) } catch (err) { toast.error(apiErrorMessage(err)) } finally { setStarting(false) } } const openAuthWindow = () => { if (authUI?.mode === 'url' && authUI.authUrl) { window.open(authUI.authUrl, '_blank', 'noopener') } } const resetAuth = () => { stopPolling() setAuthUI(null) setAuthStatus('') } return (
{existing?.has_credential && (

✓ 该账号已授权;重新授权会替换现有令牌

)} {!authUI && (
{AUTH_SOURCE_OPTIONS.map((option) => { const active = authSource === option.key const disabled = Boolean(option.needsRelayKey && !relayKeyConfigured) return ( ) })}
{authSource === 'built_in_appid' && (
setAppKeyword(e.target.value)} />
{filteredApps.length === 0 &&

未找到匹配的官方应用

}
)} {authSource === 'custom_appid' && ( setAppID(e.target.value)} /> )} {authSource === 'built_in_relay' && ( )} {authSource === 'third_party_service' && ( )}
)} {authUI && (
{authUI.mode === 'qrcode' && authUI.qrcodeUrl ? (

{authStatus || '等待扫码…'}

请使用 115 手机客户端扫码并确认授权,5 分钟内有效

) : (

请点击下方按钮在新窗口完成授权:

完成后回到此页面等待自动确认(授权成功后可关闭弹窗)

)}
{authStatus}
)}
) } // ─── STRM 设置 ──────────────────────────────────────────────────────────────── 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.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 }))} /> ))}
)}
) } // ─── 添加/编辑同步目录 ──────────────────────────────────────────────────────── export function StrmSyncPathDialog({ accounts, existing, onClose, onSaved, }: { accounts: StrmAccount[] existing: StrmSyncPath | null onClose: () => void onSaved: () => void }) { const [form, setForm] = useState(() => ({ name: existing?.name ?? '', provider: existing?.provider ?? 'cloud115', account_id: existing?.account_id ?? '', remote_path: existing?.remote_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, 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) const set = (key: K, value: StrmSyncPathInput[K]) => setForm((f) => ({ ...f, [key]: value })) const isLocal = form.provider === 'local' const availableAccounts = accounts.filter((a) => a.provider === form.provider && a.has_credential) const submit = async (event: FormEvent) => { event.preventDefault() setSaving(true) try { 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 && ( )}
set('remote_path', e.target.value)} />
{isLocal && ( )} {!isLocal && form.account_id && ( )}
set('local_path', e.target.value)} />
高级选项
set('strm_base_url', e.target.value)} /> set('min_video_size_mb', Number(e.target.value))} />
set('video_ext', e.target.value)} /> set('meta_ext', e.target.value)} /> set('exclude_name', e.target.value)} />
set('cron', e.target.value)} />
set('download_meta', v)} /> set('upload_meta', v)} /> set('delete_dir', v)} /> set('enable_cron', v)} /> set('enabled', v)} />
{browsing && form.account_id && ( { set('remote_path', id) setBrowsing(false) }} onClose={() => setBrowsing(false)} /> )} {browsingLocal && ( { set(browsingLocal, path) setBrowsingLocal(null) }} onClose={() => setBrowsingLocal(null)} /> )}
) } function ToggleRow({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) { return ( ) } // ─── 远端目录浏览选择器 ─────────────────────────────────────────────────────── function StrmDirBrowserDialog({ accountId, initialDir, onSelect, onClose, }: { accountId: string initialDir?: string onSelect: (path: string) => void onClose: () => void }) { const [dir, setDir] = useState(initialDir ?? '') const [crumbs, setCrumbs] = useState([]) const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const load = async (target: string) => { setLoading(true) try { const list = await strmAPI.listRemoteDir(accountId, target) setEntries(list) setDir(target) setCrumbs(target ? target.split('/').filter(Boolean) : []) } catch (err) { toast.error(apiErrorMessage(err)) } finally { setLoading(false) } } useEffect(() => { load(initialDir ?? '').catch(() => undefined) // eslint-disable-next-line react-hooks/exhaustive-deps }, [accountId]) const enterDir = (id: string) => load(id).catch(() => undefined) return (
event.stopPropagation()} >

选择远端目录

{crumbs.map((crumb, index) => ( {crumb} ))} {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]}` }