NavidromeRestApi.ets 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. import { http } from '@kit.NetworkKit';
  2. import { WebDavAccount } from '../../viewmodel/WebDavAccount';
  3. import { ServerLogUtil } from '../util/ServerLogUtil';
  4. import { navidromeApi } from './NavidromeApi';
  5. const TAG = 'heanup NavidromeRestApi';
  6. interface NavidromeLoginResponse {
  7. id?: string;
  8. token?: string;
  9. }
  10. interface NavidromeLoginBody {
  11. username: string;
  12. password: string;
  13. }
  14. interface NavidromeAuthContext {
  15. token: string;
  16. clientId: string;
  17. }
  18. class QueryParam {
  19. key: string;
  20. value: string;
  21. constructor(key: string, value: string) {
  22. this.key = key;
  23. this.value = value;
  24. }
  25. }
  26. export interface NavidromeRestSong {
  27. id: string;
  28. title?: string;
  29. album?: string;
  30. albumId?: string;
  31. artist?: string;
  32. artistId?: string;
  33. duration?: number;
  34. bitRate?: number;
  35. suffix?: string;
  36. size?: number;
  37. createdAt?: string;
  38. genre?: string;
  39. track?: number;
  40. year?: number;
  41. contentType?: string;
  42. coverArt?: string;
  43. coverArtId?: string;
  44. coverArtPath?: string;
  45. embedArtPath?: string;
  46. lyrics?: string; // JSON格式的歌词,需要通过 convertJsonLyricsToLrc 转换为LRC格式
  47. }
  48. export interface NavidromeRestArtist {
  49. id: string;
  50. name?: string;
  51. albumCount?: number;
  52. songCount?: number;
  53. playCount?: number;
  54. mediumImageUrl?: string;
  55. largeImageUrl?: string;
  56. coverArt?: string;
  57. coverArtId?: string;
  58. coverArtPath?: string;
  59. coverUrl?: string;
  60. }
  61. export interface NavidromeRestAlbum {
  62. id: string;
  63. name?: string;
  64. artist?: string;
  65. artistId?: string;
  66. songCount?: number;
  67. duration?: number;
  68. minYear?: number;
  69. maxYear?: number;
  70. createdAt?: string;
  71. embedArtPath?: string;
  72. coverArt?: string;
  73. coverArtId?: string;
  74. coverArtPath?: string;
  75. coverUrl?: string;
  76. }
  77. export interface NavidromeRestPlaylist {
  78. id: string;
  79. name?: string;
  80. comment?: string;
  81. duration?: number;
  82. size?: number;
  83. songCount?: number;
  84. ownerName?: string;
  85. ownerId?: string;
  86. public?: boolean;
  87. path?: string;
  88. sync?: boolean;
  89. createdAt?: string;
  90. updatedAt?: string;
  91. rules?: object;
  92. evaluatedAt?: string;
  93. }
  94. // 歌单API返回的歌曲数据,包含mediaFileId字段
  95. interface NavidromePlaylistSong extends NavidromeRestSong {
  96. mediaFileId?: string;
  97. }
  98. export interface NavidromePagedResponse<T> {
  99. data: T[];
  100. nextStart: number | null;
  101. }
  102. export class NavidromeRestApi {
  103. private authCache: Map<string, NavidromeAuthContext> = new Map();
  104. private readonly PAGE_SIZE: number = 500;
  105. async fetchSongPage(account: WebDavAccount, start: number): Promise<NavidromePagedResponse<NavidromeRestSong>> {
  106. return this.fetchPage<NavidromeRestSong>(account, '/api/song', start, 'createdAt', 'DESC');
  107. }
  108. async fetchArtistPage(account: WebDavAccount, start: number): Promise<NavidromePagedResponse<NavidromeRestArtist>> {
  109. return this.fetchPage<NavidromeRestArtist>(account, '/api/artist', start, 'name', 'ASC','albumartist');
  110. }
  111. async fetchAlbumPage(account: WebDavAccount, start: number): Promise<NavidromePagedResponse<NavidromeRestAlbum>> {
  112. return this.fetchPage<NavidromeRestAlbum>(account, '/api/album', start, 'name', 'ASC');
  113. }
  114. async fetchPlaylistPage(account: WebDavAccount, start: number): Promise<NavidromePagedResponse<NavidromeRestPlaylist>> {
  115. return this.fetchPage<NavidromeRestPlaylist>(account, '/api/playlist', start, 'name', 'ASC');
  116. }
  117. async fetchSongsByArtist(account: WebDavAccount, artistId: string, label?: string): Promise<NavidromeRestSong[]> {
  118. // 这里判断fetchSongsWithFilter如果返回空数据,则用搜索的接口去搜索艺术家 label就是艺术家名称
  119. const songs = await this.fetchSongsWithFilter(account, [new QueryParam('artist_id', artistId)]);
  120. // 如果通过 artist_id 查询结果为空,且有艺术家名称,则使用搜索接口
  121. if ((!songs || songs.length === 0) && label && label.trim().length > 0) {
  122. void ServerLogUtil.warn(TAG, `通过 artist_id 查询为空,尝试搜索艺术家: "${label}"`);
  123. try {
  124. const searchResults = await navidromeApi.searchSongs(account, label.trim(), 500);
  125. // 将 NavidromeSong 转换为 NavidromeRestSong
  126. if (searchResults && searchResults.length > 0) {
  127. void ServerLogUtil.info(TAG, `搜索接口返回 ${searchResults.length} 首歌曲`);
  128. return this.convertToRestSongs(searchResults);
  129. }
  130. } catch (error) {
  131. const err = error as Error;
  132. void ServerLogUtil.error(TAG, `搜索艺术家歌曲失败: ${err.message}`);
  133. }
  134. }
  135. return songs ?? [];
  136. }
  137. async fetchSongsByAlbum(account: WebDavAccount, albumId: string, label?: string): Promise<NavidromeRestSong[]> {
  138. // 这里判断fetchSongsWithFilter如果返回空数据,则用搜索的接口去搜索专辑 label就是专辑名称
  139. const songs = await this.fetchSongsWithFilter(account, [new QueryParam('album_id', albumId)]);
  140. // 如果通过 album_id 查询结果为空,且有专辑名称,则使用搜索接口
  141. if ((!songs || songs.length === 0) && label && label.trim().length > 0) {
  142. void ServerLogUtil.warn(TAG, `通过 album_id 查询为空,尝试搜索专辑: "${label}"`);
  143. try {
  144. const searchResults = await navidromeApi.searchSongs(account, label.trim(), 500);
  145. // 将 NavidromeSong 转换为 NavidromeRestSong
  146. if (searchResults && searchResults.length > 0) {
  147. void ServerLogUtil.info(TAG, `搜索接口返回 ${searchResults.length} 首歌曲`);
  148. return this.convertToRestSongs(searchResults);
  149. }
  150. } catch (error) {
  151. const err = error as Error;
  152. void ServerLogUtil.error(TAG, `搜索专辑歌曲失败: ${err.message}`);
  153. }
  154. }
  155. return songs ?? [];
  156. }
  157. async fetchSongsByPlaylist(account: WebDavAccount, playlistId: string): Promise<NavidromeRestSong[]> {
  158. const results: NavidromeRestSong[] = [];
  159. let start = 0;
  160. while (true) {
  161. const end = start + this.PAGE_SIZE;
  162. const params = [
  163. new QueryParam('_start', `${start}`),
  164. new QueryParam('_end', `${end}`),
  165. new QueryParam('_sort', 'createdAt'),
  166. new QueryParam('_order', 'DESC')
  167. ];
  168. const path = `/api/playlist/${playlistId}/tracks`;
  169. // 获取原始响应数据,使用NavidromePlaylistSong类型(包含mediaFileId字段)
  170. const chunk = await this.get<NavidromePlaylistSong[]>(account, path, params);
  171. if (!chunk || chunk.length === 0) {
  172. break;
  173. }
  174. // 处理歌单API返回的数据,将mediaFileId映射到id字段
  175. const processed: NavidromeRestSong[] = [];
  176. for (let i = 0; i < chunk.length; i++) {
  177. const item = chunk[i];
  178. // 如果存在mediaFileId,使用它作为id;否则使用原id
  179. const songId = item.mediaFileId && item.mediaFileId.length > 0 ? item.mediaFileId : item.id;
  180. if (item.mediaFileId && item.mediaFileId.length > 0) {
  181. void ServerLogUtil.debug('NavidromePlaylist', `歌单歌曲使用mediaFileId作为id: ${item.mediaFileId}`);
  182. }
  183. const processedItem: NavidromeRestSong = {
  184. id: songId,
  185. title: item.title,
  186. album: item.album,
  187. albumId: item.albumId,
  188. artist: item.artist,
  189. artistId: item.artistId,
  190. duration: item.duration,
  191. bitRate: item.bitRate,
  192. suffix: item.suffix,
  193. size: item.size,
  194. createdAt: item.createdAt,
  195. genre: item.genre,
  196. track: item.track,
  197. year: item.year,
  198. contentType: item.contentType,
  199. coverArt: item.coverArt,
  200. coverArtId: item.coverArtId,
  201. coverArtPath: item.coverArtPath,
  202. embedArtPath: item.embedArtPath,
  203. lyrics: item.lyrics,
  204. };
  205. processed.push(processedItem);
  206. }
  207. results.push(...processed);
  208. if (processed.length < this.PAGE_SIZE) {
  209. break;
  210. }
  211. start = end;
  212. }
  213. return results;
  214. }
  215. // 将 NavidromeSong 转换为 NavidromeRestSong
  216. private convertToRestSongs(songs: import('./NavidromeApi').NavidromeSong[]): NavidromeRestSong[] {
  217. return songs.map((song): NavidromeRestSong => ({
  218. id: song.id,
  219. title: song.title,
  220. artist: song.artist,
  221. artistId: song.artistId,
  222. album: song.album,
  223. albumId: song.albumId,
  224. duration: song.duration,
  225. bitRate: song.bitRate,
  226. suffix: song.suffix,
  227. size: song.size,
  228. createdAt: song.created,
  229. genre: song.genre,
  230. track: song.track,
  231. year: song.year,
  232. contentType: song.contentType,
  233. coverArt: song.coverArt,
  234. coverArtId: song.coverArt,
  235. coverArtPath: undefined,
  236. embedArtPath: undefined,
  237. lyrics: song.lyrics,
  238. }));
  239. }
  240. private async fetchPage<T extends object>(account: WebDavAccount, path: string,
  241. start: number, sortField: string, order: 'ASC' | 'DESC',role?:string): Promise<NavidromePagedResponse<T>> {
  242. const end = start + this.PAGE_SIZE;
  243. const params: Array<QueryParam> = [
  244. new QueryParam('_start', `${start}`),
  245. new QueryParam('_end', `${end}`),
  246. new QueryParam('_sort', sortField),
  247. new QueryParam('_order', order)
  248. ];
  249. if(role){
  250. params.push(new QueryParam('_role', role));
  251. }
  252. void ServerLogUtil.debug(TAG, `${path} 分页请求: start=${start}, end=${end}`);
  253. const chunk = await this.get<T[]>(account, path, params);
  254. const nextStart = chunk && chunk.length === this.PAGE_SIZE ? end : null;
  255. return {
  256. data: chunk ?? [],
  257. nextStart
  258. };
  259. }
  260. private async fetchSongsWithFilter(account: WebDavAccount, extraParams: QueryParam[]): Promise<NavidromeRestSong[]> {
  261. const results: NavidromeRestSong[] = [];
  262. let start = 0;
  263. while (true) {
  264. const end = start + this.PAGE_SIZE;
  265. const params = [
  266. new QueryParam('_start', `${start}`),
  267. new QueryParam('_end', `${end}`),
  268. new QueryParam('_sort', 'createdAt'),
  269. new QueryParam('_order', 'DESC'),
  270. ...extraParams
  271. ];
  272. const chunk = await this.get<NavidromeRestSong[]>(account, '/api/song', params);
  273. if (!chunk || chunk.length === 0) {
  274. break;
  275. }
  276. results.push(...chunk);
  277. if (chunk.length < this.PAGE_SIZE) {
  278. break;
  279. }
  280. start = end;
  281. }
  282. return results;
  283. }
  284. private async get<T>(account: WebDavAccount, path: string, params?: Array<QueryParam>, retry: boolean = true): Promise<T> {
  285. const httpRequest = http.createHttp();
  286. try {
  287. const auth = await this.ensureAuth(account);
  288. const query = this.buildQueryString(params);
  289. const url = `${this.buildRootBase(account)}${path}${query}`;
  290. void ServerLogUtil.info(TAG, `GET ${url}`);
  291. void ServerLogUtil.debug(TAG, `请求参数: ${JSON.stringify(params ?? [])}`)
  292. const response = await httpRequest.request(url, {
  293. method: http.RequestMethod.GET,
  294. connectTimeout: 10000,
  295. readTimeout: 15000,
  296. expectDataType: http.HttpDataType.STRING,
  297. header: this.buildAuthHeader(auth)
  298. });
  299. if (response.responseCode === 401 && retry) {
  300. this.invalidateAuth(account);
  301. void ServerLogUtil.warn(TAG, `401需要重试: ${url}`)
  302. return this.get(account, path, params, false);
  303. }
  304. if (response.responseCode !== 200) {
  305. void ServerLogUtil.error(TAG, `GET ${url} 失败 code=${response.responseCode}`);
  306. throw new Error(`Navidrome API 请求失败: HTTP ${response.responseCode}`);
  307. }
  308. void ServerLogUtil.info(TAG, `GET ${url} 成功 code=${response.responseCode}`);
  309. return JSON.parse(response.result as string) as T;
  310. } finally {
  311. httpRequest.destroy();
  312. }
  313. }
  314. private async ensureAuth(account: WebDavAccount): Promise<NavidromeAuthContext> {
  315. const key = this.getAccountKey(account);
  316. const cached = this.authCache.get(key);
  317. if (cached) {
  318. return cached;
  319. }
  320. const auth = await this.login(account);
  321. this.authCache.set(key, auth);
  322. return auth;
  323. }
  324. private invalidateAuth(account: WebDavAccount): void {
  325. const key = this.getAccountKey(account);
  326. if (this.authCache.has(key)) {
  327. this.authCache.delete(key);
  328. }
  329. }
  330. private async login(account: WebDavAccount): Promise<NavidromeAuthContext> {
  331. const httpRequest = http.createHttp();
  332. try {
  333. const rootBase = this.buildRootBase(account);
  334. const url = `${rootBase}/auth/login`;
  335. const username = account.account?.trim();
  336. const password = account.password?.trim();
  337. if (!username || !password) {
  338. throw new Error('Navidrome账号缺少用户名或密码');
  339. }
  340. void ServerLogUtil.info(TAG, `登录 ${ServerLogUtil.sanitizeAccount(account)}`)
  341. const response = await httpRequest.request(url, {
  342. method: http.RequestMethod.POST,
  343. connectTimeout: 10000,
  344. readTimeout: 15000,
  345. expectDataType: http.HttpDataType.STRING,
  346. header: {
  347. 'Content-Type': 'application/json; charset=utf-8',
  348. 'Accept': 'application/json; charset=utf-8',
  349. 'Accept-Charset': 'utf-8'
  350. },
  351. extraData: JSON.stringify(this.buildLoginBody(username, password))
  352. });
  353. if (response.responseCode !== 200) {
  354. void ServerLogUtil.error(TAG, `登录失败 code=${response.responseCode}`);
  355. throw new Error(`Navidrome 登录失败: HTTP ${response.responseCode}`);
  356. }
  357. const body = JSON.parse(response.result as string) as NavidromeLoginResponse;
  358. if (!body.token || !body.id) {
  359. throw new Error('Navidrome 登录响应缺少 token 信息');
  360. }
  361. void ServerLogUtil.info(TAG, '登录成功,已获取token');
  362. return {
  363. token: body.token,
  364. clientId: body.id
  365. };
  366. } catch (error) {
  367. const err = error as Error;
  368. void ServerLogUtil.error(TAG, `登录异常: ${err.message}`);
  369. throw err;
  370. } finally {
  371. httpRequest.destroy();
  372. }
  373. }
  374. private buildAuthHeader(auth: NavidromeAuthContext): Record<string, string> {
  375. return {
  376. 'x-nd-authorization': `Bearer ${auth.token}`,
  377. 'x-nd-client-unique-id': auth.clientId,
  378. 'Accept': 'application/json; charset=utf-8',
  379. 'Accept-Charset': 'utf-8'
  380. };
  381. }
  382. private buildLoginBody(username: string, password: string): NavidromeLoginBody {
  383. const body: NavidromeLoginBody = {
  384. username,
  385. password
  386. };
  387. return body;
  388. }
  389. private buildQueryString(params?: Array<QueryParam>): string {
  390. if (!params || params.length === 0) {
  391. return '';
  392. }
  393. const parts: string[] = [];
  394. for (let i = 0; i < params.length; i++) {
  395. const param = params[i];
  396. parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`);
  397. }
  398. return parts.length > 0 ? `?${parts.join('&')}` : '';
  399. }
  400. private buildRootBase(account: WebDavAccount): string {
  401. const protocol = account.enableHttps ? 'https' : 'http';
  402. const host = (account.isUseLocalHost && account.localHost ? account.localHost : account.host)?.trim();
  403. if (!host || host.length === 0) {
  404. throw new Error('Navidrome账号缺少服务器地址');
  405. }
  406. const port = account.port && account.port > 0 ? `:${account.port}` : '';
  407. const prefix = this.resolveRootPath(account.navidromeBasePath);
  408. void ServerLogUtil.debug(TAG, `buildRootBase => ${protocol}://${host}${port}${prefix}`)
  409. return `${protocol}://${host}${port}${prefix}`;
  410. }
  411. private resolveRootPath(path?: string): string {
  412. if (!path) {
  413. return '';
  414. }
  415. let normalized = path.trim();
  416. if (normalized.length === 0) {
  417. return '';
  418. }
  419. if (!normalized.startsWith('/')) {
  420. normalized = `/${normalized}`;
  421. }
  422. while (normalized.endsWith('/') && normalized.length > 1) {
  423. normalized = normalized.slice(0, -1);
  424. }
  425. const lower = normalized.toLowerCase();
  426. if (lower === '/rest') {
  427. return '';
  428. }
  429. if (lower.endsWith('/rest')) {
  430. const prefix = normalized.slice(0, normalized.length - 5);
  431. return prefix === '/' ? '' : prefix;
  432. }
  433. return normalized === '/' ? '' : normalized;
  434. }
  435. /**
  436. * 获取歌曲详细信息(用于调试)
  437. * 使用 Navidrome REST API: GET /api/song/{id}
  438. * @param account Navidrome 账号信息
  439. * @param songId 歌曲ID
  440. * @returns 歌曲详细信息对象
  441. */
  442. async getLyricsBySongId(account: WebDavAccount, songId: string): Promise<string | undefined> {
  443. try {
  444. void ServerLogUtil.info(TAG, `========== 获取歌曲详细信息 ==========`)
  445. void ServerLogUtil.info(TAG, `歌曲ID: ${songId}`)
  446. const path = `/api/song/${songId}`;
  447. const song = await this.get<NavidromeRestSong>(account, path);
  448. if (song) {
  449. void ServerLogUtil.info(TAG, `✅ 获取歌曲信息成功`);
  450. void ServerLogUtil.info(TAG, `完整JSON:\n${JSON.stringify(song, null, 2)}`);
  451. // 特别检查歌词字段
  452. if (song.lyrics) {
  453. void ServerLogUtil.info(TAG, `✅ 歌曲包含歌词字段`);
  454. void ServerLogUtil.info(TAG, `原始歌词JSON:\n${song.lyrics}`);
  455. // 将JSON格式的歌词转换为LRC格式
  456. const lrcLyrics = this.convertJsonLyricsToLrc(song.lyrics);
  457. void ServerLogUtil.info(TAG, `✅ 转换后的LRC歌词:\n${lrcLyrics}`);
  458. return lrcLyrics;
  459. } else {
  460. void ServerLogUtil.warn(TAG, `⚠️ 歌曲没有歌词字段`);
  461. }
  462. } else {
  463. void ServerLogUtil.error(TAG, `❌ 获取歌曲信息失败,返回为空`);
  464. }
  465. return undefined;
  466. } catch (error) {
  467. const err = error as Error;
  468. void ServerLogUtil.error(TAG, `❌ 获取歌曲详细信息异常: ${err.message}`);
  469. return undefined;
  470. }
  471. }
  472. /**
  473. * 将Navidrome的JSON格式歌词转换为LRC格式
  474. * @param jsonLyrics JSON格式的歌词字符串
  475. * @returns LRC格式的歌词字符串
  476. */
  477. private convertJsonLyricsToLrc(jsonLyrics: string): string {
  478. try {
  479. void ServerLogUtil.debug(TAG, `开始转换JSON歌词到LRC格式`);
  480. // 解析JSON,使用明确的类型
  481. const jsonData: Array<object> | null = JSON.parse(jsonLyrics) as Array<object> | null;
  482. // 检查是否是数组格式
  483. if (!jsonData || jsonData.length === 0) {
  484. void ServerLogUtil.warn(TAG, `歌词不是数组格式或为空,直接返回原文本`);
  485. return jsonLyrics;
  486. }
  487. const lrcLines: string[] = [];
  488. // 遍历所有语言版本
  489. for (let i = 0; i < jsonData.length; i++) {
  490. const langItem = jsonData[i];
  491. if (!langItem) {
  492. continue;
  493. }
  494. // 定义歌词行接口
  495. interface LyricLine {
  496. start: number;
  497. value: string;
  498. }
  499. // 定义语言数据接口
  500. interface LangData {
  501. lang: string;
  502. line: LyricLine[];
  503. }
  504. // 使用接口类型进行类型检查
  505. if (this.isValidLangData(langItem)) {
  506. const langData: LangData = langItem as LangData;
  507. void ServerLogUtil.debug(TAG, `处理语言: ${langData.lang}, 歌词行数: ${langData.line.length}`);
  508. // 将每一行转换为LRC格式
  509. for (let j = 0; j < langData.line.length; j++) {
  510. const lineData = langData.line[j];
  511. if (lineData && typeof lineData.start === 'number' && typeof lineData.value === 'string') {
  512. const start = lineData.start;
  513. const value = lineData.value;
  514. // 转换时间为LRC格式 [mm:ss.ms]
  515. const minutes = Math.floor(start / 60000);
  516. const seconds = Math.floor((start % 60000) / 1000);
  517. const milliseconds = start % 1000;
  518. const timeTag = `[${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}]`;
  519. lrcLines.push(`${timeTag}${value}`);
  520. }
  521. }
  522. }
  523. }
  524. // 按时间排序
  525. lrcLines.sort();
  526. const result = lrcLines.join('\n');
  527. void ServerLogUtil.info(TAG, `✅ 成功转换歌词,共 ${lrcLines.length} 行`);
  528. return result;
  529. } catch (error) {
  530. const err = error as Error;
  531. void ServerLogUtil.error(TAG, `❌ 转换歌词格式失败: ${err.message}`);
  532. void ServerLogUtil.debug(TAG, `返回原始歌词文本`);
  533. return jsonLyrics;
  534. }
  535. }
  536. /**
  537. * 检查对象是否是有效的语言数据结构
  538. */
  539. private isValidLangData(obj: object): boolean {
  540. if (!obj || typeof obj !== 'object') {
  541. return false;
  542. }
  543. const record = obj as Record<string, object>;
  544. // 检查是否有 lang 和 line 属性
  545. if (!record.lang || !record.line) {
  546. return false;
  547. }
  548. // 检查 lang 是否是字符串
  549. if (typeof record.lang !== 'string') {
  550. return false;
  551. }
  552. // 检查 line 是否是数组
  553. if (!Array.isArray(record.line)) {
  554. return false;
  555. }
  556. return true;
  557. }
  558. /**
  559. * 获取歌词 (旧方式 - 通过 artist 和 title)
  560. * 使用 Subsonic API: /rest/getLyrics
  561. * @param account Navidrome 账号信息
  562. * @param artist 歌手名(可选)
  563. * @param title 歌曲名(可选)
  564. * @returns 歌词文本,如果获取失败返回空字符串返回的歌词没有时间戳,所以废弃使用
  565. * @deprecated 建议使用 getLyricsBySongId 代替
  566. */
  567. async getLyrics(account: WebDavAccount, artist?: string, title?: string): Promise<string> {
  568. const httpRequest = http.createHttp();
  569. try {
  570. // 构建Subsonic API参数
  571. const params: Array<QueryParam> = [
  572. new QueryParam('u', account.account ?? ''),
  573. new QueryParam('p', account.password ?? ''),
  574. new QueryParam('v', '1.16.1'),
  575. new QueryParam('c', 'TTMusic'),
  576. new QueryParam('f', 'json')
  577. ];
  578. // 添加可选参数
  579. if (artist && artist.trim().length > 0) {
  580. params.push(new QueryParam('artist', artist.trim()));
  581. }
  582. if (title && title.trim().length > 0) {
  583. params.push(new QueryParam('title', title.trim()));
  584. }
  585. const query = this.buildQueryString(params);
  586. const url = `${this.buildRootBase(account)}/rest/getLyrics${query}`;
  587. void ServerLogUtil.info(TAG, `获取歌词 GET ${url}`);
  588. const response = await httpRequest.request(url, {
  589. method: http.RequestMethod.GET,
  590. connectTimeout: 10000,
  591. readTimeout: 15000,
  592. expectDataType: http.HttpDataType.STRING,
  593. header: {
  594. 'Accept': 'application/xml; charset=utf-8',
  595. 'Accept-Charset': 'utf-8'
  596. }
  597. });
  598. if (response.responseCode !== 200) {
  599. void ServerLogUtil.error(TAG, `获取歌词失败 code=${response.responseCode}`);
  600. return '';
  601. }
  602. void ServerLogUtil.info(TAG, `获取歌词成功 code=${response.responseCode}`);
  603. const xmlText = response.result as string;
  604. void ServerLogUtil.info(TAG, `获取歌词成功 xmlText=${xmlText}`);
  605. // 解析XML响应,提取lyrics标签内容
  606. return this.parseLyricsFromXml(xmlText);
  607. } catch (error) {
  608. const err = error as Error;
  609. void ServerLogUtil.error(TAG, `获取歌词异常: ${err.message}`);
  610. return '';
  611. } finally {
  612. httpRequest.destroy();
  613. }
  614. }
  615. /**
  616. * 从Subsonic API的XML响应中解析歌词文本
  617. * @param xmlText XML响应文本
  618. * @returns 歌词文本,如果解析失败返回空字符串
  619. */
  620. private parseLyricsFromXml(xmlText: string): string {
  621. try {
  622. // 查找 <lyrics> 标签
  623. const lyricsMatch = xmlText.match(/<lyrics[^>]*>([\s\S]*?)<\/lyrics>/);
  624. if (!lyricsMatch || lyricsMatch.length < 2) {
  625. void ServerLogUtil.warn(TAG, 'XML响应中未找到lyrics标签');
  626. return '';
  627. }
  628. // 提取歌词内容并处理XML转义字符
  629. let lyrics = lyricsMatch[1];
  630. // 处理XML转义字符
  631. lyrics = lyrics
  632. .replace(/&amp;/g, '&')
  633. .replace(/&lt;/g, '<')
  634. .replace(/&gt;/g, '>')
  635. .replace(/&quot;/g, '"')
  636. .replace(/&apos;/g, "'")
  637. .trim();
  638. void ServerLogUtil.info(TAG, `成功解析歌词 lyrics=${lyrics}`);
  639. return lyrics;
  640. } catch (error) {
  641. const err = error as Error;
  642. void ServerLogUtil.error(TAG, `解析歌词XML失败: ${err.message}`);
  643. return '';
  644. }
  645. }
  646. resolveResourceUrl(account: WebDavAccount, path?: string): string | undefined {
  647. if (!path || path.trim().length === 0) {
  648. return undefined;
  649. }
  650. let normalized = path.trim();
  651. if (normalized.startsWith('http')) {
  652. return normalized;
  653. }
  654. if (!normalized.startsWith('/')) {
  655. normalized = `/${normalized}`;
  656. }
  657. return `${this.buildRootBase(account)}${normalized}`;
  658. }
  659. private getAccountKey(account: WebDavAccount): string {
  660. if (account.id && account.id > 0) {
  661. return account.id.toString();
  662. }
  663. return `${account.host ?? ''}_${account.account ?? ''}`;
  664. }
  665. }
  666. export const navidromeRestApi = new NavidromeRestApi();