diff --git a/src/app/api/emby/subtitle/[token]/[filename]/route.ts b/src/app/api/emby/subtitle/[token]/[filename]/route.ts new file mode 100644 index 0000000..bfeb85b --- /dev/null +++ b/src/app/api/emby/subtitle/[token]/[filename]/route.ts @@ -0,0 +1,198 @@ +/* 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'; +import { hasFeaturePermission } from '@/lib/permissions'; + +export const runtime = 'nodejs'; + +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); +} + +async function validateEmbyProxyAccess(request: NextRequest, requestToken: string) { + const globalToken = process.env.TVBOX_SUBSCRIBE_TOKEN; + const authInfo = getAuthInfoFromCookie(request); + + let hasValidToken = false; + if (requestToken === 'proxy') { + // 固定 proxy token 仅用于同源登录态访问,仍需下面的 cookie 权限校验 + hasValidToken = false; + } else if (globalToken && requestToken === globalToken) { + hasValidToken = true; + } else { + const { db } = await import('@/lib/db'); + const username = await db.getUsernameByTvboxToken(requestToken); + if (username) { + const userInfo = await db.getUserInfoV2(username); + const allowed = await hasFeaturePermission(username, 'emby'); + if (userInfo && !userInfo.banned && allowed) { + hasValidToken = true; + } + } + } + + const hasValidAuth = !!( + authInfo?.username && + (await hasFeaturePermission(authInfo.username, 'emby')) + ); + + return hasValidToken || hasValidAuth; +} + +function getFormatFromFilename(filename: string) { + return filename.match(/\.([a-z0-9]+)$/i)?.[1]?.toLowerCase(); +} + +function getSubtitleContentType(format: string, fallback?: string | null) { + if (fallback) return fallback; + if (format === 'vtt') return 'text/vtt; charset=utf-8'; + if (format === 'ass' || format === 'ssa' || format === 'srt') { + return 'text/plain; charset=utf-8'; + } + return 'application/octet-stream'; +} + +/** + * GET /api/emby/subtitle/{token}/subtitle.ass?itemId=xxx&mediaSourceId=xxx&streamIndex=2&format=ass + * 代理 Emby 字幕,避免浏览器/JASSUB Worker 直接访问 Emby 时遇到 CORS、鉴权或自定义 UA 问题。 + */ +export async function GET( + request: NextRequest, + { params }: { params: { token: string; filename: string } } +) { + try { + const allowed = await validateEmbyProxyAccess(request, params.token); + if (!allowed) { + return NextResponse.json({ error: '未授权' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const itemId = searchParams.get('itemId'); + const mediaSourceId = searchParams.get('mediaSourceId'); + const streamIndexValue = searchParams.get('streamIndex'); + const embyKey = searchParams.get('embyKey') || undefined; + const requestedFormat = + searchParams.get('format')?.toLowerCase() || + getFormatFromFilename(params.filename) || + 'vtt'; + + if (!itemId || !mediaSourceId || streamIndexValue === null) { + return NextResponse.json( + { error: '缺少 itemId、mediaSourceId 或 streamIndex 参数' }, + { status: 400 } + ); + } + + if (!/^[a-z0-9]+$/i.test(requestedFormat)) { + return NextResponse.json({ error: '字幕格式非法' }, { status: 400 }); + } + + const streamIndex = Number(streamIndexValue); + if (!Number.isInteger(streamIndex) || streamIndex < 0) { + return NextResponse.json({ error: 'streamIndex 参数非法' }, { status: 400 }); + } + + let client = await getEmbyClient(embyKey); + let subtitleUrl = await client.getSubtitleStreamUrl( + itemId, + mediaSourceId, + streamIndex, + requestedFormat, + true + ); + + const requestHeaders: HeadersInit = { + 'User-Agent': client.getUserAgent(), + }; + + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), 60000); + + let subtitleResponse: Response; + try { + subtitleResponse = await fetch(subtitleUrl, { + headers: requestHeaders, + signal: abortController.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + if (subtitleResponse.status === 401) { + const { embyManager } = await import('@/lib/emby-manager'); + embyManager.clearCache(); + client = await getEmbyClient(embyKey); + subtitleUrl = await client.getSubtitleStreamUrl( + itemId, + mediaSourceId, + streamIndex, + requestedFormat, + true + ); + + const retryAbortController = new AbortController(); + const retryTimeoutId = setTimeout(() => retryAbortController.abort(), 60000); + try { + subtitleResponse = await fetch(subtitleUrl, { + headers: requestHeaders, + signal: retryAbortController.signal, + }); + } finally { + clearTimeout(retryTimeoutId); + } + } + + if (!subtitleResponse.ok) { + console.error('[Emby Subtitle] 获取字幕失败:', { + itemId, + mediaSourceId, + streamIndex, + requestedFormat, + status: subtitleResponse.status, + statusText: subtitleResponse.statusText, + }); + return NextResponse.json( + { error: '获取字幕失败' }, + { status: subtitleResponse.status || 500 } + ); + } + + const headers = new Headers(); + headers.set( + 'Content-Type', + getSubtitleContentType( + requestedFormat, + subtitleResponse.headers.get('content-type') + ) + ); + headers.set('Cache-Control', 'private, max-age=3600'); + + const contentLength = subtitleResponse.headers.get('content-length'); + if (contentLength) headers.set('Content-Length', contentLength); + + return new NextResponse(subtitleResponse.body, { + status: subtitleResponse.status, + headers, + }); + } catch (error) { + if ((error as Error).name === 'AbortError') { + return NextResponse.json({ error: '字幕请求超时' }, { status: 504 }); + } + + console.error('[Emby Subtitle] 错误:', error); + return NextResponse.json( + { error: '字幕代理失败: ' + (error as Error).message }, + { status: 500 } + ); + } +} diff --git a/src/app/api/source-detail/route.ts b/src/app/api/source-detail/route.ts index 333de67..403bf5d 100644 --- a/src/app/api/source-detail/route.ts +++ b/src/app/api/source-detail/route.ts @@ -201,10 +201,8 @@ export async function GET(request: NextRequest) { const client = await embyManager.getClient(embyKey); - // 获取代理 token(如果启用了代理) - const proxyToken = client.isProxyEnabled() - ? await getProxyToken(request) - : null; + // 获取代理 token(图片/字幕代理使用;没有 token 时会回退到登录态校验) + const proxyToken = await getProxyToken(request); // 获取媒体详情 const item = await client.getItem(id); @@ -212,7 +210,7 @@ export async function GET(request: NextRequest) { // 根据类型处理 if (item.Type === 'Movie') { // 电影 - const subtitles = client.getSubtitles(item); + const subtitles = client.getSubtitles(item, proxyToken); const result = { source: sourceCode, // 保持与请求一致(emby 或 emby_key) @@ -277,7 +275,7 @@ export async function GET(request: NextRequest) { .toString() .padStart(2, '0')}`; }), - subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), + subtitles: allEpisodes.map((ep) => client.getSubtitles(ep, proxyToken)), proxyMode: false, }; diff --git a/src/app/play/page.tsx b/src/app/play/page.tsx index 9846901..91dade9 100644 --- a/src/app/play/page.tsx +++ b/src/app/play/page.tsx @@ -132,6 +132,17 @@ interface CustomSubtitleState { content?: string; } +interface SourceSubtitleItem { + label: string; + url: string; + fallbackUrl?: string; + fallbackFormat?: string; + format?: string; + sourceFormat?: string; + codec?: string; + renderMode?: 'native' | 'jassub'; +} + interface JassubSubtitleInstance { setTrack?: (content: string) => void | Promise; setTrackByUrl?: (url: string) => void | Promise; @@ -1956,6 +1967,20 @@ function PlayPageClient() { return ADVANCED_SUBTITLE_FORMATS.has(format.toLowerCase()); }; + const getSourceSubtitleFormat = (subtitle?: SourceSubtitleItem | null) => { + return ( + subtitle?.format || + subtitle?.sourceFormat || + subtitle?.codec || + '' + ).toLowerCase(); + }; + + const isAdvancedSourceSubtitle = (subtitle?: SourceSubtitleItem | null) => { + return subtitle?.renderMode === 'jassub' || + isAdvancedSubtitleFormat(getSourceSubtitleFormat(subtitle)); + }; + const getJassubSubtitleInstance = (): JassubSubtitleInstance | null => { return artPlayerRef.current?.plugins?.artplayerPluginJassub?.instance || null; }; @@ -2004,13 +2029,17 @@ function PlayPageClient() { }; const ensureJassubSubtitleInstance = async ( - initialContent: string + initialTrack: { content?: string; url?: string } ): Promise<{ instance: JassubSubtitleInstance; created: boolean }> => { const existingInstance = getJassubSubtitleInstance(); if (existingInstance) { return { instance: existingInstance, created: false }; } + if (!initialTrack.content && !initialTrack.url) { + throw new Error('缺少高级字幕内容'); + } + if (!artPlayerRef.current) { throw new Error('播放器尚未就绪'); } @@ -2021,7 +2050,9 @@ function PlayPageClient() { artPlayerRef.current.plugins.add( artplayerPluginJassub({ - subContent: initialContent, + ...(initialTrack.content + ? { subContent: initialTrack.content } + : { subUrl: initialTrack.url }), workerUrl: `${JASSUB_ASSET_BASE}/jassub-worker.js`, wasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker.wasm`, modernWasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker-modern.wasm`, @@ -2045,7 +2076,7 @@ function PlayPageClient() { if (!artPlayerRef.current) return; artPlayerRef.current.subtitle.show = false; - const { instance, created } = await ensureJassubSubtitleInstance(content); + const { instance, created } = await ensureJassubSubtitleInstance({ content }); // 新建实例时 subContent 已作为初始轨道传入;复用实例时需要显式切轨。 if (!created) { @@ -2055,6 +2086,63 @@ function PlayPageClient() { currentSubtitleLabelRef.current = label; }; + const switchAdvancedSubtitleByUrl = async (url: string, label: string) => { + if (!artPlayerRef.current) return; + + artPlayerRef.current.subtitle.show = false; + const { instance, created } = await ensureJassubSubtitleInstance({ url }); + + // 新建实例时 subUrl 已作为初始轨道传入;复用实例时需要显式切轨。 + if (!created) { + if (instance.setTrackByUrl) { + await instance.setTrackByUrl(url); + } else { + const response = await fetch(url, { + credentials: 'include', + cache: 'no-store', + }); + if (!response.ok) { + throw new Error(`高级字幕加载失败 (${response.status})`); + } + await instance.setTrack?.(await response.text()); + } + } + + currentSubtitleLabelRef.current = label; + }; + + const switchSourceSubtitle = async (subtitle: SourceSubtitleItem) => { + if (!subtitle.url) return; + + if (isAdvancedSourceSubtitle(subtitle)) { + try { + await switchAdvancedSubtitleByUrl(subtitle.url, subtitle.label); + return; + } catch (error) { + if (!subtitle.fallbackUrl) { + throw error; + } + + console.warn('[Subtitle] 高级字幕加载失败,尝试降级为普通字幕:', error); + switchSubtitle(subtitle.fallbackUrl, subtitle.label); + + const message = `高级字幕渲染失败,已降级为普通字幕:${subtitle.label}`; + if (artPlayerRef.current) { + artPlayerRef.current.notice.show = message; + } + setToast({ + message, + type: 'info', + duration: 5000, + onClose: () => setToast(null), + }); + } + return; + } + + switchSubtitle(subtitle.url, subtitle.label); + }; + const removeSubtitleSetting = () => { try { artPlayerRef.current?.setting.remove('subtitle-selector'); @@ -2066,7 +2154,7 @@ function PlayPageClient() { const updateSubtitleSetting = () => { if (!artPlayerRef.current) return; - const sourceSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []; + const sourceSubtitles = (detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []) as SourceSubtitleItem[]; const customSubtitle = customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current ? customSubtitleRef.current @@ -2077,12 +2165,19 @@ function PlayPageClient() { const subtitleOptions = [ { html: '关闭', action: 'close' }, { html: '上传本地字幕', action: 'upload' }, - ...sourceSubtitles.map((sub: any) => ({ - html: sub.label, - action: 'switch', - engine: 'native', - url: sub.url, - })), + ...sourceSubtitles.map((sub: SourceSubtitleItem) => { + const isAdvanced = isAdvancedSourceSubtitle(sub); + const format = getSourceSubtitleFormat(sub); + return { + html: sub.label, + action: 'switch', + engine: isAdvanced ? 'jassub' : 'native', + url: sub.url, + fallbackUrl: sub.fallbackUrl, + fallbackFormat: sub.fallbackFormat, + format, + }; + }), ...(customSubtitle ? [ { @@ -2115,8 +2210,21 @@ function PlayPageClient() { return currentSubtitleLabelRef.current; } - if (item.engine === 'jassub' && item.content) { - void switchAdvancedSubtitle(item.content, item.html).catch((error) => { + if (item.engine === 'jassub') { + const switchPromise = item.content + ? switchAdvancedSubtitle(item.content, item.html) + : item.url + ? switchSourceSubtitle({ + label: item.html, + url: item.url, + fallbackUrl: item.fallbackUrl, + fallbackFormat: item.fallbackFormat, + format: item.format, + renderMode: 'jassub', + }) + : Promise.resolve(); + + void switchPromise.catch((error) => { console.warn('[Subtitle] 高级字幕切换失败:', error); setToast({ message: error instanceof Error ? error.message : '高级字幕切换失败', @@ -4773,11 +4881,16 @@ function PlayPageClient() { if (!artPlayerRef.current || !detail) return; revokeCustomSubtitle(); - const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || []; + const currentSubtitles = (detail.subtitles?.[currentEpisodeIndex] || []) as SourceSubtitleItem[]; // 如果有字幕,更新播放器字幕 if (currentSubtitles.length > 0) { - switchSubtitle(currentSubtitles[0].url, currentSubtitles[0].label); + currentSubtitleLabelRef.current = currentSubtitles[0].label; + void switchSourceSubtitle(currentSubtitles[0]).catch((error) => { + console.warn('[Subtitle] 源字幕加载失败:', error); + artPlayerRef.current.subtitle.show = false; + currentSubtitleLabelRef.current = '关闭'; + }); } else { artPlayerRef.current.subtitle.show = false; currentSubtitleLabelRef.current = '关闭'; @@ -6574,9 +6687,11 @@ function PlayPageClient() { Artplayer.USE_RAF = true; // 获取当前集的字幕 - const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || []; + const currentSubtitles = (detailRef.current?.subtitles?.[currentEpisodeIndex] || []) as SourceSubtitleItem[]; + const defaultSubtitle = currentSubtitles[0]; + const shouldUseNativeInitialSubtitle = !!defaultSubtitle && !isAdvancedSourceSubtitle(defaultSubtitle); const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em'; - currentSubtitleLabelRef.current = currentSubtitles[0]?.label || '关闭'; + currentSubtitleLabelRef.current = defaultSubtitle?.label || '关闭'; artPlayerRef.current = new Artplayer({ container: artRef.current!, @@ -6598,9 +6713,9 @@ function PlayPageClient() { aspectRatio: false, fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器 fullscreenWeb: true, // 保留网页全屏按钮(所有平台) - ...(currentSubtitles.length > 0 ? { + ...(shouldUseNativeInitialSubtitle ? { subtitle: { - url: currentSubtitles[0].url, + url: defaultSubtitle!.url, type: 'vtt', style: { color: '#fff', @@ -7715,8 +7830,22 @@ function PlayPageClient() { applyProgressThumbConfig(); - // 添加字幕切换和本地字幕上传功能 - updateSubtitleSetting(); + // 添加字幕切换和本地字幕上传功能;ASS/SSA 需要播放器 ready 后挂载 JASSUB + const readySubtitles = (detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []) as SourceSubtitleItem[]; + const readyDefaultSubtitle = readySubtitles[0]; + if (readyDefaultSubtitle && isAdvancedSourceSubtitle(readyDefaultSubtitle)) { + void switchSourceSubtitle(readyDefaultSubtitle) + .catch((error) => { + console.warn('[Subtitle] 高级字幕自动加载失败:', error); + if (artPlayerRef.current) { + artPlayerRef.current.subtitle.show = false; + } + currentSubtitleLabelRef.current = '关闭'; + }) + .finally(updateSubtitleSetting); + } else { + updateSubtitleSetting(); + } // 添加字幕大小设置 if (artPlayerRef.current) { diff --git a/src/lib/emby.client.ts b/src/lib/emby.client.ts index 10315c0..8a04ab8 100644 --- a/src/lib/emby.client.ts +++ b/src/lib/emby.client.ts @@ -41,6 +41,19 @@ interface EmbyItem { }>; } +export interface EmbySubtitle { + url: string; + fallbackUrl?: string; + fallbackFormat?: string; + language: string; + label: string; + format: string; + sourceFormat: string; + codec?: string; + isExternal?: boolean; + renderMode: 'native' | 'jassub'; +} + interface EmbyItemsResult { Items: EmbyItem[]; TotalRecordCount: number; @@ -536,8 +549,89 @@ export class EmbyClient { return url; } - getSubtitles(item: EmbyItem): Array<{ url: string; language: string; label: string }> { - const subtitles: Array<{ url: string; language: string; label: string }> = []; + private normalizeSubtitleFormat(codec?: string, deliveryUrl?: string): string { + const normalizedCodec = codec?.trim().toLowerCase(); + + if (normalizedCodec) { + const codecMap: Record = { + subrip: 'srt', + webvtt: 'vtt', + 'text/vtt': 'vtt', + 'hdmv_pgs_subtitle': 'pgs', + pgssub: 'pgs', + dvdsub: 'sub', + dvbsub: 'sub', + }; + + return codecMap[normalizedCodec] || normalizedCodec; + } + + const extension = deliveryUrl + ?.split('?')[0] + ?.match(/\.([a-z0-9]+)$/i)?.[1] + ?.toLowerCase(); + + return extension || 'unknown'; + } + + private getSubtitleTargetFormat(sourceFormat: string): string { + return sourceFormat === 'ass' || sourceFormat === 'ssa' ? sourceFormat : 'vtt'; + } + + private buildSubtitleStreamUrl( + itemId: string, + mediaSourceId: string, + streamIndex: number, + format: string, + proxyToken?: string | null, + forceDirectUrl = false + ): string { + const safeFormat = /^[a-z0-9]+$/i.test(format) ? format.toLowerCase() : 'vtt'; + + if (!forceDirectUrl) { + const subscribeToken = proxyToken || 'proxy'; + const params = new URLSearchParams({ + itemId, + mediaSourceId, + streamIndex: streamIndex.toString(), + format: safeFormat, + }); + + if (this.embyKey) { + params.set('embyKey', this.embyKey); + } + + return `/api/emby/subtitle/${encodeURIComponent(subscribeToken)}/subtitle.${safeFormat}?${params.toString()}`; + } + + const params = new URLSearchParams(); + const token = this.apiKey || this.authToken; + if (token) params.set('api_key', token); + + const queryString = params.toString(); + return `${this.serverUrl}/Videos/${itemId}/${mediaSourceId}/Subtitles/${streamIndex}/Stream.${safeFormat}${queryString ? '?' + queryString : ''}`; + } + + async getSubtitleStreamUrl( + itemId: string, + mediaSourceId: string, + streamIndex: number, + format: string, + forceDirectUrl = false + ): Promise { + await this.ensureAuthenticated(); + return this.buildSubtitleStreamUrl( + itemId, + mediaSourceId, + streamIndex, + format, + undefined, + forceDirectUrl + ); + } + + getSubtitles(item: EmbyItem, proxyToken?: string | null): EmbySubtitle[] { + const subtitles: EmbySubtitle[] = []; if (!item.MediaSources || item.MediaSources.length === 0) { return subtitles; @@ -548,29 +642,43 @@ export class EmbyClient { return subtitles; } - const token = this.apiKey || this.authToken; - mediaSource.MediaStreams .filter((stream) => stream.Type === 'Subtitle') .forEach((stream) => { const language = stream.Language || 'unknown'; - const label = stream.DisplayTitle || `${language} (${stream.Codec})`; + const sourceFormat = this.normalizeSubtitleFormat(stream.Codec, stream.DeliveryUrl); + const targetFormat = this.getSubtitleTargetFormat(sourceFormat); + const renderMode = targetFormat === 'ass' || targetFormat === 'ssa' ? 'jassub' : 'native'; + const label = stream.DisplayTitle || `${language} (${stream.Codec || targetFormat})`; - // 外部字幕使用 DeliveryUrl - if (stream.IsExternal && stream.DeliveryUrl) { - subtitles.push({ - url: `${this.serverUrl}${stream.DeliveryUrl}`, - language, - label, - }); - } else { - // 内嵌字幕使用 Stream API - subtitles.push({ - url: `${this.serverUrl}/Videos/${item.Id}/${mediaSource.Id}/Subtitles/${stream.Index}/Stream.vtt?api_key=${token}`, - language, - label, - }); - } + subtitles.push({ + url: this.buildSubtitleStreamUrl( + item.Id, + mediaSource.Id, + stream.Index, + targetFormat, + proxyToken + ), + ...(renderMode === 'jassub' + ? { + fallbackUrl: this.buildSubtitleStreamUrl( + item.Id, + mediaSource.Id, + stream.Index, + 'vtt', + proxyToken + ), + fallbackFormat: 'vtt', + } + : {}), + language, + label, + format: targetFormat, + sourceFormat, + codec: stream.Codec, + isExternal: stream.IsExternal, + renderMode, + }); }); return subtitles; diff --git a/src/lib/special-sources-detail.ts b/src/lib/special-sources-detail.ts index 73041bb..9742c9d 100644 --- a/src/lib/special-sources-detail.ts +++ b/src/lib/special-sources-detail.ts @@ -38,7 +38,7 @@ export async function getEmbyDetail( // 根据类型处理 if (item.Type === 'Movie') { // 电影 - const subtitles = client.getSubtitles(item); + const subtitles = client.getSubtitles(item, proxyToken); return { source: source, // 保持与请求一致(emby 或 emby_key) @@ -89,7 +89,7 @@ export async function getEmbyDetail( const episodeNum = ep.IndexNumber || 1; return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`; }), - subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)), + subtitles: allEpisodes.map((ep) => client.getSubtitles(ep, proxyToken)), proxyMode: false, }; } else { diff --git a/src/lib/types.ts b/src/lib/types.ts index de358d9..387d349 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -278,7 +278,18 @@ export interface SearchResult { vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等) vod_total?: number; // 总集数 proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片 - subtitles?: Array>; // 字幕列表(按集数索引) + subtitles?: Array>; // 字幕列表(按集数索引) tmdb_id?: number; // TMDB ID rating?: number; // 评分 initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数) diff --git a/src/middleware.ts b/src/middleware.ts index dc6e3a0..94e0144 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -183,6 +183,6 @@ function isTVModePath(pathname: string): boolean { // 配置middleware匹配规则 export const config = { matcher: [ - '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|qr-login|warning|tv/login|api/login|api/register|api/logout|api/auth/oidc|api/auth/qr|api/auth/refresh|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|api/emby/sources|tvbox/).*)', + '/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|qr-login|warning|tv/login|api/login|api/register|api/logout|api/auth/oidc|api/auth/qr|api/auth/refresh|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|api/emby/subtitle|api/emby/sources|tvbox/).*)', ], };