| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631 |
- 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;
- 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<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 => {
- 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<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 [];
- }
- 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<DaoLiYuTrack[]> {
- 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<DaoLiYuTrack[]> {
- 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<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);
- // 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<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();
|