From 2736c9e845b57cab5c600931c0a710501736f0c8 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Thu, 8 Jan 2026 00:14:07 +0800 Subject: [PATCH] =?UTF-8?q?emby=E6=94=AF=E6=8C=81=E5=A4=9A=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/admin/page.tsx | 580 +++++++++++++----- src/app/api/admin/config/route.ts | 51 ++ src/app/api/emby/cms-proxy/[token]/route.ts | 41 +- src/app/api/emby/detail/route.ts | 24 +- src/app/api/emby/list/route.ts | 52 +- .../api/emby/play/[token]/[filename]/route.ts | 64 +- src/app/api/emby/sources/route.ts | 27 + src/app/api/emby/views/route.ts | 52 +- src/app/api/search/route.ts | 110 ++-- src/app/api/search/ws/route.ts | 184 +++--- src/app/api/source-detail/route.ts | 31 +- src/app/api/tvbox/subscribe/route.ts | 21 +- src/app/layout.tsx | 8 +- src/app/play/page.tsx | 66 +- src/app/private-library/page.tsx | 247 ++++++-- src/app/search/page.tsx | 24 +- src/components/EpisodeSelector.tsx | 2 +- src/components/VideoCard.tsx | 4 +- src/lib/admin.types.ts | 37 +- src/lib/config.ts | 54 ++ src/lib/emby-cache.ts | 27 +- src/lib/emby-manager.ts | 182 ++++++ 22 files changed, 1316 insertions(+), 572 deletions(-) create mode 100644 src/app/api/emby/sources/route.ts create mode 100644 src/lib/emby-manager.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 244d17d..ecdf110 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3399,7 +3399,7 @@ const OpenListConfigComponent = ({ ); }; -// Emby 媒体库配置组件 +// Emby 媒体库配置组件 - 多源管理版本 const EmbyConfigComponent = ({ config, refreshConfig, @@ -3409,67 +3409,209 @@ const EmbyConfigComponent = ({ }) => { const { alertModal, showAlert, hideAlert } = useAlertModal(); const { isLoading, withLoading } = useLoadingState(); - const [enabled, setEnabled] = useState(false); - const [serverURL, setServerURL] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); - const [userId, setUserId] = useState(''); + // 源列表状态 + const [sources, setSources] = useState([]); + const [editingSource, setEditingSource] = useState(null); + const [showAddForm, setShowAddForm] = useState(false); + + // 表单状态 + const [formData, setFormData] = useState({ + key: '', + name: '', + enabled: true, + ServerURL: '', + ApiKey: '', + Username: '', + Password: '', + UserId: '', + isDefault: false, + }); + + // 从配置加载源列表 useEffect(() => { - if (config?.EmbyConfig) { - setEnabled(config.EmbyConfig.Enabled || false); - setServerURL(config.EmbyConfig.ServerURL || ''); - setApiKey(config.EmbyConfig.ApiKey || ''); - setUsername(config.EmbyConfig.Username || ''); - setPassword(config.EmbyConfig.Password || ''); - setUserId(config.EmbyConfig.UserId || ''); + if (config?.EmbyConfig?.Sources) { + setSources(config.EmbyConfig.Sources); + } else if (config?.EmbyConfig?.ServerURL) { + // 兼容旧格式 + setSources([{ + key: 'default', + name: 'Emby', + enabled: config.EmbyConfig.Enabled || false, + ServerURL: config.EmbyConfig.ServerURL, + ApiKey: config.EmbyConfig.ApiKey, + Username: config.EmbyConfig.Username, + Password: config.EmbyConfig.Password, + UserId: config.EmbyConfig.UserId, + isDefault: true, + }]); } }, [config]); + // 重置表单 + const resetForm = () => { + setFormData({ + key: '', + name: '', + enabled: true, + ServerURL: '', + ApiKey: '', + Username: '', + Password: '', + UserId: '', + isDefault: false, + }); + setEditingSource(null); + setShowAddForm(false); + }; + + // 开始编辑 + const handleEdit = (source: any) => { + setFormData({ ...source }); + setEditingSource(source); + setShowAddForm(false); + }; + + // 开始添加 + const handleAdd = () => { + resetForm(); + setShowAddForm(true); + }; + + // 保存源(添加或更新) const handleSave = async () => { - await withLoading('saveEmby', async () => { + // 验证必填字段 + if (!formData.key || !formData.name || !formData.ServerURL) { + showError('请填写必填字段:标识符、名称、服务器地址', showAlert); + return; + } + + // 验证key唯一性 + if (!editingSource && sources.some(s => s.key === formData.key)) { + showError('标识符已存在,请使用其他标识符', showAlert); + return; + } + + await withLoading('saveEmbySource', async () => { try { - const response = await fetch('/api/admin/emby', { + let newSources; + if (editingSource) { + // 更新现有源 + newSources = sources.map(s => + s.key === editingSource.key ? formData : s + ); + } else { + // 添加新源 + newSources = [...sources, formData]; + } + + // 保存到配置 + const response = await fetch('/api/admin/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - action: 'save', - Enabled: enabled, - ServerURL: serverURL, - ApiKey: apiKey, - Username: username, - Password: password, - UserId: userId, + ...config, + EmbyConfig: { + Sources: newSources, + }, }), }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || '保存失败'); + throw new Error('保存失败'); } await refreshConfig(); - showSuccess('保存成功', showAlert); + resetForm(); + showSuccess(editingSource ? '更新成功' : '添加成功', showAlert); } catch (error) { showError(error instanceof Error ? error.message : '保存失败', showAlert); - throw error; } }); }; - const handleTest = async () => { - await withLoading('testEmby', async () => { + // 删除源 + const handleDelete = async (source: any) => { + if (sources.length === 1) { + showError('至少需要保留一个Emby源', showAlert); + return; + } + + if (!confirm(`确定要删除 "${source.name}" 吗?`)) { + return; + } + + await withLoading('deleteEmbySource', async () => { + try { + const newSources = sources.filter(s => s.key !== source.key); + + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...config, + EmbyConfig: { + Sources: newSources, + }, + }), + }); + + if (!response.ok) { + throw new Error('删除失败'); + } + + await refreshConfig(); + showSuccess('删除成功', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '删除失败', showAlert); + } + }); + }; + + // 切换启用状态 + const handleToggleEnabled = async (source: any) => { + await withLoading('toggleEmbySource', async () => { + try { + const newSources = sources.map(s => + s.key === source.key ? { ...s, enabled: !s.enabled } : s + ); + + const response = await fetch('/api/admin/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...config, + EmbyConfig: { + Sources: newSources, + }, + }), + }); + + if (!response.ok) { + throw new Error('更新失败'); + } + + await refreshConfig(); + showSuccess(source.enabled ? '已禁用' : '已启用', showAlert); + } catch (error) { + showError(error instanceof Error ? error.message : '更新失败', showAlert); + } + }); + }; + + // 测试连接 + const handleTest = async (source: any) => { + await withLoading('testEmbySource', async () => { try { const response = await fetch('/api/admin/emby', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'test', - ServerURL: serverURL, - ApiKey: apiKey, - Username: username, - Password: password, + ServerURL: source.ServerURL, + ApiKey: source.ApiKey, + Username: source.Username, + Password: source.Password, }), }); @@ -3486,6 +3628,7 @@ const EmbyConfigComponent = ({ }); }; + // 清除缓存 const handleClearCache = async () => { await withLoading('clearEmbyCache', async () => { try { @@ -3523,127 +3666,280 @@ const EmbyConfigComponent = ({ onConfirm={alertModal.onConfirm} /> - {/* 启用开关 */} -
- - + {/* 源列表 */} +
+
+

+ Emby 源列表 ({sources.length}) +

+ +
+ + {sources.length === 0 ? ( +
+ 暂无Emby源,点击"添加新源"开始配置 +
+ ) : ( + sources.map((source) => ( +
+
+
+
+

+ {source.name} +

+ {source.isDefault && ( + + 默认 + + )} + + {source.enabled ? '已启用' : '已禁用'} + +
+

+ 标识符: {source.key} +

+

+ 服务器: {source.ServerURL} +

+ {source.UserId && ( +

+ 用户ID: {source.UserId} +

+ )} +
+
+ + + + +
+
+
+ )) + )}
- {/* 服务器地址 */} -
- - setServerURL(e.target.value)} - placeholder='http://192.168.1.100:8096' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> -
+ {/* 添加/编辑表单 */} + {(showAddForm || editingSource) && ( +
+

+ {editingSource ? '编辑 Emby 源' : '添加新的 Emby 源'} +

- {/* API Key */} -
- - setApiKey(e.target.value)} - placeholder='输入 Emby API Key' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> -

- 推荐使用 API Key 认证。如果不使用 API Key,请填写下方的用户名和密码。 -

-
+
+ {/* 标识符 */} +
+ + setFormData({ ...formData, key: e.target.value })} + disabled={!!editingSource} + placeholder='home, office, etc.' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 disabled:bg-gray-100 dark:disabled:bg-gray-700' + /> +

+ 唯一标识符,只能包含字母、数字、下划线,创建后不可修改 +

+
- {/* 用户名 */} -
- - setUsername(e.target.value)} - placeholder='Emby 用户名' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> -
+ {/* 名称 */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder='家庭Emby, 公司Emby, etc.' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
- {/* 密码 */} -
- - setPassword(e.target.value)} - placeholder='Emby 密码' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> -
+ {/* 服务器地址 */} +
+ + setFormData({ ...formData, ServerURL: e.target.value })} + placeholder='http://192.168.1.100:8096' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
- {/* 用户 ID */} -
- - setUserId(e.target.value)} - placeholder='aab507c58e874de6a9bd12388d72f4d2' - className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' - /> -

- 从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/... -

-
+ {/* API Key */} +
+ + setFormData({ ...formData, ApiKey: e.target.value })} + placeholder='输入 Emby API Key' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +

+ 推荐使用 API Key 认证。如果不使用 API Key,请填写下方的用户名和密码。 +

+
- {/* 操作按钮 */} -
- - -
+ {/* 用户名 */} +
+ + setFormData({ ...formData, Username: e.target.value })} + placeholder='Emby 用户名' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
- {/* 清除缓存按钮 */} -
+ {/* 密码 */} +
+ + setFormData({ ...formData, Password: e.target.value })} + placeholder='Emby 密码' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +
+ + {/* 用户 ID */} +
+ + setFormData({ ...formData, UserId: e.target.value })} + placeholder='aab507c58e874de6a9bd12388d72f4d2' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100' + /> +

+ 从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/... +

+
+ + {/* 启用开关 */} +
+ + +
+ + {/* 默认源开关 */} +
+ + +
+ + {/* 操作按钮 */} +
+ + +
+
+
+ )} + + {/* 全局操作 */} +
diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts index 8fbe7eb..8aebd61 100644 --- a/src/app/api/admin/config/route.ts +++ b/src/app/api/admin/config/route.ts @@ -78,3 +78,54 @@ export async function GET(request: NextRequest) { ); } } + +export async function POST(request: NextRequest) { + const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage'; + if (storageType === 'localstorage') { + return NextResponse.json( + { error: '不支持本地存储进行管理员配置' }, + { status: 400 } + ); + } + + const authInfo = getAuthInfoFromCookie(request); + if (!authInfo || !authInfo.username) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const username = authInfo.username; + + try { + const newConfig = await request.json(); + + // 权限检查 + if (username !== process.env.USERNAME) { + const { db } = await import('@/lib/db'); + const userInfoV2 = await db.getUserInfoV2(username); + + if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) { + return NextResponse.json({ error: '权限不足' }, { status: 401 }); + } + } + + // 保存配置 + const { db } = await import('@/lib/db'); + const { configSelfCheck, setCachedConfig } = await import('@/lib/config'); + + // 自检配置 + const checkedConfig = configSelfCheck(newConfig); + + // 保存到数据库 + await db.saveAdminConfig(checkedConfig); + + // 更新缓存 + await setCachedConfig(checkedConfig); + + return NextResponse.json({ success: true, message: '配置已保存' }); + } catch (error) { + console.error('保存配置失败:', error); + return NextResponse.json( + { error: '保存配置失败: ' + (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/emby/cms-proxy/[token]/route.ts b/src/app/api/emby/cms-proxy/[token]/route.ts index 2588895..77a4082 100644 --- a/src/app/api/emby/cms-proxy/[token]/route.ts +++ b/src/app/api/emby/cms-proxy/[token]/route.ts @@ -47,10 +47,9 @@ export async function GET( try { const config = await getConfig(); - const embyConfig = config.EmbyConfig; - // 验证 Emby 配置 - if (!embyConfig?.Enabled || !embyConfig.ServerURL) { + // 验证 Emby 配置(多源) + if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { return NextResponse.json({ code: 0, msg: 'Emby 未配置或未启用', @@ -62,31 +61,18 @@ export async function GET( }); } - const client = new EmbyClient(embyConfig); + // 获取 embyKey 参数 + const embyKey = searchParams.get('embyKey') || undefined; - // 如果没有 UserId,需要先认证 - if (!embyConfig.UserId && embyConfig.Username && embyConfig.Password) { - const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); - embyConfig.UserId = authResult.User.Id; - } - - if (!embyConfig.UserId) { - return NextResponse.json({ - code: 0, - msg: 'Emby 认证失败', - page: 1, - pagecount: 0, - limit: 0, - total: 0, - list: [], - }); - } + // 使用 EmbyManager 获取客户端 + const { embyManager } = await import('@/lib/emby-manager'); + const client = await embyManager.getClient(embyKey); // 路由处理 if (wd) { // 搜索模式 if (ac === 'detail') { - return await handleDetailBySearch(client, wd, requestToken, request); + return await handleDetailBySearch(client, wd, requestToken, embyKey, request); } return await handleSearch(client, wd); } else if (ids || ac === 'detail') { @@ -102,7 +88,7 @@ export async function GET( list: [], }); } - return await handleDetail(client, ids, requestToken, request); + return await handleDetail(client, ids, requestToken, embyKey, request); } else { // 列表模式 return await handleSearch(client, ''); @@ -161,6 +147,7 @@ async function handleDetailBySearch( client: EmbyClient, query: string, token: string, + embyKey: string | undefined, request: NextRequest ) { const result = await client.getItems({ @@ -183,7 +170,7 @@ async function handleDetailBySearch( }); } - return await handleDetail(client, result.Items[0].Id, token, request); + return await handleDetail(client, result.Items[0].Id, token, embyKey, request); } /** @@ -193,6 +180,7 @@ async function handleDetail( client: EmbyClient, itemId: string, token: string, + embyKey: string | undefined, request: NextRequest ) { const item = await client.getItem(itemId); @@ -203,11 +191,12 @@ async function handleDetail( (host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https'); const baseUrl = process.env.SITE_BASE || `${proto}://${host}`; + const embyKeyParam = embyKey ? `&embyKey=${embyKey}` : ''; let vodPlayUrl = ''; if (item.Type === 'Movie') { // 电影:单个播放链接(使用代理,添加 .mp4 扩展名) - const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}`; + const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}${embyKeyParam}`; vodPlayUrl = `正片$${proxyUrl}`; } else if (item.Type === 'Series') { // 剧集:获取所有集 @@ -222,7 +211,7 @@ async function handleDetail( }) .map((ep) => { const title = `第${ep.IndexNumber}集`; - const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}`; + const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}${embyKeyParam}`; return `${title}$${proxyUrl}`; }); diff --git a/src/app/api/emby/detail/route.ts b/src/app/api/emby/detail/route.ts index 24a3cad..6e89622 100644 --- a/src/app/api/emby/detail/route.ts +++ b/src/app/api/emby/detail/route.ts @@ -2,38 +2,22 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConfig } from '@/lib/config'; -import { EmbyClient } from '@/lib/emby.client'; +import { embyManager } from '@/lib/emby-manager'; export const runtime = 'nodejs'; export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const itemId = searchParams.get('id'); + const embyKey = searchParams.get('embyKey') || undefined; if (!itemId) { return NextResponse.json({ error: '缺少媒体ID' }, { status: 400 }); } try { - const config = await getConfig(); - const embyConfig = config.EmbyConfig; - - if (!embyConfig?.Enabled || !embyConfig.ServerURL) { - return NextResponse.json({ error: 'Emby 未配置或未启用' }, { status: 400 }); - } - - const client = new EmbyClient(embyConfig); - - // 如果没有 UserId,需要先认证 - if (!embyConfig.UserId && embyConfig.Username && embyConfig.Password) { - const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); - embyConfig.UserId = authResult.User.Id; - } - - if (!embyConfig.UserId) { - return NextResponse.json({ error: 'Emby 认证失败' }, { status: 401 }); - } + // 获取Emby客户端 + const client = await embyManager.getClient(embyKey); // 获取媒体详情 const item = await client.getItem(itemId); diff --git a/src/app/api/emby/list/route.ts b/src/app/api/emby/list/route.ts index 88fba0a..2f45bf6 100644 --- a/src/app/api/emby/list/route.ts +++ b/src/app/api/emby/list/route.ts @@ -2,8 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getConfig } from '@/lib/config'; -import { EmbyClient } from '@/lib/emby.client'; +import { embyManager } from '@/lib/emby-manager'; import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache'; export const runtime = 'nodejs'; @@ -13,56 +12,17 @@ export async function GET(request: NextRequest) { const page = parseInt(searchParams.get('page') || '1'); const pageSize = parseInt(searchParams.get('pageSize') || '20'); const parentId = searchParams.get('parentId') || undefined; + const embyKey = searchParams.get('embyKey') || undefined; try { // 检查缓存 - const cached = getCachedEmbyList(page, pageSize, parentId); + const cached = getCachedEmbyList(page, pageSize, parentId, embyKey); if (cached) { return NextResponse.json(cached); } - const config = await getConfig(); - const embyConfig = config.EmbyConfig; - - if (!embyConfig?.Enabled || !embyConfig.ServerURL) { - return NextResponse.json({ - error: 'Emby 未配置或未启用', - list: [], - totalPages: 0, - currentPage: page, - total: 0, - }); - } - - // 创建 Emby 客户端 - const client = new EmbyClient(embyConfig); - - // 如果使用用户名密码且没有 UserId,需要先认证 - if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) { - try { - const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); - embyConfig.UserId = authResult.User.Id; - } catch (error) { - return NextResponse.json({ - error: 'Emby 认证失败: ' + (error as Error).message, - list: [], - totalPages: 0, - currentPage: page, - total: 0, - }); - } - } - - // 验证认证信息:必须有 ApiKey 或 UserId - if (!embyConfig.ApiKey && !embyConfig.UserId) { - return NextResponse.json({ - error: 'Emby 认证失败,请检查配置', - list: [], - totalPages: 0, - currentPage: page, - total: 0, - }); - } + // 获取Emby客户端 + const client = await embyManager.getClient(embyKey); // 获取媒体列表 const result = await client.getItems({ @@ -96,7 +56,7 @@ export async function GET(request: NextRequest) { }; // 缓存结果 - setCachedEmbyList(page, pageSize, response, parentId); + setCachedEmbyList(page, pageSize, response, parentId, embyKey); return NextResponse.json(response); } catch (error) { diff --git a/src/app/api/emby/play/[token]/[filename]/route.ts b/src/app/api/emby/play/[token]/[filename]/route.ts index c6a5620..3d332fa 100644 --- a/src/app/api/emby/play/[token]/[filename]/route.ts +++ b/src/app/api/emby/play/[token]/[filename]/route.ts @@ -7,51 +7,18 @@ import { getConfig } from '@/lib/config'; export const runtime = 'nodejs'; -// 内存缓存 Emby 配置,避免每次请求都读取配置 -let cachedEmbyConfig: { - serverURL: string; - apiKey: string; - timestamp: number; -} | null = null; - -const CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存 - /** - * 获取缓存的 Emby 配置 + * 获取 Emby 客户端 */ -async function getCachedEmbyConfig() { - const now = Date.now(); - - // 如果缓存存在且未过期,直接返回 - if (cachedEmbyConfig && (now - cachedEmbyConfig.timestamp) < CACHE_TTL) { - return cachedEmbyConfig; - } - - // 否则重新获取配置 +async function getEmbyClient(embyKey?: string) { const config = await getConfig(); - const embyConfig = config.EmbyConfig; - if ( - !embyConfig || - !embyConfig.Enabled || - !embyConfig.ServerURL - ) { + if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { throw new Error('Emby 未配置或未启用'); } - const apiKey = embyConfig.ApiKey || embyConfig.AuthToken; - if (!apiKey) { - throw new Error('Emby 认证信息缺失'); - } - - // 更新缓存 - cachedEmbyConfig = { - serverURL: embyConfig.ServerURL, - apiKey, - timestamp: now, - }; - - return cachedEmbyConfig; + const { embyManager } = await import('@/lib/emby-manager'); + return await embyManager.getClient(embyKey); } /** @@ -84,16 +51,17 @@ export async function GET( } const itemId = searchParams.get('itemId'); + const embyKey = searchParams.get('embyKey') || undefined; if (!itemId) { return NextResponse.json({ error: '缺少 itemId 参数' }, { status: 400 }); } - // 使用缓存的配置 - const embyConfig = await getCachedEmbyConfig(); + // 获取 Emby 客户端 + let client = await getEmbyClient(embyKey); // 构建 Emby 原始播放链接 - const embyStreamUrl = `${embyConfig.serverURL}/Videos/${itemId}/stream?Static=true&api_key=${embyConfig.apiKey}`; + let embyStreamUrl = client.getStreamUrl(itemId); // 构建请求头,转发 Range 请求 const requestHeaders: HeadersInit = {}; @@ -103,10 +71,22 @@ export async function GET( } // 流式代理视频内容 - const videoResponse = await fetch(embyStreamUrl, { + let videoResponse = await fetch(embyStreamUrl, { headers: requestHeaders, }); + // 如果返回 401,尝试重新认证并重试 + if (videoResponse.status === 401) { + console.log('[Emby Play] 收到 401 错误,尝试重新认证'); + const { embyManager } = await import('@/lib/emby-manager'); + embyManager.clearCache(); + client = await getEmbyClient(embyKey); + embyStreamUrl = client.getStreamUrl(itemId); + videoResponse = await fetch(embyStreamUrl, { + headers: requestHeaders, + }); + } + if (!videoResponse.ok) { console.error('[Emby Play] 获取视频流失败:', { itemId, diff --git a/src/app/api/emby/sources/route.ts b/src/app/api/emby/sources/route.ts new file mode 100644 index 0000000..fcfd0b5 --- /dev/null +++ b/src/app/api/emby/sources/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from 'next/server'; + +import { embyManager } from '@/lib/emby-manager'; + +export const runtime = 'nodejs'; + +/** + * 获取所有启用的Emby源列表 + */ +export async function GET() { + try { + const sources = await embyManager.getEnabledSources(); + + return NextResponse.json({ + sources: sources.map(s => ({ + key: s.key, + name: s.name, + })), + }); + } catch (error) { + console.error('[Emby Sources] 获取Emby源列表失败:', error); + return NextResponse.json( + { error: '获取Emby源列表失败', sources: [] }, + { status: 500 } + ); + } +} diff --git a/src/app/api/emby/views/route.ts b/src/app/api/emby/views/route.ts index 57780e1..f3bca93 100644 --- a/src/app/api/emby/views/route.ts +++ b/src/app/api/emby/views/route.ts @@ -1,54 +1,26 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; -import { getConfig } from '@/lib/config'; -import { EmbyClient } from '@/lib/emby.client'; +import { embyManager } from '@/lib/emby-manager'; import { getCachedEmbyViews, setCachedEmbyViews } from '@/lib/emby-cache'; export const runtime = 'nodejs'; -export async function GET() { +export async function GET(request: NextRequest) { try { - // 检查缓存 - const cached = getCachedEmbyViews(); + const { searchParams } = new URL(request.url); + const embyKey = searchParams.get('embyKey') || undefined; + + // 检查缓存(按embyKey缓存) + const cacheKey = embyKey || 'default'; + const cached = getCachedEmbyViews(cacheKey); if (cached) { return NextResponse.json(cached); } - const config = await getConfig(); - const embyConfig = config.EmbyConfig; - - if (!embyConfig?.Enabled || !embyConfig.ServerURL) { - return NextResponse.json({ - error: 'Emby 未配置或未启用', - views: [], - }); - } - - // 创建 Emby 客户端 - const client = new EmbyClient(embyConfig); - - // 如果使用用户名密码且没有 UserId,需要先认证 - if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) { - try { - const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password); - embyConfig.UserId = authResult.User.Id; - } catch (error) { - return NextResponse.json({ - error: 'Emby 认证失败: ' + (error as Error).message, - views: [], - }); - } - } - - // 验证认证信息:必须有 ApiKey 或 UserId - if (!embyConfig.ApiKey && !embyConfig.UserId) { - return NextResponse.json({ - error: 'Emby 认证失败,请检查配置', - views: [], - }); - } + // 获取Emby客户端 + const client = await embyManager.getClient(embyKey); // 获取媒体库列表 const views = await client.getUserViews(); @@ -68,7 +40,7 @@ export async function GET() { }; // 缓存结果 - setCachedEmbyViews(response); + setCachedEmbyViews(cacheKey, response); return NextResponse.json(response); } catch (error) { diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 37e5c8b..8cb6086 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -44,53 +44,57 @@ export async function GET(request: NextRequest) { config.OpenListConfig?.Password ); - // 检查是否配置了 Emby - const hasEmby = !!( - config.EmbyConfig?.Enabled && - config.EmbyConfig?.ServerURL && - config.EmbyConfig?.UserId - ); + // 获取所有启用的 Emby 源 + const { embyManager } = await import('@/lib/emby-manager'); + const embySourcesMap = await embyManager.getAllClients(); + const embySources = Array.from(embySourcesMap.values()); - // 搜索 Emby(如果配置了)- 异步带超时 - const embyPromise = hasEmby - ? Promise.race([ - (async () => { - try { - const { EmbyClient } = await import('@/lib/emby.client'); - const client = new EmbyClient(config.EmbyConfig!); - const searchResult = await client.getItems({ - searchTerm: query, - IncludeItemTypes: 'Movie,Series', - Recursive: true, - Fields: 'Overview,ProductionYear', - Limit: 50, - }); - return searchResult.Items.map((item) => ({ - id: item.Id, - source: 'emby', - source_name: 'Emby', - title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), - episodes: [], - episodes_titles: [], - year: item.ProductionYear?.toString() || '', - desc: item.Overview || '', - type_name: item.Type === 'Movie' ? '电影' : '电视剧', - douban_id: 0, - })); - } catch (error) { - console.error('[Search] 搜索 Emby 失败:', error); - return []; - } - })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Emby timeout')), 20000) - ), - ]).catch((error) => { - console.error('[Search] 搜索 Emby 超时:', error); - return []; - }) - : Promise.resolve([]); + console.log('[Search] Emby sources count:', embySources.length); + console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name }))); + + // 为每个 Emby 源创建搜索 Promise(全部并发,无限制) + const embyPromises = embySources.map(({ client, config: embyConfig }) => + Promise.race([ + (async () => { + try { + const searchResult = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 50, + }); + + // 如果只有一个Emby源,保持旧格式(向后兼容) + const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`; + const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name; + + return searchResult.Items.map((item) => ({ + id: item.Id, + source: sourceValue, + source_name: sourceName, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + episodes: [], + episodes_titles: [], + year: item.ProductionYear?.toString() || '', + desc: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + douban_id: 0, + })); + } catch (error) { + console.error(`[Search] 搜索 ${embyConfig.name} 失败:`, error); + return []; + } + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`${embyConfig.name} timeout`)), 20000) + ), + ]).catch((error) => { + console.error(`[Search] 搜索 ${embyConfig.name} 超时:`, error); + return []; + }) + ); // 搜索 OpenList(如果配置了)- 异步带超时 const openlistPromise = hasOpenList @@ -164,12 +168,20 @@ export async function GET(request: NextRequest) { ); try { - const [embyResults, openlistResults, ...apiResults] = await Promise.all([ - embyPromise, + const allResults = await Promise.all([ openlistPromise, + ...embyPromises, ...searchPromises, ]); - let flattenedResults = [...embyResults, ...openlistResults, ...apiResults.flat()]; + + // 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果 + const openlistResults = allResults[0]; + const embyResultsArray = allResults.slice(1, 1 + embyPromises.length); + const apiResults = allResults.slice(1 + embyPromises.length); + + // 合并所有 Emby 结果 + const embyResults = embyResultsArray.flat(); + let flattenedResults = [...openlistResults, ...embyResults, ...apiResults.flat()]; if (!config.SiteConfig.DisableYellowFilter) { flattenedResults = flattenedResults.filter((result) => { const typeName = result.type_name || ''; diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index 4667595..e3d6080 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -41,11 +41,11 @@ export async function GET(request: NextRequest) { config.OpenListConfig?.Password ); - // 检查是否配置了 Emby + // 检查是否配置了 Emby(支持多源) const hasEmby = !!( - config.EmbyConfig?.Enabled && - config.EmbyConfig?.ServerURL && - config.EmbyConfig?.UserId + config.EmbyConfig?.Sources && + config.EmbyConfig.Sources.length > 0 && + config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL) ); // 共享状态 @@ -73,11 +73,23 @@ export async function GET(request: NextRequest) { } }; + // 获取 Emby 源数量 + let embySourcesCount = 0; + if (hasEmby) { + try { + const { embyManager } = await import('@/lib/emby-manager'); + const embySourcesMap = await embyManager.getAllClients(); + embySourcesCount = embySourcesMap.size; + } catch (error) { + console.error('[Search WS] 获取 Emby 源数量失败:', error); + } + } + // 发送开始事件 const startEvent = `data: ${JSON.stringify({ type: 'start', query, - totalSources: apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0), + totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount, timestamp: Date.now() })}\n\n`; @@ -89,75 +101,105 @@ export async function GET(request: NextRequest) { let completedSources = 0; const allResults: any[] = []; - // 搜索 Emby(如果配置了)- 异步带超时 + // 搜索 Emby(如果配置了)- 异步带超时,支持多源 if (hasEmby) { - Promise.race([ - (async () => { - try { - const { EmbyClient } = await import('@/lib/emby.client'); - const client = new EmbyClient(config.EmbyConfig!); - const searchResult = await client.getItems({ - searchTerm: query, - IncludeItemTypes: 'Movie,Series', - Recursive: true, - Fields: 'Overview,ProductionYear', - Limit: 50, - }); - return searchResult.Items.map((item) => ({ - id: item.Id, - source: 'emby', - source_name: 'Emby', - title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), - episodes: [], - episodes_titles: [], - year: item.ProductionYear?.toString() || '', - desc: item.Overview || '', - type_name: item.Type === 'Movie' ? '电影' : '电视剧', - douban_id: 0, - })); - } catch (error) { - console.error('[Search WS] 搜索 Emby 失败:', error); - return []; - } - })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Emby timeout')), 20000) - ), - ]) - .then((embyResults: any) => { - completedSources++; - if (!streamClosed) { - const sourceEvent = `data: ${JSON.stringify({ - type: 'source_result', - source: 'emby', - sourceName: 'Emby', - results: embyResults, - timestamp: Date.now() - })}\n\n`; - if (!safeEnqueue(encoder.encode(sourceEvent))) { - streamClosed = true; - return; + (async () => { + let embyCompletedCount = 0; + try { + const { embyManager } = await import('@/lib/emby-manager'); + const embySourcesMap = await embyManager.getAllClients(); + const embySources = Array.from(embySourcesMap.values()); + + // 为每个 Emby 源并发搜索,并单独发送结果 + const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => { + try { + const searchResult = await client.getItems({ + searchTerm: query, + IncludeItemTypes: 'Movie,Series', + Recursive: true, + Fields: 'Overview,ProductionYear', + Limit: 50, + }); + + const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`; + const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name; + + const results = searchResult.Items.map((item) => ({ + id: item.Id, + source: sourceValue, + source_name: sourceName, + title: item.Name, + poster: client.getImageUrl(item.Id, 'Primary'), + episodes: [], + episodes_titles: [], + year: item.ProductionYear?.toString() || '', + desc: item.Overview || '', + type_name: item.Type === 'Movie' ? '电影' : '电视剧', + douban_id: 0, + })); + + // 单独发送每个源的结果 + embyCompletedCount++; + completedSources++; + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: sourceValue, + sourceName: sourceName, + results: results, + timestamp: Date.now() + })}\n\n`; + if (safeEnqueue(encoder.encode(sourceEvent))) { + if (results.length > 0) { + allResults.push(...results); + } + } else { + streamClosed = true; + } + } + + return results; + } catch (error) { + console.error(`[Search WS] 搜索 ${embyConfig.name} 失败:`, error); + embyCompletedCount++; + completedSources++; + // 发送空结果 + if (!streamClosed) { + const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`; + const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name; + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: sourceValue, + sourceName: sourceName, + results: [], + timestamp: Date.now() + })}\n\n`; + safeEnqueue(encoder.encode(sourceEvent)); + } + return []; } - if (embyResults.length > 0) { - allResults.push(...embyResults); + }); + + await Promise.all(embySearchPromises); + } catch (error) { + console.error('[Search WS] 搜索 Emby 整体失败:', error); + // 如果整个 emby 搜索失败,需要补齐未完成的源 + const remainingSources = embySourcesCount - embyCompletedCount; + for (let i = 0; i < remainingSources; i++) { + completedSources++; + if (!streamClosed) { + const sourceEvent = `data: ${JSON.stringify({ + type: 'source_result', + source: 'emby', + sourceName: 'Emby', + results: [], + timestamp: Date.now() + })}\n\n`; + safeEnqueue(encoder.encode(sourceEvent)); } } - }) - .catch((error) => { - console.error('[Search WS] 搜索 Emby 超时:', error); - completedSources++; - if (!streamClosed) { - const sourceEvent = `data: ${JSON.stringify({ - type: 'source_result', - source: 'emby', - sourceName: 'Emby', - results: [], - timestamp: Date.now() - })}\n\n`; - safeEnqueue(encoder.encode(sourceEvent)); - } - }); + } + })(); } // 搜索 OpenList(如果配置了)- 异步带超时 @@ -315,7 +357,7 @@ export async function GET(request: NextRequest) { } // 检查是否所有源都已完成 - if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0)) { + if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) { if (!streamClosed) { // 发送最终完成事件 const completeEvent = `data: ${JSON.stringify({ diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index a50b202..73d2b84 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -27,18 +27,29 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: '缺少必要参数' }, { status: 400 }); } - // 特殊处理 emby 源 - if (sourceCode === 'emby') { + // 特殊处理 emby 源(支持多源) + if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) { try { const config = await getConfig(); - const embyConfig = config.EmbyConfig; - if (!embyConfig || !embyConfig.Enabled || !embyConfig.ServerURL) { + // 检查是否有启用的 Emby 源 + if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { throw new Error('Emby 未配置或未启用'); } - const { EmbyClient } = await import('@/lib/emby.client'); - const client = new EmbyClient(embyConfig); + // 解析 embyKey + let embyKey: string | undefined; + if (sourceCode.startsWith('emby_')) { + embyKey = sourceCode.substring(5); // 'emby_'.length = 5 + } + + // 使用 EmbyManager 获取客户端和配置 + const { embyManager } = await import('@/lib/emby-manager'); + const sources = await embyManager.getEnabledSources(); + const sourceConfig = sources.find(s => s.key === embyKey); + const sourceName = sourceConfig?.name || 'Emby'; + + const client = await embyManager.getClient(embyKey); // 获取媒体详情 const item = await client.getItem(id); @@ -49,8 +60,8 @@ export async function GET(request: NextRequest) { const subtitles = client.getSubtitles(item); const result = { - source: 'emby', - source_name: 'Emby', + source: sourceCode, // 保持与请求一致(emby 或 emby_key) + source_name: sourceName, id: item.Id, title: item.Name, poster: client.getImageUrl(item.Id, 'Primary'), @@ -83,8 +94,8 @@ export async function GET(request: NextRequest) { }); const result = { - source: 'emby', - source_name: 'Emby', + source: sourceCode, // 保持与请求一致(emby 或 emby_key) + source_name: sourceName, id: item.Id, title: item.Name, poster: client.getImageUrl(item.Id, 'Primary'), diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts index 7dc93ea..a7c5d29 100644 --- a/src/app/api/tvbox/subscribe/route.ts +++ b/src/app/api/tvbox/subscribe/route.ts @@ -71,12 +71,9 @@ export async function GET(request: NextRequest) { config.OpenListConfig?.Password ); - // 检查是否配置了 Emby - const hasEmby = !!( - config.EmbyConfig?.Enabled && - config.EmbyConfig?.ServerURL && - (config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password)) - ); + // 获取所有启用的 Emby 源 + const { embyManager } = await import('@/lib/emby-manager'); + const embySources = await embyManager.getEnabledSources(); // 构建 OpenList 站点配置 const openlistSites = hasOpenList ? [{ @@ -90,17 +87,17 @@ export async function GET(request: NextRequest) { ext: '', }] : []; - // 构建 Emby 站点配置 - const embySites = hasEmby ? [{ - key: 'emby', - name: 'Emby媒体库', + // 构建 Emby 站点配置(为每个启用的Emby源生成独立站点) + const embySites = embySources.map(source => ({ + key: `emby_${source.key}`, + name: source.name || 'Emby媒体库', type: 1, - api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}`, + api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}?embyKey=${source.key}`, searchable: 1, quickSearch: 1, filterable: 1, ext: '', - }] : []; + })); // 构建TVBOX订阅数据 const tvboxSubscription = { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c3ffe9e..9bb0871 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -126,11 +126,11 @@ export default async function RootLayout({ config.OpenListConfig?.Username && config.OpenListConfig?.Password ); - // 检查是否启用了 Emby 功能 + // 检查是否启用了 Emby 功能(支持多源) embyEnabled = !!( - config.EmbyConfig?.Enabled && - config.EmbyConfig?.ServerURL && - (config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password)) + config.EmbyConfig?.Sources && + config.EmbyConfig.Sources.length > 0 && + config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL) ); } diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index e76d870..7c2e3ca 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -456,12 +456,20 @@ function PlayPageClient() { const [doubanCardSubtitle, setDoubanCardSubtitle] = useState(''); const [doubanAka, setDoubanAka] = useState([]); const [doubanYear, setDoubanYear] = useState(''); // 从 pubdate 提取的年份 - // 当前源和ID - const [currentSource, setCurrentSource] = useState( - searchParams.get('source') || '' - ); + + // 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby') + const [currentSource, setCurrentSource] = useState(searchParams.get('source') || ''); const [currentId, setCurrentId] = useState(searchParams.get('id') || ''); + // 解析 source 参数以获取 embyKey(仅用于 API 调用) + const parseSourceForApi = (source: string): { source: string; embyKey?: string } => { + if (source.startsWith('emby_')) { + const key = source.substring(5); + return { source: 'emby', embyKey: key }; + } + return { source }; + }; + // 搜索所需信息 const [searchTitle] = useState(searchParams.get('stitle') || ''); const [searchType] = useState(searchParams.get('stype') || ''); @@ -501,8 +509,6 @@ function PlayPageClient() { // 监听 URL 参数变化,当切换到不同视频时重新加载页面 useEffect(() => { const urlTitle = searchParams.get('title') || ''; - const urlSource = searchParams.get('source') || ''; - const urlId = searchParams.get('id') || ''; // 只在切换到不同视频时重新加载页面(title变化) // 换源(source/id变化)由播放器自己处理,不需要刷新页面 @@ -2366,6 +2372,7 @@ function PlayPageClient() { if (currentSource && currentId) { // 先快速获取当前源的详情 try { + // currentSource 已经是完整格式(如 'emby_wumei') const currentSourceDetail = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); if (currentSourceDetail.length > 0) { detailData = currentSourceDetail[0]; @@ -2415,8 +2422,9 @@ function PlayPageClient() { detailData = target; // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息 - if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) { + if ((detailData.source === 'openlist' || detailData.source === 'emby' || detailData.source.startsWith('emby_')) && (!detailData.episodes || detailData.episodes.length === 0)) { console.log('[Play] OpenList/Emby source has no episodes, fetching detail...'); + // currentSource 已经是完整格式 const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle); if (detailSources.length > 0) { detailData = detailSources[0]; @@ -2437,9 +2445,22 @@ function PlayPageClient() { setLoadingStage('preferring'); setLoadingMessage('⚡ 正在优选最佳播放源...'); - // 过滤掉 openlist 和 emby 源,它们不参与测速 - const sourcesToTest = sourcesInfo.filter(s => s.source !== 'openlist' && s.source !== 'emby'); - const excludedSources = sourcesInfo.filter(s => s.source === 'openlist' || s.source === 'emby'); + // 过滤掉 openlist 和所有 emby 源,它们不参与测速 + const sourcesToTest = sourcesInfo.filter(s => { + // 检查是否为 openlist + if (s.source === 'openlist') return false; + + // 检查是否为 emby 源(包括 emby 和 emby_xxx 格式) + if (s.source === 'emby' || s.source.startsWith('emby_')) return false; + + return true; + }); + + const excludedSources = sourcesInfo.filter(s => + s.source === 'openlist' || + s.source === 'emby' || + s.source.startsWith('emby_') + ); if (sourcesToTest.length > 0) { detailData = await preferBestSource(sourcesToTest); @@ -2463,6 +2484,7 @@ function PlayPageClient() { } setNeedPrefer(false); + // 直接使用 detailData.source(已经是完整格式) setCurrentSource(detailData.source); setCurrentId(detailData.id); setVideoYear(detailData.year); @@ -2475,12 +2497,12 @@ function PlayPageClient() { setCurrentEpisodeIndex(0); } - // 规范URL参数 + // 规范URL参数(不更新title,避免循环刷新) const newUrl = new URL(window.location.href); newUrl.searchParams.set('source', detailData.source); newUrl.searchParams.set('id', detailData.id); newUrl.searchParams.set('year', detailData.year); - newUrl.searchParams.set('title', detailData.title); + // 保持原有的 title,不更新 newUrl.searchParams.delete('prefer'); window.history.replaceState({}, '', newUrl.toString()); @@ -2545,21 +2567,17 @@ function PlayPageClient() { // 只在URL参数存在且与当前状态不同时才处理 if (urlSource && urlId && (urlSource !== currentSource || urlId !== currentId)) { - console.log('[PlayPage] Detected source/id change from URL:', { - urlSource, - urlId, - currentSource, - currentId - }); - // 检查新的source和id是否在可用源列表中 + // 如果 availableSources 还是空的,说明数据还在加载中,不做处理 + if (availableSources.length === 0) { + return; + } + const targetSource = availableSources.find( (source) => source.source === urlSource && source.id === urlId ); if (targetSource) { - console.log('[PlayPage] Found matching source in available sources, updating...'); - // 记录当前播放进度 const currentPlayTime = artPlayerRef.current?.currentTime || 0; @@ -2567,7 +2585,7 @@ function PlayPageClient() { const episodeParam = searchParams.get('episode'); const targetEpisode = episodeParam ? parseInt(episodeParam, 10) - 1 : 0; - // 更新视频源信息 + // 更新视频源信息(urlSource 已经是完整格式) setCurrentSource(urlSource); setCurrentId(urlId); setVideoTitle(targetSource.title); @@ -2589,7 +2607,6 @@ function PlayPageClient() { } } } else { - console.log('[PlayPage] Source not found in available sources, reloading page...'); // 如果新源不在可用列表中,强制刷新页面重新加载 window.location.reload(); } @@ -2712,7 +2729,7 @@ function PlayPageClient() { } // 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息 - if ((newDetail.source === 'openlist' || newDetail.source === 'emby') && (!newDetail.episodes || newDetail.episodes.length === 0)) { + if ((newDetail.source === 'openlist' || newDetail.source === 'emby' || newDetail.source.startsWith('emby_')) && (!newDetail.episodes || newDetail.episodes.length === 0)) { try { const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`); if (detailResponse.ok) { @@ -2768,6 +2785,7 @@ function PlayPageClient() { setVideoYear(newDetail.year); setVideoCover(newDetail.poster); setVideoDoubanId(newDetail.douban_id || 0); + // newSource 已经是完整格式 setCurrentSource(newSource); setCurrentId(newId); setDetail(newDetail); diff --git a/src/app/private-library/page.tsx b/src/app/private-library/page.tsx index b55d9b3..2d248d2 100644 --- a/src/app/private-library/page.tsx +++ b/src/app/private-library/page.tsx @@ -9,7 +9,12 @@ import CapsuleSwitch from '@/components/CapsuleSwitch'; import PageLayout from '@/components/PageLayout'; import VideoCard from '@/components/VideoCard'; -type LibrarySource = 'openlist' | 'emby'; +type LibrarySourceType = 'openlist' | 'emby'; + +interface EmbySourceOption { + key: string; + name: string; +} interface Video { id: string; @@ -43,7 +48,21 @@ export default function PrivateLibraryPage() { return { OPENLIST_ENABLED: false, EMBY_ENABLED: false }; }, []); - const [source, setSource] = useState('openlist'); + // 解析URL中的source参数(支持 emby:emby1 格式) + const parseSourceParam = (sourceParam: string | null): { sourceType: LibrarySourceType; embyKey?: string } => { + if (!sourceParam) return { sourceType: 'openlist' }; + + if (sourceParam.includes(':')) { + const [type, key] = sourceParam.split(':'); + return { sourceType: type as LibrarySourceType, embyKey: key }; + } + + return { sourceType: sourceParam as LibrarySourceType }; + }; + + const [sourceType, setSourceType] = useState('openlist'); + const [embyKey, setEmbyKey] = useState(); + const [embySourceOptions, setEmbySourceOptions] = useState([]); const [videos, setVideos] = useState([]); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); @@ -57,6 +76,7 @@ export default function PrivateLibraryPage() { const observerTarget = useRef(null); const isFetchingRef = useRef(false); const scrollContainerRef = useRef(null); + const embyScrollContainerRef = useRef(null); const isDraggingRef = useRef(false); const startXRef = useRef(0); const scrollLeftRef = useRef(0); @@ -64,14 +84,20 @@ export default function PrivateLibraryPage() { // 从URL初始化状态,并检查配置自动跳转 useEffect(() => { - const urlSource = searchParams.get('source') as LibrarySource; + const urlSourceParam = searchParams.get('source'); const urlView = searchParams.get('view'); + // 解析source参数 + const parsed = parseSourceParam(urlSourceParam); + // 如果 OpenList 未配置但 Emby 已配置,强制使用 Emby if (!runtimeConfig.OPENLIST_ENABLED && runtimeConfig.EMBY_ENABLED) { - setSource('emby'); - } else if (urlSource && (urlSource === 'openlist' || urlSource === 'emby')) { - setSource(urlSource); + setSourceType('emby'); + } else if (parsed.sourceType) { + setSourceType(parsed.sourceType); + if (parsed.embyKey) { + setEmbyKey(parsed.embyKey); + } } if (urlView) { @@ -81,19 +107,51 @@ export default function PrivateLibraryPage() { isInitializedRef.current = true; }, [searchParams, runtimeConfig]); + // 获取Emby源列表 + useEffect(() => { + const fetchEmbySources = async () => { + try { + const response = await fetch('/api/emby/sources'); + if (response.ok) { + const data = await response.json(); + setEmbySourceOptions(data.sources || []); + + // 如果没有设置embyKey,使用第一个源 + if (!embyKey && data.sources && data.sources.length > 0) { + setEmbyKey(data.sources[0].key); + } + } + } catch (error) { + console.error('获取Emby源列表失败:', error); + } + }; + + if (sourceType === 'emby') { + fetchEmbySources(); + } + }, [sourceType]); + // 更新URL参数 useEffect(() => { if (!isInitializedRef.current) return; const params = new URLSearchParams(); - params.set('source', source); - if (source === 'emby' && selectedView !== 'all') { + + // 构建source参数 + if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) { + params.set('source', `emby:${embyKey}`); + } else { + params.set('source', sourceType); + } + + if (sourceType === 'emby' && selectedView !== 'all') { params.set('view', selectedView); } - router.replace(`/private-library?${params.toString()}`, { scroll: false }); - }, [source, selectedView, router]); - // 切换源时重置所有状态(但不在初始化时执行) + router.replace(`/private-library?${params.toString()}`, { scroll: false }); + }, [sourceType, embyKey, selectedView, router, embySourceOptions.length]); + + // 切换源类型时重置所有状态(但不在初始化时执行) useEffect(() => { if (!isInitializedRef.current) return; @@ -103,7 +161,7 @@ export default function PrivateLibraryPage() { setError(''); setSelectedView('all'); isFetchingRef.current = false; - }, [source]); + }, [sourceType, embyKey]); // 切换分类时重置状态(但不在初始化时执行) useEffect(() => { @@ -118,12 +176,13 @@ export default function PrivateLibraryPage() { // 获取 Emby 媒体库列表 useEffect(() => { - if (source !== 'emby') return; + if (sourceType !== 'emby' || !embyKey) return; const fetchEmbyViews = async () => { setLoadingViews(true); try { - const response = await fetch('/api/emby/views'); + const params = new URLSearchParams({ embyKey }); + const response = await fetch(`/api/emby/views?${params.toString()}`); const data = await response.json(); if (data.error) { @@ -151,7 +210,7 @@ export default function PrivateLibraryPage() { }; fetchEmbyViews(); - }, [source]); + }, [sourceType, embyKey, searchParams]); // 鼠标拖动滚动 const handleMouseDown = (e: React.MouseEvent) => { @@ -196,13 +255,13 @@ export default function PrivateLibraryPage() { } // 如果选择了 openlist 但未配置,不发起请求 - if (source === 'openlist' && !runtimeConfig.OPENLIST_ENABLED) { + if (sourceType === 'openlist' && !runtimeConfig.OPENLIST_ENABLED) { setLoading(false); return; } - // 如果选择了 emby 但未配置,不发起请求 - if (source === 'emby' && !runtimeConfig.EMBY_ENABLED) { + // 如果选择了 emby 但未配置或没有embyKey,不发起请求 + if (sourceType === 'emby' && (!runtimeConfig.EMBY_ENABLED || !embyKey)) { setLoading(false); return; } @@ -217,9 +276,9 @@ export default function PrivateLibraryPage() { } setError(''); - const endpoint = source === 'openlist' + const endpoint = sourceType === 'openlist' ? `/api/openlist/list?page=${page}&pageSize=${pageSize}` - : `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}`; + : `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`; const response = await fetch(endpoint); @@ -266,11 +325,17 @@ export default function PrivateLibraryPage() { }; fetchVideos(); - }, [source, page, selectedView, runtimeConfig]); + }, [sourceType, embyKey, page, selectedView, runtimeConfig]); const handleVideoClick = (video: Video) => { + // 构建source参数 + let sourceParam = sourceType; + if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) { + sourceParam = `emby:${embyKey}`; + } + // 跳转到播放页面 - router.push(`/play?source=${source}&id=${encodeURIComponent(video.id)}`); + router.push(`/play?source=${sourceParam}&id=${encodeURIComponent(video.id)}`); }; // 使用 Intersection Observer 监听滚动 @@ -313,20 +378,89 @@ export default function PrivateLibraryPage() {

- {/* 源切换器 */} + {/* 第一级:源类型选择(OpenList / Emby) */}
- setSource(value as LibrarySource)} - /> +
+ + +
- {/* Emby 分类选择器 */} - {source === 'emby' && ( + {/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */} + {sourceType === 'emby' && embySourceOptions.length > 1 && ( +
+
+
{ + if (!embyScrollContainerRef.current) return; + isDraggingRef.current = true; + startXRef.current = e.pageX - embyScrollContainerRef.current.offsetLeft; + scrollLeftRef.current = embyScrollContainerRef.current.scrollLeft; + embyScrollContainerRef.current.style.cursor = 'grabbing'; + embyScrollContainerRef.current.style.userSelect = 'none'; + }} + onMouseLeave={() => { + if (!embyScrollContainerRef.current) return; + isDraggingRef.current = false; + embyScrollContainerRef.current.style.cursor = 'grab'; + embyScrollContainerRef.current.style.userSelect = 'auto'; + }} + onMouseUp={() => { + if (!embyScrollContainerRef.current) return; + isDraggingRef.current = false; + embyScrollContainerRef.current.style.cursor = 'grab'; + embyScrollContainerRef.current.style.userSelect = 'auto'; + }} + onMouseMove={(e) => { + if (!isDraggingRef.current || !embyScrollContainerRef.current) return; + e.preventDefault(); + const x = e.pageX - embyScrollContainerRef.current.offsetLeft; + const walk = (x - startXRef.current) * 2; + embyScrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk; + }} + > +
+ {embySourceOptions.map((option) => ( + + ))} +
+
+
+
+ )} + + {/* 第三级:Emby 媒体库分类选择器 */} + {sourceType === 'emby' && (
{loadingViews ? (
@@ -391,7 +525,7 @@ export default function PrivateLibraryPage() { ) : videos.length === 0 ? (

- {source === 'openlist' + {sourceType === 'openlist' ? '暂无视频,请在管理面板配置 OpenList 并刷新' : '暂无视频,请在管理面板配置 Emby'}

@@ -399,24 +533,33 @@ export default function PrivateLibraryPage() { ) : ( <>
- {videos.map((video) => ( - 0 - ? video.voteAverage.toFixed(1) - : '' - } - from='search' - /> - ))} + {videos.map((video) => { + // 构建source参数用于VideoCard + // 如果是emby源且有embyKey,使用下划线格式 + let sourceParam = sourceType; + if (sourceType === 'emby' && embyKey) { + sourceParam = `emby_${embyKey}`; + } + + return ( + 0 + ? video.voteAverage.toFixed(1) + : '' + } + from='search' + /> + ); + })}
{/* 滚动加载指示器 - 始终渲染以便 observer 可以监听 */} diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 1e98ab7..2f2d54b 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -292,19 +292,23 @@ function SearchPageClient() { { label: '全部来源', value: 'all' }, ...Array.from(sourcesSet.entries()) .sort((a, b) => { - // 优先排序:emby 和 openlist 置于最前 - const prioritySources = ['emby', 'openlist']; - const aIsPriority = prioritySources.includes(a[0]); - const bIsPriority = prioritySources.includes(b[0]); + // 判断是否为 openlist + const aIsOpenList = a[0] === 'openlist'; + const bIsOpenList = b[0] === 'openlist'; - if (aIsPriority && !bIsPriority) return -1; - if (!aIsPriority && bIsPriority) return 1; - if (aIsPriority && bIsPriority) { - // 两者都是优先源,按照 prioritySources 数组顺序排列 - return prioritySources.indexOf(a[0]) - prioritySources.indexOf(b[0]); + // 判断是否为 emby 源(包括 emby 和 emby:xxx 格式) + const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby:'); + const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby:'); + + // 优先级:OpenList(100) > Emby(90) > 其他(0) + const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0; + const bPriority = bIsOpenList ? 100 : bIsEmby ? 90 : 0; + + if (aPriority !== bPriority) { + return bPriority - aPriority; // 降序排列 } - // 其他来源按字母顺序排列 + // 同优先级内按名称排序 return a[1].localeCompare(b[1]); }) .map(([value, label]) => ({ label, value })), diff --git a/src/components/EpisodeSelector.tsx b/src/components/EpisodeSelector.tsx index efca823..5dcaaa5 100644 --- a/src/components/EpisodeSelector.tsx +++ b/src/components/EpisodeSelector.tsx @@ -897,7 +897,7 @@ const EpisodeSelector: React.FC = ({ {/* 重新测试按钮 */} {(() => { // 私人影库和 Emby 不显示重新测试按钮 - if (source.source === 'openlist' || source.source === 'emby') { + if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) { return null; } diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index c52b3cd..f53e430 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1219,7 +1219,7 @@ const VideoCard = forwardRef(function VideoCard {config.showSourceName && source_name && !cmsData && ( (function VideoCard
; + // 旧格式:单源配置(向后兼容) + Enabled?: boolean; + ServerURL?: string; + ApiKey?: string; + Username?: string; + Password?: string; + UserId?: string; + AuthToken?: string; + Libraries?: string[]; + LastSyncTime?: number; + ItemCount?: number; }; } diff --git a/src/lib/config.ts b/src/lib/config.ts index 665a914..dfb1632 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -335,9 +335,25 @@ export async function getConfig(): Promise { await db.saveAdminConfig(adminConfig); } } + + // 检查是否有旧格式Emby配置需要迁移 + const needsEmbyMigration = adminConfig.EmbyConfig && + adminConfig.EmbyConfig.ServerURL && + !adminConfig.EmbyConfig.Sources; + adminConfig = configSelfCheck(adminConfig); cachedConfig = adminConfig; + // 如果进行了Emby配置迁移,保存到数据库 + if (!dbReadFailed && needsEmbyMigration) { + try { + await db.saveAdminConfig(adminConfig); + console.log('[Config] Emby配置迁移已保存到数据库'); + } catch (error) { + console.error('[Config] 保存迁移后的配置失败:', error); + } + } + // 自动迁移用户(如果配置中有用户且V2存储支持) // 过滤掉站长后检查是否有需要迁移的用户 const nonOwnerUsers = adminConfig.UserConfig.Users.filter( @@ -476,6 +492,44 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig { return true; }); + // Emby配置迁移:将旧格式迁移到新格式 + if (adminConfig.EmbyConfig) { + // 如果是旧格式(有ServerURL但没有Sources) + if (adminConfig.EmbyConfig.ServerURL && !adminConfig.EmbyConfig.Sources) { + console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式'); + const oldConfig = adminConfig.EmbyConfig; + adminConfig.EmbyConfig = { + Sources: [{ + key: 'default', + name: 'Emby', + enabled: oldConfig.Enabled ?? false, + ServerURL: oldConfig.ServerURL, + ApiKey: oldConfig.ApiKey, + Username: oldConfig.Username, + Password: oldConfig.Password, + UserId: oldConfig.UserId, + AuthToken: oldConfig.AuthToken, + Libraries: oldConfig.Libraries, + LastSyncTime: oldConfig.LastSyncTime, + ItemCount: oldConfig.ItemCount, + isDefault: true, + }], + }; + } + + // Emby源去重 + if (adminConfig.EmbyConfig.Sources) { + const seenEmbyKeys = new Set(); + adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter((source) => { + if (seenEmbyKeys.has(source.key)) { + return false; + } + seenEmbyKeys.add(source.key); + return true; + }); + } + } + return adminConfig; } diff --git a/src/lib/emby-cache.ts b/src/lib/emby-cache.ts index 559298b..2ce56ba 100644 --- a/src/lib/emby-cache.ts +++ b/src/lib/emby-cache.ts @@ -15,8 +15,9 @@ const EMBY_VIEWS_CACHE_KEY = 'emby:views'; /** * 生成 Emby 列表缓存键 */ -function makeListCacheKey(page: number, pageSize: number, parentId?: string): string { - return parentId ? `emby:list:${page}:${pageSize}:${parentId}` : `emby:list:${page}:${pageSize}`; +function makeListCacheKey(page: number, pageSize: number, parentId?: string, embyKey?: string): string { + const keyPrefix = embyKey ? `emby:${embyKey}` : 'emby'; + return parentId ? `${keyPrefix}:list:${page}:${pageSize}:${parentId}` : `${keyPrefix}:list:${page}:${pageSize}`; } /** @@ -25,9 +26,10 @@ function makeListCacheKey(page: number, pageSize: number, parentId?: string): st export function getCachedEmbyList( page: number, pageSize: number, - parentId?: string + parentId?: string, + embyKey?: string ): any | null { - const key = makeListCacheKey(page, pageSize, parentId); + const key = makeListCacheKey(page, pageSize, parentId, embyKey); const entry = EMBY_CACHE.get(key); if (!entry) return null; @@ -47,10 +49,11 @@ export function setCachedEmbyList( page: number, pageSize: number, data: any, - parentId?: string + parentId?: string, + embyKey?: string ): void { const now = Date.now(); - const key = makeListCacheKey(page, pageSize, parentId); + const key = makeListCacheKey(page, pageSize, parentId, embyKey); EMBY_CACHE.set(key, { expiresAt: now + EMBY_CACHE_TTL_MS, data, @@ -69,13 +72,14 @@ export function clearEmbyCache(): { cleared: number } { /** * 获取缓存的 Emby 媒体库列表 */ -export function getCachedEmbyViews(): any | null { - const entry = EMBY_CACHE.get(EMBY_VIEWS_CACHE_KEY); +export function getCachedEmbyViews(embyKey: string = 'default'): any | null { + const cacheKey = `${EMBY_VIEWS_CACHE_KEY}:${embyKey}`; + const entry = EMBY_CACHE.get(cacheKey); if (!entry) return null; // 检查是否过期 if (entry.expiresAt <= Date.now()) { - EMBY_CACHE.delete(EMBY_VIEWS_CACHE_KEY); + EMBY_CACHE.delete(cacheKey); return null; } @@ -85,9 +89,10 @@ export function getCachedEmbyViews(): any | null { /** * 设置缓存的 Emby 媒体库列表 */ -export function setCachedEmbyViews(data: any): void { +export function setCachedEmbyViews(embyKey: string = 'default', data: any): void { const now = Date.now(); - EMBY_CACHE.set(EMBY_VIEWS_CACHE_KEY, { + const cacheKey = `${EMBY_VIEWS_CACHE_KEY}:${embyKey}`; + EMBY_CACHE.set(cacheKey, { expiresAt: now + EMBY_VIEWS_CACHE_TTL_MS, data, }); diff --git a/src/lib/emby-manager.ts b/src/lib/emby-manager.ts new file mode 100644 index 0000000..8d1b706 --- /dev/null +++ b/src/lib/emby-manager.ts @@ -0,0 +1,182 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { EmbyClient } from './emby.client'; +import { getConfig } from './config'; +import { AdminConfig } from './admin.types'; + +interface EmbySourceConfig { + key: string; + name: string; + enabled: boolean; + ServerURL: string; + ApiKey?: string; + Username?: string; + Password?: string; + UserId?: string; + AuthToken?: string; + Libraries?: string[]; + LastSyncTime?: number; + ItemCount?: number; + isDefault?: boolean; +} + +class EmbyManager { + private static instance: EmbyManager; + private clients: Map = new Map(); + + private constructor() {} + + static getInstance(): EmbyManager { + if (!EmbyManager.instance) { + EmbyManager.instance = new EmbyManager(); + } + return EmbyManager.instance; + } + + /** + * 从配置中获取所有Emby源(支持新旧格式) + */ + private async getSources(): Promise { + const config = await getConfig(); + + // 如果是新格式(Sources数组) + if (config.EmbyConfig?.Sources && Array.isArray(config.EmbyConfig.Sources)) { + return config.EmbyConfig.Sources; + } + + // 如果是旧格式(单源配置),转换为数组格式 + if (config.EmbyConfig?.ServerURL) { + return [{ + key: 'default', + name: 'Emby', + enabled: config.EmbyConfig.Enabled ?? false, + ServerURL: config.EmbyConfig.ServerURL, + ApiKey: config.EmbyConfig.ApiKey, + Username: config.EmbyConfig.Username, + Password: config.EmbyConfig.Password, + UserId: config.EmbyConfig.UserId, + AuthToken: config.EmbyConfig.AuthToken, + Libraries: config.EmbyConfig.Libraries, + LastSyncTime: config.EmbyConfig.LastSyncTime, + ItemCount: config.EmbyConfig.ItemCount, + isDefault: true, + }]; + } + + return []; + } + + /** + * 获取指定key的EmbyClient + * @param key Emby源的key,如果不指定则使用默认源 + */ + async getClient(key?: string): Promise { + const sources = await this.getSources(); + + if (sources.length === 0) { + throw new Error('未配置 Emby 源'); + } + + // 如果没有指定key,使用默认源(第一个或标记为default的) + if (!key) { + const defaultSource = sources.find(s => s.isDefault) || sources[0]; + key = defaultSource.key; + } + + // 从缓存获取或创建新实例 + if (!this.clients.has(key)) { + const sourceConfig = sources.find(s => s.key === key); + if (!sourceConfig) { + throw new Error(`未找到 Emby 源: ${key}`); + } + + if (!sourceConfig.enabled) { + throw new Error(`Emby 源已禁用: ${sourceConfig.name}`); + } + + this.clients.set(key, new EmbyClient(sourceConfig)); + } + + return this.clients.get(key)!; + } + + /** + * 获取所有启用的EmbyClient + */ + async getAllClients(): Promise> { + const sources = await this.getSources(); + const enabledSources = sources.filter(s => s.enabled); + const result = new Map(); + + for (const source of enabledSources) { + if (!this.clients.has(source.key)) { + this.clients.set(source.key, new EmbyClient(source)); + } + result.set(source.key, { + client: this.clients.get(source.key)!, + config: source, + }); + } + + return result; + } + + /** + * 获取所有启用的Emby源配置 + */ + async getEnabledSources(): Promise { + const sources = await this.getSources(); + return sources.filter(s => s.enabled); + } + + /** + * 检查是否配置了Emby + */ + async hasEmby(): Promise { + const sources = await this.getSources(); + return sources.some(s => s.enabled && s.ServerURL); + } + + /** + * 清除缓存的客户端实例 + */ + clearCache() { + this.clients.clear(); + } +} + +export const embyManager = EmbyManager.getInstance(); + +/** + * 配置迁移函数:将旧格式配置迁移到新格式 + */ +export function migrateEmbyConfig(config: AdminConfig): AdminConfig { + // 如果已经是新格式,直接返回 + if (config.EmbyConfig?.Sources) { + return config; + } + + // 如果是旧格式,迁移到新格式 + if (config.EmbyConfig && config.EmbyConfig.ServerURL) { + const oldConfig = config.EmbyConfig; + config.EmbyConfig = { + Sources: [{ + key: 'default', + name: 'Emby', + enabled: oldConfig.Enabled ?? false, + ServerURL: oldConfig.ServerURL, + ApiKey: oldConfig.ApiKey, + Username: oldConfig.Username, + Password: oldConfig.Password, + UserId: oldConfig.UserId, + AuthToken: oldConfig.AuthToken, + Libraries: oldConfig.Libraries, + LastSyncTime: oldConfig.LastSyncTime, + ItemCount: oldConfig.ItemCount, + isDefault: true, + }], + }; + } + + return config; +}