Browse Source

feat(network): 添加AudioStation和Plex歌词获取功能

- 在AudioStationApi中实现getLyric方法用于获取歌词
- 在PlexApi中实现getLyric方法和XML歌词解析逻辑
- 添加resolveLyricText和pickLyricText辅助方法处理歌词数据
- 在LyricService中增加fetchAudioStationLyric和fetchPlexLyric方法
- 更新LocalMusic页面支持AudioStation和Plex歌词获取
- 修改NavidromePage处理AudioStation歌曲封面ID逻辑
- 添加音频站歌曲封面ID的识别和解析功能
chendeben 6 months ago
parent
commit
eb16c20a78

+ 55 - 0
entry/src/main/ets/common/network/AudioStationApi.ets

@@ -723,6 +723,26 @@ export class AudioStationApi {
     return `${url}?${this.buildQuery(params)}`;
     return `${url}?${this.buildQuery(params)}`;
   }
   }
 
 
+  async getLyric(account: WebDavAccount, songId: string): Promise<string> {
+    if (!songId) {
+      return '';
+    }
+    const auth = await this.ensureAuth(account);
+    const url = this.buildWebApiUrl(account, 'AudioStation/lyrics.cgi');
+    const params = [
+      new QueryParam('api', 'SYNO.AudioStation.Lyrics'),
+      new QueryParam('version', '1'),
+      new QueryParam('method', 'getlyrics'),
+      new QueryParam('id', songId),
+      new QueryParam('_sid', auth.sid)
+    ];
+    const response = await this.get<AudioStationResponse<Record<string, JsonValue>>>(url, params);
+    if (!response.success) {
+      return '';
+    }
+    return this.resolveLyricText(response.data);
+  }
+
   async buildStreamUrl(account: WebDavAccount, songId: string): Promise<string> {
   async buildStreamUrl(account: WebDavAccount, songId: string): Promise<string> {
     if (!songId) {
     if (!songId) {
       throw new Error('无效的AudioStation歌曲ID');
       throw new Error('无效的AudioStation歌曲ID');
@@ -875,6 +895,41 @@ export class AudioStationApi {
     return this.parseResponse<T>(response.result);
     return this.parseResponse<T>(response.result);
   }
   }
 
 
+  private resolveLyricText(data?: Record<string, JsonValue>): string {
+    if (!data) {
+      return '';
+    }
+    const direct = this.pickLyricText(data);
+    if (direct) {
+      return direct;
+    }
+    const keys = Object.keys(data);
+    for (let i = 0; i < keys.length; i++) {
+      const key = keys[i];
+      const value = data[key];
+      if (value && typeof value === 'object') {
+        const nested = value as Record<string, JsonValue>;
+        const nestedText = this.pickLyricText(nested);
+        if (nestedText) {
+          return nestedText;
+        }
+      }
+    }
+    return '';
+  }
+
+  private pickLyricText(data: Record<string, JsonValue>): string {
+    const candidates = ['lyrics', 'lyric', 'text'];
+    for (let i = 0; i < candidates.length; i++) {
+      const key = candidates[i];
+      const value = data[key];
+      if (typeof value === 'string' && value.trim().length > 0) {
+        return value;
+      }
+    }
+    return '';
+  }
+
   private parseResponse<T>(payload: string | Object): T {
   private parseResponse<T>(payload: string | Object): T {
     if (typeof payload === 'string') {
     if (typeof payload === 'string') {
       const trimmed = payload.trim();
       const trimmed = payload.trim();

+ 103 - 0
entry/src/main/ets/common/network/PlexApi.ets

@@ -274,6 +274,16 @@ export class PlexApi {
     return `${baseUrl}${normalizedPath}${joiner}X-Plex-Token=${encodeURIComponent(auth.token)}`;
     return `${baseUrl}${normalizedPath}${joiner}X-Plex-Token=${encodeURIComponent(auth.token)}`;
   }
   }
 
 
+  async getLyric(account: WebDavAccount, songId: string): Promise<string> {
+    if (!songId) {
+      return '';
+    }
+    const xml = await this.getXml(account, `/library/streams/${songId}`, [
+      new QueryParam('format', 'xml')
+    ]);
+    return this.parseLyrics(xml);
+  }
+
   private async ensureMusicSectionId(account: WebDavAccount): Promise<string> {
   private async ensureMusicSectionId(account: WebDavAccount): Promise<string> {
     const auth = await this.ensureAuth(account);
     const auth = await this.ensureAuth(account);
     if (auth.musicSectionId) {
     if (auth.musicSectionId) {
@@ -565,6 +575,99 @@ export class PlexApi {
     return results;
     return results;
   }
   }
 
 
+  private parseLyrics(xmlText: string): string {
+    const blocks: string[] = [];
+    const lyricsTag = /<Lyrics\b[^>]*>([\s\S]*?)<\/Lyrics>/g;
+    let lyricsMatch = lyricsTag.exec(xmlText);
+    while (lyricsMatch) {
+      const block = lyricsMatch[1] ?? '';
+      const lines = this.parseLyricLines(block);
+      if (lines.length > 0) {
+        blocks.push(lines.join('\n'));
+      }
+      lyricsMatch = lyricsTag.exec(xmlText);
+    }
+    return blocks.join('\n').trim();
+  }
+
+  private parseLyricLines(block: string): string[] {
+    const lines: string[] = [];
+    const lineTag = /<Line\b([^>]*)>([\s\S]*?)<\/Line>/g;
+    let lineMatch = lineTag.exec(block);
+    while (lineMatch) {
+      const attrs = this.parseAttributes(lineMatch[1]);
+      const text = this.parseLyricLineText(lineMatch[2] ?? '', attrs);
+      const timeTag = this.formatLyricTime(attrs.time ?? attrs.start ?? attrs.begin);
+      if (text) {
+        lines.push(timeTag ? `${timeTag}${text}` : text);
+      }
+      lineMatch = lineTag.exec(block);
+    }
+    const selfClosing = /<Line\b([^>]*)\/>/g;
+    let selfMatch = selfClosing.exec(block);
+    while (selfMatch) {
+      const attrs = this.parseAttributes(selfMatch[1]);
+      const text = this.parseLyricLineText('', attrs);
+      const timeTag = this.formatLyricTime(attrs.time ?? attrs.start ?? attrs.begin);
+      if (text) {
+        lines.push(timeTag ? `${timeTag}${text}` : text);
+      }
+      selfMatch = selfClosing.exec(block);
+    }
+    return lines;
+  }
+
+  private parseLyricLineText(inner: string, lineAttrs: Record<string, string>): string {
+    const parts: string[] = [];
+    const spanTag = /<Span\b([^>]*)\/>/g;
+    let spanMatch = spanTag.exec(inner);
+    while (spanMatch) {
+      const spanAttrs = this.parseAttributes(spanMatch[1]);
+      if (spanAttrs.text) {
+        parts.push(spanAttrs.text);
+      }
+      spanMatch = spanTag.exec(inner);
+    }
+    const fullSpanTag = /<Span\b([^>]*)>([\s\S]*?)<\/Span>/g;
+    let fullMatch = fullSpanTag.exec(inner);
+    while (fullMatch) {
+      const spanAttrs = this.parseAttributes(fullMatch[1]);
+      const text = spanAttrs.text ?? this.decodeXmlEntities(fullMatch[2] ?? '');
+      if (text) {
+        parts.push(text);
+      }
+      fullMatch = fullSpanTag.exec(inner);
+    }
+    if (parts.length > 0) {
+      return parts.join('');
+    }
+    if (lineAttrs.text) {
+      return lineAttrs.text;
+    }
+    return '';
+  }
+
+  private formatLyricTime(raw?: string): string | undefined {
+    if (!raw) {
+      return undefined;
+    }
+    const value = Number(raw);
+    if (!Number.isFinite(value)) {
+      return undefined;
+    }
+    let totalMs = value;
+    if (value < 1000) {
+      totalMs = Math.round(value * 1000);
+    }
+    const minutes = Math.floor(totalMs / 60000);
+    const seconds = Math.floor((totalMs % 60000) / 1000);
+    const centiseconds = Math.floor((totalMs % 1000) / 10);
+    const mm = minutes.toString().padStart(2, '0');
+    const ss = seconds.toString().padStart(2, '0');
+    const cc = centiseconds.toString().padStart(2, '0');
+    return `[${mm}:${ss}.${cc}]`;
+  }
+
   private extractTagAttributes(xmlText: string, tagName: string): Record<string, string> {
   private extractTagAttributes(xmlText: string, tagName: string): Record<string, string> {
     const tagMatch = xmlText.match(new RegExp(`<${tagName}\\b([^>]*)\\/?>`));
     const tagMatch = xmlText.match(new RegExp(`<${tagName}\\b([^>]*)\\/?>`));
     if (!tagMatch) {
     if (!tagMatch) {

+ 86 - 0
entry/src/main/ets/common/service/LyricService.ets

@@ -1,6 +1,8 @@
 import { navidromeRestApi } from '../network/NavidromeRestApi';
 import { navidromeRestApi } from '../network/NavidromeRestApi';
 import { jellyfinApi } from '../network/JellyfinApi';
 import { jellyfinApi } from '../network/JellyfinApi';
 import { embyApi } from '../network/EmbyApi';
 import { embyApi } from '../network/EmbyApi';
+import { audioStationApi } from '../network/AudioStationApi';
+import { plexApi } from '../network/PlexApi';
 import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import { ServerLogUtil } from '../util/ServerLogUtil';
 import { ServerLogUtil } from '../util/ServerLogUtil';
 import { StrUtil } from '@pura/harmony-utils';
 import { StrUtil } from '@pura/harmony-utils';
@@ -161,6 +163,90 @@ class LyricService {
       return '';
       return '';
     }
     }
   }
   }
+
+  /**
+   * 从AudioStation服务器获取歌词
+   * @param currentSong 当前歌曲对象
+   * @param getAccountById 根据账号ID获取账号信息的回调
+   * @returns 歌词文本,如果获取失败返回空字符串
+   */
+  async fetchAudioStationLyric(
+    currentSong: SongData,
+    getAccountById: AccountGetter
+  ): Promise<string> {
+    if (!currentSong || !currentSong.webdav_account_id) {
+      return '';
+    }
+
+    try {
+      ServerLogUtil.info(TAG, '尝试从AudioStation服务器获取歌词');
+      const account = await getAccountById(currentSong.webdav_account_id);
+      if (!account) {
+        throw new Error('AudioStation账号不可用');
+      }
+
+      const songId = currentSong.webdav_id || '';
+      if (!songId) {
+        throw new Error('AudioStation歌曲ID不可用');
+      }
+
+      const lyric = await audioStationApi.getLyric(account, songId);
+
+      if (StrUtil.isNotEmpty(lyric)) {
+        ServerLogUtil.info(TAG, '成功从AudioStation服务器获取到歌词');
+        return lyric;
+      } else {
+        ServerLogUtil.info(TAG, 'AudioStation服务器未返回歌词');
+        return '';
+      }
+    } catch (error) {
+      const err = error as Error;
+      ServerLogUtil.error(TAG, `从AudioStation服务器获取歌词失败: ${err.message}`);
+      return '';
+    }
+  }
+
+  /**
+   * 从Plex服务器获取歌词
+   * @param currentSong 当前歌曲对象
+   * @param getAccountById 根据账号ID获取账号信息的回调
+   * @returns 歌词文本,如果获取失败返回空字符串
+   */
+  async fetchPlexLyric(
+    currentSong: SongData,
+    getAccountById: AccountGetter
+  ): Promise<string> {
+    if (!currentSong || !currentSong.webdav_account_id) {
+      return '';
+    }
+
+    try {
+      ServerLogUtil.info(TAG, '尝试从Plex服务器获取歌词');
+      const account = await getAccountById(currentSong.webdav_account_id);
+      if (!account) {
+        throw new Error('Plex账号不可用');
+      }
+
+      const songId = currentSong.webdav_id || '';
+      if (!songId) {
+        throw new Error('Plex歌曲ID不可用');
+      }
+
+      const lyric = await plexApi.getLyric(account, songId);
+
+      if (StrUtil.isNotEmpty(lyric)) {
+        ServerLogUtil.info(TAG, '成功从Plex服务器获取到歌词');
+        return lyric;
+      } else {
+        ServerLogUtil.info(TAG, 'Plex服务器未返回歌词');
+        return '';
+      }
+    } catch (error) {
+      const err = error as Error;
+      ServerLogUtil.error(TAG, `从Plex服务器获取歌词失败: ${err.message}`);
+      return '';
+    }
+  }
 }
 }
 
 
 export const lyricService = new LyricService();
 export const lyricService = new LyricService();

+ 11 - 2
entry/src/main/ets/view/LocalMusic.ets

@@ -101,6 +101,8 @@ import {
   isBaiduType,
   isBaiduType,
   isJellyfinType,
   isJellyfinType,
   isEmbyType,
   isEmbyType,
+  isAudioStationType,
+  isPlexType,
   isRemoteCloudType,
   isRemoteCloudType,
   WorkerEditMusicResult,
   WorkerEditMusicResult,
   WebDavMetadataUpdatePayload
   WebDavMetadataUpdatePayload
@@ -9948,9 +9950,10 @@ export struct LocalMusic {
       return
       return
     }
     }
 
 
-    // 2. 尝试从服务器获取歌词 (Navidrome/Jellyfin/Emby)
+    // 2. 尝试从服务器获取歌词 (Navidrome/Jellyfin/Emby/AudioStation/Plex)
     if (this.currentSong && (isJellyfinType(this.currentSong.type)||isNavidromeType(this.currentSong.type)
     if (this.currentSong && (isJellyfinType(this.currentSong.type)||isNavidromeType(this.currentSong.type)
-      || isEmbyType(this.currentSong.type))) {
+      || isEmbyType(this.currentSong.type) || isAudioStationType(this.currentSong.type)
+      || isPlexType(this.currentSong.type))) {
       const manager = RemoteDriveManager.getInstance();
       const manager = RemoteDriveManager.getInstance();
       const getAccount = (accountId: string) => manager.getWebDavAccountById(accountId);
       const getAccount = (accountId: string) => manager.getWebDavAccountById(accountId);
 
 
@@ -9974,6 +9977,12 @@ export struct LocalMusic {
       if (!serverLyric && isEmbyType(songData.type)) {
       if (!serverLyric && isEmbyType(songData.type)) {
         serverLyric = await lyricService.fetchEmbyLyric(songData, getAccount);
         serverLyric = await lyricService.fetchEmbyLyric(songData, getAccount);
       }
       }
+      if (!serverLyric && isAudioStationType(songData.type)) {
+        serverLyric = await lyricService.fetchAudioStationLyric(songData, getAccount);
+      }
+      if (!serverLyric && isPlexType(songData.type)) {
+        serverLyric = await lyricService.fetchPlexLyric(songData, getAccount);
+      }
 
 
       if (serverLyric) {
       if (serverLyric) {
         this.setLyricToControllers(serverLyric, lyricPath);
         this.setLyricToControllers(serverLyric, lyricPath);

+ 20 - 5
entry/src/main/ets/view/NavidromePage.ets

@@ -1557,6 +1557,7 @@ export struct NavidromePage {
     return songs.map(song => {
     return songs.map(song => {
       const artistName = song.artist ?? song.albumArtist ?? '';
       const artistName = song.artist ?? song.albumArtist ?? '';
       const albumKey = this.buildAudioStationAlbumKey(song.album, song.albumArtist ?? song.artist);
       const albumKey = this.buildAudioStationAlbumKey(song.album, song.albumArtist ?? song.artist);
+      const songCoverId = song.id ? `as-song:${song.id}` : albumKey;
       const restSong: NavidromeRestSong = {
       const restSong: NavidromeRestSong = {
         id: song.id,
         id: song.id,
         title: song.title,
         title: song.title,
@@ -1571,7 +1572,7 @@ export struct NavidromePage {
         track: song.track,
         track: song.track,
         year: song.year,
         year: song.year,
         contentType: song.container ? `audio/${song.container}` : undefined,
         contentType: song.container ? `audio/${song.container}` : undefined,
-        coverArtId: albumKey
+        coverArtId: songCoverId
       };
       };
       return restSong;
       return restSong;
     });
     });
@@ -1817,7 +1818,7 @@ export struct NavidromePage {
     // }
     // }
 
 
     if (!this.isNavidromeAccount(account)) {
     if (!this.isNavidromeAccount(account)) {
-      const fallbackId = song.albumId ?? song.id;
+      const fallbackId = song.coverArtId ?? song.albumId ?? song.id;
       void ServerLogUtil.debug('NavidromePageCover', `歌曲使用非Navidrome账号封面 - title: ${song.title}, fallbackId: ${fallbackId}`);
       void ServerLogUtil.debug('NavidromePageCover', `歌曲使用非Navidrome账号封面 - title: ${song.title}, fallbackId: ${fallbackId}`);
       return this.buildCoverUrl(account, fallbackId);
       return this.buildCoverUrl(account, fallbackId);
     }
     }
@@ -1877,9 +1878,15 @@ export struct NavidromePage {
         void ServerLogUtil.debug('NavidromePageCover', `调用 embyApi.buildPrimaryImageUrl - coverId: ${normalizedId}, size: ${size}`);
         void ServerLogUtil.debug('NavidromePageCover', `调用 embyApi.buildPrimaryImageUrl - coverId: ${normalizedId}, size: ${size}`);
         url = await embyApi.buildPrimaryImageUrl(account, normalizedId, size, size);
         url = await embyApi.buildPrimaryImageUrl(account, normalizedId, size, size);
       } else if (this.isAudioStationAccount(account)) {
       } else if (this.isAudioStationAccount(account)) {
-        const key = this.parseAudioStationAlbumKey(normalizedId);
-        void ServerLogUtil.debug('NavidromePageCover', `调用 audioStationApi.buildAlbumCoverUrl - album=${key.name}, artist=${key.artist}`);
-        url = await audioStationApi.buildAlbumCoverUrl(account, key.name, key.artist);
+        if (this.isAudioStationSongCoverId(normalizedId)) {
+          const songId = this.stripAudioStationSongCoverId(normalizedId);
+          void ServerLogUtil.debug('NavidromePageCover', `调用 audioStationApi.buildSongCoverUrl - songId=${songId}`);
+          url = await audioStationApi.buildSongCoverUrl(account, songId);
+        } else {
+          const key = this.parseAudioStationAlbumKey(normalizedId);
+          void ServerLogUtil.debug('NavidromePageCover', `调用 audioStationApi.buildAlbumCoverUrl - album=${key.name}, artist=${key.artist}`);
+          url = await audioStationApi.buildAlbumCoverUrl(account, key.name, key.artist);
+        }
       } else if (this.isPlexAccount(account)) {
       } else if (this.isPlexAccount(account)) {
         void ServerLogUtil.debug('NavidromePageCover', `调用 plexApi.buildImageUrl - coverId: ${normalizedId}`);
         void ServerLogUtil.debug('NavidromePageCover', `调用 plexApi.buildImageUrl - coverId: ${normalizedId}`);
         url = plexApi.buildImageUrl(account, normalizedId);
         url = plexApi.buildImageUrl(account, normalizedId);
@@ -1904,6 +1911,14 @@ export struct NavidromePage {
     return url;
     return url;
   }
   }
 
 
+  private isAudioStationSongCoverId(value: string): boolean {
+    return value.startsWith('as-song:');
+  }
+
+  private stripAudioStationSongCoverId(value: string): string {
+    return value.replace(/^as-song:/, '');
+  }
+
   /**
   /**
    * 记录账号缓存信息
    * 记录账号缓存信息
    */
    */