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

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',

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('#');

View File

@@ -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 }
);
}
}

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,

View File

@@ -64,6 +64,7 @@ export default async function RootLayout({
let recommendationDataSource = 'Mixed';
let tmdbApiKey = '';
let openListEnabled = false;
let embyEnabled = false;
let loginBackgroundImage = '';
let registerBackgroundImage = '';
let enableRegistration = false;
@@ -124,6 +125,12 @@ export default async function RootLayout({
config.OpenListConfig?.Username &&
config.OpenListConfig?.Password
);
// 检查是否启用了 Emby 功能
embyEnabled = !!(
config.EmbyConfig?.Enabled &&
config.EmbyConfig?.ServerURL &&
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
);
}
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
@@ -142,6 +149,8 @@ export default async function RootLayout({
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
OPENLIST_ENABLED: openListEnabled,
EMBY_ENABLED: embyEnabled,
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled,
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
ENABLE_REGISTRATION: enableRegistration,

View File

@@ -90,8 +90,8 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
},
];
// 如果配置了 OpenList添加私人影库入口
if (runtimeConfig?.OPENLIST_ENABLED) {
// 如果配置了 OpenList 或 Emby,添加私人影库入口
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
items.push({
icon: FolderOpen,
label: '私人影库',

View File

@@ -181,8 +181,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
},
];
// 如果配置了 OpenList添加私人影库入口
if (runtimeConfig?.OPENLIST_ENABLED) {
// 如果配置了 OpenList 或 Emby,添加私人影库入口
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
items.push({
icon: FolderOpen,
label: '私人影库',

View File

@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
// 配置middleware匹配规则
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play).*)',
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play).*)',
],
};