DaoLiYuApi.ets 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { http } from '@kit.NetworkKit';
  2. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  3. import { ServerLogUtil } from '../util/ServerLogUtil';
  4. const TAG = 'heanup DaoLiYuApi';
  5. interface DaoLiYuLoginResponse {
  6. token?: string;
  7. user?: DaoLiYuUser;
  8. }
  9. interface DaoLiYuUser {
  10. id?: string;
  11. email?: string;
  12. displayName?: string;
  13. role?: string;
  14. avatarUrl?: string | null;
  15. avatarPath?: string | null;
  16. }
  17. interface DaoLiYuAuthContext {
  18. token: string;
  19. }
  20. interface DaoLiYuPagedData<T> {
  21. items?: T[];
  22. total?: number;
  23. skip?: number;
  24. take?: number;
  25. }
  26. interface DaoLiYuAlbumEntry {
  27. id?: string;
  28. title?: string;
  29. normalizedTitle?: string;
  30. releaseYear?: number | null;
  31. releaseDate?: string | null;
  32. description?: string | null;
  33. albumArtist?: string | null;
  34. coverArtPath?: string | null;
  35. coverArtUrl?: string | null;
  36. artists?: string[];
  37. createdAt?: string;
  38. updatedAt?: string;
  39. }
  40. interface DaoLiYuArtistEntry {
  41. id?: string;
  42. name?: string;
  43. normalizedName?: string;
  44. bio?: string | null;
  45. coverArtPath?: string | null;
  46. coverArtUrl?: string | null;
  47. createdAt?: string;
  48. updatedAt?: string;
  49. trackCount?: number;
  50. albumCount?: number;
  51. isFavorite?: boolean;
  52. }
  53. interface DaoLiYuPlaylistTrackEntry {
  54. id?: string;
  55. playlistId?: string;
  56. trackId?: string;
  57. order?: number;
  58. addedAt?: string;
  59. track?: DaoLiYuTrackEntry;
  60. }
  61. interface DaoLiYuPlaylistEntry {
  62. id?: string;
  63. name?: string;
  64. description?: string | null;
  65. isPublic?: boolean;
  66. coverArtPath?: string | null;
  67. coverArtUrl?: string | null;
  68. userId?: string;
  69. createdAt?: string;
  70. updatedAt?: string;
  71. tracks?: DaoLiYuPlaylistTrackEntry[];
  72. trackCount?: number;
  73. _count?: DaoLiYuPlaylistCount;
  74. }
  75. interface DaoLiYuPlaylistCount {
  76. tracks?: number;
  77. }
  78. interface DaoLiYuTrackAlbum {
  79. id?: string;
  80. title?: string;
  81. coverArtPath?: string | null;
  82. coverArtUrl?: string | null;
  83. releaseYear?: number | null;
  84. artists?: string[];
  85. }
  86. interface DaoLiYuArtistInfo {
  87. id?: string;
  88. name?: string;
  89. }
  90. interface DaoLiYuTrackArtist {
  91. id?: string;
  92. trackId?: string;
  93. artistId?: string;
  94. order?: number;
  95. artist?: DaoLiYuArtistInfo;
  96. }
  97. interface DaoLiYuTrackEntry {
  98. id?: string;
  99. title?: string;
  100. normalizedTitle?: string;
  101. trackNumber?: number | null;
  102. discNumber?: number | null;
  103. durationSeconds?: number | null;
  104. year?: number | null;
  105. filePath?: string;
  106. fileSize?: number | null;
  107. fileFormat?: string | null;
  108. detectedContainer?: string | null;
  109. detectedCodec?: string | null;
  110. bitrate?: number | null;
  111. sampleRate?: number | null;
  112. playCount?: number | null;
  113. lastPlayedAt?: string | null;
  114. hash?: string | null;
  115. lyrics?: string | null;
  116. coverArtPath?: string | null;
  117. coverArtUrl?: string | null;
  118. genres?: string[] | null;
  119. albumId?: string | null;
  120. albumArtist?: string | null;
  121. createdAt?: string;
  122. updatedAt?: string;
  123. album?: DaoLiYuTrackAlbum;
  124. artists?: DaoLiYuTrackArtist[];
  125. }
  126. class QueryParam {
  127. key: string;
  128. value: string;
  129. constructor(key: string, value: string) {
  130. this.key = key;
  131. this.value = value;
  132. }
  133. }
  134. export interface DaoLiYuArtist {
  135. id: string;
  136. name: string;
  137. coverArtUrl?: string;
  138. trackCount?: number;
  139. albumCount?: number;
  140. }
  141. export interface DaoLiYuAlbum {
  142. id: string;
  143. title: string;
  144. artist?: string;
  145. year?: number;
  146. coverArtUrl?: string;
  147. }
  148. export interface DaoLiYuTrack {
  149. id: string;
  150. title?: string;
  151. album?: string;
  152. albumId?: string;
  153. artist?: string;
  154. artistId?: string;
  155. durationSeconds?: number;
  156. size?: number;
  157. suffix?: string;
  158. bitRate?: number;
  159. sampleRate?: number;
  160. track?: number;
  161. year?: number;
  162. coverArtUrl?: string;
  163. lyrics?: string;
  164. createdAt?: string;
  165. mimeType?: string;
  166. }
  167. export interface DaoLiYuPlaylist {
  168. id: string;
  169. name?: string;
  170. description?: string | null;
  171. coverArtUrl?: string;
  172. songCount?: number;
  173. }
  174. export interface DaoLiYuPagedResponse<T> {
  175. items: T[];
  176. nextStart: number | null;
  177. }
  178. export class DaoLiYuApi {
  179. private authCache: Map<string, DaoLiYuAuthContext> = new Map();
  180. async getArtistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuArtist>> {
  181. const params = [
  182. new QueryParam('skip', `${start}`),
  183. new QueryParam('take', `${size}`)
  184. ];
  185. const data = await this.get<DaoLiYuPagedData<DaoLiYuArtistEntry>>(account, '/api/library/artists', params);
  186. const items = (data.items ?? [])
  187. .filter(item => item.id && item.name)
  188. .map(item => {
  189. return {
  190. id: item.id as string,
  191. name: item.name as string,
  192. coverArtUrl: item.coverArtUrl ?? undefined,
  193. trackCount: item.trackCount ?? undefined,
  194. albumCount: item.albumCount ?? undefined
  195. } as DaoLiYuArtist;
  196. });
  197. const nextStart = this.resolveNextStart(data, start, items.length);
  198. return { items, nextStart };
  199. }
  200. async getAlbumsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuAlbum>> {
  201. const params = [
  202. new QueryParam('skip', `${start}`),
  203. new QueryParam('take', `${size}`)
  204. ];
  205. const data = await this.get<DaoLiYuPagedData<DaoLiYuAlbumEntry>>(account, '/api/library/albums', params);
  206. const items = (data.items ?? [])
  207. .filter(item => item.id && item.title)
  208. .map(item => {
  209. const artistName = item.albumArtist ?? (item.artists && item.artists.length > 0 ? item.artists[0] : undefined) ?? undefined;
  210. return {
  211. id: item.id as string,
  212. title: item.title as string,
  213. artist: artistName ?? undefined,
  214. year: item.releaseYear ?? undefined,
  215. coverArtUrl: item.coverArtUrl ?? undefined
  216. } as DaoLiYuAlbum;
  217. });
  218. const nextStart = this.resolveNextStart(data, start, items.length);
  219. return { items, nextStart };
  220. }
  221. async getTracksPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuTrack>> {
  222. const params = [
  223. new QueryParam('skip', `${start}`),
  224. new QueryParam('take', `${size}`)
  225. ];
  226. const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
  227. const items = (data.items ?? [])
  228. .filter(item => item.id)
  229. .map(item => this.mapTrack(item));
  230. const nextStart = this.resolveNextStart(data, start, items.length);
  231. return { items, nextStart };
  232. }
  233. async getPlaylistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylist>> {
  234. const raw = await this.getPlaylistsPageRaw(account, start, size);
  235. const items = raw.items.map(item => ({
  236. id: item.id as string,
  237. name: item.name ?? undefined,
  238. description: item.description ?? undefined,
  239. coverArtUrl: item.coverArtUrl ?? undefined,
  240. songCount: item.trackCount ?? item._count?.tracks ?? (item.tracks ? item.tracks.length : undefined)
  241. } as DaoLiYuPlaylist));
  242. return { items, nextStart: raw.nextStart };
  243. }
  244. private async getPlaylistsPageRaw(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylistEntry>> {
  245. const params = [
  246. new QueryParam('skip', `${start}`),
  247. new QueryParam('take', `${size}`)
  248. ];
  249. const data = await this.get<DaoLiYuPagedData<DaoLiYuPlaylistEntry>>(account, '/api/playlists/public', params);
  250. const items = (data.items ?? []).filter(item => item.id);
  251. const nextStart = this.resolveNextStart(data, start, items.length);
  252. return { items, nextStart };
  253. }
  254. async getPlaylistTracks(account: WebDavAccount, playlistId: string): Promise<DaoLiYuTrack[]> {
  255. if (!playlistId) {
  256. return [];
  257. }
  258. const detail = await this.get<DaoLiYuPlaylistEntry>(account, `/api/playlists/${encodeURIComponent(playlistId)}`, []);
  259. if (!detail?.tracks || detail.tracks.length === 0) {
  260. return [];
  261. }
  262. return detail.tracks
  263. .map(track => track.track)
  264. .filter(track => track && track.id)
  265. .map(track => this.mapTrack(track as DaoLiYuTrackEntry));
  266. }
  267. async getTracksByAlbumId(account: WebDavAccount, albumId: string, albumName?: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
  268. if (!albumId && !albumName) {
  269. return [];
  270. }
  271. const matches: DaoLiYuTrack[] = [];
  272. // 用于名称匹配的规范化名称(去除前后空格,转小写)
  273. const normalizedName = albumName?.trim().toLowerCase();
  274. await this.scanTracks(account, limit, track => {
  275. // 优先按 albumId 匹配
  276. if (albumId && track.albumId === albumId) {
  277. matches.push(track);
  278. return true;
  279. }
  280. // 后备:按专辑名称匹配(处理 albumId 为空的情况)
  281. if (normalizedName && track.album) {
  282. const trackAlbumName = track.album.trim().toLowerCase();
  283. if (trackAlbumName === normalizedName) {
  284. matches.push(track);
  285. return true;
  286. }
  287. }
  288. return false;
  289. });
  290. return matches;
  291. }
  292. async getTracksByArtistId(account: WebDavAccount, artistId: string, artistName?: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
  293. if (!artistId && !artistName) {
  294. return [];
  295. }
  296. const matches: DaoLiYuTrack[] = [];
  297. // 用于名称匹配的规范化名称(去除前后空格,转小写)
  298. const normalizedName = artistName?.trim().toLowerCase();
  299. await this.scanTracks(account, limit, track => {
  300. // 优先按 artistId 匹配
  301. if (artistId && track.artistId === artistId) {
  302. matches.push(track);
  303. return true;
  304. }
  305. // 后备:按艺术家名称匹配(处理 artistId 为空的情况)
  306. if (normalizedName && track.artist) {
  307. const trackArtistName = track.artist.trim().toLowerCase();
  308. if (trackArtistName === normalizedName) {
  309. matches.push(track);
  310. return true;
  311. }
  312. }
  313. return false;
  314. });
  315. return matches;
  316. }
  317. async searchTracks(account: WebDavAccount, keyword: string, limit: number = 500): Promise<DaoLiYuTrack[]> {
  318. const rawQuery = keyword.trim();
  319. const query = rawQuery.toLowerCase();
  320. if (rawQuery.length === 0) {
  321. return [];
  322. }
  323. const params = [
  324. new QueryParam('search', rawQuery),
  325. new QueryParam('take', `${limit}`)
  326. ];
  327. const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
  328. const items = (data.items ?? [])
  329. .filter(item => item.id)
  330. .map(item => this.mapTrack(item));
  331. if (items.length > 0) {
  332. return items;
  333. }
  334. const results: DaoLiYuTrack[] = [];
  335. await this.scanTracks(account, limit, track => {
  336. const haystack = [track.title, track.artist, track.album]
  337. .filter(Boolean)
  338. .join(' ')
  339. .toLowerCase();
  340. if (haystack.includes(query)) {
  341. results.push(track);
  342. return true;
  343. }
  344. return false;
  345. });
  346. return results;
  347. }
  348. async buildStreamUrl(account: WebDavAccount, trackId: string): Promise<string> {
  349. if (!trackId) {
  350. throw new Error('DaoLiYu track id is empty');
  351. }
  352. const auth = await this.ensureAuth(account);
  353. const baseUrl = this.buildBaseUrl(account);
  354. const token = encodeURIComponent(auth.token);
  355. return `${baseUrl}/api/tracks/${encodeURIComponent(trackId)}/stream?token=${token}`;
  356. }
  357. buildImageUrl(account: WebDavAccount, path?: string): string | undefined {
  358. if (!path) {
  359. return undefined;
  360. }
  361. const trimmed = path.trim();
  362. if (trimmed.length === 0) {
  363. return undefined;
  364. }
  365. if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
  366. return trimmed;
  367. }
  368. const baseUrl = this.buildBaseUrl(account);
  369. const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
  370. return `${baseUrl}${normalized}`;
  371. }
  372. private mapTrack(item: DaoLiYuTrackEntry): DaoLiYuTrack {
  373. const albumTitle = item.album?.title ?? undefined;
  374. const coverArtUrl = item.coverArtUrl ?? item.album?.coverArtUrl ?? undefined;
  375. const artistId = item.artists?.[0]?.artistId ?? item.artists?.[0]?.artist?.id;
  376. const artistName = item.artists?.[0]?.artist?.name ?? item.albumArtist ?? undefined;
  377. const suffix = item.fileFormat ?? item.detectedContainer ?? undefined;
  378. const mimeType = suffix ? `audio/${suffix}` : undefined;
  379. return {
  380. id: item.id as string,
  381. title: item.title ?? undefined,
  382. album: albumTitle ?? undefined,
  383. albumId: item.albumId ?? item.album?.id ?? undefined,
  384. artist: artistName ?? undefined,
  385. artistId: artistId ?? undefined,
  386. durationSeconds: item.durationSeconds ?? undefined,
  387. size: item.fileSize ?? undefined,
  388. suffix: suffix ?? undefined,
  389. bitRate: item.bitrate ?? undefined,
  390. sampleRate: item.sampleRate ?? undefined,
  391. track: item.trackNumber ?? undefined,
  392. year: item.year ?? item.album?.releaseYear ?? undefined,
  393. coverArtUrl: coverArtUrl ?? undefined,
  394. lyrics: item.lyrics ?? undefined,
  395. createdAt: item.createdAt ?? undefined,
  396. mimeType: mimeType ?? undefined
  397. };
  398. }
  399. private async scanTracks(
  400. account: WebDavAccount,
  401. limit: number,
  402. onTrack: (track: DaoLiYuTrack) => boolean
  403. ): Promise<void> {
  404. let start = 0;
  405. const size = 200;
  406. let matched = 0;
  407. while (true) {
  408. const page = await this.getTracksPage(account, start, size);
  409. if (page.items.length === 0) {
  410. break;
  411. }
  412. for (let i = 0; i < page.items.length; i++) {
  413. const isMatch = onTrack(page.items[i]);
  414. if (isMatch) {
  415. matched++;
  416. if (limit > 0 && matched >= limit) {
  417. return;
  418. }
  419. }
  420. }
  421. if (page.nextStart === null) {
  422. break;
  423. }
  424. start = page.nextStart;
  425. }
  426. }
  427. private async ensureAuth(account: WebDavAccount): Promise<DaoLiYuAuthContext> {
  428. const key = this.buildCacheKey(account);
  429. const cached = this.authCache.get(key);
  430. if (cached?.token) {
  431. return cached;
  432. }
  433. const token = await this.login(account);
  434. const auth: DaoLiYuAuthContext = { token };
  435. this.authCache.set(key, auth);
  436. return auth;
  437. }
  438. private async login(account: WebDavAccount): Promise<string> {
  439. if (!account.account || !account.password) {
  440. throw new Error('DaoLiYu account or password is empty');
  441. }
  442. const httpRequest = http.createHttp();
  443. const url = this.buildApiUrl(account, '/api/auth/login');
  444. const response = await httpRequest.request(url, {
  445. method: http.RequestMethod.POST,
  446. header: {
  447. Accept: 'application/json',
  448. 'Content-Type': 'application/json'
  449. },
  450. extraData: JSON.stringify({
  451. email: account.account,
  452. password: account.password
  453. }),
  454. connectTimeout: 10000,
  455. readTimeout: 15000,
  456. expectDataType: http.HttpDataType.STRING
  457. });
  458. httpRequest.destroy();
  459. if (response.responseCode < 200 || response.responseCode >= 300) {
  460. throw new Error(`DaoLiYu login failed: HTTP ${response.responseCode}`);
  461. }
  462. const text = typeof response.result === 'string' ? response.result : '';
  463. const data = this.safeJsonParse<DaoLiYuLoginResponse>(text);
  464. if (!data?.token) {
  465. throw new Error('DaoLiYu login failed: token missing');
  466. }
  467. void ServerLogUtil.info(TAG, 'DaoLiYu login success');
  468. return data.token as string;
  469. }
  470. private async get<T>(account: WebDavAccount, path: string, params: QueryParam[]): Promise<T> {
  471. const auth = await this.ensureAuth(account);
  472. const url = this.buildApiUrl(account, path, params);
  473. const httpRequest = http.createHttp();
  474. const response = await httpRequest.request(url, {
  475. method: http.RequestMethod.GET,
  476. header: {
  477. Accept: 'application/json',
  478. Authorization: `Bearer ${auth.token}`
  479. },
  480. connectTimeout: 10000,
  481. readTimeout: 15000,
  482. expectDataType: http.HttpDataType.STRING
  483. });
  484. httpRequest.destroy();
  485. if (response.responseCode < 200 || response.responseCode >= 300) {
  486. throw new Error(`DaoLiYu request failed: HTTP ${response.responseCode}`);
  487. }
  488. const text = typeof response.result === 'string' ? response.result : '';
  489. const data = this.safeJsonParse<T>(text);
  490. return data as T;
  491. }
  492. private buildApiUrl(account: WebDavAccount, path: string, params: QueryParam[] = []): string {
  493. const baseUrl = this.buildBaseUrl(account);
  494. const normalizedPath = path.startsWith('/') ? path : `/${path}`;
  495. const query = params
  496. .filter(param => param.key && param.value !== undefined && param.value !== null)
  497. .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
  498. .join('&');
  499. return `${baseUrl}${normalizedPath}${query ? `?${query}` : ''}`;
  500. }
  501. private buildBaseUrl(account: WebDavAccount): string {
  502. const scheme = account.enableHttps ? 'https' : 'http';
  503. const port = account.port || 80;
  504. return `${scheme}://${account.host}:${port}`;
  505. }
  506. private buildCacheKey(account: WebDavAccount): string {
  507. return `${account.id ?? account.host}_${account.account}`;
  508. }
  509. private resolveNextStart<T>(data: DaoLiYuPagedData<T>, start: number, received: number): number | null {
  510. const total = typeof data.total === 'number' ? data.total : undefined;
  511. const skip = typeof data.skip === 'number' ? data.skip : start;
  512. const take = typeof data.take === 'number' ? data.take : received;
  513. const next = skip + take;
  514. if (total !== undefined) {
  515. return next < total ? next : null;
  516. }
  517. return received > 0 ? start + received : null;
  518. }
  519. private safeJsonParse<T>(raw: string): T | null {
  520. if (!raw) {
  521. return null;
  522. }
  523. try {
  524. return JSON.parse(raw) as T;
  525. } catch (error) {
  526. void ServerLogUtil.error(TAG, `DaoLiYu parse JSON failed: ${(error as Error).message}`);
  527. return null;
  528. }
  529. }
  530. }
  531. export const daoLiYuApi = new DaoLiYuApi();