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 (
{label}
{children}
{hint && {hint} }
)
}
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 (
)
}
// ─── 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 (
)
}
// ─── 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 (
setAuthSource(option.key)}
className={
'rounded-xl border-2 p-2.5 text-left transition disabled:cursor-not-allowed disabled:opacity-40 ' +
(active ? 'border-brand-400 bg-brand-50' : 'border-gray-100 bg-white hover:border-gray-200')
}
>
{option.label}
{option.desc}
{disabled && 需在 STRM 设置配置共享密钥
}
)
})}
{authSource === 'built_in_appid' && (
)}
{authSource === 'custom_appid' && (
setAppID(e.target.value)} />
)}
{authSource === 'built_in_relay' && (
setRelayProvider(e.target.value as typeof relayProvider)}>
QMediaSync(oauth.qmediasync.cn)
MQFamily(api.mqfamily.top)
)}
{authSource === 'third_party_service' && (
setThirdParty(e.target.value as typeof thirdParty)}>
MoviePilot(https://movie-pilot.org)
CloudDrive(redirect115.zhenyunpan.com)
)}
{starting ? : }
{authSource === 'built_in_appid' || authSource === 'custom_appid' ? '获取登录二维码' : '获取授权链接'}
)}
{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 ? (
) : (
)}
)
}
// ─── 添加/编辑同步目录 ────────────────────────────────────────────────────────
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 (
{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 (
onChange(e.target.checked)}
/>
{label}
)
}
// ─── 远端目录浏览选择器 ───────────────────────────────────────────────────────
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()}
>
选择远端目录
enterDir('')}>
根目录
{crumbs.map((crumb, index) => (
{crumb}
))}
{dir || '(根目录 / 0)'}
{loading ? (
) : entries.length === 0 ? (
该目录为空
) : (
{entries.map((entry) => {
const Icon = entry.is_dir ? FolderPlus : HardDrive
return (
(entry.is_dir ? enterDir(entry.id) : undefined)}
onDoubleClick={() => entry.is_dir && enterDir(entry.id)}
>
{entry.name}
{entry.is_dir ? (
{
e.stopPropagation()
onSelect(entry.id)
}}
>
选择此目录
) : (
{formatEntrySize(entry.size)}
)}
)
})}
)}
双击进入目录,点击「选择此目录」使用该目录 ID / 路径
onSelect(dir)} disabled={loading}>
选择当前目录
)
}
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]}`
}