JellyfinApi.ets 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726
  1. import { http } from '@kit.NetworkKit';
  2. import { PreferencesUtil } from '@pura/harmony-utils';
  3. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  4. import { ServerLogUtil } from '../util/ServerLogUtil';
  5. const TAG = 'heanup JellyfinApi';
  6. const CLIENT_NAME = 'TTMusic';
  7. const CLIENT_VERSION = '1.0.0';
  8. const DEVICE_NAME = 'HarmonyOS';
  9. const DEVICE_ID_KEY = 'jellyfin_device_id';
  10. interface JellyfinAuthResponse {
  11. AccessToken?: string;
  12. User?: JellyfinUser;
  13. }
  14. interface JellyfinUser {
  15. Id?: string;
  16. Name?: string;
  17. }
  18. interface JellyfinAuthContext {
  19. token: string;
  20. userId: string;
  21. deviceId: string;
  22. userName?: string;
  23. }
  24. interface JellyfinItemImageTags {
  25. Primary?: string;
  26. }
  27. interface JellyfinItem {
  28. Id?: string;
  29. Name?: string;
  30. Type?: string;
  31. Album?: string;
  32. AlbumId?: string;
  33. Artists?: string[];
  34. ArtistItems?: Array<JellyfinPerson>;
  35. AlbumArtists?: Array<JellyfinPerson>;
  36. RunTimeTicks?: number;
  37. ProductionYear?: number;
  38. IndexNumber?: number;
  39. ImageTags?: JellyfinItemImageTags;
  40. MediaSources?: Array<JellyfinMediaSource>;
  41. }
  42. interface JellyfinItemsResponse {
  43. Items?: JellyfinItem[];
  44. }
  45. export interface JellyfinPagedResponse<T> {
  46. items: T[];
  47. nextStart: number | null;
  48. }
  49. interface JellyfinPerson {
  50. Id?: string;
  51. Name?: string;
  52. }
  53. interface JellyfinMediaStream {
  54. Type?: string;
  55. BitRate?: number;
  56. SampleRate?: number;
  57. Channels?: number;
  58. }
  59. interface JellyfinMediaSource {
  60. Size?: number;
  61. Container?: string;
  62. MediaStreams?: Array<JellyfinMediaStream>;
  63. }
  64. interface JellyfinLyricLine {
  65. Text?: string;
  66. Start?: number;
  67. }
  68. interface JellyfinLyricData {
  69. Lyrics?: JellyfinLyricLine[];
  70. }
  71. interface JellyfinAuthRequestBody {
  72. Username: string;
  73. Pw: string;
  74. }
  75. interface JellyfinAuthHeaders {
  76. Authorization: string;
  77. 'X-Emby-Token': string;
  78. Accept: string;
  79. }
  80. interface JellyfinLoginHeaders {
  81. 'Content-Type': string;
  82. Accept: string;
  83. Authorization: string;
  84. }
  85. class QueryParam {
  86. key: string;
  87. value: string;
  88. constructor(key: string, value: string) {
  89. this.key = key;
  90. this.value = value;
  91. }
  92. }
  93. export interface JellyfinArtist {
  94. id: string;
  95. name: string;
  96. albumCount?: number;
  97. songCount?: number;
  98. }
  99. export interface JellyfinAlbum {
  100. id: string;
  101. name: string;
  102. artist?: string;
  103. songCount?: number;
  104. year?: number;
  105. }
  106. export interface JellyfinSong {
  107. id: string;
  108. title: string;
  109. album?: string;
  110. albumId?: string;
  111. artist?: string;
  112. artistId?: string;
  113. durationSeconds?: number;
  114. size?: number;
  115. suffix?: string;
  116. bitRate?: number;
  117. sampleRate?: number;
  118. track?: number;
  119. year?: number;
  120. }
  121. export class JellyfinApi {
  122. private authCache: Map<string, JellyfinAuthContext> = new Map();
  123. async getArtists(account: WebDavAccount): Promise<JellyfinArtist[]> {
  124. const params: Array<QueryParam> = [
  125. new QueryParam('SortBy', 'SortName'),
  126. new QueryParam('SortOrder', 'Ascending')
  127. ];
  128. const response = await this.get<JellyfinItemsResponse>(account, '/Artists', params);
  129. const items = response.Items ?? [];
  130. return items
  131. .filter(item => item.Id && item.Name)
  132. .map(item => {
  133. const artist: JellyfinArtist = {
  134. id: item.Id as string,
  135. name: item.Name as string
  136. };
  137. return artist;
  138. });
  139. }
  140. async getArtistAlbums(account: WebDavAccount, artistId: string): Promise<JellyfinAlbum[]> {
  141. const auth = await this.ensureAuth(account);
  142. const params: Array<QueryParam> = [
  143. new QueryParam('IncludeItemTypes', 'MusicAlbum'),
  144. new QueryParam('Recursive', 'true'),
  145. new QueryParam('SortBy', 'SortName'),
  146. new QueryParam('SortOrder', 'Ascending'),
  147. new QueryParam('ArtistIds', artistId)
  148. ];
  149. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  150. const items = response.Items ?? [];
  151. return items
  152. .filter(item => item.Id && item.Name)
  153. .map(item => {
  154. const album: JellyfinAlbum = {
  155. id: item.Id as string,
  156. name: item.Name as string,
  157. artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
  158. year: item.ProductionYear
  159. };
  160. return album;
  161. });
  162. }
  163. async getAlbums(account: WebDavAccount): Promise<JellyfinAlbum[]> {
  164. const auth = await this.ensureAuth(account);
  165. const params: Array<QueryParam> = [
  166. new QueryParam('IncludeItemTypes', 'MusicAlbum'),
  167. new QueryParam('Recursive', 'true'),
  168. new QueryParam('SortBy', 'SortName'),
  169. new QueryParam('SortOrder', 'Ascending')
  170. ];
  171. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  172. const items = response.Items ?? [];
  173. return items
  174. .filter(item => item.Id && item.Name)
  175. .map(item => {
  176. const album: JellyfinAlbum = {
  177. id: item.Id as string,
  178. name: item.Name as string,
  179. artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
  180. year: item.ProductionYear
  181. };
  182. return album;
  183. });
  184. }
  185. async getAlbum(account: WebDavAccount, albumId: string): Promise<JellyfinAlbum | null> {
  186. const auth = await this.ensureAuth(account);
  187. const item = await this.get<JellyfinItem>(account, `/Users/${auth.userId}/Items/${albumId}`);
  188. if (!item || !item.Id || !item.Name) {
  189. return null;
  190. }
  191. const album: JellyfinAlbum = {
  192. id: item.Id,
  193. name: item.Name,
  194. artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
  195. year: item.ProductionYear
  196. };
  197. return album;
  198. }
  199. async getAlbumSongs(account: WebDavAccount, albumId: string): Promise<JellyfinSong[]> {
  200. const auth = await this.ensureAuth(account);
  201. const params: Array<QueryParam> = [
  202. new QueryParam('IncludeItemTypes', 'Audio'),
  203. new QueryParam('ParentId', albumId),
  204. new QueryParam('SortBy', 'ParentIndexNumber,IndexNumber,SortName'),
  205. new QueryParam('SortOrder', 'Ascending'),
  206. new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
  207. ];
  208. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  209. const items = response.Items ?? [];
  210. const songs: JellyfinSong[] = [];
  211. for (let i = 0; i < items.length; i++) {
  212. const song = this.toSong(items[i]);
  213. if (song) {
  214. songs.push(song);
  215. }
  216. }
  217. return songs;
  218. }
  219. async getArtistSongs(account: WebDavAccount, artistId: string, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
  220. const auth = await this.ensureAuth(account);
  221. const params: Array<QueryParam> = [
  222. new QueryParam('IncludeItemTypes', 'Audio'),
  223. new QueryParam('Recursive', 'true'),
  224. new QueryParam('SortBy', 'SortName'),
  225. new QueryParam('SortOrder', 'Ascending'),
  226. new QueryParam('ArtistIds', artistId),
  227. new QueryParam('StartIndex', startIndex.toString()),
  228. new QueryParam('Limit', limit.toString()),
  229. new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
  230. ];
  231. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  232. const items = response.Items ?? [];
  233. const songs: JellyfinSong[] = [];
  234. for (let i = 0; i < items.length; i++) {
  235. const song = this.toSong(items[i]);
  236. if (song) {
  237. songs.push(song);
  238. }
  239. }
  240. const nextStart = items.length < limit ? null : startIndex + items.length;
  241. const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
  242. return result;
  243. }
  244. async getSongsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
  245. const auth = await this.ensureAuth(account);
  246. const params: Array<QueryParam> = [
  247. new QueryParam('IncludeItemTypes', 'Audio'),
  248. new QueryParam('Recursive', 'true'),
  249. new QueryParam('SortBy', 'SortName'),
  250. new QueryParam('SortOrder', 'Ascending'),
  251. new QueryParam('StartIndex', startIndex.toString()),
  252. new QueryParam('Limit', limit.toString()),
  253. new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
  254. ];
  255. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  256. const items = response.Items ?? [];
  257. const songs: JellyfinSong[] = [];
  258. for (let i = 0; i < items.length; i++) {
  259. const song = this.toSong(items[i]);
  260. if (song) {
  261. songs.push(song);
  262. }
  263. }
  264. const nextStart = items.length < limit ? null : startIndex + items.length;
  265. const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
  266. return result;
  267. }
  268. async getArtistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinArtist>> {
  269. const params: Array<QueryParam> = [
  270. new QueryParam('SortBy', 'SortName'),
  271. new QueryParam('SortOrder', 'Ascending'),
  272. new QueryParam('StartIndex', startIndex.toString()),
  273. new QueryParam('Limit', limit.toString())
  274. ];
  275. const response = await this.get<JellyfinItemsResponse>(account, '/Artists', params);
  276. const items = response.Items ?? [];
  277. const artists: JellyfinArtist[] = items
  278. .filter(item => item.Id && item.Name)
  279. .map(item => {
  280. const artist: JellyfinArtist = {
  281. id: item.Id as string,
  282. name: item.Name as string
  283. };
  284. return artist;
  285. });
  286. const nextStart = items.length < limit ? null : startIndex + items.length;
  287. const result: JellyfinPagedResponse<JellyfinArtist> = { items: artists, nextStart: nextStart };
  288. return result;
  289. }
  290. async getAlbumsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinAlbum>> {
  291. const auth = await this.ensureAuth(account);
  292. const params: Array<QueryParam> = [
  293. new QueryParam('IncludeItemTypes', 'MusicAlbum'),
  294. new QueryParam('Recursive', 'true'),
  295. new QueryParam('SortBy', 'SortName'),
  296. new QueryParam('SortOrder', 'Ascending'),
  297. new QueryParam('StartIndex', startIndex.toString()),
  298. new QueryParam('Limit', limit.toString())
  299. ];
  300. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  301. const items = response.Items ?? [];
  302. const albums: JellyfinAlbum[] = items
  303. .filter(item => item.Id && item.Name)
  304. .map(item => {
  305. const album: JellyfinAlbum = {
  306. id: item.Id as string,
  307. name: item.Name as string,
  308. artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
  309. year: item.ProductionYear
  310. };
  311. return album;
  312. });
  313. const nextStart = items.length < limit ? null : startIndex + items.length;
  314. const result: JellyfinPagedResponse<JellyfinAlbum> = { items: albums, nextStart: nextStart };
  315. return result;
  316. }
  317. async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
  318. const auth = await this.ensureAuth(account);
  319. const params: Array<QueryParam> = [
  320. new QueryParam('IncludeItemTypes', 'Audio'),
  321. new QueryParam('Recursive', 'true'),
  322. new QueryParam('SearchTerm', keyword),
  323. new QueryParam('SortBy', 'SortName'),
  324. new QueryParam('SortOrder', 'Ascending'),
  325. new QueryParam('StartIndex', startIndex.toString()),
  326. new QueryParam('Limit', limit.toString()),
  327. new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
  328. ];
  329. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  330. const items = response.Items ?? [];
  331. const songs: JellyfinSong[] = [];
  332. for (let i = 0; i < items.length; i++) {
  333. const song = this.toSong(items[i]);
  334. if (song) {
  335. songs.push(song);
  336. }
  337. }
  338. const nextStart = items.length < limit ? null : startIndex + items.length;
  339. const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
  340. return result;
  341. }
  342. async getAllSongs(account: WebDavAccount): Promise<JellyfinSong[]> {
  343. const auth = await this.ensureAuth(account);
  344. const params: Array<QueryParam> = [
  345. new QueryParam('IncludeItemTypes', 'Audio'),
  346. new QueryParam('Recursive', 'true'),
  347. new QueryParam('SortBy', 'SortName'),
  348. new QueryParam('SortOrder', 'Ascending'),
  349. new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
  350. ];
  351. const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
  352. const items = response.Items ?? [];
  353. const songs: JellyfinSong[] = [];
  354. const seenIds = new Set<string>();
  355. const seenComposite = new Set<string>();
  356. for (let i = 0; i < items.length; i++) {
  357. const itemId = items[i].Id as string | undefined;
  358. if (!itemId || seenIds.has(itemId)) {
  359. continue;
  360. }
  361. const song = this.toSong(items[i]);
  362. if (song) {
  363. const compositeKey = `${song.title ?? ''}__${song.artist ?? ''}__${song.album ?? ''}__${song.durationSeconds ?? ''}`;
  364. if (seenComposite.has(compositeKey)) {
  365. continue;
  366. }
  367. songs.push(song);
  368. seenIds.add(song.id);
  369. seenComposite.add(compositeKey);
  370. }
  371. }
  372. return songs;
  373. }
  374. async buildStreamUrl(account: WebDavAccount, itemId: string, useStatic: boolean = false): Promise<string> {
  375. const auth = await this.ensureAuth(account);
  376. const baseUrl = this.buildBaseUrl(account);
  377. const apiKey = encodeURIComponent(auth.token);
  378. const params: string[] = [`api_key=${apiKey}`];
  379. if (useStatic) {
  380. params.push('static=true');
  381. }
  382. return `${baseUrl}/Audio/${encodeURIComponent(itemId)}/stream?${params.join('&')}`;
  383. }
  384. async buildDownloadUrl(account: WebDavAccount, itemId: string): Promise<string> {
  385. const auth = await this.ensureAuth(account);
  386. const baseUrl = this.buildBaseUrl(account);
  387. const apiKey = encodeURIComponent(auth.token);
  388. return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Download?api_key=${apiKey}`;
  389. }
  390. async canAccessUrl(account: WebDavAccount, url: string): Promise<boolean> {
  391. const httpRequest = http.createHttp();
  392. try {
  393. const auth = await this.ensureAuth(account);
  394. const response = await httpRequest.request(url, {
  395. method: http.RequestMethod.HEAD,
  396. connectTimeout: 10000,
  397. readTimeout: 10000,
  398. expectDataType: http.HttpDataType.STRING,
  399. header: this.buildAuthHeaderObject(auth)
  400. });
  401. return response.responseCode === 200 || response.responseCode === 206;
  402. } catch (_error) {
  403. return false;
  404. } finally {
  405. httpRequest.destroy();
  406. }
  407. }
  408. async buildPrimaryImageUrl(account: WebDavAccount, itemId: string, width: number = 600, height: number = 600): Promise<string> {
  409. const auth = await this.ensureAuth(account);
  410. const baseUrl = this.buildBaseUrl(account);
  411. const apiKey = encodeURIComponent(auth.token);
  412. return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Primary?fillHeight=${height}&fillWidth=${width}&quality=90&api_key=${apiKey}`;
  413. }
  414. /**
  415. * 获取歌词
  416. * GET: /Audio/{id}/Lyrics
  417. * @param account WebDavAccount账号信息
  418. * @param itemId 歌曲ID
  419. * @returns 歌词文本,如果获取失败返回空字符串
  420. */
  421. async getLyric(account: WebDavAccount, itemId: string): Promise<string> {
  422. const httpRequest = http.createHttp();
  423. try {
  424. const auth = await this.ensureAuth(account);
  425. const baseUrl = this.buildBaseUrl(account);
  426. const url = `${baseUrl}/Audio/${encodeURIComponent(itemId)}/Lyrics`;
  427. void ServerLogUtil.info(TAG, `获取Jellyfin歌词: ${url}`);
  428. const response = await httpRequest.request(url, {
  429. method: http.RequestMethod.GET,
  430. connectTimeout: 10000,
  431. readTimeout: 15000,
  432. expectDataType: http.HttpDataType.STRING,
  433. header: this.buildAuthHeaderObject(auth)
  434. });
  435. if (response.responseCode < 200 || response.responseCode >= 300) {
  436. void ServerLogUtil.error(TAG, `获取Jellyfin歌词失败 code=${response.responseCode}`);
  437. return '';
  438. }
  439. void ServerLogUtil.info(TAG, `获取Jellyfin歌词成功 code=${response.responseCode}`);
  440. const lyricData = JSON.parse(response.result as string) as JellyfinLyricData;
  441. // 转换为标准 LRC 格式
  442. return this.convertJellyfinLyricToLrc(lyricData);
  443. } catch (error) {
  444. const err = error as Error;
  445. void ServerLogUtil.error(TAG, `获取Jellyfin歌词异常: ${err.message}`);
  446. return '';
  447. } finally {
  448. httpRequest.destroy();
  449. }
  450. }
  451. /**
  452. * 将Jellyfin歌词数据转换为LRC格式
  453. * @param lyricData Jellyfin歌词数据
  454. * @returns LRC格式歌词字符串
  455. */
  456. private convertJellyfinLyricToLrc(lyricData: JellyfinLyricData): string {
  457. if (!lyricData || !lyricData.Lyrics || !Array.isArray(lyricData.Lyrics)) {
  458. return '';
  459. }
  460. const lines: string[] = [];
  461. for (let i = 0; i < lyricData.Lyrics.length; i++) {
  462. const lyricLine = lyricData.Lyrics[i];
  463. if (lyricLine.Text && lyricLine.Start !== undefined) {
  464. // 将纳秒转换为毫秒,再转换为秒
  465. const milliseconds = Math.floor(lyricLine.Start / 1000000); // 纳秒转毫秒
  466. const seconds = milliseconds / 1000; // 毫秒转秒
  467. const minutes = Math.floor(seconds / 60);
  468. const remainingSeconds = (seconds % 60).toFixed(2);
  469. const timeTag = `[${String(minutes).padStart(2, '0')}:${remainingSeconds.padStart(5, '0')}]`;
  470. lines.push(`${timeTag}${lyricLine.Text}`);
  471. }
  472. }
  473. return lines.join('\n');
  474. }
  475. async getAuthHeaders(account: WebDavAccount): Promise<Map<string, string>> {
  476. const auth = await this.ensureAuth(account);
  477. return this.buildAuthHeaderMap(auth);
  478. }
  479. private async get<T>(account: WebDavAccount, path: string, params?: Array<QueryParam>): Promise<T> {
  480. return this.request<T>(account, http.RequestMethod.GET, path, params);
  481. }
  482. private async request<T>(
  483. account: WebDavAccount,
  484. method: http.RequestMethod,
  485. path: string,
  486. params?: Array<QueryParam>,
  487. body?: object,
  488. retry: boolean = true
  489. ): Promise<T> {
  490. const httpRequest = http.createHttp();
  491. try {
  492. const auth = await this.ensureAuth(account);
  493. const query = this.buildQueryString(params);
  494. const url = `${this.buildBaseUrl(account)}${path}${query ? `?${query}` : ''}`;
  495. void ServerLogUtil.info(TAG, `${method} ${url}`);
  496. const response = await httpRequest.request(url, {
  497. method,
  498. connectTimeout: 10000,
  499. readTimeout: 15000,
  500. expectDataType: http.HttpDataType.STRING,
  501. header: this.buildAuthHeaderObject(auth),
  502. extraData: body ? JSON.stringify(body) : undefined
  503. });
  504. if (response.responseCode === 401 && retry) {
  505. this.invalidateAuth(account);
  506. void ServerLogUtil.warn(TAG, `401 重试: ${url}`);
  507. return this.request<T>(account, method, path, params, body, false);
  508. }
  509. if (response.responseCode < 200 || response.responseCode >= 300) {
  510. throw new Error(`Jellyfin API 请求失败: HTTP ${response.responseCode}`);
  511. }
  512. return JSON.parse(response.result as string) as T;
  513. } finally {
  514. httpRequest.destroy();
  515. }
  516. }
  517. private async ensureAuth(account: WebDavAccount): Promise<JellyfinAuthContext> {
  518. const key = this.getAccountKey(account);
  519. const cached = this.authCache.get(key);
  520. if (cached) {
  521. return cached;
  522. }
  523. const auth = await this.login(account);
  524. this.authCache.set(key, auth);
  525. return auth;
  526. }
  527. private invalidateAuth(account: WebDavAccount): void {
  528. const key = this.getAccountKey(account);
  529. this.authCache.delete(key);
  530. }
  531. private async login(account: WebDavAccount): Promise<JellyfinAuthContext> {
  532. const httpRequest = http.createHttp();
  533. try {
  534. const url = `${this.buildBaseUrl(account)}/Users/AuthenticateByName`;
  535. const deviceId = this.ensureDeviceId();
  536. const headers: JellyfinLoginHeaders = {
  537. 'Content-Type': 'application/json',
  538. 'Accept': 'application/json',
  539. 'Authorization': this.buildLoginHeader(deviceId)
  540. };
  541. const body: JellyfinAuthRequestBody = {
  542. Username: account.account ?? '',
  543. Pw: account.password ?? ''
  544. };
  545. const response = await httpRequest.request(url, {
  546. method: http.RequestMethod.POST,
  547. connectTimeout: 10000,
  548. readTimeout: 15000,
  549. expectDataType: http.HttpDataType.STRING,
  550. header: headers,
  551. extraData: JSON.stringify(body)
  552. });
  553. if (response.responseCode < 200 || response.responseCode >= 300) {
  554. throw new Error(`Jellyfin 登录失败: HTTP ${response.responseCode}`);
  555. }
  556. const payload = JSON.parse(response.result as string) as JellyfinAuthResponse;
  557. const token = payload.AccessToken ?? '';
  558. const userId = payload.User?.Id ?? '';
  559. if (!token || !userId) {
  560. throw new Error('Jellyfin 登录返回缺少 AccessToken 或 UserId');
  561. }
  562. void ServerLogUtil.info(TAG, `Jellyfin 登录成功 user=${payload.User?.Name ?? ''}`);
  563. return {
  564. token,
  565. userId,
  566. deviceId,
  567. userName: payload.User?.Name
  568. };
  569. } finally {
  570. httpRequest.destroy();
  571. }
  572. }
  573. private buildBaseUrl(account: WebDavAccount): string {
  574. const protocol = account.enableHttps ? 'https' : 'http';
  575. const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host;
  576. const portPart = account.port && ((protocol === 'https' && account.port !== 443) || (protocol === 'http' && account.port !== 80))
  577. ? `:${account.port}`
  578. : '';
  579. const basePath = this.normalizeBasePath(account.jellyfinBasePath);
  580. return `${protocol}://${host}${portPart}${basePath}`;
  581. }
  582. private normalizeBasePath(path?: string): string {
  583. if (!path || path.trim().length === 0) {
  584. return '';
  585. }
  586. let normalized = path.trim();
  587. if (!normalized.startsWith('/')) {
  588. normalized = `/${normalized}`;
  589. }
  590. if (normalized.length > 1 && normalized.endsWith('/')) {
  591. normalized = normalized.slice(0, -1);
  592. }
  593. return normalized === '/' ? '' : normalized;
  594. }
  595. private buildAuthHeaderObject(auth: JellyfinAuthContext): JellyfinAuthHeaders {
  596. const headers: JellyfinAuthHeaders = {
  597. 'Authorization': this.buildAuthorizationHeader(auth),
  598. 'X-Emby-Token': auth.token,
  599. 'Accept': 'application/json'
  600. };
  601. return headers;
  602. }
  603. private buildAuthHeaderMap(auth: JellyfinAuthContext): Map<string, string> {
  604. const headers = new Map<string, string>();
  605. headers.set('Authorization', this.buildAuthorizationHeader(auth));
  606. headers.set('X-Emby-Token', auth.token);
  607. headers.set('Accept', 'application/json');
  608. return headers;
  609. }
  610. private buildAuthorizationHeader(auth: JellyfinAuthContext): string {
  611. return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${auth.deviceId}", Version="${CLIENT_VERSION}", Token="${auth.token}"`;
  612. }
  613. private buildLoginHeader(deviceId: string): string {
  614. return `MediaBrowser Client="${CLIENT_NAME}", Device="${DEVICE_NAME}", DeviceId="${deviceId}", Version="${CLIENT_VERSION}"`;
  615. }
  616. private ensureDeviceId(): string {
  617. let deviceId = PreferencesUtil.getStringSync(DEVICE_ID_KEY, '');
  618. if (!deviceId || deviceId.length === 0) {
  619. deviceId = `ttmusic-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
  620. PreferencesUtil.putSync(DEVICE_ID_KEY, deviceId);
  621. }
  622. return deviceId;
  623. }
  624. private buildQueryString(params?: Array<QueryParam>): string {
  625. if (!params) {
  626. return '';
  627. }
  628. const parts: string[] = [];
  629. for (let i = 0; i < params.length; i++) {
  630. const param = params[i];
  631. if (!param.value || param.value.length === 0) {
  632. continue;
  633. }
  634. parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`);
  635. }
  636. return parts.join('&');
  637. }
  638. private getAccountKey(account: WebDavAccount): string {
  639. return `${account.id ?? ''}|${account.host}|${account.port}|${account.account}`;
  640. }
  641. private toSong(item: JellyfinItem): JellyfinSong | null {
  642. if (!item.Id || !item.Name) {
  643. return null;
  644. }
  645. const mediaSource = item.MediaSources && item.MediaSources.length > 0 ? item.MediaSources[0] : undefined;
  646. const audioStream = mediaSource?.MediaStreams?.find(stream => stream.Type === 'Audio');
  647. const durationSeconds = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10000000) : undefined;
  648. const song: JellyfinSong = {
  649. id: item.Id,
  650. title: item.Name,
  651. album: item.Album,
  652. albumId: item.AlbumId,
  653. artist: item.Artists?.[0] ?? item.ArtistItems?.[0]?.Name,
  654. artistId: item.ArtistItems?.[0]?.Id,
  655. durationSeconds,
  656. size: mediaSource?.Size,
  657. suffix: mediaSource?.Container,
  658. bitRate: audioStream?.BitRate,
  659. sampleRate: audioStream?.SampleRate,
  660. track: item.IndexNumber,
  661. year: item.ProductionYear
  662. };
  663. return song;
  664. }
  665. }
  666. export const jellyfinApi = new JellyfinApi();