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);
|
const client = await embyManager.getClient(embyKey);
|
||||||
|
|
||||||
// 获取代理 token(如果启用了代理)
|
// 获取代理 token(图片/字幕代理使用;没有 token 时会回退到登录态校验)
|
||||||
const proxyToken = client.isProxyEnabled()
|
const proxyToken = await getProxyToken(request);
|
||||||
? await getProxyToken(request)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// 获取媒体详情
|
// 获取媒体详情
|
||||||
const item = await client.getItem(id);
|
const item = await client.getItem(id);
|
||||||
@@ -212,7 +210,7 @@ export async function GET(request: NextRequest) {
|
|||||||
// 根据类型处理
|
// 根据类型处理
|
||||||
if (item.Type === 'Movie') {
|
if (item.Type === 'Movie') {
|
||||||
// 电影
|
// 电影
|
||||||
const subtitles = client.getSubtitles(item);
|
const subtitles = client.getSubtitles(item, proxyToken);
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
||||||
@@ -277,7 +275,7 @@ export async function GET(request: NextRequest) {
|
|||||||
.toString()
|
.toString()
|
||||||
.padStart(2, '0')}`;
|
.padStart(2, '0')}`;
|
||||||
}),
|
}),
|
||||||
subtitles: allEpisodes.map((ep) => client.getSubtitles(ep)),
|
subtitles: allEpisodes.map((ep) => client.getSubtitles(ep, proxyToken)),
|
||||||
proxyMode: false,
|
proxyMode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+145
-16
@@ -132,6 +132,17 @@ interface CustomSubtitleState {
|
|||||||
content?: string;
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SourceSubtitleItem {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
fallbackUrl?: string;
|
||||||
|
fallbackFormat?: string;
|
||||||
|
format?: string;
|
||||||
|
sourceFormat?: string;
|
||||||
|
codec?: string;
|
||||||
|
renderMode?: 'native' | 'jassub';
|
||||||
|
}
|
||||||
|
|
||||||
interface JassubSubtitleInstance {
|
interface JassubSubtitleInstance {
|
||||||
setTrack?: (content: string) => void | Promise<void>;
|
setTrack?: (content: string) => void | Promise<void>;
|
||||||
setTrackByUrl?: (url: string) => void | Promise<void>;
|
setTrackByUrl?: (url: string) => void | Promise<void>;
|
||||||
@@ -1956,6 +1967,20 @@ function PlayPageClient() {
|
|||||||
return ADVANCED_SUBTITLE_FORMATS.has(format.toLowerCase());
|
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 => {
|
const getJassubSubtitleInstance = (): JassubSubtitleInstance | null => {
|
||||||
return artPlayerRef.current?.plugins?.artplayerPluginJassub?.instance || null;
|
return artPlayerRef.current?.plugins?.artplayerPluginJassub?.instance || null;
|
||||||
};
|
};
|
||||||
@@ -2004,13 +2029,17 @@ function PlayPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ensureJassubSubtitleInstance = async (
|
const ensureJassubSubtitleInstance = async (
|
||||||
initialContent: string
|
initialTrack: { content?: string; url?: string }
|
||||||
): Promise<{ instance: JassubSubtitleInstance; created: boolean }> => {
|
): Promise<{ instance: JassubSubtitleInstance; created: boolean }> => {
|
||||||
const existingInstance = getJassubSubtitleInstance();
|
const existingInstance = getJassubSubtitleInstance();
|
||||||
if (existingInstance) {
|
if (existingInstance) {
|
||||||
return { instance: existingInstance, created: false };
|
return { instance: existingInstance, created: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!initialTrack.content && !initialTrack.url) {
|
||||||
|
throw new Error('缺少高级字幕内容');
|
||||||
|
}
|
||||||
|
|
||||||
if (!artPlayerRef.current) {
|
if (!artPlayerRef.current) {
|
||||||
throw new Error('播放器尚未就绪');
|
throw new Error('播放器尚未就绪');
|
||||||
}
|
}
|
||||||
@@ -2021,7 +2050,9 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
artPlayerRef.current.plugins.add(
|
artPlayerRef.current.plugins.add(
|
||||||
artplayerPluginJassub({
|
artplayerPluginJassub({
|
||||||
subContent: initialContent,
|
...(initialTrack.content
|
||||||
|
? { subContent: initialTrack.content }
|
||||||
|
: { subUrl: initialTrack.url }),
|
||||||
workerUrl: `${JASSUB_ASSET_BASE}/jassub-worker.js`,
|
workerUrl: `${JASSUB_ASSET_BASE}/jassub-worker.js`,
|
||||||
wasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker.wasm`,
|
wasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker.wasm`,
|
||||||
modernWasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker-modern.wasm`,
|
modernWasmUrl: `${JASSUB_ASSET_BASE}/jassub-worker-modern.wasm`,
|
||||||
@@ -2045,7 +2076,7 @@ function PlayPageClient() {
|
|||||||
if (!artPlayerRef.current) return;
|
if (!artPlayerRef.current) return;
|
||||||
|
|
||||||
artPlayerRef.current.subtitle.show = false;
|
artPlayerRef.current.subtitle.show = false;
|
||||||
const { instance, created } = await ensureJassubSubtitleInstance(content);
|
const { instance, created } = await ensureJassubSubtitleInstance({ content });
|
||||||
|
|
||||||
// 新建实例时 subContent 已作为初始轨道传入;复用实例时需要显式切轨。
|
// 新建实例时 subContent 已作为初始轨道传入;复用实例时需要显式切轨。
|
||||||
if (!created) {
|
if (!created) {
|
||||||
@@ -2055,6 +2086,63 @@ function PlayPageClient() {
|
|||||||
currentSubtitleLabelRef.current = label;
|
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 = () => {
|
const removeSubtitleSetting = () => {
|
||||||
try {
|
try {
|
||||||
artPlayerRef.current?.setting.remove('subtitle-selector');
|
artPlayerRef.current?.setting.remove('subtitle-selector');
|
||||||
@@ -2066,7 +2154,7 @@ function PlayPageClient() {
|
|||||||
const updateSubtitleSetting = () => {
|
const updateSubtitleSetting = () => {
|
||||||
if (!artPlayerRef.current) return;
|
if (!artPlayerRef.current) return;
|
||||||
|
|
||||||
const sourceSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || [];
|
const sourceSubtitles = (detailRef.current?.subtitles?.[currentEpisodeIndexRef.current] || []) as SourceSubtitleItem[];
|
||||||
const customSubtitle =
|
const customSubtitle =
|
||||||
customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current
|
customSubtitleRef.current?.episodeIndex === currentEpisodeIndexRef.current
|
||||||
? customSubtitleRef.current
|
? customSubtitleRef.current
|
||||||
@@ -2077,12 +2165,19 @@ function PlayPageClient() {
|
|||||||
const subtitleOptions = [
|
const subtitleOptions = [
|
||||||
{ html: '关闭', action: 'close' },
|
{ html: '关闭', action: 'close' },
|
||||||
{ html: '上传本地字幕', action: 'upload' },
|
{ html: '上传本地字幕', action: 'upload' },
|
||||||
...sourceSubtitles.map((sub: any) => ({
|
...sourceSubtitles.map((sub: SourceSubtitleItem) => {
|
||||||
|
const isAdvanced = isAdvancedSourceSubtitle(sub);
|
||||||
|
const format = getSourceSubtitleFormat(sub);
|
||||||
|
return {
|
||||||
html: sub.label,
|
html: sub.label,
|
||||||
action: 'switch',
|
action: 'switch',
|
||||||
engine: 'native',
|
engine: isAdvanced ? 'jassub' : 'native',
|
||||||
url: sub.url,
|
url: sub.url,
|
||||||
})),
|
fallbackUrl: sub.fallbackUrl,
|
||||||
|
fallbackFormat: sub.fallbackFormat,
|
||||||
|
format,
|
||||||
|
};
|
||||||
|
}),
|
||||||
...(customSubtitle
|
...(customSubtitle
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -2115,8 +2210,21 @@ function PlayPageClient() {
|
|||||||
return currentSubtitleLabelRef.current;
|
return currentSubtitleLabelRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.engine === 'jassub' && item.content) {
|
if (item.engine === 'jassub') {
|
||||||
void switchAdvancedSubtitle(item.content, item.html).catch((error) => {
|
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);
|
console.warn('[Subtitle] 高级字幕切换失败:', error);
|
||||||
setToast({
|
setToast({
|
||||||
message: error instanceof Error ? error.message : '高级字幕切换失败',
|
message: error instanceof Error ? error.message : '高级字幕切换失败',
|
||||||
@@ -4773,11 +4881,16 @@ function PlayPageClient() {
|
|||||||
if (!artPlayerRef.current || !detail) return;
|
if (!artPlayerRef.current || !detail) return;
|
||||||
|
|
||||||
revokeCustomSubtitle();
|
revokeCustomSubtitle();
|
||||||
const currentSubtitles = detail.subtitles?.[currentEpisodeIndex] || [];
|
const currentSubtitles = (detail.subtitles?.[currentEpisodeIndex] || []) as SourceSubtitleItem[];
|
||||||
|
|
||||||
// 如果有字幕,更新播放器字幕
|
// 如果有字幕,更新播放器字幕
|
||||||
if (currentSubtitles.length > 0) {
|
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 {
|
} else {
|
||||||
artPlayerRef.current.subtitle.show = false;
|
artPlayerRef.current.subtitle.show = false;
|
||||||
currentSubtitleLabelRef.current = '关闭';
|
currentSubtitleLabelRef.current = '关闭';
|
||||||
@@ -6574,9 +6687,11 @@ function PlayPageClient() {
|
|||||||
Artplayer.USE_RAF = true;
|
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';
|
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||||
currentSubtitleLabelRef.current = currentSubtitles[0]?.label || '关闭';
|
currentSubtitleLabelRef.current = defaultSubtitle?.label || '关闭';
|
||||||
|
|
||||||
artPlayerRef.current = new Artplayer({
|
artPlayerRef.current = new Artplayer({
|
||||||
container: artRef.current!,
|
container: artRef.current!,
|
||||||
@@ -6598,9 +6713,9 @@ function PlayPageClient() {
|
|||||||
aspectRatio: false,
|
aspectRatio: false,
|
||||||
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
fullscreen: !isIOS, // iOS 禁用原生全屏按钮,避免触发系统播放器
|
||||||
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
fullscreenWeb: true, // 保留网页全屏按钮(所有平台)
|
||||||
...(currentSubtitles.length > 0 ? {
|
...(shouldUseNativeInitialSubtitle ? {
|
||||||
subtitle: {
|
subtitle: {
|
||||||
url: currentSubtitles[0].url,
|
url: defaultSubtitle!.url,
|
||||||
type: 'vtt',
|
type: 'vtt',
|
||||||
style: {
|
style: {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
@@ -7715,8 +7830,22 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
applyProgressThumbConfig();
|
applyProgressThumbConfig();
|
||||||
|
|
||||||
// 添加字幕切换和本地字幕上传功能
|
// 添加字幕切换和本地字幕上传功能;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();
|
updateSubtitleSetting();
|
||||||
|
}
|
||||||
|
|
||||||
// 添加字幕大小设置
|
// 添加字幕大小设置
|
||||||
if (artPlayerRef.current) {
|
if (artPlayerRef.current) {
|
||||||
|
|||||||
+126
-18
@@ -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 {
|
interface EmbyItemsResult {
|
||||||
Items: EmbyItem[];
|
Items: EmbyItem[];
|
||||||
TotalRecordCount: number;
|
TotalRecordCount: number;
|
||||||
@@ -536,8 +549,89 @@ export class EmbyClient {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSubtitles(item: EmbyItem): Array<{ url: string; language: string; label: string }> {
|
private normalizeSubtitleFormat(codec?: string, deliveryUrl?: string): string {
|
||||||
const subtitles: Array<{ url: string; language: string; label: 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) {
|
if (!item.MediaSources || item.MediaSources.length === 0) {
|
||||||
return subtitles;
|
return subtitles;
|
||||||
@@ -548,29 +642,43 @@ export class EmbyClient {
|
|||||||
return subtitles;
|
return subtitles;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = this.apiKey || this.authToken;
|
|
||||||
|
|
||||||
mediaSource.MediaStreams
|
mediaSource.MediaStreams
|
||||||
.filter((stream) => stream.Type === 'Subtitle')
|
.filter((stream) => stream.Type === 'Subtitle')
|
||||||
.forEach((stream) => {
|
.forEach((stream) => {
|
||||||
const language = stream.Language || 'unknown';
|
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({
|
subtitles.push({
|
||||||
url: `${this.serverUrl}${stream.DeliveryUrl}`,
|
url: this.buildSubtitleStreamUrl(
|
||||||
language,
|
item.Id,
|
||||||
label,
|
mediaSource.Id,
|
||||||
});
|
stream.Index,
|
||||||
} else {
|
targetFormat,
|
||||||
// 内嵌字幕使用 Stream API
|
proxyToken
|
||||||
subtitles.push({
|
),
|
||||||
url: `${this.serverUrl}/Videos/${item.Id}/${mediaSource.Id}/Subtitles/${stream.Index}/Stream.vtt?api_key=${token}`,
|
...(renderMode === 'jassub'
|
||||||
language,
|
? {
|
||||||
label,
|
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;
|
return subtitles;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export async function getEmbyDetail(
|
|||||||
// 根据类型处理
|
// 根据类型处理
|
||||||
if (item.Type === 'Movie') {
|
if (item.Type === 'Movie') {
|
||||||
// 电影
|
// 电影
|
||||||
const subtitles = client.getSubtitles(item);
|
const subtitles = client.getSubtitles(item, proxyToken);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: source, // 保持与请求一致(emby 或 emby_key)
|
source: source, // 保持与请求一致(emby 或 emby_key)
|
||||||
@@ -89,7 +89,7 @@ export async function getEmbyDetail(
|
|||||||
const episodeNum = ep.IndexNumber || 1;
|
const episodeNum = ep.IndexNumber || 1;
|
||||||
return `S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}`;
|
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,
|
proxyMode: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
+12
-1
@@ -278,7 +278,18 @@ export interface SearchResult {
|
|||||||
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
|
vod_remarks?: string; // 视频备注信息(如"全80集"、"更新至25集"等)
|
||||||
vod_total?: number; // 总集数
|
vod_total?: number; // 总集数
|
||||||
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
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
|
tmdb_id?: number; // TMDB ID
|
||||||
rating?: number; // 评分
|
rating?: number; // 评分
|
||||||
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
||||||
|
|||||||
+1
-1
@@ -183,6 +183,6 @@ function isTVModePath(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
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