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
+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; // 初始集数索引(用于小雅源从文件点击进入时指定集数)