feat: Emby 挂载独立管理(媒体库级选择挂载 + 每库代理开关)

- 新增 EmbyMount 表与 /admin/emby/mounts CRUD:把远程 Emby 的媒体库按需
  挂载到本项目(同一服务器可选择性挂载/全量挂载),代理开关下沉到每个
  挂载(一个 Emby 服务器可部分库代理、部分库直连)
- 伪装 ID 改为 embyremote~{mountID}~{remoteID}:播放/详情/状态/图片链路
  全部经 ResolveMount 解析挂载与账号
- 新增「Emby 挂载」独立菜单页(/emby-mount):账号管理(添加/测试/编辑/
  删除)+ 挂载列表(代理切换/停用/取消)+ 选择挂载对话框(多选+全量)
- STRM 管理页过滤 emby_remote 账号;账号表单移除 emby 类型
- Boot 幂等 AutoSeedMounts:旧 emby 账号自动全量挂载(沿用旧代理配置),
  升级后媒体库不消失
- 修复:远程流请求注入浏览器 UA(防 Cloudflare 风控)+ 代理强制 Static=true
  (阻止远程 ffmpeg 转码调度,反代纯字节中继)
- 全局搜索改为按挂载逐个搜索(结果归属与伪装 ID 正确)
This commit is contained in:
truewhile
2026-09-01 13:53:15 +08:00
parent 5a5555d28a
commit 73b95a8e38
24 changed files with 1331 additions and 186 deletions
+19
View File
@@ -0,0 +1,19 @@
import { api } from './client'
import type { EmbyMount, EmbyMountInput, RemoteEmbyView } from '../types/emby'
export const embyAPI = {
listMounts: () => api.get<EmbyMount[]>('/admin/emby/mounts').then((r) => r.data),
createMounts: (payload: EmbyMountInput) =>
api.post<{ ok: boolean; created: number }>('/admin/emby/mounts', payload).then((r) => r.data),
updateMount: (id: string, payload: { name?: string; proxy_play?: boolean; enabled?: boolean }) =>
api.put<EmbyMount>(`/admin/emby/mounts/${id}`, payload).then((r) => r.data),
deleteMount: (id: string) => api.delete(`/admin/emby/mounts/${id}`).then((r) => r.data),
listAccountViews: (accountId: string) =>
api.get<RemoteEmbyView[]>(`/admin/emby/accounts/${accountId}/views`).then((r) => r.data),
fullMountAccount: (accountId: string, proxy: boolean) =>
api
.post<{ ok: boolean; created: number }>(`/admin/emby/accounts/${accountId}/full-mount`, null, {
params: { proxy: proxy ? 1 : 0 },
})
.then((r) => r.data),
}
+2
View File
@@ -26,6 +26,7 @@ const ProfileManagementPage = lazy(() =>
import('./pages/ProfileManagementPage').then((m) => ({ default: m.ProfileManagementPage })),
)
const SettingsPage = lazy(() => import('./pages/SettingsPage').then((m) => ({ default: m.SettingsPage })))
const EmbyMountPage = lazy(() => import('./pages/EmbyMountPage').then((m) => ({ default: m.EmbyMountPage })))
const StrmManagePage = lazy(() => import('./pages/StrmManagePage').then((m) => ({ default: m.StrmManagePage })))
const StrmDownloadQueuePage = lazy(() =>
import('./pages/StrmQueuePage').then((m) => ({ default: m.StrmDownloadQueuePage })),
@@ -62,6 +63,7 @@ export const appRoutes: AppRoute[] = [
{ path: 'tools', element: <Navigate to="/files" replace /> },
{ path: 'files', element: <FileManagerPage />, adminOnly: true },
{ path: 'settings', element: <SettingsPage />, adminOnly: true },
{ path: 'emby-mount', element: <EmbyMountPage />, adminOnly: true },
{ path: 'strm', element: <StrmManagePage />, adminOnly: true },
{ path: 'strm/downloads', element: <StrmDownloadQueuePage />, adminOnly: true },
{ path: 'strm/uploads', element: <StrmUploadQueuePage />, adminOnly: true },
+3
View File
@@ -6,6 +6,7 @@ import {
Library,
Settings,
Sparkles,
Tv,
Upload,
User,
Users,
@@ -27,6 +28,8 @@ export const LAYOUT_NAV_ITEMS: LayoutNavItem[] = [
{ to: '/admin', label: '用户管理', icon: Users, adminOnly: true },
{ to: '/files', label: '文件管理', icon: FolderOpen, adminOnly: true },
{ to: '/settings', label: '系统设置', icon: Settings, adminOnly: true },
// Emby 挂载(设置区):远程 Emby 媒体库挂载
{ to: '/emby-mount', label: 'Emby 挂载', icon: Tv, adminOnly: true },
// STRM 管理(设置区):网盘目录生成 strm 与元数据下载/上传队列
{ to: '/strm', label: 'STRM 管理', icon: FileOutput, adminOnly: true },
{ to: '/strm/downloads', label: '下载队列', icon: Download, adminOnly: true },
+518
View File
@@ -0,0 +1,518 @@
/* eslint-disable react-refresh/only-export-components */
import { useCallback, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
import {
Check,
Globe,
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'
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 [url, setUrl] = useState('')
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 submit = async (event: React.FormEvent) => {
event.preventDefault()
setSaving(true)
try {
const config: Record<string, string> = {
...(url ? { url } : {}),
...(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('请填写 Emby 地址与密码(或 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 (
<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="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>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} />
</button>
</div>
<form onSubmit={submit} className="space-y-4">
<label className="block text-sm">
<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="grid grid-cols-2 gap-3">
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">用户名</span>
<input className={inputCls} value={username} onChange={(e) => setUsername(e.target.value)} />
</label>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">密码</span>
<input className={inputCls} type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</label>
</div>
<label className="block text-sm">
<span className="mb-1 block font-semibold text-ink-600">API Key(可选)</span>
<input
className={inputCls}
value={token}
placeholder="留空则用用户名/密码自动认证"
onChange={(e) => setToken(e.target.value)}
/>
</label>
{existing && (
<label className="flex cursor-pointer items-center gap-2 text-sm text-ink-100">
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
启用该账号
</label>
)}
<div className="flex justify-end gap-2 pt-2">
<button type="button" onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
取消
</button>
<button type="submit" disabled={saving} className="neon-button disabled:opacity-50">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Server size={16} />}
保存
</button>
</div>
</form>
</div>
</div>
)
}
// ─── 添加挂载对话框 ────────────────────────────────────────────────────────────
function MountDialog({
account,
onClose,
onSaved,
}: {
account: StrmAccount
onClose: () => void
onSaved: () => void
}) {
const [views, setViews] = useState<RemoteEmbyView[]>([])
const [selected, setSelected] = useState<Record<string, boolean>>({})
const [proxy, setProxy] = useState<Record<string, boolean>>({})
const nameOverride: Record<string, string> = {}
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<string, boolean> = {}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-3xl border border-sand-200 bg-white p-6 shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="font-display text-xl font-bold text-ink-600">选择要挂载的媒体库</h3>
<p className="text-xs text-sand-500">
「{account.name}」上的远程媒体库,勾选后出现在本项目媒体库;未勾选的不挂载
</p>
</div>
<button onClick={onClose} className="rounded-lg p-1.5 text-sand-500 hover:bg-gray-100">
<X size={18} />
</button>
</div>
<div className="mb-3 flex items-center justify-between rounded-xl bg-gray-50 px-4 py-2.5 text-sm">
<label className="flex items-center gap-2 font-semibold text-ink-600">
<input type="checkbox" className="h-4 w-4 accent-primary-400" checked={allProxy} onChange={(e) => setAllProxy(e.target.checked)} />
全部走本服务器代理播放
</label>
<span className="text-xs text-sand-500">关闭(默认)= 客户端直连远程 Emby 拉流</span>
</div>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
{loading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-sand-500">
<Loader2 size={16} className="animate-spin" /> 正在读取远程媒体库…
</div>
) : views.length === 0 ? (
<p className="py-10 text-center text-sm text-sand-500">远程服务器没有可挂载的媒体库</p>
) : (
views.map((v) => {
const checked = selected[v.remote_view_id]
const disabled = v.already_mounted
return (
<div key={v.remote_view_id} className={`flex items-center gap-3 rounded-xl border px-3.5 py-2.5 ${disabled ? 'border-gray-100 bg-gray-50 opacity-60' : 'border-sand-200 bg-white'}`}>
<input
type="checkbox"
className="h-4 w-4 accent-primary-400"
checked={disabled ? false : checked}
disabled={disabled}
onChange={() => toggleView(v.remote_view_id)}
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink-600">{v.remote_view_name}</p>
<p className="text-xs text-sand-500">
{v.collection_type || 'mixed'}
{v.child_count > 0 ? ` · ${v.child_count} 条目` : ''}
{disabled ? ' · 已挂载' : ''}
</p>
</div>
{!disabled && (
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-xs font-semibold text-ink-100">
<input
type="checkbox"
className="h-3.5 w-3.5 accent-primary-400"
checked={proxy[v.remote_view_id] || false}
disabled={allProxy}
onChange={() => toggleProxy(v.remote_view_id)}
/>
代理
</label>
)}
</div>
)
})
)}
</div>
<div className="mt-4 flex justify-end gap-2">
<button onClick={onClose} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-ink-100 hover:bg-gray-50">
取消
</button>
<button type="button" onClick={submit} disabled={saving || loading} className="neon-button disabled:opacity-50">
{saving ? <Loader2 size={16} className="animate-spin" /> : <Tv size={16} />}
挂载所选媒体库
</button>
</div>
</div>
</div>
)
}
// ─── 页面主体 ──────────────────────────────────────────────────────────────────
export function EmbyMountPage() {
const [accounts, setAccounts] = useState<StrmAccount[]>([])
const [mounts, setMounts] = useState<EmbyMount[]>([])
const [loading, setLoading] = useState(true)
const [accountDialog, setAccountDialog] = useState<StrmAccount | null | 'new'>(null)
const [mountDialogFor, setMountDialogFor] = useState<StrmAccount | null>(null)
const [testingID, setTestingID] = useState<string | null>(null)
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 (
<div className="space-y-8">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="font-display text-3xl font-bold text-ink-600">
Emby 挂载
<span className="text-sand-500">
{' '}
({mounts.length} 个媒体库)
</span>
</h1>
<p className="text-sm text-ink-50">
添加远程 Emby 服务器后,按需挂载其媒体库到本项目;播放默认直连原 Emby,可对单个媒体库开启本机代理
</p>
</div>
<button onClick={() => setAccountDialog('new')} className="neon-button">
<Server size={16} />
添加 Emby 账号
</button>
</div>
{loading ? (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-sand-500">
<Loader2 size={18} className="animate-spin" /> 加载中…
</div>
) : accounts.length === 0 ? (
<div className="rounded-3xl border border-dashed border-sand-300 bg-white/60 p-14 text-center">
<Globe size={36} className="mx-auto mb-3 text-sand-400" />
<p className="font-semibold text-ink-600">还没有 Emby 服务器</p>
<p className="mt-1 text-sm text-sand-500">点击右上角「添加 Emby 账号」,填入服务器地址与凭据后即可挂载媒体库</p>
</div>
) : (
accounts.map((acct) => {
const acctMounts = mounts.filter((m) => m.account_id === acct.id)
return (
<div key={acct.id} className="overflow-hidden rounded-3xl border border-sand-200 bg-white shadow-card">
{/* 账号头部 */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-sand-100 bg-gradient-to-r from-brand-50/60 to-transparent px-5 py-4">
<div className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-brand-100 text-brand-600">
<Server size={20} />
</span>
<div>
<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.last_test_result ? ` · 最近测试:${acct.last_test_ok ? '正常' : acct.last_test_result}` : ''}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={() => testAccount(acct)} disabled={testingID === acct.id} className="iconButtonCls">
{testingID === acct.id ? <Loader2 size={13} className="animate-spin" /> : <RefreshCw size={13} />}
测试
</button>
<button onClick={() => setAccountDialog(acct)} className="iconButtonCls">
<Pencil size={13} />
编辑
</button>
<button onClick={() => removeAccount(acct)} className="iconButtonCls !text-red-500 hover:!bg-red-50">
<Trash2 size={13} />
删除
</button>
</div>
</div>
{/* 挂载列表 */}
<div className="px-5 py-4">
{acctMounts.length === 0 ? (
<p className="py-6 text-center text-sm text-sand-500">
尚未挂载媒体库 —— 点击下方「选择挂载」,或一键全量挂载该服务器的全部媒体库
</p>
) : (
<div className="space-y-2">
{acctMounts.map((m) => (
<div key={m.id} className={`flex items-center gap-3 rounded-xl border px-4 py-3 ${m.enabled ? 'border-sand-200 bg-gray-50/60' : 'border-gray-100 bg-gray-50 opacity-60'}`}>
<Tv size={16} className="shrink-0 text-brand-500" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-ink-600">
{m.name || m.remote_view_name || '未命名'}
</p>
<p className="text-xs text-sand-500">
{m.collection_type || 'mixed'}
{m.proxy_play ? ' · 本机代理播放' : ' · 直连播放'}
</p>
</div>
<button
onClick={() => toggleMountProxy(m, !m.proxy_play)}
disabled={!m.enabled}
className="rounded-lg border border-gray-200 px-2.5 py-1.5 text-xs font-semibold text-ink-100 hover:bg-gray-100 disabled:opacity-40"
title="切换播放代理"
>
{m.proxy_play ? '代理中' : '直连'}
</button>
<button
onClick={() => toggleMount(m, !m.enabled)}
className="rounded-lg border border-gray-200 p-1.5 text-ink-100 hover:bg-gray-100"
title={m.enabled ? '停用挂载' : '启用挂载'}
>
{m.enabled ? <Power size={14} /> : <PowerOff size={14} />}
</button>
<button onClick={() => removeMount(m)} className="rounded-lg border border-gray-200 p-1.5 text-red-500 hover:bg-red-50" title="取消挂载">
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<button onClick={() => setMountDialogFor(acct)} className="btn-outline">
<Plus size={14} />
选择挂载媒体库
</button>
<button onClick={() => fullMount(acct)} className="btn-outline">
<Check size={14} />
全量挂载
</button>
</div>
</div>
</div>
)
})
)}
{accountDialog && (
<AccountDialog existing={accountDialog === 'new' ? null : accountDialog} onClose={() => setAccountDialog(null)} onSaved={() => { setAccountDialog(null); load() }} />
)}
{mountDialogFor && <MountDialog account={mountDialogFor} onClose={() => setMountDialogFor(null)} onSaved={() => { setMountDialogFor(null); load() }} />}
</div>
)
}
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
-1
View File
@@ -91,7 +91,6 @@ 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 兼容服务' },
{ provider: 'emby_remote', label: 'Emby 远程挂载', desc: '聚合远程 Emby 媒体库,不落库实时透传' },
]
export function StrmAccountDialog({
+1 -1
View File
@@ -74,7 +74,7 @@ export function StrmManagePage() {
const refresh = useCallback(async () => {
try {
const [accts, pths, recs] = await Promise.all([
strmAPI.listAccounts(),
strmAPI.listAccounts().then((rows) => rows.filter((a) => a.provider !== 'emby_remote')),
strmAPI.listPaths(),
strmAPI.listRecords(),
])
+36
View File
@@ -0,0 +1,36 @@
// Emby 远程挂载类型定义(与后端 internal/model/emby_mount.go 对应)。
export interface EmbyMount {
id: string
account_id: string
remote_view_id: string
remote_view_name: string
collection_type: string
name: string
proxy_play: boolean
enabled: boolean
created_at: string
updated_at: string
/** 所属账号名(接口侧拼装) */
account_name?: string
}
export interface EmbyMountInput {
account_id: string
views: EmbyViewInput[]
}
export interface EmbyViewInput {
remote_view_id: string
remote_view_name: string
collection_type: string
name?: string
proxy_play: boolean
}
export interface RemoteEmbyView {
remote_view_id: string
remote_view_name: string
collection_type: string
child_count: number
already_mounted: boolean
}