chendeben пре 6 месеци
родитељ
комит
ba365cd127
1 измењених фајлова са 553 додато и 0 уклоњено
  1. 553 0
      entry/src/main/ets/common/network/DaoLiYuApi.ets

+ 553 - 0
entry/src/main/ets/common/network/DaoLiYuApi.ets

@@ -0,0 +1,553 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import { ServerLogUtil } from '../util/ServerLogUtil';
+
+const TAG = 'heanup DaoLiYuApi';
+
+interface DaoLiYuLoginResponse {
+  token?: string;
+  user?: DaoLiYuUser;
+}
+
+interface DaoLiYuUser {
+  id?: string;
+  email?: string;
+  displayName?: string;
+  role?: string;
+  avatarUrl?: string | null;
+  avatarPath?: string | null;
+}
+
+interface DaoLiYuAuthContext {
+  token: string;
+}
+
+interface DaoLiYuPagedData<T> {
+  items?: T[];
+  total?: number;
+  skip?: number;
+  take?: number;
+}
+
+interface DaoLiYuAlbumEntry {
+  id?: string;
+  title?: string;
+  normalizedTitle?: string;
+  releaseYear?: number | null;
+  releaseDate?: string | null;
+  description?: string | null;
+  albumArtist?: string | null;
+  coverArtPath?: string | null;
+  coverArtUrl?: string | null;
+  artists?: string[];
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+interface DaoLiYuArtistEntry {
+  id?: string;
+  name?: string;
+  normalizedName?: string;
+  bio?: string | null;
+  coverArtPath?: string | null;
+  coverArtUrl?: string | null;
+  createdAt?: string;
+  updatedAt?: string;
+  trackCount?: number;
+  albumCount?: number;
+  isFavorite?: boolean;
+}
+
+interface DaoLiYuPlaylistTrackEntry {
+  id?: string;
+  playlistId?: string;
+  trackId?: string;
+  order?: number;
+  addedAt?: string;
+  track?: DaoLiYuTrackEntry;
+}
+
+interface DaoLiYuPlaylistEntry {
+  id?: string;
+  name?: string;
+  description?: string | null;
+  isPublic?: boolean;
+  coverArtPath?: string | null;
+  coverArtUrl?: string | null;
+  userId?: string;
+  createdAt?: string;
+  updatedAt?: string;
+  tracks?: DaoLiYuPlaylistTrackEntry[];
+  trackCount?: number;
+  _count?: DaoLiYuPlaylistCount;
+}
+
+interface DaoLiYuPlaylistCount {
+  tracks?: number;
+}
+
+interface DaoLiYuTrackAlbum {
+  id?: string;
+  title?: string;
+  coverArtPath?: string | null;
+  coverArtUrl?: string | null;
+  releaseYear?: number | null;
+  artists?: string[];
+}
+
+interface DaoLiYuArtistInfo {
+  id?: string;
+  name?: string;
+}
+
+interface DaoLiYuTrackArtist {
+  id?: string;
+  trackId?: string;
+  artistId?: string;
+  order?: number;
+  artist?: DaoLiYuArtistInfo;
+}
+
+interface DaoLiYuTrackEntry {
+  id?: string;
+  title?: string;
+  normalizedTitle?: string;
+  trackNumber?: number | null;
+  discNumber?: number | null;
+  durationSeconds?: number | null;
+  year?: number | null;
+  filePath?: string;
+  fileSize?: number | null;
+  fileFormat?: string | null;
+  detectedContainer?: string | null;
+  detectedCodec?: string | null;
+  bitrate?: number | null;
+  sampleRate?: number | null;
+  playCount?: number | null;
+  lastPlayedAt?: string | null;
+  hash?: string | null;
+  lyrics?: string | null;
+  coverArtPath?: string | null;
+  coverArtUrl?: string | null;
+  genres?: string[] | null;
+  albumId?: string | null;
+  albumArtist?: string | null;
+  createdAt?: string;
+  updatedAt?: string;
+  album?: DaoLiYuTrackAlbum;
+  artists?: DaoLiYuTrackArtist[];
+}
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export interface DaoLiYuArtist {
+  id: string;
+  name: string;
+  coverArtUrl?: string;
+  trackCount?: number;
+  albumCount?: number;
+}
+
+export interface DaoLiYuAlbum {
+  id: string;
+  title: string;
+  artist?: string;
+  year?: number;
+  coverArtUrl?: string;
+}
+
+export interface DaoLiYuTrack {
+  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;
+  coverArtUrl?: string;
+  lyrics?: string;
+  createdAt?: string;
+  mimeType?: string;
+}
+
+export interface DaoLiYuPlaylist {
+  id: string;
+  name?: string;
+  description?: string | null;
+  coverArtUrl?: string;
+  songCount?: number;
+}
+
+export interface DaoLiYuPagedResponse<T> {
+  items: T[];
+  nextStart: number | null;
+}
+
+export class DaoLiYuApi {
+  private authCache: Map<string, DaoLiYuAuthContext> = new Map();
+
+  async getArtistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuArtist>> {
+    const params = [
+      new QueryParam('skip', `${start}`),
+      new QueryParam('take', `${size}`)
+    ];
+    const data = await this.get<DaoLiYuPagedData<DaoLiYuArtistEntry>>(account, '/api/library/artists', params);
+    const items = (data.items ?? [])
+      .filter(item => item.id && item.name)
+      .map(item => {
+        return {
+          id: item.id as string,
+          name: item.name as string,
+          coverArtUrl: item.coverArtUrl ?? undefined,
+          trackCount: item.trackCount ?? undefined,
+          albumCount: item.albumCount ?? undefined
+        } as DaoLiYuArtist;
+      });
+    const nextStart = this.resolveNextStart(data, start, items.length);
+    return { items, nextStart };
+  }
+
+  async getAlbumsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuAlbum>> {
+    const params = [
+      new QueryParam('skip', `${start}`),
+      new QueryParam('take', `${size}`)
+    ];
+    const data = await this.get<DaoLiYuPagedData<DaoLiYuAlbumEntry>>(account, '/api/library/albums', params);
+    const items = (data.items ?? [])
+      .filter(item => item.id && item.title)
+      .map(item => {
+        const artistName = item.albumArtist ?? (item.artists && item.artists.length > 0 ? item.artists[0] : undefined) ?? undefined;
+        return {
+          id: item.id as string,
+          title: item.title as string,
+          artist: artistName ?? undefined,
+          year: item.releaseYear ?? undefined,
+          coverArtUrl: item.coverArtUrl ?? undefined
+        } as DaoLiYuAlbum;
+      });
+    const nextStart = this.resolveNextStart(data, start, items.length);
+    return { items, nextStart };
+  }
+
+  async getTracksPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuTrack>> {
+    const params = [
+      new QueryParam('skip', `${start}`),
+      new QueryParam('take', `${size}`)
+    ];
+    const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
+    const items = (data.items ?? [])
+      .filter(item => item.id)
+      .map(item => this.mapTrack(item));
+    const nextStart = this.resolveNextStart(data, start, items.length);
+    return { items, nextStart };
+  }
+
+  async getPlaylistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylist>> {
+    const raw = await this.getPlaylistsPageRaw(account, start, size);
+    const items = raw.items.map(item => ({
+      id: item.id as string,
+      name: item.name ?? undefined,
+      description: item.description ?? undefined,
+      coverArtUrl: item.coverArtUrl ?? undefined,
+      songCount: item.trackCount ?? item._count?.tracks ?? (item.tracks ? item.tracks.length : undefined)
+    } as DaoLiYuPlaylist));
+    return { items, nextStart: raw.nextStart };
+  }
+
+  private async getPlaylistsPageRaw(account: WebDavAccount, start: number = 0, size: number = 200): Promise<DaoLiYuPagedResponse<DaoLiYuPlaylistEntry>> {
+    const params = [
+      new QueryParam('skip', `${start}`),
+      new QueryParam('take', `${size}`)
+    ];
+    const data = await this.get<DaoLiYuPagedData<DaoLiYuPlaylistEntry>>(account, '/api/playlists/public', params);
+    const items = (data.items ?? []).filter(item => item.id);
+    const nextStart = this.resolveNextStart(data, start, items.length);
+    return { items, nextStart };
+  }
+
+  async getPlaylistTracks(account: WebDavAccount, playlistId: string): Promise<DaoLiYuTrack[]> {
+    if (!playlistId) {
+      return [];
+    }
+    const detail = await this.get<DaoLiYuPlaylistEntry>(account, `/api/playlists/${encodeURIComponent(playlistId)}`, []);
+    if (!detail?.tracks || detail.tracks.length === 0) {
+      return [];
+    }
+    return detail.tracks
+      .map(track => track.track)
+      .filter(track => track && track.id)
+      .map(track => this.mapTrack(track as DaoLiYuTrackEntry));
+  }
+
+  async getTracksByAlbumId(account: WebDavAccount, albumId: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
+    if (!albumId) {
+      return [];
+    }
+    const matches: DaoLiYuTrack[] = [];
+    await this.scanTracks(account, limit, track => {
+      if (track.albumId === albumId) {
+        matches.push(track);
+        return true;
+      }
+      return false;
+    });
+    return matches;
+  }
+
+  async getTracksByArtistId(account: WebDavAccount, artistId: string, limit: number = 0): Promise<DaoLiYuTrack[]> {
+    if (!artistId) {
+      return [];
+    }
+    const matches: DaoLiYuTrack[] = [];
+    await this.scanTracks(account, limit, track => {
+      if (track.artistId === artistId) {
+        matches.push(track);
+        return true;
+      }
+      return false;
+    });
+    return matches;
+  }
+
+  async searchTracks(account: WebDavAccount, keyword: string, limit: number = 500): Promise<DaoLiYuTrack[]> {
+    const rawQuery = keyword.trim();
+    const query = rawQuery.toLowerCase();
+    if (rawQuery.length === 0) {
+      return [];
+    }
+    const params = [
+      new QueryParam('search', rawQuery),
+      new QueryParam('take', `${limit}`)
+    ];
+    const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
+    const items = (data.items ?? [])
+      .filter(item => item.id)
+      .map(item => this.mapTrack(item));
+    if (items.length > 0) {
+      return items;
+    }
+    const results: DaoLiYuTrack[] = [];
+    await this.scanTracks(account, limit, track => {
+      const haystack = [track.title, track.artist, track.album]
+        .filter(Boolean)
+        .join(' ')
+        .toLowerCase();
+      if (haystack.includes(query)) {
+        results.push(track);
+        return true;
+      }
+      return false;
+    });
+    return results;
+  }
+
+  async buildStreamUrl(account: WebDavAccount, trackId: string): Promise<string> {
+    if (!trackId) {
+      throw new Error('DaoLiYu track id is empty');
+    }
+    const auth = await this.ensureAuth(account);
+    const baseUrl = this.buildBaseUrl(account);
+    const token = encodeURIComponent(auth.token);
+    return `${baseUrl}/api/tracks/${encodeURIComponent(trackId)}/stream?token=${token}`;
+  }
+
+  buildImageUrl(account: WebDavAccount, path?: string): string | undefined {
+    if (!path) {
+      return undefined;
+    }
+    const trimmed = path.trim();
+    if (trimmed.length === 0) {
+      return undefined;
+    }
+    if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
+      return trimmed;
+    }
+    const baseUrl = this.buildBaseUrl(account);
+    const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
+    return `${baseUrl}${normalized}`;
+  }
+
+  private mapTrack(item: DaoLiYuTrackEntry): DaoLiYuTrack {
+    const albumTitle = item.album?.title ?? undefined;
+    const coverArtUrl = item.coverArtUrl ?? item.album?.coverArtUrl ?? undefined;
+    const artistId = item.artists?.[0]?.artistId ?? item.artists?.[0]?.artist?.id;
+    const artistName = item.artists?.[0]?.artist?.name ?? item.albumArtist ?? undefined;
+    const suffix = item.fileFormat ?? item.detectedContainer ?? undefined;
+    const mimeType = suffix ? `audio/${suffix}` : undefined;
+    return {
+      id: item.id as string,
+      title: item.title ?? undefined,
+      album: albumTitle ?? undefined,
+      albumId: item.albumId ?? item.album?.id ?? undefined,
+      artist: artistName ?? undefined,
+      artistId: artistId ?? undefined,
+      durationSeconds: item.durationSeconds ?? undefined,
+      size: item.fileSize ?? undefined,
+      suffix: suffix ?? undefined,
+      bitRate: item.bitrate ?? undefined,
+      sampleRate: item.sampleRate ?? undefined,
+      track: item.trackNumber ?? undefined,
+      year: item.year ?? item.album?.releaseYear ?? undefined,
+      coverArtUrl: coverArtUrl ?? undefined,
+      lyrics: item.lyrics ?? undefined,
+      createdAt: item.createdAt ?? undefined,
+      mimeType: mimeType ?? undefined
+    };
+  }
+
+  private async scanTracks(
+    account: WebDavAccount,
+    limit: number,
+    onTrack: (track: DaoLiYuTrack) => boolean
+  ): Promise<void> {
+    let start = 0;
+    const size = 200;
+    let matched = 0;
+    while (true) {
+      const page = await this.getTracksPage(account, start, size);
+      if (page.items.length === 0) {
+        break;
+      }
+      for (let i = 0; i < page.items.length; i++) {
+        const isMatch = onTrack(page.items[i]);
+        if (isMatch) {
+          matched++;
+          if (limit > 0 && matched >= limit) {
+            return;
+          }
+        }
+      }
+      if (page.nextStart === null) {
+        break;
+      }
+      start = page.nextStart;
+    }
+  }
+
+  private async ensureAuth(account: WebDavAccount): Promise<DaoLiYuAuthContext> {
+    const key = this.buildCacheKey(account);
+    const cached = this.authCache.get(key);
+    if (cached?.token) {
+      return cached;
+    }
+    const token = await this.login(account);
+    const auth: DaoLiYuAuthContext = { token };
+    this.authCache.set(key, auth);
+    return auth;
+  }
+
+  private async login(account: WebDavAccount): Promise<string> {
+    if (!account.account || !account.password) {
+      throw new Error('DaoLiYu account or password is empty');
+    }
+    const httpRequest = http.createHttp();
+    const url = this.buildApiUrl(account, '/api/auth/login');
+    const response = await httpRequest.request(url, {
+      method: http.RequestMethod.POST,
+      header: {
+        Accept: 'application/json',
+        'Content-Type': 'application/json'
+      },
+      extraData: JSON.stringify({
+        email: account.account,
+        password: account.password
+      }),
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    httpRequest.destroy();
+    if (response.responseCode < 200 || response.responseCode >= 300) {
+      throw new Error(`DaoLiYu login failed: HTTP ${response.responseCode}`);
+    }
+    const text = typeof response.result === 'string' ? response.result : '';
+    const data = this.safeJsonParse<DaoLiYuLoginResponse>(text);
+    if (!data?.token) {
+      throw new Error('DaoLiYu login failed: token missing');
+    }
+    void ServerLogUtil.info(TAG, 'DaoLiYu login success');
+    return data.token as string;
+  }
+
+  private async get<T>(account: WebDavAccount, path: string, params: QueryParam[]): Promise<T> {
+    const auth = await this.ensureAuth(account);
+    const url = this.buildApiUrl(account, path, params);
+    const httpRequest = http.createHttp();
+    const response = await httpRequest.request(url, {
+      method: http.RequestMethod.GET,
+      header: {
+        Accept: 'application/json',
+        Authorization: `Bearer ${auth.token}`
+      },
+      connectTimeout: 10000,
+      readTimeout: 15000,
+      expectDataType: http.HttpDataType.STRING
+    });
+    httpRequest.destroy();
+    if (response.responseCode < 200 || response.responseCode >= 300) {
+      throw new Error(`DaoLiYu request failed: HTTP ${response.responseCode}`);
+    }
+    const text = typeof response.result === 'string' ? response.result : '';
+    const data = this.safeJsonParse<T>(text);
+    return data as T;
+  }
+
+  private buildApiUrl(account: WebDavAccount, path: string, params: QueryParam[] = []): string {
+    const baseUrl = this.buildBaseUrl(account);
+    const normalizedPath = path.startsWith('/') ? path : `/${path}`;
+    const query = params
+      .filter(param => param.key && param.value !== undefined && param.value !== null)
+      .map(param => `${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`)
+      .join('&');
+    return `${baseUrl}${normalizedPath}${query ? `?${query}` : ''}`;
+  }
+
+  private buildBaseUrl(account: WebDavAccount): string {
+    const scheme = account.enableHttps ? 'https' : 'http';
+    const port = account.port || 80;
+    return `${scheme}://${account.host}:${port}`;
+  }
+
+  private buildCacheKey(account: WebDavAccount): string {
+    return `${account.id ?? account.host}_${account.account}`;
+  }
+
+  private resolveNextStart<T>(data: DaoLiYuPagedData<T>, start: number, received: number): number | null {
+    const total = typeof data.total === 'number' ? data.total : undefined;
+    const skip = typeof data.skip === 'number' ? data.skip : start;
+    const take = typeof data.take === 'number' ? data.take : received;
+    const next = skip + take;
+    if (total !== undefined) {
+      return next < total ? next : null;
+    }
+    return received > 0 ? start + received : null;
+  }
+
+  private safeJsonParse<T>(raw: string): T | null {
+    if (!raw) {
+      return null;
+    }
+    try {
+      return JSON.parse(raw) as T;
+    } catch (error) {
+      void ServerLogUtil.error(TAG, `DaoLiYu parse JSON failed: ${(error as Error).message}`);
+      return null;
+    }
+  }
+}
+
+export const daoLiYuApi = new DaoLiYuApi();