|
@@ -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();
|