import { http } from '@kit.NetworkKit'; import { WebDavAccount } from '../../viewmodel/WebDavAccount'; import { ServerLogUtil } from '../util/ServerLogUtil'; import { navidromeApi } from './NavidromeApi'; const TAG = 'heanup NavidromeRestApi'; interface NavidromeLoginResponse { id?: string; token?: string; } interface NavidromeLoginBody { username: string; password: string; } interface NavidromeAuthContext { token: string; clientId: string; } class QueryParam { key: string; value: string; constructor(key: string, value: string) { this.key = key; this.value = value; } } export interface NavidromeRestSong { id: string; title?: string; album?: string; albumId?: string; artist?: string; artistId?: string; duration?: number; bitRate?: number; suffix?: string; size?: number; createdAt?: string; genre?: string; track?: number; year?: number; contentType?: string; coverArt?: string; coverArtId?: string; coverArtPath?: string; embedArtPath?: string; lyrics?: string; // JSON格式的歌词,需要通过 convertJsonLyricsToLrc 转换为LRC格式 } export interface NavidromeRestArtist { id: string; name?: string; albumCount?: number; songCount?: number; playCount?: number; mediumImageUrl?: string; largeImageUrl?: string; coverArt?: string; coverArtId?: string; coverArtPath?: string; coverUrl?: string; } export interface NavidromeRestAlbum { id: string; name?: string; artist?: string; artistId?: string; songCount?: number; duration?: number; minYear?: number; maxYear?: number; createdAt?: string; embedArtPath?: string; coverArt?: string; coverArtId?: string; coverArtPath?: string; coverUrl?: string; } export interface NavidromeRestPlaylist { id: string; name?: string; comment?: string; duration?: number; size?: number; songCount?: number; ownerName?: string; ownerId?: string; public?: boolean; path?: string; sync?: boolean; createdAt?: string; updatedAt?: string; rules?: object; evaluatedAt?: string; } // 歌单API返回的歌曲数据,包含mediaFileId字段 interface NavidromePlaylistSong extends NavidromeRestSong { mediaFileId?: string; } export interface NavidromePagedResponse { data: T[]; nextStart: number | null; } export class NavidromeRestApi { private authCache: Map = new Map(); private readonly PAGE_SIZE: number = 500; async fetchSongPage(account: WebDavAccount, start: number): Promise> { return this.fetchPage(account, '/api/song', start, 'createdAt', 'DESC'); } async fetchArtistPage(account: WebDavAccount, start: number): Promise> { return this.fetchPage(account, '/api/artist', start, 'name', 'ASC','albumartist'); } async fetchAlbumPage(account: WebDavAccount, start: number): Promise> { return this.fetchPage(account, '/api/album', start, 'name', 'ASC'); } async fetchPlaylistPage(account: WebDavAccount, start: number): Promise> { return this.fetchPage(account, '/api/playlist', start, 'name', 'ASC'); } async fetchSongsByArtist(account: WebDavAccount, artistId: string, label?: string): Promise { // 这里判断fetchSongsWithFilter如果返回空数据,则用搜索的接口去搜索艺术家 label就是艺术家名称 const songs = await this.fetchSongsWithFilter(account, [new QueryParam('artist_id', artistId)]); // 如果通过 artist_id 查询结果为空,且有艺术家名称,则使用搜索接口 if ((!songs || songs.length === 0) && label && label.trim().length > 0) { void ServerLogUtil.warn(TAG, `通过 artist_id 查询为空,尝试搜索艺术家: "${label}"`); try { const searchResults = await navidromeApi.searchSongs(account, label.trim(), 500); // 将 NavidromeSong 转换为 NavidromeRestSong if (searchResults && searchResults.length > 0) { void ServerLogUtil.info(TAG, `搜索接口返回 ${searchResults.length} 首歌曲`); return this.convertToRestSongs(searchResults); } } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `搜索艺术家歌曲失败: ${err.message}`); } } return songs ?? []; } async fetchSongsByAlbum(account: WebDavAccount, albumId: string, label?: string): Promise { // 这里判断fetchSongsWithFilter如果返回空数据,则用搜索的接口去搜索专辑 label就是专辑名称 const songs = await this.fetchSongsWithFilter(account, [new QueryParam('album_id', albumId)]); // 如果通过 album_id 查询结果为空,且有专辑名称,则使用搜索接口 if ((!songs || songs.length === 0) && label && label.trim().length > 0) { void ServerLogUtil.warn(TAG, `通过 album_id 查询为空,尝试搜索专辑: "${label}"`); try { const searchResults = await navidromeApi.searchSongs(account, label.trim(), 500); // 将 NavidromeSong 转换为 NavidromeRestSong if (searchResults && searchResults.length > 0) { void ServerLogUtil.info(TAG, `搜索接口返回 ${searchResults.length} 首歌曲`); return this.convertToRestSongs(searchResults); } } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `搜索专辑歌曲失败: ${err.message}`); } } return songs ?? []; } async fetchSongsByPlaylist(account: WebDavAccount, playlistId: string): Promise { const results: NavidromeRestSong[] = []; let start = 0; while (true) { const end = start + this.PAGE_SIZE; const params = [ new QueryParam('_start', `${start}`), new QueryParam('_end', `${end}`), new QueryParam('_sort', 'createdAt'), new QueryParam('_order', 'DESC') ]; const path = `/api/playlist/${playlistId}/tracks`; // 获取原始响应数据,使用NavidromePlaylistSong类型(包含mediaFileId字段) const chunk = await this.get(account, path, params); if (!chunk || chunk.length === 0) { break; } // 处理歌单API返回的数据,将mediaFileId映射到id字段 const processed: NavidromeRestSong[] = []; for (let i = 0; i < chunk.length; i++) { const item = chunk[i]; // 如果存在mediaFileId,使用它作为id;否则使用原id const songId = item.mediaFileId && item.mediaFileId.length > 0 ? item.mediaFileId : item.id; if (item.mediaFileId && item.mediaFileId.length > 0) { void ServerLogUtil.debug('NavidromePlaylist', `歌单歌曲使用mediaFileId作为id: ${item.mediaFileId}`); } const processedItem: NavidromeRestSong = { id: songId, title: item.title, album: item.album, albumId: item.albumId, artist: item.artist, artistId: item.artistId, duration: item.duration, bitRate: item.bitRate, suffix: item.suffix, size: item.size, createdAt: item.createdAt, genre: item.genre, track: item.track, year: item.year, contentType: item.contentType, coverArt: item.coverArt, coverArtId: item.coverArtId, coverArtPath: item.coverArtPath, embedArtPath: item.embedArtPath, lyrics: item.lyrics, }; processed.push(processedItem); } results.push(...processed); if (processed.length < this.PAGE_SIZE) { break; } start = end; } return results; } // 将 NavidromeSong 转换为 NavidromeRestSong private convertToRestSongs(songs: import('./NavidromeApi').NavidromeSong[]): NavidromeRestSong[] { return songs.map((song): NavidromeRestSong => ({ id: song.id, title: song.title, artist: song.artist, artistId: song.artistId, album: song.album, albumId: song.albumId, duration: song.duration, bitRate: song.bitRate, suffix: song.suffix, size: song.size, createdAt: song.created, genre: song.genre, track: song.track, year: song.year, contentType: song.contentType, coverArt: song.coverArt, coverArtId: song.coverArt, coverArtPath: undefined, embedArtPath: undefined, lyrics: song.lyrics, })); } private async fetchPage(account: WebDavAccount, path: string, start: number, sortField: string, order: 'ASC' | 'DESC',role?:string): Promise> { const end = start + this.PAGE_SIZE; const params: Array = [ new QueryParam('_start', `${start}`), new QueryParam('_end', `${end}`), new QueryParam('_sort', sortField), new QueryParam('_order', order) ]; if(role){ params.push(new QueryParam('_role', role)); } void ServerLogUtil.debug(TAG, `${path} 分页请求: start=${start}, end=${end}`); const chunk = await this.get(account, path, params); const nextStart = chunk && chunk.length === this.PAGE_SIZE ? end : null; return { data: chunk ?? [], nextStart }; } private async fetchSongsWithFilter(account: WebDavAccount, extraParams: QueryParam[]): Promise { const results: NavidromeRestSong[] = []; let start = 0; while (true) { const end = start + this.PAGE_SIZE; const params = [ new QueryParam('_start', `${start}`), new QueryParam('_end', `${end}`), new QueryParam('_sort', 'createdAt'), new QueryParam('_order', 'DESC'), ...extraParams ]; const chunk = await this.get(account, '/api/song', params); if (!chunk || chunk.length === 0) { break; } results.push(...chunk); if (chunk.length < this.PAGE_SIZE) { break; } start = end; } return results; } private async get(account: WebDavAccount, path: string, params?: Array, retry: boolean = true): Promise { const httpRequest = http.createHttp(); try { const auth = await this.ensureAuth(account); const query = this.buildQueryString(params); const url = `${this.buildRootBase(account)}${path}${query}`; void ServerLogUtil.info(TAG, `GET ${url}`); void ServerLogUtil.debug(TAG, `请求参数: ${JSON.stringify(params ?? [])}`) const response = await httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING, header: this.buildAuthHeader(auth) }); if (response.responseCode === 401 && retry) { this.invalidateAuth(account); void ServerLogUtil.warn(TAG, `401需要重试: ${url}`) return this.get(account, path, params, false); } if (response.responseCode !== 200) { void ServerLogUtil.error(TAG, `GET ${url} 失败 code=${response.responseCode}`); throw new Error(`Navidrome API 请求失败: HTTP ${response.responseCode}`); } void ServerLogUtil.info(TAG, `GET ${url} 成功 code=${response.responseCode}`); return JSON.parse(response.result as string) as T; } finally { httpRequest.destroy(); } } private async ensureAuth(account: WebDavAccount): Promise { const key = this.getAccountKey(account); const cached = this.authCache.get(key); if (cached) { return cached; } const auth = await this.login(account); this.authCache.set(key, auth); return auth; } private invalidateAuth(account: WebDavAccount): void { const key = this.getAccountKey(account); if (this.authCache.has(key)) { this.authCache.delete(key); } } private async login(account: WebDavAccount): Promise { const httpRequest = http.createHttp(); try { const rootBase = this.buildRootBase(account); const url = `${rootBase}/auth/login`; const username = account.account?.trim(); const password = account.password?.trim(); if (!username || !password) { throw new Error('Navidrome账号缺少用户名或密码'); } void ServerLogUtil.info(TAG, `登录 ${ServerLogUtil.sanitizeAccount(account)}`) const response = await httpRequest.request(url, { method: http.RequestMethod.POST, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING, header: { 'Content-Type': 'application/json; charset=utf-8', 'Accept': 'application/json; charset=utf-8', 'Accept-Charset': 'utf-8' }, extraData: JSON.stringify(this.buildLoginBody(username, password)) }); if (response.responseCode !== 200) { void ServerLogUtil.error(TAG, `登录失败 code=${response.responseCode}`); throw new Error(`Navidrome 登录失败: HTTP ${response.responseCode}`); } const body = JSON.parse(response.result as string) as NavidromeLoginResponse; if (!body.token || !body.id) { throw new Error('Navidrome 登录响应缺少 token 信息'); } void ServerLogUtil.info(TAG, '登录成功,已获取token'); return { token: body.token, clientId: body.id }; } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `登录异常: ${err.message}`); throw err; } finally { httpRequest.destroy(); } } private buildAuthHeader(auth: NavidromeAuthContext): Record { return { 'x-nd-authorization': `Bearer ${auth.token}`, 'x-nd-client-unique-id': auth.clientId, 'Accept': 'application/json; charset=utf-8', 'Accept-Charset': 'utf-8' }; } private buildLoginBody(username: string, password: string): NavidromeLoginBody { const body: NavidromeLoginBody = { username, password }; return body; } private buildQueryString(params?: Array): string { if (!params || params.length === 0) { return ''; } const parts: string[] = []; for (let i = 0; i < params.length; i++) { const param = params[i]; parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`); } return parts.length > 0 ? `?${parts.join('&')}` : ''; } private buildRootBase(account: WebDavAccount): string { const protocol = account.enableHttps ? 'https' : 'http'; const host = (account.isUseLocalHost && account.localHost ? account.localHost : account.host)?.trim(); if (!host || host.length === 0) { throw new Error('Navidrome账号缺少服务器地址'); } const port = account.port && account.port > 0 ? `:${account.port}` : ''; const prefix = this.resolveRootPath(account.navidromeBasePath); void ServerLogUtil.debug(TAG, `buildRootBase => ${protocol}://${host}${port}${prefix}`) return `${protocol}://${host}${port}${prefix}`; } private resolveRootPath(path?: string): string { if (!path) { return ''; } let normalized = path.trim(); if (normalized.length === 0) { return ''; } if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } while (normalized.endsWith('/') && normalized.length > 1) { normalized = normalized.slice(0, -1); } const lower = normalized.toLowerCase(); if (lower === '/rest') { return ''; } if (lower.endsWith('/rest')) { const prefix = normalized.slice(0, normalized.length - 5); return prefix === '/' ? '' : prefix; } return normalized === '/' ? '' : normalized; } /** * 获取歌曲详细信息(用于调试) * 使用 Navidrome REST API: GET /api/song/{id} * @param account Navidrome 账号信息 * @param songId 歌曲ID * @returns 歌曲详细信息对象 */ async getLyricsBySongId(account: WebDavAccount, songId: string): Promise { try { void ServerLogUtil.info(TAG, `========== 获取歌曲详细信息 ==========`) void ServerLogUtil.info(TAG, `歌曲ID: ${songId}`) const path = `/api/song/${songId}`; const song = await this.get(account, path); if (song) { void ServerLogUtil.info(TAG, `✅ 获取歌曲信息成功`); void ServerLogUtil.info(TAG, `完整JSON:\n${JSON.stringify(song, null, 2)}`); // 特别检查歌词字段 if (song.lyrics) { void ServerLogUtil.info(TAG, `✅ 歌曲包含歌词字段`); void ServerLogUtil.info(TAG, `原始歌词JSON:\n${song.lyrics}`); // 将JSON格式的歌词转换为LRC格式 const lrcLyrics = this.convertJsonLyricsToLrc(song.lyrics); void ServerLogUtil.info(TAG, `✅ 转换后的LRC歌词:\n${lrcLyrics}`); return lrcLyrics; } else { void ServerLogUtil.warn(TAG, `⚠️ 歌曲没有歌词字段`); } } else { void ServerLogUtil.error(TAG, `❌ 获取歌曲信息失败,返回为空`); } return undefined; } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `❌ 获取歌曲详细信息异常: ${err.message}`); return undefined; } } /** * 将Navidrome的JSON格式歌词转换为LRC格式 * @param jsonLyrics JSON格式的歌词字符串 * @returns LRC格式的歌词字符串 */ private convertJsonLyricsToLrc(jsonLyrics: string): string { try { void ServerLogUtil.debug(TAG, `开始转换JSON歌词到LRC格式`); // 解析JSON,使用明确的类型 const jsonData: Array | null = JSON.parse(jsonLyrics) as Array | null; // 检查是否是数组格式 if (!jsonData || jsonData.length === 0) { void ServerLogUtil.warn(TAG, `歌词不是数组格式或为空,直接返回原文本`); return jsonLyrics; } const lrcLines: string[] = []; // 遍历所有语言版本 for (let i = 0; i < jsonData.length; i++) { const langItem = jsonData[i]; if (!langItem) { continue; } // 定义歌词行接口 interface LyricLine { start: number; value: string; } // 定义语言数据接口 interface LangData { lang: string; line: LyricLine[]; } // 使用接口类型进行类型检查 if (this.isValidLangData(langItem)) { const langData: LangData = langItem as LangData; void ServerLogUtil.debug(TAG, `处理语言: ${langData.lang}, 歌词行数: ${langData.line.length}`); // 将每一行转换为LRC格式 for (let j = 0; j < langData.line.length; j++) { const lineData = langData.line[j]; if (lineData && typeof lineData.start === 'number' && typeof lineData.value === 'string') { const start = lineData.start; const value = lineData.value; // 转换时间为LRC格式 [mm:ss.ms] const minutes = Math.floor(start / 60000); const seconds = Math.floor((start % 60000) / 1000); const milliseconds = start % 1000; const timeTag = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}]`; lrcLines.push(`${timeTag}${value}`); } } } } // 按时间排序 lrcLines.sort(); const result = lrcLines.join('\n'); void ServerLogUtil.info(TAG, `✅ 成功转换歌词,共 ${lrcLines.length} 行`); return result; } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `❌ 转换歌词格式失败: ${err.message}`); void ServerLogUtil.debug(TAG, `返回原始歌词文本`); return jsonLyrics; } } /** * 检查对象是否是有效的语言数据结构 */ private isValidLangData(obj: object): boolean { if (!obj || typeof obj !== 'object') { return false; } const record = obj as Record; // 检查是否有 lang 和 line 属性 if (!record.lang || !record.line) { return false; } // 检查 lang 是否是字符串 if (typeof record.lang !== 'string') { return false; } // 检查 line 是否是数组 if (!Array.isArray(record.line)) { return false; } return true; } /** * 获取歌词 (旧方式 - 通过 artist 和 title) * 使用 Subsonic API: /rest/getLyrics * @param account Navidrome 账号信息 * @param artist 歌手名(可选) * @param title 歌曲名(可选) * @returns 歌词文本,如果获取失败返回空字符串返回的歌词没有时间戳,所以废弃使用 * @deprecated 建议使用 getLyricsBySongId 代替 */ async getLyrics(account: WebDavAccount, artist?: string, title?: string): Promise { const httpRequest = http.createHttp(); try { // 构建Subsonic API参数 const params: Array = [ new QueryParam('u', account.account ?? ''), new QueryParam('p', account.password ?? ''), new QueryParam('v', '1.16.1'), new QueryParam('c', 'TTMusic'), new QueryParam('f', 'json') ]; // 添加可选参数 if (artist && artist.trim().length > 0) { params.push(new QueryParam('artist', artist.trim())); } if (title && title.trim().length > 0) { params.push(new QueryParam('title', title.trim())); } const query = this.buildQueryString(params); const url = `${this.buildRootBase(account)}/rest/getLyrics${query}`; void ServerLogUtil.info(TAG, `获取歌词 GET ${url}`); const response = await httpRequest.request(url, { method: http.RequestMethod.GET, connectTimeout: 10000, readTimeout: 15000, expectDataType: http.HttpDataType.STRING, header: { 'Accept': 'application/xml; charset=utf-8', 'Accept-Charset': 'utf-8' } }); if (response.responseCode !== 200) { void ServerLogUtil.error(TAG, `获取歌词失败 code=${response.responseCode}`); return ''; } void ServerLogUtil.info(TAG, `获取歌词成功 code=${response.responseCode}`); const xmlText = response.result as string; void ServerLogUtil.info(TAG, `获取歌词成功 xmlText=${xmlText}`); // 解析XML响应,提取lyrics标签内容 return this.parseLyricsFromXml(xmlText); } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `获取歌词异常: ${err.message}`); return ''; } finally { httpRequest.destroy(); } } /** * 从Subsonic API的XML响应中解析歌词文本 * @param xmlText XML响应文本 * @returns 歌词文本,如果解析失败返回空字符串 */ private parseLyricsFromXml(xmlText: string): string { try { // 查找 标签 const lyricsMatch = xmlText.match(/]*>([\s\S]*?)<\/lyrics>/); if (!lyricsMatch || lyricsMatch.length < 2) { void ServerLogUtil.warn(TAG, 'XML响应中未找到lyrics标签'); return ''; } // 提取歌词内容并处理XML转义字符 let lyrics = lyricsMatch[1]; // 处理XML转义字符 lyrics = lyrics .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .trim(); void ServerLogUtil.info(TAG, `成功解析歌词 lyrics=${lyrics}`); return lyrics; } catch (error) { const err = error as Error; void ServerLogUtil.error(TAG, `解析歌词XML失败: ${err.message}`); return ''; } } resolveResourceUrl(account: WebDavAccount, path?: string): string | undefined { if (!path || path.trim().length === 0) { return undefined; } let normalized = path.trim(); if (normalized.startsWith('http')) { return normalized; } if (!normalized.startsWith('/')) { normalized = `/${normalized}`; } return `${this.buildRootBase(account)}${normalized}`; } private getAccountKey(account: WebDavAccount): string { if (account.id && account.id > 0) { return account.id.toString(); } return `${account.host ?? ''}_${account.account ?? ''}`; } } export const navidromeRestApi = new NavidromeRestApi();