/* 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; } 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; }>; }>; } 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; constructor(config: EmbyConfig) { let serverUrl = config.ServerURL.replace(/\/$/, ''); // 存储高级选项 this.removeEmbyPrefix = config.removeEmbyPrefix || false; this.appendMediaSourceId = config.appendMediaSourceId || false; this.transcodeMp4 = config.transcodeMp4 || false; // 如果 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 { // 如果有 ApiKey,不需要认证 if (this.apiKey) return; // 如果有 AuthToken,假设它是有效的 if (this.authToken) return; // 如果有用户名和密码,自动认证 if (this.username && this.password) { const authResult = await this.authenticate(this.username, this.password); this.authToken = authResult.AccessToken; this.userId = authResult.User.Id; } } private getHeaders(): Record { const headers: Record = { '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 params = new URLSearchParams({ Username: username, Pw: password, }); const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Emby-Authorization': 'MediaBrowser Client="LunaTV", Device="Web", DeviceId="lunatv-web", Version="1.0.0"', }, body: params.toString(), }); 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(); try { 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; } catch (error) { throw error; } } async getUserViews(): Promise { 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.password && !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 { 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('X-Emby-Token', token); } const url = `${this.serverUrl}/Users/${this.userId}/Items?${searchParams.toString()}`; const response = await fetch(url); // 如果是 401 错误且有用户名密码,尝试重新认证 if (response.status === 401 && this.username && this.password && !this.apiKey) { const authResult = await this.authenticate(this.username, this.password); this.authToken = authResult.AccessToken; this.userId = authResult.User.Id; // 重试请求 searchParams.set('X-Emby-Token', 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 { 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.password && !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 { 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.password && !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 { 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.password && !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 { 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): string { 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: boolean = true): Promise { const token = this.apiKey || this.authToken; 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; } getSubtitles(item: EmbyItem): Array<{ url: string; language: string; label: string }> { const subtitles: Array<{ url: string; language: string; label: string }> = []; if (!item.MediaSources || item.MediaSources.length === 0) { return subtitles; } const mediaSource = item.MediaSources[0]; if (!mediaSource.MediaStreams) { 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})`; // 外部字幕使用 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, }); } }); return subtitles; } }