/* eslint-disable @typescript-eslint/no-explicit-any */ interface EmbyConfig { ServerURL: string; ApiKey?: string; Username?: string; Password?: string; UserId?: string; AuthToken?: string; } 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; constructor(config: EmbyConfig) { let serverUrl = config.ServerURL.replace(/\/$/, ''); // 如果 URL 不包含 /emby 路径,自动添加 if (!serverUrl.endsWith('/emby')) { serverUrl += '/emby'; } this.serverUrl = serverUrl; this.apiKey = config.ApiKey; this.userId = config.UserId; this.authToken = config.AuthToken; this.username = config.Username; this.password = config.Password; console.log('[EmbyClient] constructor - ServerURL:', this.serverUrl); console.log('[EmbyClient] constructor - ApiKey:', this.apiKey); console.log('[EmbyClient] constructor - UserId:', this.userId); console.log('[EmbyClient] constructor - AuthToken:', this.authToken); } private async ensureAuthenticated(): Promise { // 如果有 ApiKey,不需要认证 if (this.apiKey) return; // 如果有 AuthToken,假设它是有效的 if (this.authToken) return; // 如果有用户名和密码,自动认证 if (this.username && this.password) { console.log('[EmbyClient] Auto-authenticating with username/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`; console.log('[EmbyClient] authenticate - URL:', url); 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(); console.error('[EmbyClient] authenticate - Error:', response.status, errorText); 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(); console.log('[EmbyClient] getCurrentUser - URL:', url); console.log('[EmbyClient] getCurrentUser - Headers:', JSON.stringify(headers, null, 2)); try { const response = await fetch(url, { headers }); console.log('[EmbyClient] getCurrentUser - Status:', response.status); if (!response.ok) { const errorText = await response.text(); console.error('[EmbyClient] getCurrentUser - Error Response:', errorText); throw new Error(`获取当前用户信息失败 (${response.status}): ${errorText}`); } const data = await response.json(); console.log('[EmbyClient] getCurrentUser - Success:', data); return data; } catch (error) { console.error('[EmbyClient] getCurrentUser - Exception:', 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}` : ''}`; console.log('[EmbyClient] getUserViews - URL:', url); const response = await fetch(url); // 如果是 401 错误且有用户名密码,尝试重新认证 if (response.status === 401 && this.username && this.password && !this.apiKey) { console.log('[EmbyClient] Token expired, re-authenticating...'); 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()}`; console.log('[EmbyClient] getItems - URL:', url); console.log('[EmbyClient] getItems - Token:', token); console.log('[EmbyClient] getItems - UserId:', this.userId); const response = await fetch(url); // 如果是 401 错误且有用户名密码,尝试重新认证 if (response.status === 401 && this.username && this.password && !this.apiKey) { console.log('[EmbyClient] Token expired, re-authenticating...'); 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) { console.log('[EmbyClient] Token expired, re-authenticating...'); 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) { console.log('[EmbyClient] Token expired, re-authenticating...'); 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) { console.log('[EmbyClient] Token expired, re-authenticating...'); 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 : ''}`; } getStreamUrl(itemId: string, direct: boolean = true): string { if (direct) { return `${this.serverUrl}/Videos/${itemId}/stream?Static=true&api_key=${this.apiKey || this.authToken}`; } return `${this.serverUrl}/Videos/${itemId}/master.m3u8?api_key=${this.apiKey || this.authToken}`; } 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; } }