Files
MoonTVPlus/src/lib/emby.client.ts
T
2026-06-14 20:33:11 +08:00

695 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable @typescript-eslint/no-explicit-any */
interface EmbyConfig {
ServerURL: string;
ApiKey?: string;
Username?: string;
Password?: string;
UserId?: string;
AuthToken?: string;
// 高级流媒体选项
removeEmbyPrefix?: boolean;
appendMediaSourceId?: boolean;
transcodeMp4?: boolean;
proxyPlay?: boolean; // 视频播放代理开关
customUserAgent?: string; // 自定义User-Agent
key?: string; // Emby源的唯一标识
}
interface EmbyItem {
Id: string;
Name: string;
Type: 'Movie' | 'Series' | 'Season' | 'Episode';
Overview?: string;
ProductionYear?: number;
CommunityRating?: number;
PremiereDate?: string;
ImageTags?: { Primary?: string };
ParentIndexNumber?: number;
IndexNumber?: number;
MediaSources?: Array<{
Id: string;
MediaStreams?: Array<{
Type: string;
Index: number;
DisplayTitle?: string;
Language?: string;
Codec?: string;
IsExternal?: boolean;
DeliveryUrl?: string;
}>;
}>;
}
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;
}
interface GetItemsParams {
ParentId?: string;
IncludeItemTypes?: string;
Recursive?: boolean;
Fields?: string;
SortBy?: string;
SortOrder?: string;
StartIndex?: number;
Limit?: number;
searchTerm?: string;
}
interface EmbyView {
Id: string;
Name: string;
CollectionType?: string;
}
export class EmbyClient {
private serverUrl: string;
private apiKey?: string;
private userId?: string;
private authToken?: string;
private username?: string;
private password?: string;
private removeEmbyPrefix: boolean;
private appendMediaSourceId: boolean;
private transcodeMp4: boolean;
private proxyPlay: boolean;
private embyKey?: string;
private customUserAgent: string;
constructor(config: EmbyConfig) {
let serverUrl = config.ServerURL.replace(/\/$/, '');
// 存储高级选项
this.removeEmbyPrefix = config.removeEmbyPrefix || false;
this.appendMediaSourceId = config.appendMediaSourceId || false;
this.transcodeMp4 = config.transcodeMp4 || false;
this.proxyPlay = config.proxyPlay || false;
this.embyKey = config.key;
// 设置自定义UA,如果没有设置则使用默认浏览器UA
this.customUserAgent = config.customUserAgent || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
// 如果 URL 不包含 /emby 路径,自动添加(除非启用了 removeEmbyPrefix
if (!serverUrl.endsWith('/emby') && !this.removeEmbyPrefix) {
serverUrl += '/emby';
}
// 如果启用了 removeEmbyPrefix 且 URL 包含 /emby,移除它
if (this.removeEmbyPrefix && serverUrl.endsWith('/emby')) {
serverUrl = serverUrl.slice(0, -5); // Remove '/emby'
}
this.serverUrl = serverUrl;
this.apiKey = config.ApiKey;
this.userId = config.UserId;
this.authToken = config.AuthToken;
this.username = config.Username;
this.password = config.Password;
}
private async ensureAuthenticated(): Promise<void> {
// 如果有 ApiKey,不需要认证
if (this.apiKey) return;
// 如果有 AuthToken,假设它是有效的
if (this.authToken) return;
// 如果有用户名,自动认证(密码可选)
if (this.username) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
}
}
private getHeaders(includeContentType: boolean = false): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': this.customUserAgent,
};
// 只在需要时添加 Content-TypePOST/PUT 请求)
if (includeContentType) {
headers['Content-Type'] = 'application/json';
}
if (this.apiKey) {
headers['X-Emby-Token'] = this.apiKey;
} else if (this.authToken) {
headers['X-Emby-Token'] = this.authToken;
}
return headers;
}
async authenticate(username: string, password: string): Promise<{ AccessToken: string; User: { Id: string } }> {
const url = `${this.serverUrl}/Users/AuthenticateByName`;
const body = JSON.stringify({
Username: username,
Pw: password,
});
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Emby-Authorization': 'MediaBrowser Client="LunaTV", Device="Web", DeviceId="lunatv-web", Version="1.0.0"',
'User-Agent': this.customUserAgent,
},
body: body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Emby 认证失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
this.authToken = data.AccessToken;
this.userId = data.User.Id;
return data;
}
async getCurrentUser(): Promise<{ Id: string; Name: string }> {
const url = `${this.serverUrl}/Users/Me`;
const headers = this.getHeaders();
const response = await fetch(url, { headers });
if (!response.ok) {
const errorText = await response.text();
throw new Error(`获取当前用户信息失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
return data;
}
async getUserViews(): Promise<EmbyView[]> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Users/${this.userId}/Views${token ? `?api_key=${token}` : ''}`;
const response = await fetch(url);
// 如果是 401 错误且有用户名密码,尝试重新认证
if (response.status === 401 && this.username && !this.apiKey) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
// 重试请求
const retryUrl = `${this.serverUrl}/Users/${this.userId}/Views?api_key=${this.authToken}`;
const retryResponse = await fetch(retryUrl);
if (!retryResponse.ok) {
const errorText = await retryResponse.text();
throw new Error(`获取 Emby 媒体库列表失败 (${retryResponse.status}): ${errorText}`);
}
const retryData = await retryResponse.json();
return retryData.Items || [];
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`获取 Emby 媒体库列表失败 (${response.status}): ${errorText}`);
}
const data = await response.json();
return data.Items || [];
}
async getItems(params: GetItemsParams): Promise<EmbyItemsResult> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const searchParams = new URLSearchParams();
if (params.ParentId) searchParams.set('ParentId', params.ParentId);
if (params.IncludeItemTypes) searchParams.set('IncludeItemTypes', params.IncludeItemTypes);
if (params.Recursive !== undefined) searchParams.set('Recursive', params.Recursive.toString());
if (params.Fields) searchParams.set('Fields', params.Fields);
if (params.SortBy) searchParams.set('SortBy', params.SortBy);
if (params.SortOrder) searchParams.set('SortOrder', params.SortOrder);
if (params.StartIndex !== undefined) searchParams.set('StartIndex', params.StartIndex.toString());
if (params.Limit !== undefined) searchParams.set('Limit', params.Limit.toString());
if (params.searchTerm) searchParams.set('searchTerm', params.searchTerm);
// 添加认证参数
const token = this.apiKey || this.authToken;
if (token) {
searchParams.set('api_key', token);
}
const url = `${this.serverUrl}/Users/${this.userId}/Items?${searchParams.toString()}`;
const response = await fetch(url);
// 如果是 401 错误且有用户名密码,尝试重新认证
if (response.status === 401 && this.username && !this.apiKey) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
// 重试请求
searchParams.set('api_key', this.authToken);
const retryUrl = `${this.serverUrl}/Users/${this.userId}/Items?${searchParams.toString()}`;
const retryResponse = await fetch(retryUrl);
if (!retryResponse.ok) {
const errorText = await retryResponse.text();
throw new Error(`获取 Emby 媒体列表失败 (${retryResponse.status}): ${errorText}`);
}
return await retryResponse.json();
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`获取 Emby 媒体列表失败 (${response.status}): ${errorText}`);
}
return await response.json();
}
async getItem(itemId: string): Promise<EmbyItem> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Users/${this.userId}/Items/${itemId}?Fields=MediaSources${token ? `&api_key=${token}` : ''}`;
const response = await fetch(url);
// 如果是 401 错误且有用户名密码,尝试重新认证
if (response.status === 401 && this.username && !this.apiKey) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
// 重试请求
const retryToken = this.authToken;
const retryUrl = `${this.serverUrl}/Users/${this.userId}/Items/${itemId}?Fields=MediaSources${retryToken ? `&api_key=${retryToken}` : ''}`;
const retryResponse = await fetch(retryUrl);
if (!retryResponse.ok) {
throw new Error('获取 Emby 媒体详情失败');
}
return await retryResponse.json();
}
if (!response.ok) {
throw new Error('获取 Emby 媒体详情失败');
}
return await response.json();
}
async getSeasons(seriesId: string): Promise<EmbyItem[]> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Shows/${seriesId}/Seasons?userId=${this.userId}${token ? `&api_key=${token}` : ''}`;
const response = await fetch(url);
// 如果是 401 错误且有用户名密码,尝试重新认证
if (response.status === 401 && this.username && !this.apiKey) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
// 重试请求
const retryToken = this.authToken;
const retryUrl = `${this.serverUrl}/Shows/${seriesId}/Seasons?userId=${this.userId}${retryToken ? `&api_key=${retryToken}` : ''}`;
const retryResponse = await fetch(retryUrl);
if (!retryResponse.ok) {
throw new Error('获取 Emby 季列表失败');
}
const retryData = await retryResponse.json();
return retryData.Items || [];
}
if (!response.ok) {
throw new Error('获取 Emby 季列表失败');
}
const data = await response.json();
return data.Items || [];
}
async getEpisodes(seriesId: string, seasonId?: string): Promise<EmbyItem[]> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID,请在管理面板重新保存 Emby 配置');
}
const token = this.apiKey || this.authToken;
const searchParams = new URLSearchParams({
userId: this.userId!,
Fields: 'MediaSources',
});
if (seasonId) {
searchParams.set('seasonId', seasonId);
}
if (token) {
searchParams.set('api_key', token);
}
const url = `${this.serverUrl}/Shows/${seriesId}/Episodes?${searchParams.toString()}`;
const response = await fetch(url);
// 如果是 401 错误且有用户名密码,尝试重新认证
if (response.status === 401 && this.username && !this.apiKey) {
const authResult = await this.authenticate(this.username, this.password || '');
this.authToken = authResult.AccessToken;
this.userId = authResult.User.Id;
// 重试请求
const retrySearchParams = new URLSearchParams({
userId: this.userId!,
Fields: 'MediaSources',
});
if (seasonId) {
retrySearchParams.set('seasonId', seasonId);
}
if (this.authToken) {
retrySearchParams.set('api_key', this.authToken);
}
const retryUrl = `${this.serverUrl}/Shows/${seriesId}/Episodes?${retrySearchParams.toString()}`;
const retryResponse = await fetch(retryUrl);
if (!retryResponse.ok) {
throw new Error('获取 Emby 集列表失败');
}
const retryData = await retryResponse.json();
return retryData.Items || [];
}
if (!response.ok) {
throw new Error('获取 Emby 集列表失败');
}
const data = await response.json();
return data.Items || [];
}
async checkConnectivity(): Promise<boolean> {
try {
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/System/Info/Public${token ? `?api_key=${token}` : ''}`;
const response = await fetch(url);
return response.ok;
} catch {
return false;
}
}
getImageUrl(itemId: string, imageType: 'Primary' | 'Backdrop' | 'Logo' = 'Primary', maxWidth?: number, proxyToken?: string, forceDirectUrl = false): string {
// 如果启用了代理播放且不是强制获取直接URL,返回代理 URL
if (this.proxyPlay && !forceDirectUrl) {
// 使用固定的token占位符,实际验证在服务端进行
const subscribeToken = proxyToken || 'proxy';
const params = new URLSearchParams();
params.set('imageType', imageType);
if (maxWidth) params.set('maxWidth', maxWidth.toString());
if (this.embyKey) params.set('embyKey', this.embyKey);
return `/api/emby/image/${subscribeToken}/${itemId}?${params.toString()}`;
}
// 否则返回直连 URL
const params = new URLSearchParams();
const token = this.apiKey || this.authToken;
if (maxWidth) params.set('maxWidth', maxWidth.toString());
if (token) params.set('api_key', token);
const queryString = params.toString();
return `${this.serverUrl}/Items/${itemId}/Images/${imageType}${queryString ? '?' + queryString : ''}`;
}
/**
* 获取 PlaybackInfo 以获取 MediaSourceId
*/
async getPlaybackInfo(itemId: string): Promise<{ MediaSourceId?: string }> {
await this.ensureAuthenticated();
if (!this.userId) {
throw new Error('未配置 Emby 用户 ID');
}
const token = this.apiKey || this.authToken;
const url = `${this.serverUrl}/Items/${itemId}/PlaybackInfo?UserId=${this.userId}${token ? `&api_key=${token}` : ''}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
return {};
}
const data = await response.json();
const mediaSourceId = data.MediaSources?.[0]?.Id;
return { MediaSourceId: mediaSourceId };
} catch (error) {
return {};
}
}
async getStreamUrl(itemId: string, direct = true, forceDirectUrl = false): Promise<string> {
await this.ensureAuthenticated();
const token = this.apiKey || this.authToken;
// 如果启用了代理播放且不是强制获取直接URL,返回代理URL
if (this.proxyPlay && !forceDirectUrl) {
// 使用固定的token占位符,实际验证在服务端进行
const subscribeToken = 'proxy';
const filename = this.transcodeMp4 ? 'video.mp4' : 'video';
// 构建代理URL(相对路径)
let proxyUrl = `/api/emby/play/${subscribeToken}/${filename}?itemId=${itemId}`;
// 如果有embyKey,添加到查询参数
if (this.embyKey) {
proxyUrl += `&embyKey=${this.embyKey}`;
}
return proxyUrl;
}
// 原有的直接播放逻辑
let url: string;
if (direct) {
// 选项3: 转码mp4
if (this.transcodeMp4) {
url = `${this.serverUrl}/Videos/${itemId}/stream.mp4?api_key=${token}`;
} else {
url = `${this.serverUrl}/Videos/${itemId}/stream?Static=true&api_key=${token}`;
}
// 选项2: 拼接MediaSourceId参数
if (this.appendMediaSourceId) {
try {
const playbackInfo = await this.getPlaybackInfo(itemId);
if (playbackInfo.MediaSourceId) {
url += `&MediaSourceId=${playbackInfo.MediaSourceId}`;
}
} catch (error) {
// 继续使用不带 MediaSourceId 的 URL
}
}
} else {
url = `${this.serverUrl}/Videos/${itemId}/master.m3u8?api_key=${token}`;
}
return url;
}
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;
}
const mediaSource = item.MediaSources[0];
if (!mediaSource.MediaStreams) {
return subtitles;
}
mediaSource.MediaStreams
.filter((stream) => stream.Type === 'Subtitle')
.forEach((stream) => {
const language = stream.Language || 'unknown';
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})`;
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;
}
getUserAgent(): string {
return this.customUserAgent;
}
isProxyEnabled(): boolean {
return this.proxyPlay;
}
}