diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e67497c..8a5b184 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3512,6 +3512,7 @@ const EmbyConfigComponent = ({ appendMediaSourceId: false, transcodeMp4: false, proxyPlay: false, + customUserAgent: '', }); // 从配置加载源列表 @@ -3551,6 +3552,7 @@ const EmbyConfigComponent = ({ appendMediaSourceId: false, transcodeMp4: false, proxyPlay: false, + customUserAgent: '', }); setEditingSource(null); setShowAddForm(false); @@ -4246,7 +4248,7 @@ const EmbyConfigComponent = ({ {/* 视频播放代理开关 */} -
+

视频播放代理 @@ -4268,6 +4270,23 @@ const EmbyConfigComponent = ({ />

+ + {/* 自定义User-Agent */} +
+ + setFormData({ ...formData, customUserAgent: e.target.value })} + placeholder='留空使用默认浏览器UA' + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white text-sm' + /> +

+ 用于登录、获取影片和代理视频时的User-Agent,留空则使用默认浏览器UA +

+
{/* 操作按钮 */} diff --git a/src/app/api/emby/cms-proxy/[token]/route.ts b/src/app/api/emby/cms-proxy/[token]/route.ts index b1b4a1f..12e07f0 100644 --- a/src/app/api/emby/cms-proxy/[token]/route.ts +++ b/src/app/api/emby/cms-proxy/[token]/route.ts @@ -140,7 +140,7 @@ async function handleSearch(client: EmbyClient, query: string) { const list = result.Items.map((item) => ({ vod_id: item.Id, vod_name: item.Name, - vod_pic: client.getImageUrl(item.Id, 'Primary'), + vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, requestToken), vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', vod_year: item.ProductionYear?.toString() || '', vod_content: item.Overview || '', @@ -247,7 +247,7 @@ async function handleDetail( { vod_id: item.Id, vod_name: item.Name, - vod_pic: client.getImageUrl(item.Id, 'Primary'), + vod_pic: client.getImageUrl(item.Id, 'Primary', undefined, requestToken), vod_remarks: item.Type === 'Movie' ? '电影' : '剧集', vod_year: item.ProductionYear?.toString() || '', vod_content: item.Overview || '', diff --git a/src/app/api/emby/detail/route.ts b/src/app/api/emby/detail/route.ts index 94908a5..3c0e2d6 100644 --- a/src/app/api/emby/detail/route.ts +++ b/src/app/api/emby/detail/route.ts @@ -3,6 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { embyManager } from '@/lib/emby-manager'; +import { getProxyToken } from '@/lib/emby-token'; export const runtime = 'nodejs'; @@ -19,6 +20,9 @@ export async function GET(request: NextRequest) { // 获取Emby客户端 const client = await embyManager.getClient(embyKey); + // 获取代理 token(如果启用了代理) + const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + // 获取媒体详情 const item = await client.getItem(itemId); @@ -54,7 +58,7 @@ export async function GET(request: NextRequest) { title: item.Name, type: item.Type === 'Movie' ? 'movie' : 'tv', overview: item.Overview || '', - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), year: item.ProductionYear?.toString() || '', rating: item.CommunityRating || 0, playUrl: item.Type === 'Movie' ? await client.getStreamUrl(item.Id) : undefined, diff --git a/src/app/api/emby/image/[token]/[itemId]/route.ts b/src/app/api/emby/image/[token]/[itemId]/route.ts new file mode 100644 index 0000000..e588301 --- /dev/null +++ b/src/app/api/emby/image/[token]/[itemId]/route.ts @@ -0,0 +1,130 @@ +/* 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 客户端 + */ +async function getEmbyClient(embyKey?: string) { + const config = await getConfig(); + + if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) { + throw new Error('Emby 未配置或未启用'); + } + + const { embyManager } = await import('@/lib/emby-manager'); + return await embyManager.getClient(embyKey); +} + +/** + * GET /api/emby/image/{token}/{itemId}?imageType=Primary&maxWidth=300&embyKey=xxx + * 代理 Emby 图片 + * + * 权限验证:TVBox Token(路径参数) 或 用户登录(满足其一即可) + */ +export async function GET( + request: NextRequest, + { params }: { params: { token: string; itemId: string } } +) { + try { + const { searchParams } = new URL(request.url); + + // 双重验证:TVBox Token(全局或用户) 或 用户登录 + const requestToken = params.token; + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const authInfo = getAuthInfoFromCookie(request); + + // 验证 TVBox Token(全局token或用户token) + let hasValidToken = false; + if (globalToken && requestToken === globalToken) { + // 全局token + hasValidToken = true; + } else { + // 检查是否是用户token + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + // 检查用户是否被封禁 + const userInfo = await db.getUserInfoV2(username); + if (userInfo && !userInfo.banned) { + hasValidToken = true; + } + } + } + + // 验证用户登录 + const hasValidAuth = authInfo && authInfo.username; + + // 两者至少满足其一 + if (!hasValidToken && !hasValidAuth) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const itemId = params.itemId; + const imageType = (searchParams.get('imageType') || 'Primary') as 'Primary' | 'Backdrop' | 'Logo'; + const maxWidth = searchParams.get('maxWidth') ? parseInt(searchParams.get('maxWidth')!) : undefined; + const embyKey = searchParams.get('embyKey') || undefined; + + // 获取 Emby 客户端 + const client = await getEmbyClient(embyKey); + + // 获取图片 URL + const imageUrl = client.getImageUrl(itemId, imageType, maxWidth); + + // 构建请求头,添加自定义 User-Agent + const requestHeaders: HeadersInit = { + 'User-Agent': client.getUserAgent(), + }; + + // 请求图片 + const imageResponse = await fetch(imageUrl, { + headers: requestHeaders, + }); + + if (!imageResponse.ok) { + console.error('[Emby Image] 获取图片失败:', { + itemId, + imageType, + status: imageResponse.status, + statusText: imageResponse.statusText, + }); + return NextResponse.json( + { error: '获取图片失败' }, + { status: 500 } + ); + } + + // 获取 Content-Type + const contentType = imageResponse.headers.get('content-type') || 'image/jpeg'; + + // 构建响应头 + const headers = new Headers(); + headers.set('Content-Type', contentType); + + // 复制重要的响应头 + const contentLength = imageResponse.headers.get('content-length'); + if (contentLength) { + headers.set('Content-Length', contentLength); + } + + // 设置缓存头 + headers.set('Cache-Control', 'public, max-age=86400'); // 缓存1天 + + // 返回图片内容 + return new NextResponse(imageResponse.body, { + status: imageResponse.status, + headers, + }); + } catch (error) { + console.error('[Emby Image] 错误:', error); + return NextResponse.json( + { error: '获取图片失败', details: (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/emby/list/route.ts b/src/app/api/emby/list/route.ts index 6b259ba..ddc97e3 100644 --- a/src/app/api/emby/list/route.ts +++ b/src/app/api/emby/list/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache'; import { embyManager } from '@/lib/emby-manager'; +import { getProxyToken } from '@/lib/emby-token'; export const runtime = 'nodejs'; @@ -31,6 +32,9 @@ export async function GET(request: NextRequest) { // 获取Emby客户端 const client = await embyManager.getClient(embyKey); + // 获取代理 token(如果启用了代理) + const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + // 获取媒体列表 const result = await client.getItems({ ParentId: parentId, @@ -46,7 +50,7 @@ export async function GET(request: NextRequest) { const list = result.Items.map((item) => ({ id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), year: item.ProductionYear?.toString() || '', rating: item.CommunityRating || 0, mediaType: item.Type === 'Movie' ? 'movie' : 'tv', diff --git a/src/app/api/emby/play/[token]/[filename]/route.ts b/src/app/api/emby/play/[token]/[filename]/route.ts index 7c0317d..b96f823 100644 --- a/src/app/api/emby/play/[token]/[filename]/route.ts +++ b/src/app/api/emby/play/[token]/[filename]/route.ts @@ -79,8 +79,10 @@ export async function GET( // 构建 Emby 原始播放链接(强制获取直接URL,避免代理循环) let embyStreamUrl = await client.getStreamUrl(itemId, true, true); - // 构建请求头,转发 Range 请求 - const requestHeaders: HeadersInit = {}; + // 构建请求头,转发 Range 请求,并添加自定义 User-Agent + const requestHeaders: HeadersInit = { + 'User-Agent': client.getUserAgent(), + }; const rangeHeader = request.headers.get('range'); if (rangeHeader) { requestHeaders['Range'] = rangeHeader; diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index bb124ab..0630ee4 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; +import { getProxyToken } from '@/lib/emby-token'; export const runtime = 'nodejs'; @@ -58,6 +59,9 @@ export async function GET(request: NextRequest) { console.log('[Search] Emby sources count:', embySources.length); console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name }))); + // 获取代理 token(用于图片代理) + const proxyToken = await getProxyToken(request); + // 为每个 Emby 源创建搜索 Promise(全部并发,无限制) const embyPromises = embySources.map(({ client, config: embyConfig }) => Promise.race([ @@ -80,7 +84,7 @@ export async function GET(request: NextRequest) { source: sourceValue, source_name: sourceName, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), episodes: [], episodes_titles: [], year: item.ProductionYear?.toString() || '', diff --git a/src/app/api/search/ws/route.ts b/src/app/api/search/ws/route.ts index e044e81..14aed20 100644 --- a/src/app/api/search/ws/route.ts +++ b/src/app/api/search/ws/route.ts @@ -6,6 +6,7 @@ import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; import { yellowWords } from '@/lib/yellow'; +import { getProxyToken } from '@/lib/emby-token'; export const runtime = 'nodejs'; @@ -123,6 +124,9 @@ export async function GET(request: NextRequest) { const embySourcesMap = await embyManager.getAllClients(); const embySources = Array.from(embySourcesMap.values()); + // 获取代理 token(用于图片代理) + const proxyToken = await getProxyToken(request); + // 为每个 Emby 源并发搜索,并单独发送结果 const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => { try { @@ -144,7 +148,7 @@ export async function GET(request: NextRequest) { source: sourceValue, source_name: sourceName, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), episodes: [], episodes_titles: [], year: item.ProductionYear?.toString() || '', diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 00be402..2c0af75 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -5,6 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config'; import { searchFromApi } from '@/lib/downstream'; +import { getProxyToken } from '@/lib/emby-token'; export const runtime = 'nodejs'; @@ -52,6 +53,9 @@ export async function GET(request: NextRequest) { const client = await embyManager.getClient(embyKey); + // 获取代理 token(如果启用了代理) + const proxyToken = client.isProxyEnabled() ? await getProxyToken(request) : null; + // 获取媒体详情 const item = await client.getItem(id); @@ -65,7 +69,7 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', @@ -99,7 +103,7 @@ export async function GET(request: NextRequest) { source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, proxyToken || undefined), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', diff --git a/src/lib/admin.types.ts b/src/lib/admin.types.ts index 847ef70..adb8218 100644 --- a/src/lib/admin.types.ts +++ b/src/lib/admin.types.ts @@ -201,6 +201,7 @@ export interface AdminConfig { appendMediaSourceId?: boolean; // 拼接MediaSourceId参数 transcodeMp4?: boolean; // 转码mp4 proxyPlay?: boolean; // 视频播放代理开关 + customUserAgent?: string; // 自定义User-Agent }>; // 旧格式:单源配置(向后兼容) Enabled?: boolean; diff --git a/src/lib/emby-token.ts b/src/lib/emby-token.ts new file mode 100644 index 0000000..376e89b --- /dev/null +++ b/src/lib/emby-token.ts @@ -0,0 +1,33 @@ +import { NextRequest } from 'next/server'; +import { getAuthInfoFromCookie } from './auth'; + +/** + * 获取用于代理的 token + * 优先级:全局 token > 用户 token > null + */ +export async function getProxyToken(request?: NextRequest): Promise { + // 1. 尝试获取全局 token + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + if (globalToken) { + return globalToken; + } + + // 2. 如果提供了 request,尝试从用户登录信息获取用户的 tvbox token + if (request) { + const authInfo = getAuthInfoFromCookie(request); + if (authInfo && authInfo.username) { + try { + const { db } = await import('./db'); + const userInfo = await db.getUserInfoV2(authInfo.username); + if (userInfo && userInfo.tvboxToken && !userInfo.banned) { + return userInfo.tvboxToken; + } + } catch (error) { + // 忽略错误,继续 + } + } + } + + // 3. 没有可用的 token + return null; +} diff --git a/src/lib/emby.client.ts b/src/lib/emby.client.ts index 9adc31a..d6d045c 100644 --- a/src/lib/emby.client.ts +++ b/src/lib/emby.client.ts @@ -12,6 +12,7 @@ interface EmbyConfig { appendMediaSourceId?: boolean; transcodeMp4?: boolean; proxyPlay?: boolean; // 视频播放代理开关 + customUserAgent?: string; // 自定义User-Agent key?: string; // Emby源的唯一标识 } @@ -75,6 +76,7 @@ export class EmbyClient { private transcodeMp4: boolean; private proxyPlay: boolean; private embyKey?: string; + private customUserAgent: string; constructor(config: EmbyConfig) { let serverUrl = config.ServerURL.replace(/\/$/, ''); @@ -85,6 +87,8 @@ export class EmbyClient { this.transcodeMp4 = config.transcodeMp4 || false; this.proxyPlay = config.proxyPlay || false; this.embyKey = config.key; + // 设置自定义UA,如果没有设置则使用默认浏览器UA + this.customUserAgent = config.customUserAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; // 如果 URL 不包含 /emby 路径,自动添加(除非启用了 removeEmbyPrefix) if (!serverUrl.endsWith('/emby') && !this.removeEmbyPrefix) { @@ -122,6 +126,7 @@ export class EmbyClient { private getHeaders(): Record { const headers: Record = { 'Content-Type': 'application/json', + 'User-Agent': this.customUserAgent, }; if (this.apiKey) { @@ -146,6 +151,7 @@ export class EmbyClient { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Emby-Authorization': 'MediaBrowser Client="LunaTV", Device="Web", DeviceId="lunatv-web", Version="1.0.0"', + 'User-Agent': this.customUserAgent, }, body: params.toString(), }); @@ -420,7 +426,18 @@ export class EmbyClient { } } - getImageUrl(itemId: string, imageType: 'Primary' | 'Backdrop' | 'Logo' = 'Primary', maxWidth?: number): string { + getImageUrl(itemId: string, imageType: 'Primary' | 'Backdrop' | 'Logo' = 'Primary', maxWidth?: number, proxyToken?: string): string { + // 如果启用了代理播放且提供了 token,返回代理 URL + if (this.proxyPlay && proxyToken) { + const params = new URLSearchParams(); + params.set('imageType', imageType); + if (maxWidth) params.set('maxWidth', maxWidth.toString()); + if (this.embyKey) params.set('embyKey', this.embyKey); + + return `/api/emby/image/${proxyToken}/${itemId}?${params.toString()}`; + } + + // 否则返回直连 URL const params = new URLSearchParams(); const token = this.apiKey || this.authToken; @@ -551,4 +568,12 @@ export class EmbyClient { return subtitles; } + + getUserAgent(): string { + return this.customUserAgent; + } + + isProxyEnabled(): boolean { + return this.proxyPlay; + } } diff --git a/src/lib/special-sources-detail.ts b/src/lib/special-sources-detail.ts index 138cda0..73041bb 100644 --- a/src/lib/special-sources-detail.ts +++ b/src/lib/special-sources-detail.ts @@ -8,7 +8,8 @@ import { SearchResult } from '@/lib/types'; */ export async function getEmbyDetail( source: string, - id: string + id: string, + proxyToken?: string | null ): Promise { const config = await getConfig(); @@ -44,7 +45,7 @@ export async function getEmbyDetail( source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '', @@ -76,7 +77,7 @@ export async function getEmbyDetail( source_name: sourceName, id: item.Id, title: item.Name, - poster: client.getImageUrl(item.Id, 'Primary'), + poster: client.getImageUrl(item.Id, 'Primary', undefined, client.isProxyEnabled() ? proxyToken || undefined : undefined), year: item.ProductionYear?.toString() || '', douban_id: 0, desc: item.Overview || '',