emby支持高级字幕
This commit is contained in:
+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; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
||||
|
||||
Reference in New Issue
Block a user