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 { 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 { items: T[]; nextStart: number | null; total?: number; } function buildDaoLiYuCoverDebugValue(value?: string): string { if (!value) { return ''; } return value.length > 72 ? `...${value.substring(value.length - 72)}` : value; } function buildDaoLiYuTrackDebugItem(track?: DaoLiYuTrack): string { if (!track) { return 'unknown'; } return `${track.title ?? track.id}|album=${track.album ?? ''}|cover=${buildDaoLiYuCoverDebugValue(track.coverArtUrl)}`; } function buildDaoLiYuTrackDebugLog(items: DaoLiYuTrack[], limit: number = 3): string { if (!items || items.length === 0) { return '[]'; } const parts: string[] = []; const maxCount = Math.min(limit, items.length); for (let i = 0; i < maxCount; i++) { parts.push(buildDaoLiYuTrackDebugItem(items[i])); } return `[${parts.join('; ')}](${items.length})`; } export function normalizeDaoLiYuTrackCoverArtUrl(account: WebDavAccount, coverArtUrl?: string): string | undefined { const normalized = daoLiYuApi.buildImageUrl(account, coverArtUrl); if (coverArtUrl || normalized) { void ServerLogUtil.info(TAG, `[cover-debug] normalizeTrackCover account=${account.name || account.host}, raw=${buildDaoLiYuCoverDebugValue(coverArtUrl)}, ` + `normalized=${buildDaoLiYuCoverDebugValue(normalized)}`); } return normalized; } export class DaoLiYuApi { private authCache: Map = new Map(); async getArtistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise> { const params = [ new QueryParam('skip', `${start}`), new QueryParam('take', `${size}`) ]; const data = await this.get>(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> { const params = [ new QueryParam('skip', `${start}`), new QueryParam('take', `${size}`) ]; const data = await this.get>(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> { const params = [ new QueryParam('skip', `${start}`), new QueryParam('take', `${size}`) ]; const data = await this.get>(account, '/api/tracks', params); const items = (data.items ?? []) .filter(item => item.id) .map(item => { const track = this.mapTrack(item); track.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, track.coverArtUrl); return track; }); const nextStart = this.resolveNextStart(data, start, items.length); const total = typeof data.total === 'number' ? data.total : undefined; const coverCount = items.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length; void ServerLogUtil.info(TAG, `[cover-debug] getTracksPage account=${account.name || account.host}, start=${start}, size=${size}, ` + `items=${items.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(items)}`); return { items, nextStart, total }; } async getPlaylistsPage(account: WebDavAccount, start: number = 0, size: number = 200): Promise> { 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> { const params = [ new QueryParam('skip', `${start}`), new QueryParam('take', `${size}`) ]; const data = await this.get>(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 { if (!playlistId) { return []; } const detail = await this.get(account, `/api/playlists/${encodeURIComponent(playlistId)}`, []); if (!detail?.tracks || detail.tracks.length === 0) { return []; } const tracks = detail.tracks .map(track => track.track) .filter(track => track && track.id) .map(track => { const mappedTrack = this.mapTrack(track as DaoLiYuTrackEntry); mappedTrack.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, mappedTrack.coverArtUrl); return mappedTrack; }); const coverCount = tracks.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length; void ServerLogUtil.info(TAG, `[cover-debug] getPlaylistTracks account=${account.name || account.host}, playlistId=${playlistId}, ` + `items=${tracks.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(tracks)}`); return tracks; } async getTracksByAlbumId(account: WebDavAccount, albumId: string, albumName?: string, limit: number = 0): Promise { if (!albumId && !albumName) { return []; } const matches: DaoLiYuTrack[] = []; // 用于名称匹配的规范化名称(去除前后空格,转小写) const normalizedName = albumName?.trim().toLowerCase(); await this.scanTracks(account, limit, track => { // 优先按 albumId 匹配 if (albumId && track.albumId === albumId) { matches.push(track); return true; } // 后备:按专辑名称匹配(处理 albumId 为空的情况) if (normalizedName && track.album) { const trackAlbumName = track.album.trim().toLowerCase(); if (trackAlbumName === normalizedName) { matches.push(track); return true; } } return false; }); return matches; } async getTracksByArtistId(account: WebDavAccount, artistId: string, artistName?: string, limit: number = 0): Promise { if (!artistId && !artistName) { return []; } const matches: DaoLiYuTrack[] = []; // 用于名称匹配的规范化名称(去除前后空格,转小写) const normalizedName = artistName?.trim().toLowerCase(); await this.scanTracks(account, limit, track => { // 优先按 artistId 匹配 if (artistId && track.artistId === artistId) { matches.push(track); return true; } // 后备:按艺术家名称匹配(处理 artistId 为空的情况) if (normalizedName && track.artist) { const trackArtistName = track.artist.trim().toLowerCase(); if (trackArtistName === normalizedName) { matches.push(track); return true; } } return false; }); return matches; } async searchTracks(account: WebDavAccount, keyword: string, limit: number = 500): Promise { 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>(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 { 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); // original=1 返回原始音频流,更有利于播放器按 Range 进行定位。 return `${baseUrl}/api/tracks/${encodeURIComponent(trackId)}/stream?token=${token}&original=1`; } 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 { 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 { 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 { 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(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(account: WebDavAccount, path: string, params: QueryParam[]): Promise { 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(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(data: DaoLiYuPagedData, 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(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();