增加emby支持
This commit is contained in:
@@ -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设定'
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 || '';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
// 跳转到播放页面,使用 id(key)而不是 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)
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user