tvbox兼容emby
This commit is contained in:
@@ -99,17 +99,6 @@ export async function GET(request: NextRequest) {
|
|||||||
// 处理返回数据,替换播放链接为代理链接
|
// 处理返回数据,替换播放链接为代理链接
|
||||||
const processedData = processPlayUrls(data, origin);
|
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, {
|
return NextResponse.json(processedData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
|||||||
@@ -206,8 +206,9 @@ async function handleDetail(
|
|||||||
let vodPlayUrl = '';
|
let vodPlayUrl = '';
|
||||||
|
|
||||||
if (item.Type === 'Movie') {
|
if (item.Type === 'Movie') {
|
||||||
// 电影:单个播放链接
|
// 电影:单个播放链接(使用代理,添加 .mp4 扩展名)
|
||||||
vodPlayUrl = `正片$${client.getStreamUrl(item.Id)}`;
|
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}`;
|
||||||
|
vodPlayUrl = `正片$${proxyUrl}`;
|
||||||
} else if (item.Type === 'Series') {
|
} else if (item.Type === 'Series') {
|
||||||
// 剧集:获取所有集
|
// 剧集:获取所有集
|
||||||
const allEpisodes = await client.getEpisodes(itemId);
|
const allEpisodes = await client.getEpisodes(itemId);
|
||||||
@@ -221,8 +222,8 @@ async function handleDetail(
|
|||||||
})
|
})
|
||||||
.map((ep) => {
|
.map((ep) => {
|
||||||
const title = `第${ep.IndexNumber}集`;
|
const title = `第${ep.IndexNumber}集`;
|
||||||
const playUrl = client.getStreamUrl(ep.Id);
|
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}`;
|
||||||
return `${title}$${playUrl}`;
|
return `${title}$${proxyUrl}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
vodPlayUrl = episodes.join('#');
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,13 @@ export async function GET(request: NextRequest) {
|
|||||||
config.OpenListConfig?.Password
|
config.OpenListConfig?.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 检查是否配置了 Emby
|
||||||
|
const hasEmby = !!(
|
||||||
|
config.EmbyConfig?.Enabled &&
|
||||||
|
config.EmbyConfig?.ServerURL &&
|
||||||
|
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
|
||||||
|
);
|
||||||
|
|
||||||
// 构建 OpenList 站点配置
|
// 构建 OpenList 站点配置
|
||||||
const openlistSites = hasOpenList ? [{
|
const openlistSites = hasOpenList ? [{
|
||||||
key: 'openlist',
|
key: 'openlist',
|
||||||
@@ -83,6 +90,18 @@ export async function GET(request: NextRequest) {
|
|||||||
ext: '',
|
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订阅数据
|
// 构建TVBOX订阅数据
|
||||||
const tvboxSubscription = {
|
const tvboxSubscription = {
|
||||||
// 站点配置
|
// 站点配置
|
||||||
@@ -90,9 +109,10 @@ export async function GET(request: NextRequest) {
|
|||||||
wallpaper: '',
|
wallpaper: '',
|
||||||
|
|
||||||
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
|
// 视频源站点 - 根据 adFilter 参数决定是否使用代理
|
||||||
// OpenList 源放在最前面
|
// OpenList 和 Emby 源放在最前面
|
||||||
sites: [
|
sites: [
|
||||||
...openlistSites,
|
...openlistSites,
|
||||||
|
...embySites,
|
||||||
...apiSites.map(site => ({
|
...apiSites.map(site => ({
|
||||||
key: site.key,
|
key: site.key,
|
||||||
name: site.name,
|
name: site.name,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default async function RootLayout({
|
|||||||
let recommendationDataSource = 'Mixed';
|
let recommendationDataSource = 'Mixed';
|
||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
let openListEnabled = false;
|
let openListEnabled = false;
|
||||||
|
let embyEnabled = false;
|
||||||
let loginBackgroundImage = '';
|
let loginBackgroundImage = '';
|
||||||
let registerBackgroundImage = '';
|
let registerBackgroundImage = '';
|
||||||
let enableRegistration = false;
|
let enableRegistration = false;
|
||||||
@@ -124,6 +125,12 @@ export default async function RootLayout({
|
|||||||
config.OpenListConfig?.Username &&
|
config.OpenListConfig?.Username &&
|
||||||
config.OpenListConfig?.Password
|
config.OpenListConfig?.Password
|
||||||
);
|
);
|
||||||
|
// 检查是否启用了 Emby 功能
|
||||||
|
embyEnabled = !!(
|
||||||
|
config.EmbyConfig?.Enabled &&
|
||||||
|
config.EmbyConfig?.ServerURL &&
|
||||||
|
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
@@ -142,6 +149,8 @@ export default async function RootLayout({
|
|||||||
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
ENABLE_OFFLINE_DOWNLOAD: process.env.NEXT_PUBLIC_ENABLE_OFFLINE_DOWNLOAD === 'true',
|
||||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||||
OPENLIST_ENABLED: openListEnabled,
|
OPENLIST_ENABLED: openListEnabled,
|
||||||
|
EMBY_ENABLED: embyEnabled,
|
||||||
|
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled,
|
||||||
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
|
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
|
||||||
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
|
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
|
||||||
ENABLE_REGISTRATION: enableRegistration,
|
ENABLE_REGISTRATION: enableRegistration,
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ const MobileBottomNav = ({ activePath }: MobileBottomNavProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
if (runtimeConfig?.OPENLIST_ENABLED) {
|
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
label: '私人影库',
|
label: '私人影库',
|
||||||
|
|||||||
@@ -181,8 +181,8 @@ const Sidebar = ({ onToggle, activePath = '/' }: SidebarProps) => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 如果配置了 OpenList,添加私人影库入口
|
// 如果配置了 OpenList 或 Emby,添加私人影库入口
|
||||||
if (runtimeConfig?.OPENLIST_ENABLED) {
|
if (runtimeConfig?.PRIVATE_LIBRARY_ENABLED) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
label: '私人影库',
|
label: '私人影库',
|
||||||
|
|||||||
+1
-1
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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