From ba8528c12ec7d54739fc8e52c88f263525a1608d Mon Sep 17 00:00:00 2001 From: mtvpls Date: Sat, 3 Jan 2026 22:21:08 +0800 Subject: [PATCH] =?UTF-8?q?tvbox=E5=85=BC=E5=AE=B9emby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/cms-proxy/route.ts | 11 -- src/app/api/emby/cms-proxy/[token]/route.ts | 9 +- .../api/emby/play/[token]/[filename]/route.ts | 160 ++++++++++++++++++ src/app/api/tvbox/subscribe/route.ts | 22 ++- src/app/layout.tsx | 9 + src/components/MobileBottomNav.tsx | 4 +- src/components/Sidebar.tsx | 4 +- src/middleware.ts | 2 +- 8 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 src/app/api/emby/play/[token]/[filename]/route.ts diff --git a/src/app/api/cms-proxy/route.ts b/src/app/api/cms-proxy/route.ts index b7b2a2c..b4e1ff0 100644 --- a/src/app/api/cms-proxy/route.ts +++ b/src/app/api/cms-proxy/route.ts @@ -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', diff --git a/src/app/api/emby/cms-proxy/[token]/route.ts b/src/app/api/emby/cms-proxy/[token]/route.ts index c80900d..2588895 100644 --- a/src/app/api/emby/cms-proxy/[token]/route.ts +++ b/src/app/api/emby/cms-proxy/[token]/route.ts @@ -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('#'); diff --git a/src/app/api/emby/play/[token]/[filename]/route.ts b/src/app/api/emby/play/[token]/[filename]/route.ts new file mode 100644 index 0000000..c6a5620 --- /dev/null +++ b/src/app/api/emby/play/[token]/[filename]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/tvbox/subscribe/route.ts b/src/app/api/tvbox/subscribe/route.ts index a443298..7dc93ea 100644 --- a/src/app/api/tvbox/subscribe/route.ts +++ b/src/app/api/tvbox/subscribe/route.ts @@ -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, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 746330f..b5f4ce6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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, diff --git a/src/components/MobileBottomNav.tsx b/src/components/MobileBottomNav.tsx index ed74e9c..f2083ed 100644 --- a/src/components/MobileBottomNav.tsx +++ b/src/components/MobileBottomNav.tsx @@ -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: '私人影库', diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 62476a1..a208ae7 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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: '私人影库', diff --git a/src/middleware.ts b/src/middleware.ts index 047d04c..4e691c8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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).*)', ], };