tvbox兼容emby

This commit is contained in:
mtvpls
2026-01-03 22:21:08 +08:00
parent 6d046aea0a
commit ba8528c12e
8 changed files with 200 additions and 21 deletions
-11
View File
@@ -99,17 +99,6 @@ export async function GET(request: NextRequest) {
// 处理返回数据,替换播放链接为代理链接
const processedData = processPlayUrls(data, origin);
// 输出处理后的第一个视频的播放信息(用于调试)
if (processedData.list && processedData.list.length > 0) {
const firstItem = processedData.list[0];
console.log('第一个视频处理后的播放信息:', {
vod_name: firstItem.vod_name,
vod_play_from: firstItem.vod_play_from,
vod_play_url_length: firstItem.vod_play_url?.length || 0,
vod_play_url_preview: firstItem.vod_play_url?.substring(0, 200) || '',
});
}
return NextResponse.json(processedData, {
headers: {
'Content-Type': 'application/json; charset=utf-8',
+5 -4
View File
@@ -206,8 +206,9 @@ async function handleDetail(
let vodPlayUrl = '';
if (item.Type === 'Movie') {
// 电影:单个播放链接
vodPlayUrl = `正片$${client.getStreamUrl(item.Id)}`;
// 电影:单个播放链接(使用代理,添加 .mp4 扩展名)
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}`;
vodPlayUrl = `正片$${proxyUrl}`;
} else if (item.Type === 'Series') {
// 剧集:获取所有集
const allEpisodes = await client.getEpisodes(itemId);
@@ -221,8 +222,8 @@ async function handleDetail(
})
.map((ep) => {
const title = `${ep.IndexNumber}`;
const playUrl = client.getStreamUrl(ep.Id);
return `${title}$${playUrl}`;
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}`;
return `${title}$${proxyUrl}`;
});
vodPlayUrl = episodes.join('#');
@@ -0,0 +1,160 @@
/* eslint-disable @typescript-eslint/no-explicit-any, no-console */
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
export const runtime = 'nodejs';
// 内存缓存 Emby 配置,避免每次请求都读取配置
let cachedEmbyConfig: {
serverURL: string;
apiKey: string;
timestamp: number;
} | null = null;
const CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
/**
* 获取缓存的 Emby 配置
*/
async function getCachedEmbyConfig() {
const now = Date.now();
// 如果缓存存在且未过期,直接返回
if (cachedEmbyConfig && (now - cachedEmbyConfig.timestamp) < CACHE_TTL) {
return cachedEmbyConfig;
}
// 否则重新获取配置
const config = await getConfig();
const embyConfig = config.EmbyConfig;
if (
!embyConfig ||
!embyConfig.Enabled ||
!embyConfig.ServerURL
) {
throw new Error('Emby 未配置或未启用');
}
const apiKey = embyConfig.ApiKey || embyConfig.AuthToken;
if (!apiKey) {
throw new Error('Emby 认证信息缺失');
}
// 更新缓存
cachedEmbyConfig = {
serverURL: embyConfig.ServerURL,
apiKey,
timestamp: now,
};
return cachedEmbyConfig;
}
/**
* GET /api/emby/play/{token}/{filename}?itemId=xxx
* 代理 Emby 视频播放链接,URL 中包含文件扩展名(如 video.mp4)
* 实际返回的内容根据 Emby 服务器的 Content-Type 决定
*
* 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可)
*/
export async function GET(
request: NextRequest,
{ params }: { params: { token: string; filename: string } }
) {
try {
const { searchParams } = new URL(request.url);
// 双重验证:TVBox Token 或 用户登录
const requestToken = params.token;
const subscribeToken = process.env.TVBOX_SUBSCRIBE_TOKEN;
const authInfo = getAuthInfoFromCookie(request);
// 验证 TVBox Token
const hasValidToken = subscribeToken && requestToken === subscribeToken;
// 验证用户登录
const hasValidAuth = authInfo && authInfo.username;
// 两者至少满足其一
if (!hasValidToken && !hasValidAuth) {
return NextResponse.json({ error: '未授权' }, { status: 401 });
}
const itemId = searchParams.get('itemId');
if (!itemId) {
return NextResponse.json({ error: '缺少 itemId 参数' }, { status: 400 });
}
// 使用缓存的配置
const embyConfig = await getCachedEmbyConfig();
// 构建 Emby 原始播放链接
const embyStreamUrl = `${embyConfig.serverURL}/Videos/${itemId}/stream?Static=true&api_key=${embyConfig.apiKey}`;
// 构建请求头,转发 Range 请求
const requestHeaders: HeadersInit = {};
const rangeHeader = request.headers.get('range');
if (rangeHeader) {
requestHeaders['Range'] = rangeHeader;
}
// 流式代理视频内容
const videoResponse = await fetch(embyStreamUrl, {
headers: requestHeaders,
});
if (!videoResponse.ok) {
console.error('[Emby Play] 获取视频流失败:', {
itemId,
status: videoResponse.status,
statusText: videoResponse.statusText,
});
return NextResponse.json(
{ error: '获取视频流失败' },
{ status: 500 }
);
}
// 获取 Content-Type
const contentType = videoResponse.headers.get('content-type') || 'video/mp4';
// 构建响应头
const headers = new Headers();
headers.set('Content-Type', contentType);
// 复制重要的响应头
const contentLength = videoResponse.headers.get('content-length');
if (contentLength) {
headers.set('Content-Length', contentLength);
}
const acceptRanges = videoResponse.headers.get('accept-ranges');
if (acceptRanges) {
headers.set('Accept-Ranges', acceptRanges);
}
const contentRange = videoResponse.headers.get('content-range');
if (contentRange) {
headers.set('Content-Range', contentRange);
}
// 使用 URL 中的文件名
headers.set('Content-Disposition', `inline; filename="${params.filename}"`);
// 流式返回视频内容,不等待下载完成
return new NextResponse(videoResponse.body, {
status: videoResponse.status,
headers,
});
} catch (error) {
console.error('[Emby Play] 错误:', error);
return NextResponse.json(
{ error: '播放失败', details: (error as Error).message },
{ status: 500 }
);
}
}
+21 -1
View File
@@ -71,6 +71,13 @@ export async function GET(request: NextRequest) {
config.OpenListConfig?.Password
);
// 检查是否配置了 Emby
const hasEmby = !!(
config.EmbyConfig?.Enabled &&
config.EmbyConfig?.ServerURL &&
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
);
// 构建 OpenList 站点配置
const openlistSites = hasOpenList ? [{
key: 'openlist',
@@ -83,6 +90,18 @@ export async function GET(request: NextRequest) {
ext: '',
}] : [];
// 构建 Emby 站点配置
const embySites = hasEmby ? [{
key: 'emby',
name: 'Emby媒体库',
type: 1,
api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}`,
searchable: 1,
quickSearch: 1,
filterable: 1,
ext: '',
}] : [];
// 构建TVBOX订阅数据
const tvboxSubscription = {
// 站点配置
@@ -90,9 +109,10 @@ export async function GET(request: NextRequest) {
wallpaper: '',
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
// OpenList 源放在最前面
// OpenList 和 Emby 源放在最前面
sites: [
...openlistSites,
...embySites,
...apiSites.map(site => ({
key: site.key,
name: site.name,