feat(emby): support multiple connection lines for remote mount (#9)

Add account-level multi-line configuration for emby_remote mounts.
Lines are stored in strm_accounts.config as a urls JSON array with
backward compatibility for the legacy single url field. The backend
automatically fails over to the next line on connection errors and
persists the working active_line index.

Frontend: multi-line editor on Emby mount page and STRM account dialog
with add/remove/reorder, line names, and URL validation.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
This commit is contained in:
truewhile
2026-09-02 11:19:56 +08:00
committed by GitHub
parent 543615a2a8
commit 97b3a7bd7f
9 changed files with 676 additions and 43 deletions
+87 -17
View File
@@ -25,6 +25,12 @@ 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'
@@ -40,21 +46,50 @@ function AccountDialog({
onSaved: () => void
}) {
const [name, setName] = useState(existing?.name ?? '')
const [url, setUrl] = useState('')
const [lines, setLines] = useState<EmbyRemoteLine[]>(() => 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 canSave = () => url !== '' && (token !== '' || password !== '')
const normalizedLines = normalizeEmbyRemoteLines(lines)
const canSave = () => normalizedLines.length > 0 && (token !== '' || password !== '' || !!existing?.has_credential)
const updateLine = (index: number, patch: Partial<EmbyRemoteLine>) => {
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<string, string> = {
...(url ? { url } : {}),
...encodeEmbyRemoteConfigLines(readyLines),
...(username ? { username } : {}),
...(password ? { password } : {}),
...(token ? { token } : {}),
@@ -69,7 +104,7 @@ function AccountDialog({
toast.success('Emby 账号已更新')
} else {
if (!canSave()) {
toast.error('请填写 Emby 地址与密码(或 API Key)')
toast.error('请填写线路地址与密码(或 API Key)')
return
}
await strmAPI.createAccount({ name: name || 'Emby 服务器', provider: 'emby_remote', config })
@@ -85,11 +120,11 @@ function AccountDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="w-full max-w-md rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-5 flex items-center justify-between">
<div>
<h3 className="font-display text-xl font-bold text-ink-600">{existing ? '编辑 Emby 账号' : '添加 Emby 账号'}</h3>
<p className="text-xs text-sand-500">连接远程 Emby 服务器(Jellyfin 暂不支持)</p>
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} />
@@ -100,16 +135,48 @@ function AccountDialog({
<span className="mb-1 block font-semibold text-ink-600">账号名称</span>
<input className={inputCls} value={name} placeholder="例如:家庭影院" onChange={(e) => setName(e.target.value)} />
</label>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">Emby 服务地址</span>
<input
className={inputCls}
value={url}
placeholder="http://192.168.1.10:8096"
onChange={(e) => setUrl(e.target.value)}
disabled={!!existing}
/>
</label>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold text-ink-600">接入线路</span>
<button type="button" onClick={addLine} className="inline-flex items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50">
<Plus size={12} />
添加线路
</button>
</div>
{lines.map((line, index) => (
<div key={`line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
线路 {index + 1}
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
</span>
<div className="flex items-center gap-1">
<button type="button" onClick={() => moveLine(index, 'up')} disabled={index === 0} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
<ArrowUp size={12} />
</button>
<button type="button" onClick={() => moveLine(index, 'down')} disabled={index === lines.length - 1} className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30">
<ArrowDown size={12} />
</button>
<button type="button" onClick={() => removeLine(index)} disabled={lines.length <= 1} className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30">
<Trash2 size={12} />
</button>
</div>
</div>
<input
className={inputCls}
value={line.name}
placeholder="线路名称,如:内网 / 外网 / IPv6"
onChange={(e) => updateLine(index, { name: e.target.value })}
/>
<input
className={inputCls}
value={line.url}
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
onChange={(e) => updateLine(index, { url: e.target.value })}
/>
</div>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
@@ -478,7 +545,7 @@ export function EmbyMountPage() {
</span>
</h1>
<p className="text-sm text-ink-50">
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;可为同一服务器配置多条线路并自动切换
</p>
</div>
<button onClick={() => setAccountDialog('new')} className="neon-button">
@@ -512,6 +579,9 @@ export function EmbyMountPage() {
<p className="font-display text-lg font-bold text-ink-600">{acct.name}</p>
<p className="text-xs text-sand-500">
{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}` : ''}
</p>
</div>
+104 -6
View File
@@ -1,15 +1,19 @@
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'
@@ -23,6 +27,12 @@ import type { StrmAccount, StrmSyncPath, StrmSyncPathInput, StrmProvider } from
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'
// ─── 弹框外壳 ────────────────────────────────────────────────────────────────
@@ -106,6 +116,9 @@ export function StrmAccountDialog({
const [name, setName] = useState(existing?.name ?? '')
const [enabled, setEnabled] = useState(existing?.enabled ?? true)
const [url, setUrl] = useState('')
const [embyLines, setEmbyLines] = useState<EmbyRemoteLine[]>(() =>
defaultEmbyRemoteLines(existing?.provider === 'emby_remote' ? existing?.emby_lines : undefined),
)
const [server, setServer] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
@@ -128,14 +141,16 @@ export function StrmAccountDialog({
...(password ? { password } : {}),
...(token ? { token } : {}),
}
case 'emby_remote':
case 'emby_remote': {
const lineConfig = encodeEmbyRemoteConfigLines(embyLines)
return {
...(url ? { url } : {}),
...lineConfig,
...(username ? { username } : {}),
...(password ? { password } : {}),
...(token ? { token } : {}),
proxy_play: proxyPlay ? 'true' : 'false',
}
}
default:
return {}
}
@@ -148,7 +163,7 @@ export function StrmAccountDialog({
case 'openlist':
return server !== '' && (token !== '' || password !== '')
case 'emby_remote':
return url !== '' && (token !== '' || password !== '')
return normalizeEmbyRemoteLines(embyLines).length > 0 && (token !== '' || password !== '')
default:
return false
}
@@ -255,9 +270,92 @@ export function StrmAccountDialog({
)}
{provider === 'emby_remote' && (
<Field label="Emby 服务地址" hint="远程 Emby 服务器地址,如 http://192.168.1.10:8096">
<input className={inputCls} value={url} placeholder="http://host:8096" onChange={(e) => setUrl(e.target.value)} />
</Field>
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-medium text-ink-100">接入线路</p>
<p className="text-xs text-sand-500">可配置内网、外网等多条线路,连接失败时按顺序自动切换</p>
</div>
<button
type="button"
onClick={() => setEmbyLines((prev) => [...prev, { name: `线路 ${prev.length + 1}`, url: '' }])}
className="inline-flex shrink-0 items-center gap-1 rounded-lg border border-gray-200 px-2.5 py-1 text-xs font-semibold text-ink-100 hover:bg-gray-50"
>
<Plus size={12} />
添加线路
</button>
</div>
{embyLines.map((line, index) => (
<div key={`emby-line-${index}`} className="space-y-2 rounded-2xl border border-sand-200 bg-gray-50/70 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold uppercase tracking-wide text-sand-500">
线路 {index + 1}
{existing?.emby_active_line === index ? ' · 当前优先' : index === 0 ? ' · 默认优先' : ''}
</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setEmbyLines((prev) => {
const target = index - 1
if (target < 0) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
disabled={index === 0}
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
>
<ArrowUp size={12} />
</button>
<button
type="button"
onClick={() => {
setEmbyLines((prev) => {
const target = index + 1
if (target >= prev.length) return prev
const next = [...prev]
const [moved] = next.splice(index, 1)
next.splice(target, 0, moved)
return next
})
}}
disabled={index === embyLines.length - 1}
className="rounded-lg border border-gray-200 p-1 text-ink-100 hover:bg-white disabled:opacity-30"
>
<ArrowDown size={12} />
</button>
<button
type="button"
onClick={() => setEmbyLines((prev) => (prev.length <= 1 ? prev : prev.filter((_, i) => i !== index)))}
disabled={embyLines.length <= 1}
className="rounded-lg border border-gray-200 p-1 text-red-500 hover:bg-red-50 disabled:opacity-30"
>
<Trash2 size={12} />
</button>
</div>
</div>
<input
className={inputCls}
value={line.name}
placeholder="线路名称,如:内网 / 外网 / IPv6"
onChange={(e) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, name: e.target.value } : item)))
}
/>
<input
className={inputCls}
value={line.url}
placeholder="http://192.168.1.10:8096 或 https://emby.example.com"
onChange={(e) =>
setEmbyLines((prev) => prev.map((item, i) => (i === index ? { ...item, url: e.target.value } : item)))
}
/>
</div>
))}
</div>
)}
{(provider === 'clouddrive2' || provider === 'emby_remote') && (
+9
View File
@@ -9,6 +9,11 @@ export const STRM_PROVIDER_LABELS: Record<StrmProvider, string> = {
emby_remote: 'Emby 远程挂载',
}
export type EmbyRemoteLine = {
name: string
url: string
}
export interface StrmAccount {
id: string
name: string
@@ -23,6 +28,10 @@ export interface StrmAccount {
provider_label: string
/** 仅远程 Emby 挂载账号返回:播放流量是否经过 MMTL 代理 */
proxy_play?: boolean
/** 仅远程 Emby 挂载账号返回:多线路配置 */
emby_lines?: EmbyRemoteLine[]
/** 当前优先使用的线路下标 */
emby_active_line?: number
}
export interface StrmAccountInput {
+31
View File
@@ -0,0 +1,31 @@
export type EmbyRemoteLine = {
name: string
url: string
}
export function normalizeEmbyRemoteLines(lines: EmbyRemoteLine[]): EmbyRemoteLine[] {
return lines
.map((line) => ({
name: line.name.trim(),
url: line.url.trim().replace(/\/+$/, ''),
}))
.filter((line) => line.url.startsWith('http://') || line.url.startsWith('https://'))
}
export function encodeEmbyRemoteConfigLines(lines: EmbyRemoteLine[]): Record<string, string> {
const normalized = normalizeEmbyRemoteLines(lines)
if (normalized.length === 0) {
return {}
}
return {
urls: JSON.stringify(normalized),
url: normalized[0].url,
}
}
export function defaultEmbyRemoteLines(existing?: EmbyRemoteLine[]): EmbyRemoteLine[] {
if (existing && existing.length > 0) {
return existing.map((line) => ({ name: line.name ?? '', url: line.url ?? '' }))
}
return [{ name: '线路 1', url: '' }]
}