增加emby支持

This commit is contained in:
mtvpls
2026-01-03 01:04:38 +08:00
parent d9dbf4ac83
commit ea636b2307
15 changed files with 2478 additions and 37 deletions
+229
View File
@@ -3399,6 +3399,222 @@ const OpenListConfigComponent = ({
);
};
// Emby 媒体库配置组件
const EmbyConfigComponent = ({
config,
refreshConfig,
}: {
config: AdminConfig | null;
refreshConfig: () => Promise<void>;
}) => {
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('');
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 || '');
}
}, [config]);
const handleSave = async () => {
await withLoading('saveEmby', async () => {
try {
const response = await fetch('/api/admin/emby', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'save',
Enabled: enabled,
ServerURL: serverURL,
ApiKey: apiKey,
Username: username,
Password: password,
UserId: userId,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '保存失败');
}
await refreshConfig();
showSuccess('保存成功', showAlert);
} catch (error) {
showError(error instanceof Error ? error.message : '保存失败', showAlert);
throw error;
}
});
};
const handleTest = async () => {
await withLoading('testEmby', 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,
}),
});
const data = await response.json();
if (data.success) {
showSuccess(data.message || 'Emby 连接测试成功', showAlert);
} else {
showError(data.message || 'Emby 连接测试失败', showAlert);
}
} catch (error) {
showError(error instanceof Error ? error.message : '测试失败', showAlert);
}
});
};
return (
<div className='space-y-6'>
<AlertModal
isOpen={alertModal.isOpen}
onClose={hideAlert}
type={alertModal.type}
title={alertModal.title}
message={alertModal.message}
timer={alertModal.timer}
showConfirm={alertModal.showConfirm}
onConfirm={alertModal.onConfirm}
/>
{/* 启用开关 */}
<div className='flex items-center justify-between'>
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
Emby
</label>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* 服务器地址 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
Emby
</label>
<input
type='text'
value={serverURL}
onChange={(e) => 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'
/>
</div>
{/* API Key */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
API Key
</label>
<input
type='password'
value={apiKey}
onChange={(e) => 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
使 API Key 使 API Key
</p>
</div>
{/* 用户名 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='text'
value={username}
onChange={(e) => 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'
/>
</div>
{/* 密码 */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
</label>
<input
type='password'
value={password}
onChange={(e) => 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'
/>
</div>
{/* 用户 ID */}
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
ID使 API Key
</label>
<input
type='text'
value={userId}
onChange={(e) => 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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
Emby ID URL /Users/[userId]/...
</p>
</div>
{/* 操作按钮 */}
<div className='flex gap-3'>
<button
onClick={handleTest}
disabled={isLoading('testEmby')}
className='px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
>
{isLoading('testEmby') ? '测试中...' : '测试连接'}
</button>
<button
onClick={handleSave}
disabled={isLoading('saveEmby')}
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg transition-colors'
>
{isLoading('saveEmby') ? '保存中...' : '保存配置'}
</button>
</div>
</div>
);
};
// 视频源配置组件
const VideoSourceConfig = ({
config,
@@ -8779,6 +8995,7 @@ function AdminPageClient() {
userConfig: false,
videoSource: false,
openListConfig: false,
embyConfig: false,
aiConfig: false,
liveSource: false,
siteConfig: false,
@@ -9092,6 +9309,18 @@ function AdminPageClient() {
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* Emby 媒体库标签 */}
<CollapsibleTab
title='Emby 媒体库'
icon={
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
}
isExpanded={expandedTabs.embyConfig}
onToggle={() => toggleTab('embyConfig')}
>
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
</CollapsibleTab>
{/* AI配置标签 */}
<CollapsibleTab
title='AI设定'
+184
View File
@@ -0,0 +1,184 @@
/* eslint-disable no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { db } from '@/lib/db';
import { EmbyClient } from '@/lib/emby.client';
export const runtime = 'nodejs';
/**
* POST /api/admin/emby
* 保存 Emby 配置
*/
export async function POST(request: NextRequest) {
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
if (storageType === 'localstorage') {
return NextResponse.json(
{ error: '不支持本地存储进行管理员配置' },
{ status: 400 }
);
}
try {
const body = await request.json();
const { action, Enabled, ServerURL, ApiKey, Username, Password, UserId, Libraries } = body;
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const username = authInfo.username;
// 获取配置
const adminConfig = await getConfig();
// 权限检查
if (username !== process.env.USERNAME) {
const userInfo = await db.getUserInfoV2(username);
if (!userInfo || userInfo.role !== 'admin' || userInfo.banned) {
return NextResponse.json({ error: '权限不足' }, { status: 401 });
}
}
if (action === 'save') {
// 如果功能未启用,允许保存空配置
if (!Enabled) {
adminConfig.EmbyConfig = {
Enabled: false,
ServerURL: ServerURL || '',
ApiKey: ApiKey || '',
Username: Username || '',
Password: Password || '',
Libraries: Libraries || [],
};
await db.saveAdminConfig(adminConfig);
return NextResponse.json({ success: true, message: 'Emby 配置已保存(未启用)' });
}
// 验证必填字段
if (!ServerURL) {
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
}
if (!ApiKey && (!Username || !Password)) {
return NextResponse.json(
{ error: '请填写 API Key 或用户名密码' },
{ status: 400 }
);
}
// 测试连接
const testConfig = {
ServerURL,
ApiKey,
Username,
Password,
UserId,
};
const client = new EmbyClient(testConfig);
// 如果使用用户名密码,先认证
let finalUserId: string | undefined = UserId; // 使用用户提供的 UserId
if (!ApiKey && Username && Password) {
try {
const authResult = await client.authenticate(Username, Password);
finalUserId = authResult.User.Id;
} catch (error) {
return NextResponse.json(
{ error: 'Emby 认证失败: ' + (error as Error).message },
{ status: 400 }
);
}
}
// 试连接
const isConnected = await client.checkConnectivity();
if (!isConnected) {
return NextResponse.json(
{ error: 'Emby 连接失败,请检查服务器地址和认证信息' },
{ status: 400 }
);
}
// 保存配置
adminConfig.EmbyConfig = {
Enabled: true,
ServerURL,
ApiKey: ApiKey || undefined,
Username: Username || undefined,
Password: Password || undefined,
UserId: finalUserId,
Libraries: Libraries || [],
LastSyncTime: Date.now(),
};
await db.saveAdminConfig(adminConfig);
return NextResponse.json({
success: true,
message: 'Emby 配置已保存并测试成功',
});
}
if (action === 'test') {
// 测试连接
if (!ServerURL) {
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
}
if (!ApiKey && (!Username || !Password)) {
return NextResponse.json(
{ error: '请填写 API Key 或用户名密码' },
{ status: 400 }
);
}
const testConfig = {
ServerURL,
ApiKey,
Username,
Password,
};
const client = new EmbyClient(testConfig);
// 如果使用用户名密码,先认证
if (!ApiKey && Username && Password) {
try {
await client.authenticate(Username, Password);
} catch (error) {
return NextResponse.json(
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
{ status: 200 }
);
}
}
// 测试连接
const isConnected = await client.checkConnectivity();
if (!isConnected) {
return NextResponse.json(
{ success: false, message: 'Emby 连接失败,请检查服务器地址和认证信息' },
{ status: 200 }
);
}
return NextResponse.json({
success: true,
message: 'Emby 连接测试成功',
});
}
return NextResponse.json({ error: '不支持的操作' }, { status: 400 });
} catch (error) {
console.error('Emby 配置保存失败:', error);
return NextResponse.json(
{ error: 'Emby 配置保存失败: ' + (error as Error).message },
{ status: 500 }
);
}
}
+252
View File
@@ -0,0 +1,252 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { EmbyClient } from '@/lib/emby.client';
export const runtime = 'nodejs';
/**
* Emby CMS 代理接口(动态路由)
* 将 Emby 媒体库转换为 TVBox 兼容的 CMS API 格式
* 路径格式:/api/emby/cms-proxy/{token}?ac=videolist&...
*/
export async function GET(
request: NextRequest,
{ params }: { params: { token: string } }
) {
const { searchParams } = new URL(request.url);
const ac = searchParams.get('ac');
const wd = searchParams.get('wd'); // 搜索关键词
const ids = searchParams.get('ids'); // 视频ID
// 检查必要参数
if (ac !== 'videolist' && ac !== 'list' && ac !== 'detail') {
return NextResponse.json(
{ code: 400, msg: '不支持的操作' },
{ status: 400 }
);
}
// 验证 TVBox Token
const requestToken = params.token;
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
if (!subscribeToken || requestToken !== subscribeToken) {
return NextResponse.json({
code: 401,
msg: '无效的访问token',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
try {
const config = await getConfig();
const embyConfig = config.EmbyConfig;
// 验证 Emby 配置
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
return NextResponse.json({
code: 0,
msg: 'Emby 未配置或未启用',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
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({
code: 0,
msg: 'Emby 认证失败',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
// 路由处理
if (wd) {
// 搜索模式
if (ac === 'detail') {
return await handleDetailBySearch(client, wd, requestToken, request);
}
return await handleSearch(client, wd);
} else if (ids || ac === 'detail') {
// 详情模式
if (!ids) {
return NextResponse.json({
code: 0,
msg: '缺少视频ID',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
return await handleDetail(client, ids, requestToken, request);
} else {
// 列表模式
return await handleSearch(client, '');
}
} catch (error) {
console.error('[Emby CMS Proxy] 错误:', error);
return NextResponse.json({
code: 500,
msg: (error as Error).message,
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
}
/**
* 处理搜索请求
*/
async function handleSearch(client: EmbyClient, query: string) {
const result = await client.getItems({
searchTerm: query || undefined,
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
Limit: 100,
});
const list = result.Items.map((item) => ({
vod_id: item.Id,
vod_name: item.Name,
vod_pic: client.getImageUrl(item.Id, 'Primary'),
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
vod_year: item.ProductionYear?.toString() || '',
vod_content: item.Overview || '',
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
}));
return NextResponse.json({
code: 1,
msg: '数据列表',
page: 1,
pagecount: 1,
limit: list.length,
total: list.length,
list,
});
}
/**
* 处理通过搜索关键词获取详情的请求
*/
async function handleDetailBySearch(
client: EmbyClient,
query: string,
token: string,
request: NextRequest
) {
const result = await client.getItems({
searchTerm: query,
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
Limit: 1,
});
if (result.Items.length === 0) {
return NextResponse.json({
code: 0,
msg: '未找到该视频',
page: 1,
pagecount: 0,
limit: 0,
total: 0,
list: [],
});
}
return await handleDetail(client, result.Items[0].Id, token, request);
}
/**
* 处理详情请求
*/
async function handleDetail(
client: EmbyClient,
itemId: string,
token: string,
request: NextRequest
) {
const item = await client.getItem(itemId);
// 获取当前请求的 baseUrl
const host = request.headers.get('host') || request.headers.get('x-forwarded-host');
const proto = request.headers.get('x-forwarded-proto') ||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
const baseUrl = process.env.SITE_BASE || `${proto}://${host}`;
let vodPlayUrl = '';
if (item.Type === 'Movie') {
// 电影:单个播放链接
vodPlayUrl = `正片$${client.getStreamUrl(item.Id)}`;
} else if (item.Type === 'Series') {
// 剧集:获取所有集
const allEpisodes = await client.getEpisodes(itemId);
const episodes = allEpisodes
.sort((a, b) => {
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
}
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
})
.map((ep) => {
const title = `${ep.IndexNumber}`;
const playUrl = client.getStreamUrl(ep.Id);
return `${title}$${playUrl}`;
});
vodPlayUrl = episodes.join('#');
}
return NextResponse.json({
code: 1,
msg: '数据列表',
page: 1,
pagecount: 1,
limit: 1,
total: 1,
list: [
{
vod_id: item.Id,
vod_name: item.Name,
vod_pic: client.getImageUrl(item.Id, 'Primary'),
vod_remarks: item.Type === 'Movie' ? '电影' : '剧集',
vod_year: item.ProductionYear?.toString() || '',
vod_content: item.Overview || '',
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
vod_play_url: vodPlayUrl,
vod_play_from: 'Emby',
},
],
});
}
+85
View File
@@ -0,0 +1,85 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { EmbyClient } from '@/lib/emby.client';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const itemId = searchParams.get('id');
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 });
}
// 获取媒体详情
const item = await client.getItem(itemId);
let episodes: any[] = [];
if (item.Type === 'Series') {
// 获取所有剧集
const allEpisodes = await client.getEpisodes(itemId);
episodes = allEpisodes
.sort((a, b) => {
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
}
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
})
.map((ep) => ({
id: ep.Id,
title: ep.Name,
episode: ep.IndexNumber || 0,
season: ep.ParentIndexNumber || 1,
overview: ep.Overview || '',
playUrl: client.getStreamUrl(ep.Id),
}));
}
return NextResponse.json({
success: true,
item: {
id: item.Id,
title: item.Name,
type: item.Type === 'Movie' ? 'movie' : 'tv',
overview: item.Overview || '',
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
rating: item.CommunityRating || 0,
playUrl: item.Type === 'Movie' ? client.getStreamUrl(item.Id) : undefined,
},
episodes: item.Type === 'Series' ? episodes : [],
});
} catch (error) {
console.error('获取 Emby 详情失败:', error);
return NextResponse.json(
{ error: '获取 Emby 详情失败: ' + (error as Error).message },
{ status: 500 }
);
}
}
+100
View File
@@ -0,0 +1,100 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { EmbyClient } from '@/lib/emby.client';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
try {
const config = await getConfig();
const embyConfig = config.EmbyConfig;
console.log('[Emby List] EmbyConfig:', JSON.stringify(embyConfig, null, 2));
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,
});
}
// 获取媒体列表
const result = await client.getItems({
IncludeItemTypes: 'Movie,Series',
Recursive: true,
Fields: 'Overview,ProductionYear',
SortBy: 'SortName',
SortOrder: 'Ascending',
StartIndex: (page - 1) * pageSize,
Limit: pageSize,
});
const list = result.Items.map((item) => ({
id: item.Id,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
rating: item.CommunityRating || 0,
mediaType: item.Type === 'Movie' ? 'movie' : 'tv',
}));
const totalPages = Math.ceil(result.TotalRecordCount / pageSize);
return NextResponse.json({
success: true,
list,
totalPages,
currentPage: page,
total: result.TotalRecordCount,
});
} catch (error) {
console.error('获取 Emby 列表失败:', error);
return NextResponse.json({
error: '获取 Emby 列表失败: ' + (error as Error).message,
list: [],
totalPages: 0,
currentPage: page,
total: 0,
});
}
}
+41 -1
View File
@@ -44,6 +44,46 @@ export async function GET(request: NextRequest) {
config.OpenListConfig?.Password
);
// 检查是否配置了 Emby
const hasEmby = !!(
config.EmbyConfig?.Enabled &&
config.EmbyConfig?.ServerURL &&
config.EmbyConfig?.UserId
);
// 搜索 Emby(如果配置了)
let embyResults: any[] = [];
if (hasEmby) {
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,
});
embyResults = 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);
}
}
// 搜索 OpenList(如果配置了)
let openlistResults: any[] = [];
if (hasOpenList) {
@@ -114,7 +154,7 @@ export async function GET(request: NextRequest) {
const successResults = results
.filter((result) => result.status === 'fulfilled')
.map((result) => (result as PromiseFulfilledResult<any>).value);
let flattenedResults = [...openlistResults, ...successResults.flat()];
let flattenedResults = [...embyResults, ...openlistResults, ...successResults.flat()];
if (!config.SiteConfig.DisableYellowFilter) {
flattenedResults = flattenedResults.filter((result) => {
const typeName = result.type_name || '';
+78 -2
View File
@@ -41,6 +41,13 @@ export async function GET(request: NextRequest) {
config.OpenListConfig?.Password
);
// 检查是否配置了 Emby
const hasEmby = !!(
config.EmbyConfig?.Enabled &&
config.EmbyConfig?.ServerURL &&
config.EmbyConfig?.UserId
);
// 共享状态
let streamClosed = false;
@@ -70,7 +77,7 @@ export async function GET(request: NextRequest) {
const startEvent = `data: ${JSON.stringify({
type: 'start',
query,
totalSources: apiSites.length + (hasOpenList ? 1 : 0),
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0),
timestamp: Date.now()
})}\n\n`;
@@ -82,6 +89,75 @@ export async function GET(request: NextRequest) {
let completedSources = 0;
const allResults: any[] = [];
// 搜索 Emby(如果配置了)
if (hasEmby) {
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,
});
const embyResults = 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,
}));
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;
}
if (embyResults.length > 0) {
allResults.push(...embyResults);
}
}
} catch (error) {
console.error('[Search WS] 搜索 Emby 失败:', error);
completedSources++;
if (!streamClosed) {
const errorEvent = `data: ${JSON.stringify({
type: 'source_error',
source: 'emby',
sourceName: 'Emby',
error: error instanceof Error ? error.message : '搜索失败',
timestamp: Date.now()
})}\n\n`;
if (!safeEnqueue(encoder.encode(errorEvent))) {
streamClosed = true;
return;
}
}
}
}
// 搜索 OpenList(如果配置了)
if (hasOpenList) {
try {
@@ -254,7 +330,7 @@ export async function GET(request: NextRequest) {
}
// 检查是否所有源都已完成
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0)) {
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0)) {
if (!streamClosed) {
// 发送最终完成事件
const completeEvent = `data: ${JSON.stringify({
+86
View File
@@ -27,6 +27,92 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
}
// 特殊处理 emby 源
if (sourceCode === 'emby') {
try {
const config = await getConfig();
const embyConfig = config.EmbyConfig;
if (!embyConfig || !embyConfig.Enabled || !embyConfig.ServerURL) {
throw new Error('Emby 未配置或未启用');
}
const { EmbyClient } = await import('@/lib/emby.client');
const client = new EmbyClient(embyConfig);
// 获取媒体详情
const item = await client.getItem(id);
// 根据类型处理
if (item.Type === 'Movie') {
// 电影
const subtitles = client.getSubtitles(item);
const result = {
source: 'emby',
source_name: 'Emby',
id: item.Id,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
douban_id: 0,
desc: item.Overview || '',
episodes: [client.getStreamUrl(item.Id)],
episodes_titles: [item.Name],
subtitles: subtitles.length > 0 ? [subtitles] : [],
proxyMode: false,
};
return NextResponse.json(result);
} else if (item.Type === 'Series') {
// 剧集 - 获取所有季和集
const seasons = await client.getSeasons(item.Id);
const allEpisodes: any[] = [];
for (const season of seasons) {
const episodes = await client.getEpisodes(item.Id, season.Id);
allEpisodes.push(...episodes);
}
// 按季和集排序
allEpisodes.sort((a, b) => {
if (a.ParentIndexNumber !== b.ParentIndexNumber) {
return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0);
}
return (a.IndexNumber || 0) - (b.IndexNumber || 0);
});
const result = {
source: 'emby',
source_name: 'Emby',
id: item.Id,
title: item.Name,
poster: client.getImageUrl(item.Id, 'Primary'),
year: item.ProductionYear?.toString() || '',
douban_id: 0,
desc: item.Overview || '',
episodes: allEpisodes.map((ep) => client.getStreamUrl(ep.Id)),
episodes_titles: allEpisodes.map((ep) => {
const seasonNum = ep.ParentIndexNumber || 1;
const episodeNum = ep.IndexNumber || 1;
return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`;
}),
subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)),
proxyMode: false,
};
return NextResponse.json(result);
} else {
throw new Error('不支持的媒体类型');
}
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: 500 }
);
}
}
// 特殊处理 openlist 源 - 直接调用 /api/detail
if (sourceCode === 'openlist') {
try {
+169 -15
View File
@@ -1328,8 +1328,8 @@ function PlayPageClient() {
!detailData.episodes ||
episodeIndex >= detailData.episodes.length
) {
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过
if (detailData?.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
// openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过
if ((detailData?.source === 'openlist' || detailData?.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
return;
}
setVideoUrl('');
@@ -2207,8 +2207,9 @@ function PlayPageClient() {
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
result.source === 'openlist' ||
result.source === 'emby' ||
(searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
@@ -2241,8 +2242,9 @@ function PlayPageClient() {
? result.year.toLowerCase() === videoYearRef.current.toLowerCase()
: true) &&
(searchType
? // openlist 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
? // openlist 和 emby 源跳过 episodes 长度检查,因为搜索时不返回详细播放列表
result.source === 'openlist' ||
result.source === 'emby' ||
(searchType === 'tv' && result.episodes.length > 1) ||
(searchType === 'movie' && result.episodes.length === 1)
: true)
@@ -2327,9 +2329,9 @@ function PlayPageClient() {
if (target) {
detailData = target;
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
console.log('[Play] OpenList source has no episodes, fetching detail...');
// 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
console.log('[Play] OpenList/Emby source has no episodes, fetching detail...');
const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle);
if (detailSources.length > 0) {
detailData = detailSources[0];
@@ -2350,14 +2352,25 @@ function PlayPageClient() {
setLoadingStage('preferring');
setLoadingMessage('⚡ 正在优选最佳播放源...');
detailData = await preferBestSource(sourcesInfo);
// 过滤掉 openlist 和 emby 源,它们不参与测速
const sourcesToTest = sourcesInfo.filter(s => s.source !== 'openlist' && s.source !== 'emby');
const excludedSources = sourcesInfo.filter(s => s.source === 'openlist' || s.source === 'emby');
if (sourcesToTest.length > 0) {
detailData = await preferBestSource(sourcesToTest);
} else if (excludedSources.length > 0) {
// 如果只有 openlist/emby 源,直接使用第一个
detailData = excludedSources[0];
} else {
detailData = sourcesInfo[0];
}
}
console.log(detailData.source, detailData.id);
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
if (detailData.source === 'openlist' && (!detailData.episodes || detailData.episodes.length === 0)) {
console.log('[Play] OpenList source has no episodes after selection, fetching detail...');
// 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
console.log('[Play] OpenList/Emby source has no episodes after selection, fetching detail...');
const detailSources = await fetchSourceDetail(detailData.source, detailData.id, detailData.title || videoTitleRef.current);
if (detailSources.length > 0) {
detailData = detailSources[0];
@@ -2498,6 +2511,68 @@ function PlayPageClient() {
}
}, [searchParams, currentSource, currentId, availableSources, currentEpisodeIndex]);
// 监听 detail 和 currentEpisodeIndex 变化,动态更新字幕
useEffect(() => {
if (!artPlayerRef.current || !detail) return;
const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || [];
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
// 如果有字幕,更新播放器字幕
if (currentSubtitles.length > 0) {
artPlayerRef.current.subtitle.switch(currentSubtitles[0].url, {
type: 'vtt',
style: {
color: '#fff',
fontSize: savedSubtitleSize,
},
encoding: 'utf-8',
});
// 移除旧的字幕设置,添加新的
try {
artPlayerRef.current.setting.remove('subtitle-selector');
} catch (e) {
// 忽略错误,可能设置项不存在
}
const subtitleOptions = [
{ html: '关闭', url: '' },
...currentSubtitles.map((sub: any) => ({
html: sub.label,
url: sub.url,
})),
];
artPlayerRef.current.setting.add({
name: 'subtitle-selector',
html: '字幕',
selector: subtitleOptions,
onSelect: function (item: any) {
if (artPlayerRef.current) {
if (item.url === '') {
artPlayerRef.current.subtitle.show = false;
} else {
artPlayerRef.current.subtitle.switch(item.url, {
name: item.html,
});
artPlayerRef.current.subtitle.show = true;
}
}
return item.html;
},
});
} else {
// 没有字幕时,隐藏字幕并移除字幕设置
artPlayerRef.current.subtitle.show = false;
try {
artPlayerRef.current.setting.remove('subtitle-selector');
} catch (e) {
// 忽略错误,可能设置项不存在
}
}
}, [detail, currentEpisodeIndex]);
// 处理换源
const handleSourceChange = async (
newSource: string,
@@ -2548,8 +2623,8 @@ function PlayPageClient() {
return;
}
// 如果是 openlist 源且 episodes 为空,需要调用 detail 接口获取完整信息
if (newDetail.source === 'openlist' && (!newDetail.episodes || newDetail.episodes.length === 0)) {
// 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
if ((newDetail.source === 'openlist' || newDetail.source === '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) {
@@ -3479,8 +3554,8 @@ function PlayPageClient() {
return;
}
// openlist 源的剧集是懒加载的,如果 episodes 为空则跳过检查
if ((currentSource === 'openlist' || detail?.source === 'openlist') && (!detail || !detail.episodes || detail.episodes.length === 0)) {
// openlist 和 emby 源的剧集是懒加载的,如果 episodes 为空则跳过检查
if ((currentSource === 'openlist' || currentSource === 'emby' || detail?.source === 'openlist' || detail?.source === 'emby') && (!detail || !detail.episodes || detail.episodes.length === 0)) {
return;
}
@@ -3595,6 +3670,10 @@ function PlayPageClient() {
Artplayer.PLAYBACK_RATE = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
Artplayer.USE_RAF = true;
// 获取当前集的字幕
const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || [];
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
artPlayerRef.current = new Artplayer({
container: artRef.current!,
url: videoUrl,
@@ -3614,6 +3693,17 @@ function PlayPageClient() {
aspectRatio: false,
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
...(currentSubtitles.length > 0 ? {
subtitle: {
url: currentSubtitles[0].url,
type: 'vtt',
style: {
color: '#fff',
fontSize: savedSubtitleSize,
},
encoding: 'utf-8',
}
} : {}),
subtitleOffset: false,
miniProgressBar: false,
mutex: true,
@@ -4532,6 +4622,70 @@ function PlayPageClient() {
setPlayerReady(true);
console.log('[PlayPage] Player ready, triggering sync setup');
// 添加字幕切换功能
const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || [];
if (currentSubtitles.length > 0 && artPlayerRef.current) {
const subtitleOptions = [
{
html: '关闭',
url: '',
},
...currentSubtitles.map((sub: any) => ({
html: sub.label,
url: sub.url,
})),
];
artPlayerRef.current.setting.add({
html: '字幕',
selector: subtitleOptions,
onSelect: function (item: any) {
if (artPlayerRef.current) {
if (item.url === '') {
// 关闭字幕
artPlayerRef.current.subtitle.show = false;
} else {
// 切换字幕
artPlayerRef.current.subtitle.switch(item.url, {
name: item.html,
});
artPlayerRef.current.subtitle.show = true;
}
}
return item.html;
},
});
}
// 添加字幕大小设置
if (artPlayerRef.current) {
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
const defaultOption = savedSubtitleSize === '1em' ? '小' : savedSubtitleSize === '3em' ? '大' : savedSubtitleSize === '4em' ? '超大' : '中';
artPlayerRef.current.setting.add({
html: '字幕大小',
selector: [
{ html: '小', size: '1em' },
{ html: '中', size: '2em' },
{ html: '大', size: '3em' },
{ html: '超大', size: '4em' },
],
onSelect: function (item: any) {
if (artPlayerRef.current) {
artPlayerRef.current.subtitle.style({
fontSize: item.size,
});
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('subtitleSize', item.size);
}
}
return item.html;
},
default: defaultOption,
});
}
// 控制截图按钮在小屏幕竖屏时隐藏
const updateScreenshotVisibility = () => {
const screenshotBtn = document.querySelector('.art-control-screenshot') as HTMLElement;
+46 -15
View File
@@ -5,23 +5,29 @@
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import CapsuleSwitch from '@/components/CapsuleSwitch';
import PageLayout from '@/components/PageLayout';
import VideoCard from '@/components/VideoCard';
type LibrarySource = 'openlist' | 'emby';
interface Video {
id: string;
folder: string;
tmdbId: number;
folder?: string;
tmdbId?: number;
title: string;
poster: string;
releaseDate: string;
overview: string;
voteAverage: number;
releaseDate?: string;
year?: string;
overview?: string;
voteAverage?: number;
rating?: number;
mediaType: 'movie' | 'tv';
}
export default function PrivateLibraryPage() {
const router = useRouter();
const [source, setSource] = useState<LibrarySource>('openlist');
const [videos, setVideos] = useState<Video[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -29,16 +35,25 @@ export default function PrivateLibraryPage() {
const [totalPages, setTotalPages] = useState(1);
const pageSize = 20;
// 切换源时重置页码
useEffect(() => {
setPage(1);
}, [source]);
useEffect(() => {
fetchVideos();
}, [page]);
}, [page, source]);
const fetchVideos = async () => {
try {
setLoading(true);
const response = await fetch(
`/api/openlist/list?page=${page}&pageSize=${pageSize}`
);
setError('');
const endpoint = source === 'openlist'
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
: `/api/emby/list?page=${page}&pageSize=${pageSize}`;
const response = await fetch(endpoint);
if (!response.ok) {
throw new Error('获取视频列表失败');
@@ -63,8 +78,8 @@ export default function PrivateLibraryPage() {
};
const handleVideoClick = (video: Video) => {
// 跳转到播放页面,使用 idkey)而不是 folder
router.push(`/play?source=openlist&id=${encodeURIComponent(video.id)}`);
// 跳转到播放页面
router.push(`/play?source=${source}&id=${encodeURIComponent(video.id)}`);
};
return (
@@ -79,6 +94,18 @@ export default function PrivateLibraryPage() {
</p>
</div>
{/* 源切换器 */}
<div className='mb-6 flex justify-center'>
<CapsuleSwitch
options={[
{ label: 'OpenList', value: 'openlist' },
{ label: 'Emby', value: 'emby' }
]}
active={source}
onChange={(value) => setSource(value as LibrarySource)}
/>
</div>
{error && (
<div className='bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6'>
<p className='text-red-800 dark:text-red-200'>{error}</p>
@@ -97,7 +124,9 @@ export default function PrivateLibraryPage() {
) : videos.length === 0 ? (
<div className='text-center py-12'>
<p className='text-gray-500 dark:text-gray-400'>
OpenList
{source === 'openlist'
? '暂无视频,请在管理面板配置 OpenList 并刷新'
: '暂无视频,请在管理面板配置 Emby'}
</p>
</div>
) : (
@@ -107,12 +136,14 @@ export default function PrivateLibraryPage() {
<VideoCard
key={video.id}
id={video.id}
source='openlist'
source={source}
title={video.title}
poster={video.poster}
year={video.releaseDate.split('-')[0]}
year={video.year || (video.releaseDate ? video.releaseDate.split('-')[0] : '')}
rate={
video.voteAverage && video.voteAverage > 0
video.rating
? video.rating.toFixed(1)
: video.voteAverage && video.voteAverage > 0
? video.voteAverage.toFixed(1)
: ''
}
+5 -4
View File
@@ -270,7 +270,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
!optimizationEnabled || // 若关闭测速则直接退出
activeTab !== 'sources' ||
availableSources.length === 0 ||
currentSource === 'openlist' // 私人影库不进行测速
currentSource === 'openlist' || // 私人影库不进行测速
currentSource === 'emby' // Emby 不进行测速
)
return;
@@ -305,7 +306,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
// 当后台加载从 true 变为 false 时(即加载完成)
if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) {
// 如果当前选项卡在换源位置,触发测速
if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist') {
if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') {
// 筛选出尚未测速的播放源
const pendingSources = availableSources.filter((source) => {
const sourceKey = `${source.source}-${source.id}`;
@@ -888,8 +889,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
</div>
{/* 重新测试按钮 */}
{(() => {
// 私人影库不显示重新测试按钮
if (source.source === 'openlist') {
// 私人影库和 Emby 不显示重新测试按钮
if (source.source === 'openlist' || source.source === 'emby') {
return null;
}
+11
View File
@@ -155,6 +155,17 @@ export interface AdminConfig {
MaxTokens?: number; // 最大回复token数,默认1000
SystemPrompt?: string; // 自定义系统提示词
};
EmbyConfig?: {
Enabled: boolean; // 是否启用Emby媒体库功能
ServerURL: string; // Emby服务器地址
ApiKey?: string; // API Key(推荐方式)
Username?: string; // 用户名(或使用API Key
Password?: string; // 密码
UserId?: string; // 用户ID(登录后获取)
Libraries?: string[]; // 要显示的媒体库ID(可选,默认全部)
LastSyncTime?: number; // 最后同步时间戳
ItemCount?: number; // 媒体项数量
};
}
export interface AdminConfigResult {
+289
View File
@@ -0,0 +1,289 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface EmbyConfig {
ServerURL: string;
ApiKey?: string;
Username?: string;
Password?: string;
UserId?: string;
}
interface EmbyItem {
Id: string;
Name: string;
Type: 'Movie' | 'Series' | 'Season' | 'Episode';
Overview?: string;
ProductionYear?: number;
CommunityRating?: number;
PremiereDate?: string;
ImageTags?: { Primary?: string };
ParentIndexNumber?: number;
IndexNumber?: number;
MediaSources?: Array<{
Id: string;
MediaStreams?: Array<{
Type: string;
Index: number;
DisplayTitle?: string;
Language?: string;
Codec?: string;
IsExternal?: boolean;
DeliveryUrl?: string;
}>;
}>;
}
interface EmbyItemsResult {
Items: EmbyItem[];
TotalRecordCount: number;
}
interface GetItemsParams {
ParentId?: string;
IncludeItemTypes?: string;
Recursive?: boolean;
Fields?: string;
SortBy?: string;
SortOrder?: string;
StartIndex?: number;
Limit?: number;
searchTerm?: string;
}
export class EmbyClient {
private serverUrl: string;
private apiKey?: string;
private userId?: string;
private authToken?: string;
constructor(config: EmbyConfig) {
let serverUrl = config.ServerURL.replace(/\/$/, '');
// 如果 URL 不包含 /emby 路径,自动添加
if (!serverUrl.endsWith('/emby')) {
serverUrl += '/emby';
}
this.serverUrl = serverUrl;
this.apiKey = config.ApiKey;
this.userId = config.UserId;
}
private getHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.apiKey) {
headers['X-Emby-Token'] = this.apiKey;
} else if (this.authToken) {
headers['X-Emby-Token'] = this.authToken;
}
return headers;
}
async authenticate(username: string, password: string): Promise<{ AccessToken: string; User: { Id: string } }> {
const response = await fetch(`${this.serverUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Username: username,
Pw: password,
}),
});
if (!response.ok) {
throw new Error('Emby 认证失败');
}
const data = await response.json();
this.authToken = data.AccessToken;
this.userId = data.User.Id;
return data;
}
async getCurrentUser(): Promise<{ Id: string; Name: string }> {
const url = `${this.serverUrl}/Users/Me`;
const headers = this.getHeaders();
console.log('[EmbyClient] getCurrentUser - URL:', url);
console.log('[EmbyClient] getCurrentUser - Headers:', JSON.stringify(headers, null, 2));
try {
const response = await fetch(url, { headers });
console.log('[EmbyClient] getCurrentUser - Status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('[EmbyClient] getCurrentUser - Error Response:', errorText);
throw new Error(`获取当前用户信息失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
console.log('[EmbyClient] getCurrentUser - Success:', data);
return data;
} catch (error) {
console.error('[EmbyClient] getCurrentUser - Exception:', error);
throw error;
}
}
async getItems(params: GetItemsParams): Promise<EmbyItemsResult> {
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const searchParams = new URLSearchParams();
if (params.ParentId) searchParams.set('ParentId', params.ParentId);
if (params.IncludeItemTypes) searchParams.set('IncludeItemTypes', params.IncludeItemTypes);
if (params.Recursive !== undefined) searchParams.set('Recursive', params.Recursive.toString());
if (params.Fields) searchParams.set('Fields', params.Fields);
if (params.SortBy) searchParams.set('SortBy', params.SortBy);
if (params.SortOrder) searchParams.set('SortOrder', params.SortOrder);
if (params.StartIndex !== undefined) searchParams.set('StartIndex', params.StartIndex.toString());
if (params.Limit !== undefined) searchParams.set('Limit', params.Limit.toString());
if (params.searchTerm) searchParams.set('searchTerm', params.searchTerm);
// 添加认证参数
const token = this.apiKey || this.authToken;
if (token) {
searchParams.set('X-Emby-Token', token);
}
const url = `${this.serverUrl}/Users/${this.userId}/Items?${searchParams.toString()}`;
console.log('[EmbyClient] getItems - URL:', url);
console.log('[EmbyClient] getItems - Token:', token);
console.log('[EmbyClient] getItems - UserId:', this.userId);
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`获取 Emby 媒体列表失败 (${response.status}): ${errorText}`);
}
return await response.json();
}
async getItem(itemId: string): Promise<EmbyItem> {
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Users/${this.userId}/Items/${itemId}?Fields=MediaSources${token ? `&api_key=${token}` : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('获取 Emby 媒体详情失败');
}
return await response.json();
}
async getSeasons(seriesId: string): Promise<EmbyItem[]> {
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Shows/${seriesId}/Seasons?userId=${this.userId}${token ? `&api_key=${token}` : ''}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('获取 Emby 季列表失败');
}
const data = await response.json();
return data.Items || [];
}
async getEpisodes(seriesId: string, seasonId?: string): Promise<EmbyItem[]> {
const token = this.apiKey || this.authToken;
const searchParams = new URLSearchParams({
userId: this.userId!,
Fields: 'MediaSources',
});
if (seasonId) {
searchParams.set('seasonId', seasonId);
}
if (token) {
searchParams.set('api_key', token);
}
const url = `${this.serverUrl}/Shows/${seriesId}/Episodes?${searchParams.toString()}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error('获取 Emby 集列表失败');
}
const data = await response.json();
return data.Items || [];
}
async checkConnectivity(): Promise<boolean> {
try {
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/System/Info/Public${token ? `?api_key=${token}` : ''}`;
const response = await fetch(url);
return response.ok;
} catch {
return false;
}
}
getImageUrl(itemId: string, imageType: 'Primary' | 'Backdrop' | 'Logo' = 'Primary', maxWidth?: number): string {
const params = new URLSearchParams();
const token = this.apiKey || this.authToken;
if (maxWidth) params.set('maxWidth', maxWidth.toString());
if (token) params.set('api_key', token);
const queryString = params.toString();
return `${this.serverUrl}/Items/${itemId}/Images/${imageType}${queryString ? '?' + queryString : ''}`;
}
getStreamUrl(itemId: string, direct: boolean = true): string {
if (direct) {
return `${this.serverUrl}/Videos/${itemId}/stream?Static=true&api_key=${this.apiKey || this.authToken}`;
}
return `${this.serverUrl}/Videos/${itemId}/master.m3u8?api_key=${this.apiKey || this.authToken}`;
}
getSubtitles(item: EmbyItem): Array<{ url: string; language: string; label: string }> {
const subtitles: Array<{ url: string; language: string; label: string }> = [];
if (!item.MediaSources || item.MediaSources.length === 0) {
return subtitles;
}
const mediaSource = item.MediaSources[0];
if (!mediaSource.MediaStreams) {
return subtitles;
}
const token = this.apiKey || this.authToken;
mediaSource.MediaStreams
.filter((stream) => stream.Type === 'Subtitle')
.forEach((stream) => {
const language = stream.Language || 'unknown';
const label = stream.DisplayTitle || `${language} (${stream.Codec})`;
// 外部字幕使用 DeliveryUrl
if (stream.IsExternal && stream.DeliveryUrl) {
subtitles.push({
url: `${this.serverUrl}${stream.DeliveryUrl}`,
language,
label,
});
} else {
// 内嵌字幕使用 Stream API
subtitles.push({
url: `${this.serverUrl}/Videos/${item.Id}/${mediaSource.Id}/Subtitles/${stream.Index}/Stream.vtt?api_key=${token}`,
language,
label,
});
}
});
return subtitles;
}
}