emby支持高级字幕
This commit is contained in:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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/).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user