import { useCallback, useEffect, useState } from 'react' import toast from 'react-hot-toast' import { ArrowDown, ArrowUp, Check, Globe, GripVertical, Loader2, Pencil, Plus, Power, PowerOff, RefreshCw, Server, Trash2, Tv, X, } from 'lucide-react' import { strmAPI } from '../api/strm' import { embyAPI } from '../api/emby' import type { StrmAccount } from '../types/strm' import type { EmbyMount, RemoteEmbyView } from '../types/emby' import { apiErrorMessage } from './StrmManagePage' import { confirmAction } from '../components/confirmAction' import { defaultEmbyRemoteLines, encodeEmbyRemoteConfigLines, normalizeEmbyRemoteLines, type EmbyRemoteLine, } from '../utils/embyRemoteLines' const inputCls = 'input-base w-full' // ─── 账号对话框(Emby 服务器连接) ───────────────────────────────────────────── function AccountDialog({ existing, onClose, onSaved, }: { existing: StrmAccount | null onClose: () => void onSaved: () => void }) { const [name, setName] = useState(existing?.name ?? '') const [lines, setLines] = useState(() => defaultEmbyRemoteLines(existing?.emby_lines)) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [token, setToken] = useState('') const [enabled, setEnabled] = useState(existing?.enabled ?? true) const [saving, setSaving] = useState(false) const normalizedLines = normalizeEmbyRemoteLines(lines) const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential) const updateLine = (index: number, patch: Partial) => { setLines((prev) => prev.map((line, i) => (i === index ? { ...line, ...patch } : line))) } const addLine = () => { setLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }]) } const removeLine = (index: number) => { setLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index))) } const moveLine = (index: number, direction: 'up' | 'down') => { setLines((prev) => { const target = direction === 'up' ? index - 1 : index + 1 if (target < 0 || target >= prev.length) return prev const next = [...prev] const [moved] = next.splice(index, 1) next.splice(target, 0, moved) return next }) } const submit = async (event: React.FormEvent) => { event.preventDefault() const readyLines = normalizeEmbyRemoteLines(lines) if (readyLines.length === 0) { toast.error('请至少填写一条有效的 Emby 线路地址') return } setSaving(true) try { const config: Record = { ...encodeEmbyRemoteConfigLines(readyLines), ...(username ? { username } : {}), ...(password ? { password } : {}), ...(token ? { token } : {}), } if (existing) { await strmAPI.updateAccount(existing.id, { name: name || existing.name, provider: existing.provider, enabled, config: Object.keys(config).length ? config : {}, }) toast.success('Emby 账号已更新') } else { if (!canSave()) { toast.error('请填写线路地址与密码(或 API Key)') return } await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config }) toast.success('Emby 账号已添加') } onSaved() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setSaving(false) } } return (
e.stopPropagation()}>

{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}

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

接入线路
{lines.map((line, index) => (
线路 {index + 1} {existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
updateLine(index, { name: e.target.value })} /> updateLine(index, { url: e.target.value })} />
))}
{existing && ( )}
) } // ─── 添加挂载对话框 ──────────────────────────────────────────────────────────── function MountDialog({ account, onClose, onSaved, }: { account: StrmAccount onClose: () => void onSaved: () => void }) { const [views, setViews] = useState([]) const [selected, setSelected] = useState>({}) const [proxy, setProxy] = useState>({}) const nameOverride: Record = {} const [allProxy, setAllProxy] = useState(false) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) useEffect(() => { embyAPI .listAccountViews(account.id) .then((rows) => { setViews(rows) const init: Record = {} rows.forEach((v) => { if (!v.already_mounted) init[v.remote_view_id] = true }) setSelected(init) }) .catch((err) => toast.error(apiErrorMessage(err))) .finally(() => setLoading(false)) }, [account.id]) const toggleView = (id: string) => setSelected((prev) => ({ ...prev, [id]: !prev[id] })) const toggleProxy = (id: string) => setProxy((prev) => ({ ...prev, [id]: !prev[id] })) const submit = async (event: React.FormEvent) => { event.preventDefault() const picked = views.filter((v) => selected[v.remote_view_id]) if (picked.length === 0) { toast.error('请至少选择一个媒体库') return } setSaving(true) try { await embyAPI.createMounts({ account_id: account.id, views: picked.map((v) => ({ remote_view_id: v.remote_view_id, remote_view_name: v.remote_view_name, collection_type: v.collection_type, name: nameOverride[v.remote_view_id] || '', proxy_play: allProxy || proxy[v.remote_view_id] || false, })), }) toast.success(`已挂载 ${picked.length} 个媒体库`) onSaved() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setSaving(false) } } return (
e.stopPropagation()}>

选择要挂载的媒体库

「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载

关闭(默认)= 客户端直连远程 Emby 拉流
{loading ? (
正在读取远程媒体库…
) : views.length === 0 ? (

远程服务器没有可挂载的媒体库

) : ( views.map((v) => { const checked = selected[v.remote_view_id] const disabled = v.already_mounted return (
toggleView(v.remote_view_id)} />

{v.remote_view_name}

{v.collection_type || 'mixed'} {v.child_count > 0 ? ` · ${v.child_count} 条目` : ''} {disabled ? ' · 已挂载' : ''}

{!disabled && ( )}
) }) )}
) } // ─── 页面主体 ────────────────────────────────────────────────────────────────── export function EmbyMountPage() { const [accounts, setAccounts] = useState([]) const [mounts, setMounts] = useState([]) const [loading, setLoading] = useState(true) const [accountDialog, setAccountDialog] = useState(null) const [mountDialogFor, setMountDialogFor] = useState(null) const [testingID, setTestingID] = useState(null) const [draggingId, setDraggingId] = useState(null) const [dragOverId, setDragOverId] = useState(null) const [reordering, setReordering] = useState(false) const applyReorder = async (nextMounts: EmbyMount[]) => { const prevMounts = mounts const updated = nextMounts.map((m, idx) => ({ ...m, sort_order: idx })) setMounts(updated) setReordering(true) try { await embyAPI.reorderMounts(updated.map((m) => m.id)) toast.success('媒体库顺序已更新') } catch (err) { setMounts(prevMounts) toast.error(apiErrorMessage(err)) } finally { setReordering(false) } } const moveMount = (m: EmbyMount, direction: 'up' | 'down') => { const acctMounts = mounts.filter((item) => item.account_id === m.account_id) const idx = acctMounts.findIndex((item) => item.id === m.id) if (idx < 0) return if (direction === 'up' && idx === 0) return if (direction === 'down' && idx === acctMounts.length - 1) return const targetIdx = direction === 'up' ? idx - 1 : idx + 1 const newAcctMounts = [...acctMounts] const [moved] = newAcctMounts.splice(idx, 1) newAcctMounts.splice(targetIdx, 0, moved) let acctPointer = 0 const nextMounts = mounts.map((item) => { if (item.account_id === m.account_id) { return newAcctMounts[acctPointer++] } return item }) applyReorder(nextMounts) } const handleDragStart = (e: React.DragEvent, id: string) => { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) setDraggingId(id) } const handleDragOver = (e: React.DragEvent, overId: string) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' if (dragOverId !== overId) { setDragOverId(overId) } } const handleDrop = (e: React.DragEvent, overId: string, accountId: string) => { e.preventDefault() const fromId = draggingId || e.dataTransfer.getData('text/plain') setDraggingId(null) setDragOverId(null) if (!fromId || fromId === overId) { return } const acctMounts = mounts.filter((item) => item.account_id === accountId) const fromIdx = acctMounts.findIndex((item) => item.id === fromId) const toIdx = acctMounts.findIndex((item) => item.id === overId) if (fromIdx < 0 || toIdx < 0) { return } const newAcctMounts = [...acctMounts] const [moved] = newAcctMounts.splice(fromIdx, 1) newAcctMounts.splice(toIdx, 0, moved) let acctPointer = 0 const nextMounts = mounts.map((item) => { if (item.account_id === accountId) { return newAcctMounts[acctPointer++] } return item }) applyReorder(nextMounts) } const load = useCallback(async () => { const [accts, mts] = await Promise.all([ strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider === 'emby_remote')), embyAPI.listMounts(), ]) setAccounts(accts) setMounts(mts) }, []) useEffect(() => { load() .catch((err) => toast.error(apiErrorMessage(err))) .finally(() => setLoading(false)) }, [load]) const testAccount = async (acct: StrmAccount) => { setTestingID(acct.id) try { const updated = await strmAPI.testAccount(acct.id) toast.success(updated.last_test_ok ? `「${updated.name}」连接正常` : `连接失败:${updated.last_test_result}`) await load() } catch (err) { toast.error(apiErrorMessage(err)) } finally { setTestingID(null) } } const removeAccount = async (acct: StrmAccount) => { if (!(await confirmAction({ message: `删除 Emby 账号「${acct.name}」?其下 ${mounts.filter((m) => m.account_id === acct.id).length} 个挂载会一并移除。`, confirmText: '删除' }))) return try { await strmAPI.deleteAccount(acct.id) toast.success('账号已删除') await load() } catch (err) { toast.error(apiErrorMessage(err)) } } const fullMount = async (acct: StrmAccount) => { if (!(await confirmAction({ message: `把「${acct.name}」上的全部媒体库一键挂载(播放默认直连远程)?`, confirmText: '全量挂载' }))) return try { const res = await embyAPI.fullMountAccount(acct.id, false) toast.success(res.created > 0 ? `已挂载全部 ${res.created} 个媒体库` : '没有新的媒体库需要挂载') await load() } catch (err) { toast.error(apiErrorMessage(err)) } } const removeMount = async (mount: EmbyMount) => { if (!(await confirmAction({ message: `取消挂载「${mount.remote_view_name || mount.name}」?媒体库列表中将不再显示。`, confirmText: '取消挂载' }))) return try { await embyAPI.deleteMount(mount.id) await load() } catch (err) { toast.error(apiErrorMessage(err)) } } const toggleMount = async (mount: EmbyMount, enabled: boolean) => { try { await embyAPI.updateMount(mount.id, { enabled }) await load() } catch (err) { toast.error(apiErrorMessage(err)) } } const toggleMountProxy = async (mount: EmbyMount, proxy: boolean) => { try { await embyAPI.updateMount(mount.id, { proxy_play: proxy }) await load() } catch (err) { toast.error(apiErrorMessage(err)) } } return (

Emby 挂载 {' '} ({mounts.length} 个媒体库)

添加远程 Emby 服务器后,按需挂载其媒体库到本项目;可为同一服务器配置多条线路并自动切换

{loading ? (
加载中…
) : accounts.length === 0 ? (

还没有 Emby 服务器

点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库

) : ( accounts.map((acct) => { const acctMounts = mounts.filter((m) => m.account_id === acct.id) return (
{/* 账号头部 */}

{acct.name}

{acct.has_credential ? '凭据已配置' : '待补全凭据'} {acct.emby_lines && acct.emby_lines.length > 0 ? ` · ${acct.emby_lines.length} 条线路` : ''} {acct.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}

{/* 挂载列表 */}
{acctMounts.length === 0 ? (

尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库

) : (
{acctMounts.map((m, index) => (
handleDragOver(e, m.id)} onDrop={(e) => handleDrop(e, m.id, acct.id)} className={`flex items-center gap-3 rounded-xl border px-3 py-3 transition-colors ${ draggingId === m.id ? 'border-dashed border-brand-400 bg-brand-50/20 opacity-50' : dragOverId === m.id ? 'border-brand-400 bg-brand-50/30' : m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60' }`} >
handleDragStart(e, m.id)} onDragEnd={() => { setDraggingId(null) setDragOverId(null) }} className="shrink-0 cursor-grab active:cursor-grabbing text-sand-400 hover:text-ink-600 p-0.5" title="按住拖拽调整顺序" >

{m.name || m.remote_view_name || '未命名'}

{m.collection_type || 'mixed'} {m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}

))}
)}
) }) )} {accountDialog && ( setAccountDialog(null)} onSaved={() => { setAccountDialog(null); load() }} /> )} {mountDialogFor && setMountDialogFor(null)} onSaved={() => { setMountDialogFor(null); load() }} />}
) } const iconButtonCls = 'inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2 py-1 text-xs font-semibold text-ink-100 transition hover:bg-gray-50' void iconButtonCls