DaoLiYuApi.ets 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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. total?: number;
  178. }
  179. function buildDaoLiYuCoverDebugValue(value?: string): string {
  180. if (!value) {
  181. return '';
  182. }
  183. return value.length > 72 ? `...${value.substring(value.length - 72)}` : value;
  184. }
  185. function buildDaoLiYuTrackDebugItem(track?: DaoLiYuTrack): string {
  186. if (!track) {
  187. return 'unknown';
  188. }
  189. return `${track.title ?? track.id}|album=${track.album ?? ''}|cover=${buildDaoLiYuCoverDebugValue(track.coverArtUrl)}`;
  190. }
  191. function buildDaoLiYuTrackDebugLog(items: DaoLiYuTrack[], limit: number = 3): string {
  192. if (!items || items.length === 0) {
  193. return '[]';
  194. }
  195. const parts: string[] = [];
  196. const maxCount = Math.min(limit, items.length);
  197. for (let i = 0; i < maxCount; i++) {
  198. parts.push(buildDaoLiYuTrackDebugItem(items[i]));
  199. }
  200. return `[${parts.join('; ')}](${items.length})`;
  201. }
  202. export function normalizeDaoLiYuTrackCoverArtUrl(account: WebDavAccount, coverArtUrl?: string): string | undefined {
  203. const normalized = daoLiYuApi.buildImageUrl(account, coverArtUrl);
  204. if (coverArtUrl || normalized) {
  205. void ServerLogUtil.info(TAG,
  206. `[cover-debug] normalizeTrackCover account=${account.name || account.host}, raw=${buildDaoLiYuCoverDebugValue(coverArtUrl)}, ` +
  207. `normalized=${buildDaoLiYuCoverDebugValue(normalized)}`);
  208. }
  209. return normalized;
  210. }
  211. export class DaoLiYuApi {
  212. private authCache: Map<string, DaoLiYuAuthContext> = new Map();
  213. async getArtistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuArtist>> {
  214. const params = [
  215. new QueryParam('skip', `${start}`),
  216. new QueryParam('take', `${size}`)
  217. ];
  218. const data = await this.get<DaoLiYuPagedData<DaoLiYuArtistEntry>>(account, '/api/library/artists', params);
  219. const items = (data.items ?? [])
  220. .filter(item => item.id && item.name)
  221. .map(item => {
  222. return {
  223. id: item.id as string,
  224. name: item.name as string,
  225. coverArtUrl: item.coverArtUrl ?? undefined,
  226. trackCount: item.trackCount ?? undefined,
  227. albumCount: item.albumCount ?? undefined
  228. } as DaoLiYuArtist;
  229. });
  230. const nextStart = this.resolveNextStart(data, start, items.length);
  231. return { items, nextStart };
  232. }
  233. async getAlbumsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuAlbum>> {
  234. const params = [
  235. new QueryParam('skip', `${start}`),
  236. new QueryParam('take', `${size}`)
  237. ];
  238. const data = await this.get<DaoLiYuPagedData<DaoLiYuAlbumEntry>>(account, '/api/library/albums', params);
  239. const items = (data.items ?? [])
  240. .filter(item => item.id && item.title)
  241. .map(item => {
  242. const artistName = item.albumArtist ?? (item.artists && item.artists.length > 0 ? item.artists[0] : undefined) ?? undefined;
  243. return {
  244. id: item.id as string,
  245. title: item.title as string,
  246. artist: artistName ?? undefined,
  247. year: item.releaseYear ?? undefined,
  248. coverArtUrl: item.coverArtUrl ?? undefined
  249. } as DaoLiYuAlbum;
  250. });
  251. const nextStart = this.resolveNextStart(data, start, items.length);
  252. return { items, nextStart };
  253. }
  254. async getTracksPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuTrack>> {
  255. const params = [
  256. new QueryParam('skip', `${start}`),
  257. new QueryParam('take', `${size}`)
  258. ];
  259. const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
  260. const items = (data.items ?? [])
  261. .filter(item => item.id)
  262. .map(item => {
  263. const track = this.mapTrack(item);
  264. track.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, track.coverArtUrl);
  265. return track;
  266. });
  267. const nextStart = this.resolveNextStart(data, start, items.length);
  268. const total = typeof data.total === 'number' ? data.total : undefined;
  269. const coverCount = items.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length;
  270. void ServerLogUtil.info(TAG,
  271. `[cover-debug] getTracksPage account=${account.name || account.host}, start=${start}, size=${size}, ` +
  272. `items=${items.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(items)}`);
  273. return { items, nextStart, total };
  274. }
  275. async getPlaylistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylist>> {
  276. const raw = await this.getPlaylistsPageRaw(account, start, size);
  277. const items = raw.items.map(item => ({
  278. id: item.id as string,
  279. name: item.name ?? undefined,
  280. description: item.description ?? undefined,
  281. coverArtUrl: item.coverArtUrl ?? undefined,
  282. songCount: item.trackCount ?? item._count?.tracks ?? (item.tracks ? item.tracks.length : undefined)
  283. } as DaoLiYuPlaylist));
  284. return { items, nextStart: raw.nextStart };
  285. }
  286. private async getPlaylistsPageRaw(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylistEntry>> {
  287. const params = [
  288. new QueryParam('skip', `${start}`),
  289. new QueryParam('take', `${size}`)
  290. ];
  291. const data = await this.get<DaoLiYuPagedData<DaoLiYuPlaylistEntry>>(account, '/api/playlists/public', params);
  292. const items = (data.items ?? []).filter(item => item.id);
  293. const nextStart = this.resolveNextStart(data, start, items.length);
  294. return { items, nextStart };
  295. }
  296. async getPlaylistTracks(account: WebDavAccount, playlistId: string): Promise<DaoLiYuTrack[]> {
  297. if (!playlistId) {
  298. return [];
  299. }
  300. const detail = await this.get<DaoLiYuPlaylistEntry>(account, `/api/playlists/${encodeURIComponent(playlistId)}`, []);
  301. if (!detail?.tracks || detail.tracks.length === 0) {
  302. return [];
  303. }
  304. const tracks = detail.tracks
  305. .map(track => track.track)
  306. .filter(track => track && track.id)
  307. .map(track => {
  308. const mappedTrack = this.mapTrack(track as DaoLiYuTrackEntry);
  309. mappedTrack.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, mappedTrack.coverArtUrl);
  310. return mappedTrack;
  311. });
  312. const coverCount = tracks.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length;
  313. void ServerLogUtil.info(TAG,
  314. `[cover-debug] getPlaylistTracks account=${account.name || account.host}, playlistId=${playlistId}, ` +
  315. `items=${tracks.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(tracks)}`);
  316. return tracks;
  317. }
  318. async getTracksByAlbumId(account: WebDavAccount, albumId: string, albumName?: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
  319. if (!albumId && !albumName) {
  320. return [];
  321. }
  322. const matches: DaoLiYuTrack[] = [];
  323. // 用于名称匹配的规范化名称(去除前后空格,转小写)
  324. const normalizedName = albumName?.trim().toLowerCase();
  325. await this.scanTracks(account, limit, track => {
  326. // 优先按 albumId 匹配
  327. if (albumId && track.albumId === albumId) {
  328. matches.push(track);
  329. return true;
  330. }
  331. // 后备:按专辑名称匹配(处理 albumId 为空的情况)
  332. if (normalizedName && track.album) {
  333. const trackAlbumName = track.album.trim().toLowerCase();
  334. if (trackAlbumName === normalizedName) {
  335. matches.push(track);
  336. return true;
  337. }
  338. }
  339. return false;
  340. });
  341. return matches;
  342. }
  343. async getTracksByArtistId(account: WebDavAccount, artistId: string, artistName?: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
  344. if (!artistId && !artistName) {
  345. return [];
  346. }
  347. const matches: DaoLiYuTrack[] = [];
  348. // 用于名称匹配的规范化名称(去除前后空格,转小写)
  349. const normalizedName = artistName?.trim().toLowerCase();
  350. await this.scanTracks(account, limit, track => {
  351. // 优先按 artistId 匹配
  352. if (artistId && track.artistId === artistId) {
  353. matches.push(track);
  354. return true;
  355. }
  356. // 后备:按艺术家名称匹配(处理 artistId 为空的情况)
  357. if (normalizedName && track.artist) {
  358. const trackArtistName = track.artist.trim().toLowerCase();
  359. if (trackArtistName === normalizedName) {
  360. matches.push(track);
  361. return true;
  362. }
  363. }
  364. return false;
  365. });
  366. return matches;
  367. }
  368. async searchTracks(account: WebDavAccount, keyword: string, limit: number = 500): Promise<DaoLiYuTrack[]> {
  369. const rawQuery = keyword.trim();
  370. const query = rawQuery.toLowerCase();
  371. if (rawQuery.length === 0) {
  372. return [];
  373. }
  374. const params = [
  375. new QueryParam('search', rawQuery),
  376. new QueryParam('take', `${limit}`)
  377. ];
  378. const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
  379. const items = (data.items ?? [])
  380. .filter(item => item.id)
  381. .map(item => this.mapTrack(item));
  382. if (items.length > 0) {
  383. return items;
  384. }
  385. const results: DaoLiYuTrack[] = [];
  386. await this.scanTracks(account, limit, track => {
  387. const haystack = [track.title, track.artist, track.album]
  388. .filter(Boolean)
  389. .join(' ')
  390. .toLowerCase();
  391. if (haystack.includes(query)) {
  392. results.push(track);
  393. return true;
  394. }
  395. return false;
  396. });
  397. return results;
  398. }
  399. async buildStreamUrl(account: WebDavAccount, trackId: string): Promise<string> {
  400. if (!trackId) {
  401. throw new Error('DaoLiYu track id is empty');
  402. }
  403. const auth = await this.ensureAuth(account);
  404. const baseUrl = this.buildBaseUrl(account);
  405. const token = encodeURIComponent(auth.token);
  406. // original=1 返回原始音频流,更有利于播放器按 Range 进行定位。
  407. return `${baseUrl}/api/tracks/${encodeURIComponent(trackId)}/stream?token=${token}&original=1`;
  408. }
  409. buildImageUrl(account: WebDavAccount, path?: string): string | undefined {
  410. if (!path) {
  411. return undefined;
  412. }
  413. const trimmed = path.trim();
  414. if (trimmed.length === 0) {
  415. return undefined;
  416. }
  417. if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
  418. return trimmed;
  419. }
  420. const baseUrl = this.buildBaseUrl(account);
  421. const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
  422. return `${baseUrl}${normalized}`;
  423. }
  424. private mapTrack(item: DaoLiYuTrackEntry): DaoLiYuTrack {
  425. const albumTitle = item.album?.title ?? undefined;
  426. const coverArtUrl = item.coverArtUrl ?? item.album?.coverArtUrl ?? undefined;
  427. const artistId = item.artists?.[0]?.artistId ?? item.artists?.[0]?.artist?.id;
  428. const artistName = item.artists?.[0]?.artist?.name ?? item.albumArtist ?? undefined;
  429. const suffix = item.fileFormat ?? item.detectedContainer ?? undefined;
  430. const mimeType = suffix ? `audio/${suffix}` : undefined;
  431. return {
  432. id: item.id as string,
  433. title: item.title ?? undefined,
  434. album: albumTitle ?? undefined,
  435. albumId: item.albumId ?? item.album?.id ?? undefined,
  436. artist: artistName ?? undefined,
  437. artistId: artistId ?? undefined,
  438. durationSeconds: item.durationSeconds ?? undefined,
  439. size: item.fileSize ?? undefined,
  440. suffix: suffix ?? undefined,
  441. bitRate: item.bitrate ?? undefined,
  442. sampleRate: item.sampleRate ?? undefined,
  443. track: item.trackNumber ?? undefined,
  444. year: item.year ?? item.album?.releaseYear ?? undefined,
  445. coverArtUrl: coverArtUrl ?? undefined,
  446. lyrics: item.lyrics ?? undefined,
  447. createdAt: item.createdAt ?? undefined,
  448. mimeType: mimeType ?? undefined
  449. };
  450. }
  451. private async scanTracks(
  452. account: WebDavAccount,
  453. limit: number,
  454. onTrack: (track: DaoLiYuTrack) => boolean
  455. ): Promise<void> {
  456. let start = 0;
  457. const size = 200;
  458. let matched = 0;
  459. while (true) {
  460. const page = await this.getTracksPage(account, start, size);
  461. if (page.items.length === 0) {
  462. break;
  463. }
  464. for (let i = 0; i < page.items.length; i++) {
  465. const isMatch = onTrack(page.items[i]);
  466. if (isMatch) {
  467. matched++;
  468. if (limit > 0 && matched >= limit) {
  469. return;
  470. }
  471. }
  472. }
  473. if (page.nextStart === null) {
  474. break;
  475. }
  476. start = page.nextStart;
  477. }
  478. }
  479. private async ensureAuth(account: WebDavAccount): Promise<DaoLiYuAuthContext> {
  480. const key = this.buildCacheKey(account);
  481. const cached = this.authCache.get(key);
  482. if (cached?.token) {
  483. return cached;
  484. }
  485. const token = await this.login(account);
  486. const auth: DaoLiYuAuthContext = { token };
  487. this.authCache.set(key, auth);
  488. return auth;
  489. }
  490. private async login(account: WebDavAccount): Promise<string> {
  491. if (!account.account || !account.password) {
  492. throw new Error('DaoLiYu account or password is empty');
  493. }
  494. const httpRequest = http.createHttp();
  495. const url = this.buildApiUrl(account, '/api/auth/login');
  496. const response = await httpRequest.request(url, {
  497. method: http.RequestMethod.POST,
  498. header: {
  499. Accept: 'application/json',
  500. 'Content-Type': 'application/json'
  501. },
  502. extraData: JSON.stringify({
  503. email: account.account,
  504. password: account.password
  505. }),
  506. connectTimeout: 10000,
  507. readTimeout: 15000,
  508. expectDataType: http.HttpDataType.STRING
  509. });
  510. httpRequest.destroy();
  511. if (response.responseCode < 200 || response.responseCode >= 300) {
  512. throw new Error(`DaoLiYu login failed: HTTP ${response.responseCode}`);
  513. }
  514. const text = typeof response.result === 'string' ? response.result : '';
  515. const data = this.safeJsonParse<DaoLiYuLoginResponse>(text);
  516. if (!data?.token) {
  517. throw new Error('DaoLiYu login failed: token missing');
  518. }
  519. void ServerLogUtil.info(TAG, 'DaoLiYu login success');
  520. return data.token as string;
  521. }
  522. private async get<T>(account: WebDavAccount, path: string, params: QueryParam[]): Promise<T> {
  523. const auth = await this.ensureAuth(account);
  524. const url = this.buildApiUrl(account, path, params);
  525. const httpRequest = http.createHttp();
  526. const response = await httpRequest.request(url, {
  527. method: http.RequestMethod.GET,
  528. header: {
  529. Accept: 'application/json',
  530. Authorization: `Bearer ${auth.token}`
  531. },
  532. connectTimeout: 10000,
  533. readTimeout: 15000,
  534. expectDataType: http.HttpDataType.STRING
  535. });
  536. httpRequest.destroy();
  537. if (response.responseCode < 200 || response.responseCode >= 300) {
  538. throw new Error(`DaoLiYu request failed: HTTP ${response.responseCode}`);
  539. }
  540. const text = typeof response.result === 'string' ? response.result : '';
  541. const data = this.safeJsonParse<T>(text);
  542. return data as T;
  543. }
  544. private buildApiUrl(account: WebDavAccount, path: string, params: QueryParam[] = []): string {
  545. const baseUrl = this.buildBaseUrl(account);
  546. const normalizedPath = path.startsWith('/') ? path : `/${path}`;
  547. const query = params
  548. .filter(param => param.key && param.value !== undefined && param.value !== null)
  549. .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
  550. .join('&');
  551. return `${baseUrl}${normalizedPath}${query ? `?${query}` : ''}`;
  552. }
  553. private buildBaseUrl(account: WebDavAccount): string {
  554. const scheme = account.enableHttps ? 'https' : 'http';
  555. const port = account.port || 80;
  556. return `${scheme}://${account.host}:${port}`;
  557. }
  558. private buildCacheKey(account: WebDavAccount): string {
  559. return `${account.id ?? account.host}_${account.account}`;
  560. }
  561. private resolveNextStart<T>(data: DaoLiYuPagedData<T>, start: number, received: number): number | null {
  562. const total = typeof data.total === 'number' ? data.total : undefined;
  563. const skip = typeof data.skip === 'number' ? data.skip : start;
  564. const take = typeof data.take === 'number' ? data.take : received;
  565. const next = skip + take;
  566. if (total !== undefined) {
  567. return next < total ? next : null;
  568. }
  569. return received > 0 ? start + received : null;
  570. }
  571. private safeJsonParse<T>(raw: string): T | null {
  572. if (!raw) {
  573. return null;
  574. }
  575. try {
  576. return JSON.parse(raw) as T;
  577. } catch (error) {
  578. void ServerLogUtil.error(TAG, `DaoLiYu parse JSON failed: ${(error as Error).message}`);
  579. return null;
  580. }
  581. }
  582. }
  583. export const daoLiYuApi = new DaoLiYuApi();