tvbox兼容emby
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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('#');
|
||||
|
||||
160
src/app/api/emby/play/[token]/[filename]/route.ts
Normal file
160
src/app/api/emby/play/[token]/[filename]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '私人影库',
|
||||
|
||||
@@ -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: '私人影库',
|
||||
|
||||
@@ -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).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user