emby支持高级字幕

This commit is contained in:
mtvpls
2026-06-14 20:33:11 +08:00
parent c45fdded59
commit 7a34950a7e
7 changed files with 494 additions and 50 deletions
@@ -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 }
);
}
}
+4 -6
View File
@@ -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,
};
+149 -20
View File
@@ -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<void>;
setTrackByUrl?: (url: string) => void | Promise<void>;
@@ -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) {
+128 -20
View File
@@ -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<string, string> = {
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<string> {
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;
+2 -2
View File
@@ -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 {
+12 -1
View File
@@ -278,7 +278,18 @@ export interface SearchResult {
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
vod_total?: number; // 总集数
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
subtitles?: Array<Array<{ label: string; url: string }>>; // 字幕列表(按集数索引)
subtitles?: Array<Array<{
label: string;
url: string;
fallbackUrl?: string;
fallbackFormat?: string;
language?: string;
format?: string; // 实际加载格式,如 vtt / ass / ssa
sourceFormat?: string; // Emby 返回的原始字幕格式
codec?: string;
isExternal?: boolean;
renderMode?: 'native' | 'jassub';
}>>; // 字幕列表(按集数索引)
tmdb_id?: number; // TMDB ID
rating?: number; // 评分
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
+1 -1
View File
@@ -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/).*)',
],
};