import { http } from '@kit.NetworkKit'; import { PreferencesUtil } from '@pura/harmony-utils'; import { WebDavAccount } from '../../viewmodel/WebDavAccount'; import { ServerLogUtil } from '../util/ServerLogUtil'; const TAG = 'heanup JellyfinApi'; const CLIENT_NAME = 'TTMusic'; const CLIENT_VERSION = '1.0.0'; const DEVICE_NAME = 'HarmonyOS'; const DEVICE_ID_KEY = 'jellyfin_device_id'; interface JellyfinAuthResponse { AccessToken?: string; User?: JellyfinUser; } interface JellyfinUser { Id?: string; Name?: string; } interface JellyfinAuthContext { token: string; userId: string; deviceId: string; userName?: string; } interface JellyfinItemImageTags { Primary?: string; } interface JellyfinItem { Id?: string; Name?: string; Type?: string; Album?: string; AlbumId?: string; Artists?: string[]; ArtistItems?: Array; AlbumArtists?: Array; RunTimeTicks?: number; ProductionYear?: number; IndexNumber?: number; ImageTags?: JellyfinItemImageTags; MediaSources?: Array; } interface JellyfinItemsResponse { Items?: JellyfinItem[]; } export interface JellyfinPagedResponse { items: T[]; nextStart: number | null; } interface JellyfinPerson { Id?: string; Name?: string; } interface JellyfinMediaStream { Type?: string; BitRate?: number; SampleRate?: number; Channels?: number; } interface JellyfinMediaSource { Size?: number; Container?: string; MediaStreams?: Array; } interface JellyfinAuthRequestBody { Username: string; Pw: string; } interface JellyfinAuthHeaders { Authorization: string; 'X-Emby-Token': string; Accept: string; } interface JellyfinLoginHeaders { 'Content-Type': string; Accept: string; Authorization: string; } class QueryParam { key: string; value: string; constructor(key: string, value: string) { this.key = key; this.value = value; } } export interface JellyfinArtist { id: string; name: string; albumCount?: number; } export interface JellyfinAlbum { id: string; name: string; artist?: string; songCount?: number; year?: number; } export interface JellyfinSong { id: string; title: string; album?: string; albumId?: string; artist?: string; artistId?: string; durationSeconds?: number; size?: number; suffix?: string; bitRate?: number; sampleRate?: number; track?: number; year?: number; } export class JellyfinApi { private authCache: Map = new Map(); async getArtists(account: WebDavAccount): Promise { const params: Array = [ new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending') ]; const response = await this.get(account, '/Artists', params); const items = response.Items ?? []; return items .filter(item => item.Id && item.Name) .map(item => { const artist: JellyfinArtist = { id: item.Id as string, name: item.Name as string }; return artist; }); } async getArtistAlbums(account: WebDavAccount, artistId: string): Promise { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'MusicAlbum'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('ArtistIds', artistId) ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; return items .filter(item => item.Id && item.Name) .map(item => { const album: JellyfinAlbum = { id: item.Id as string, name: item.Name as string, artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0], year: item.ProductionYear }; return album; }); } async getAlbums(account: WebDavAccount): Promise { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'MusicAlbum'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; return items .filter(item => item.Id && item.Name) .map(item => { const album: JellyfinAlbum = { id: item.Id as string, name: item.Name as string, artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0], year: item.ProductionYear }; return album; }); } async getAlbum(account: WebDavAccount, albumId: string): Promise { const auth = await this.ensureAuth(account); const item = await this.get(account, `/Users/${auth.userId}/Items/${albumId}`); if (!item || !item.Id || !item.Name) { return null; } const album: JellyfinAlbum = { id: item.Id, name: item.Name, artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0], year: item.ProductionYear }; return album; } async getAlbumSongs(account: WebDavAccount, albumId: string): Promise { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'Audio'), new QueryParam('ParentId', albumId), new QueryParam('SortBy', 'ParentIndexNumber,IndexNumber,SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const songs: JellyfinSong[] = []; for (let i = 0; i < items.length; i++) { const song = this.toSong(items[i]); if (song) { songs.push(song); } } return songs; } async getArtistSongs(account: WebDavAccount, artistId: string, startIndex: number = 0, limit: number = 200): Promise> { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'Audio'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('ArtistIds', artistId), new QueryParam('StartIndex', startIndex.toString()), new QueryParam('Limit', limit.toString()), new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const songs: JellyfinSong[] = []; for (let i = 0; i < items.length; i++) { const song = this.toSong(items[i]); if (song) { songs.push(song); } } const nextStart = items.length < limit ? null : startIndex + items.length; const result: JellyfinPagedResponse = { items: songs, nextStart: nextStart }; return result; } async getSongsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise> { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'Audio'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('StartIndex', startIndex.toString()), new QueryParam('Limit', limit.toString()), new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const songs: JellyfinSong[] = []; for (let i = 0; i < items.length; i++) { const song = this.toSong(items[i]); if (song) { songs.push(song); } } const nextStart = items.length < limit ? null : startIndex + items.length; const result: JellyfinPagedResponse = { items: songs, nextStart: nextStart }; return result; } async getArtistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise> { const params: Array = [ new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('StartIndex', startIndex.toString()), new QueryParam('Limit', limit.toString()) ]; const response = await this.get(account, '/Artists', params); const items = response.Items ?? []; const artists: JellyfinArtist[] = items .filter(item => item.Id && item.Name) .map(item => { const artist: JellyfinArtist = { id: item.Id as string, name: item.Name as string }; return artist; }); const nextStart = items.length < limit ? null : startIndex + items.length; const result: JellyfinPagedResponse = { items: artists, nextStart: nextStart }; return result; } async getAlbumsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise> { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'MusicAlbum'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('StartIndex', startIndex.toString()), new QueryParam('Limit', limit.toString()) ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const albums: JellyfinAlbum[] = items .filter(item => item.Id && item.Name) .map(item => { const album: JellyfinAlbum = { id: item.Id as string, name: item.Name as string, artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0], year: item.ProductionYear }; return album; }); const nextStart = items.length < limit ? null : startIndex + items.length; const result: JellyfinPagedResponse = { items: albums, nextStart: nextStart }; return result; } async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise> { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'Audio'), new QueryParam('Recursive', 'true'), new QueryParam('SearchTerm', keyword), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('StartIndex', startIndex.toString()), new QueryParam('Limit', limit.toString()), new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const songs: JellyfinSong[] = []; for (let i = 0; i < items.length; i++) { const song = this.toSong(items[i]); if (song) { songs.push(song); } } const nextStart = items.length < limit ? null : startIndex + items.length; const result: JellyfinPagedResponse = { items: songs, nextStart: nextStart }; return result; } async getAllSongs(account: WebDavAccount): Promise { const auth = await this.ensureAuth(account); const params: Array = [ new QueryParam('IncludeItemTypes', 'Audio'), new QueryParam('Recursive', 'true'), new QueryParam('SortBy', 'SortName'), new QueryParam('SortOrder', 'Ascending'), new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio') ]; const response = await this.get(account, `/Users/${auth.userId}/Items`, params); const items = response.Items ?? []; const songs: JellyfinSong[] = []; const seenIds = new Set(); const seenComposite = new Set(); for (let i = 0; i < items.length; i++) { const itemId = items[i].Id as string | undefined; if (!itemId || seenIds.has(itemId)) { continue; } const song = this.toSong(items[i]); if (song) { const compositeKey = `${song.title ?? ''}__${song.artist ?? ''}__${song.album ?? ''}__${song.durationSeconds ?? ''}`; if (seenComposite.has(compositeKey)) { continue; } songs.push(song); seenIds.add(song.id); seenComposite.add(compositeKey); } } return songs; } async buildStreamUrl(account: WebDavAccount, itemId: string): Promise { const auth = await this.ensureAuth(account); const baseUrl = this.buildBaseUrl(account); const apiKey = encodeURIComponent(auth.token); return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Download?api_key=${apiKey}`; } async buildPrimaryImageUrl(account: WebDavAccount, itemId: string, width: number = 600, height: number = 600): Promise { const auth = await this.ensureAuth(account); const baseUrl = this.buildBaseUrl(account); const apiKey = encodeURIComponent(auth.token); return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Primary?fillHeight=${height}&fillWidth=${width}&quality=90&api_key=${apiKey}`; } async getAuthHeaders(account: WebDavAccount): Promise> { const auth = await this.ensureAuth(account); return this.buildAuthHeaderMap(auth); } private async get(account: WebDavAccount, path: string, params?: Array): Promise { return this.request(account, http.RequestMethod.GET, path, params); } private async request( account: WebDavAccount, method: http.RequestMethod, path: string, params?: Array, body?: object, retry: boolean = true ): Promise { const httpRequest = http.createHttp(); try { const auth = await this.ensureAuth(account); const query = this.buildQueryString(params); const url = `${this.buildBaseUrl(account)}${path}${query ? `?${query}` : ''}`; void ServerLogUtil.info(TAG, `${method} ${url}`); const response = await httpRequest.request(url, { method, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING, header: this.buildAuthHeaderObject(auth), extraData: body ? JSON.stringify(body) : undefined }); if (response.responseCode === 401 && retry) { this.invalidateAuth(account); void ServerLogUtil.warn(TAG, `401 重试: ${url}`); return this.request(account, method, path, params, body, false); } if (response.responseCode < 200 || response.responseCode >= 300) { throw new Error(`Jellyfin API 请求失败: HTTP ${response.responseCode}`); } return JSON.parse(response.result as string) as T; } finally { httpRequest.destroy(); } } private async ensureAuth(account: WebDavAccount): Promise { const key = this.getAccountKey(account); const cached = this.authCache.get(key); if (cached) { return cached; } const auth = await this.login(account); this.authCache.set(key, auth); return auth; } private invalidateAuth(account: WebDavAccount): void { const key = this.getAccountKey(account); this.authCache.delete(key); } private async login(account: WebDavAccount): Promise { const httpRequest = http.createHttp(); try { const url = `${this.buildBaseUrl(account)}/Users/AuthenticateByName`; const deviceId = this.ensureDeviceId(); const headers: JellyfinLoginHeaders = { 'Content-Type': 'application/json', 'Accept': 'application/json', 'Authorization': this.buildLoginHeader(deviceId) }; const body: JellyfinAuthRequestBody = { Username: account.account ?? '', Pw: account.password ?? '' }; const response = await httpRequest.request(url, { method: http.RequestMethod.POST, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING, header: headers, extraData: JSON.stringify(body) }); if (response.responseCode < 200 || response.responseCode >= 300) { throw new Error(`Jellyfin 登录失败: HTTP ${response.responseCode}`); } const payload = JSON.parse(response.result as string) as JellyfinAuthResponse; const token = payload.AccessToken ?? ''; const userId = payload.User?.Id ?? ''; if (!token || !userId) { throw new Error('Jellyfin 登录返回缺少 AccessToken 或 UserId'); } void ServerLogUtil.info(TAG, `Jellyfin 登录成功 user=${payload.User?.Name ?? ''}`); return { token, userId, deviceId, userName: payload.User?.Name }; } finally { httpRequest.destroy(); } } private buildBaseUrl(account: WebDavAccount): string { const protocol = account.enableHttps ? 'https' : 'http'; const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host; const portPart = account.port && ((protocol === 'https' && account.port !== 443) || (protocol === 'http' && account.port !== 80)) ? `:${account.port}` : ''; const basePath = this.normalizeBasePath(account.jellyfinBasePath); return `${protocol}://${host}${portPart}${basePath}`; } private normalizeBasePath(path?: string): string { if (!path || path.trim().length === 0) { return ''; } let normalized = path.trim(); if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } if (normalized.length > 1 && normalized.endsWith('/')) { normalized = normalized.slice(0, -1); } return normalized === '/' ? '' : normalized; } private buildAuthHeaderObject(auth: JellyfinAuthContext): JellyfinAuthHeaders { const headers: JellyfinAuthHeaders = { 'Authorization': this.buildAuthorizationHeader(auth), 'X-Emby-Token': auth.token, 'Accept': 'application/json' }; return headers; } private buildAuthHeaderMap(auth: JellyfinAuthContext): Map { const headers = new Map(); headers.set('Authorization', this.buildAuthorizationHeader(auth)); headers.set('X-Emby-Token', auth.token); headers.set('Accept', 'application/json'); return headers; } private buildAuthorizationHeader(auth: JellyfinAuthContext): string { return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${auth.deviceId}", Version="${CLIENT_VERSION}", Token="${auth.token}"`; } private buildLoginHeader(deviceId: string): string { return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${deviceId}", Version="${CLIENT_VERSION}"`; } private ensureDeviceId(): string { let deviceId = PreferencesUtil.getStringSync(DEVICE_ID_KEY, ''); if (!deviceId || deviceId.length === 0) { deviceId = `ttmusic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; PreferencesUtil.putSync(DEVICE_ID_KEY, deviceId); } return deviceId; } private buildQueryString(params?: Array): string { if (!params) { return ''; } const parts: string[] = []; for (let i = 0; i < params.length; i++) { const param = params[i]; if (!param.value || param.value.length === 0) { continue; } parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`); } return parts.join('&'); } private getAccountKey(account: WebDavAccount): string { return `${account.id ?? ''}|${account.host}|${account.port}|${account.account}`; } private toSong(item: JellyfinItem): JellyfinSong | null { if (!item.Id || !item.Name) { return null; } const mediaSource = item.MediaSources && item.MediaSources.length > 0 ? item.MediaSources[0] : undefined; const audioStream = mediaSource?.MediaStreams?.find(stream => stream.Type === 'Audio'); const durationSeconds = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined; const song: JellyfinSong = { id: item.Id, title: item.Name, album: item.Album, albumId: item.AlbumId, artist: item.Artists?.[0] ?? item.ArtistItems?.[0]?.Name, artistId: item.ArtistItems?.[0]?.Id, durationSeconds, size: mediaSource?.Size, suffix: mediaSource?.Container, bitRate: audioStream?.BitRate, sampleRate: audioStream?.SampleRate, track: item.IndexNumber, year: item.ProductionYear }; return song; } } export const jellyfinApi = new JellyfinApi();