ソースを参照

Merge remote-tracking branch 'origin/master'

onecold 6 ヶ月 前
コミット
deac18ee9a

+ 2 - 0
entry/src/main/ets/common/constants/CommonConstants.ets

@@ -201,6 +201,8 @@ export class CommonConstants {
   static readonly TYPE_BAIDU: number = 7;//百度网盘文件
   static readonly TYPE_JELLYFIN: number = 8;//Jellyfin流媒体文件
   static readonly TYPE_EMBY: number = 9;//Emby流媒体文件
+  static readonly TYPE_AUDIOSTATION: number = 10;//AudioStation流媒体文件
+  static readonly TYPE_PLEX: number = 11;//Plex流媒体文件
 
   static readonly TYPE_LOCK: number = 200;//私密视频
   static readonly TYPE_LIKE: number = 201;//我收藏的视频

+ 2 - 0
entry/src/main/ets/common/enums/RemoteDriveType.ets

@@ -9,4 +9,6 @@ export enum RemoteDriveType {
   DISK_115 = 7,
   Jellyfin = 8,
   Emby = 9,
+  AudioStation = 10,
+  Plex = 11,
 }

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

@@ -723,6 +723,26 @@ export class AudioStationApi {
     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> {
     if (!songId) {
       throw new Error('无效的AudioStation歌曲ID');
@@ -875,6 +895,41 @@ export class AudioStationApi {
     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 {
     if (typeof payload === 'string') {
       const trimmed = payload.trim();

+ 211 - 8
entry/src/main/ets/common/network/PlexApi.ets

@@ -51,6 +51,8 @@ export interface PlexSong {
   title?: string;
   artist?: string;
   album?: string;
+  albumId?: string;
+  artistId?: string;
   durationSeconds?: number;
   size?: number;
   suffix?: string;
@@ -241,10 +243,15 @@ export class PlexApi {
     if (!resolvedPartKey) {
       throw new Error('Plex 未找到可用的播放地址');
     }
-    const baseUrl = this.buildBaseUrl(account);
     const tokenQuery = `X-Plex-Token=${encodeURIComponent(auth.token)}`;
-    const joiner = resolvedPartKey.includes('?') ? '&' : '?';
-    return `${baseUrl}${resolvedPartKey}${joiner}${tokenQuery}`;
+    if (resolvedPartKey.startsWith('http://') || resolvedPartKey.startsWith('https://')) {
+      const joiner = resolvedPartKey.includes('?') ? '&' : '?';
+      return `${resolvedPartKey}${joiner}${tokenQuery}`;
+    }
+    const baseUrl = this.buildBaseUrl(account);
+    const normalizedPart = resolvedPartKey.startsWith('/') ? resolvedPartKey : `/${resolvedPartKey}`;
+    const joiner = normalizedPart.includes('?') ? '&' : '?';
+    return `${baseUrl}${normalizedPart}${joiner}${tokenQuery}`;
   }
 
   buildImageUrl(account: WebDavAccount, path?: string): string | undefined {
@@ -259,8 +266,22 @@ export class PlexApi {
       return undefined;
     }
     const baseUrl = this.buildBaseUrl(account);
-    const joiner = path.includes('?') ? '&' : '?';
-    return `${baseUrl}${path}${joiner}X-Plex-Token=${encodeURIComponent(auth.token)}`;
+    let normalizedPath = this.normalizeImagePath(path);
+    if (!normalizedPath.startsWith('/')) {
+      normalizedPath = `/${normalizedPath}`;
+    }
+    const joiner = normalizedPath.includes('?') ? '&' : '?';
+    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> {
@@ -441,11 +462,15 @@ export class PlexApi {
       const bitRate = mediaAttrs.bitrate ? Number(mediaAttrs.bitrate) : undefined;
       const suffix = mediaAttrs.container ?? partAttrs.container;
       const size = partAttrs.size ? Number(partAttrs.size) : undefined;
+      const artistName = this.resolveTrackArtist(attrs);
+      const title = this.resolveTrackTitle(attrs.title, partAttrs.file);
       const track: PlexSong = {
         id: attrs.ratingKey ?? attrs.key ?? '',
-        title: attrs.title,
-        artist: attrs.grandparentTitle,
+        title,
+        artist: artistName,
         album: attrs.parentTitle,
+        albumId: attrs.parentRatingKey,
+        artistId: attrs.grandparentRatingKey,
         durationSeconds: durationMs ? Math.round(durationMs / 1000) : undefined,
         size,
         suffix,
@@ -465,6 +490,52 @@ export class PlexApi {
     return tracks;
   }
 
+  private resolveTrackArtist(attrs: Record<string, string>): string | undefined {
+    const candidates = [
+      attrs.originalTitle,
+      attrs.artist,
+      attrs.grandparentTitle,
+      attrs.parentTitle
+    ];
+    for (let i = 0; i < candidates.length; i++) {
+      const value = candidates[i];
+      if (value && value.trim().length > 0) {
+        return value;
+      }
+    }
+    return undefined;
+  }
+
+  private resolveTrackTitle(rawTitle?: string, filePath?: string): string | undefined {
+    const title = rawTitle ? rawTitle.trim() : '';
+    if (title.length > 0 && title.toLowerCase() !== 'various artists') {
+      return title;
+    }
+    const fileTitle = this.extractTitleFromFile(filePath);
+    if (fileTitle) {
+      return fileTitle;
+    }
+    return title.length > 0 ? title : undefined;
+  }
+
+  private extractTitleFromFile(filePath?: string): string | undefined {
+    if (!filePath) {
+      return undefined;
+    }
+    const normalized = filePath.replace(/\\/g, '/');
+    const lastSlash = normalized.lastIndexOf('/');
+    let baseName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
+    if (!baseName) {
+      return undefined;
+    }
+    const lastDot = baseName.lastIndexOf('.');
+    if (lastDot > 0) {
+      baseName = baseName.slice(0, lastDot);
+    }
+    const trimmed = baseName.trim();
+    return trimmed.length > 0 ? trimmed : undefined;
+  }
+
   private parsePlaylists(xmlText: string): PlexPlaylist[] {
     const results: PlexPlaylist[] = [];
     const playlistTag = /<Playlist\b([^>]*)\/>/g;
@@ -504,6 +575,99 @@ export class PlexApi {
     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> {
     const tagMatch = xmlText.match(new RegExp(`<${tagName}\\b([^>]*)\\/?>`));
     if (!tagMatch) {
@@ -520,7 +684,7 @@ export class PlexApi {
     const attrRegex = /([\w:-]+)="([^"]*)"/g;
     let match = attrRegex.exec(raw);
     while (match) {
-      attrs[match[1]] = match[2];
+      attrs[match[1]] = this.decodeXmlEntities(match[2]);
       match = attrRegex.exec(raw);
     }
     return attrs;
@@ -534,6 +698,45 @@ export class PlexApi {
     const next = start + receivedCount;
     return next < total ? next : null;
   }
+
+  private normalizeImagePath(path: string): string {
+    const trimmed = path.trim();
+    if (trimmed.length === 0) {
+      return trimmed;
+    }
+    if (trimmed.startsWith('/')) {
+      return trimmed;
+    }
+    const albumMatch = trimmed.match(/^(al|ar)-(\d+)$/);
+    if (albumMatch && albumMatch[2]) {
+      return `/library/metadata/${albumMatch[2]}/thumb`;
+    }
+    if (/^\d+$/.test(trimmed)) {
+      return `/library/metadata/${trimmed}/thumb`;
+    }
+    return `/${trimmed}`;
+  }
+
+  private decodeXmlEntities(value: string): string {
+    if (!value || value.indexOf('&') < 0) {
+      return value;
+    }
+    let decoded = value
+      .replace(/&lt;/g, '<')
+      .replace(/&gt;/g, '>')
+      .replace(/&quot;/g, '"')
+      .replace(/&apos;/g, '\'')
+      .replace(/&amp;/g, '&');
+    decoded = decoded.replace(/&#x([0-9a-fA-F]+);/g, (_match: string, hex: string) => {
+      const code = parseInt(hex, 16);
+      return String.fromCodePoint(code);
+    });
+    decoded = decoded.replace(/&#([0-9]+);/g, (_match: string, num: string) => {
+      const code = parseInt(num, 10);
+      return String.fromCodePoint(code);
+    });
+    return decoded;
+  }
 }
 
 export const plexApi = new PlexApi();

+ 5 - 1
entry/src/main/ets/common/network/RemoteSongCache.ets

@@ -18,7 +18,9 @@ export enum RemoteCacheType {
   BAIDU = 'baidu',
   NAVIDROME = 'navidrome',
   JELLYFIN = 'jellyfin',
-  EMBY = 'emby'
+  EMBY = 'emby',
+  AUDIOSTATION = 'audiostation',
+  PLEX = 'plex'
 }
 
 export interface CachePathInfo {
@@ -214,6 +216,8 @@ export async function clearAllRemoteCaches(): Promise<void> {
   await clearRemoteCacheByAccount(RemoteCacheType.NAVIDROME);
   await clearRemoteCacheByAccount(RemoteCacheType.JELLYFIN);
   await clearRemoteCacheByAccount(RemoteCacheType.EMBY);
+  await clearRemoteCacheByAccount(RemoteCacheType.AUDIOSTATION);
+  await clearRemoteCacheByAccount(RemoteCacheType.PLEX);
 }
 
 export async function clearWebDavCacheByAccount(accountId?: string | number): Promise<void> {

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

@@ -1,6 +1,8 @@
 import { navidromeRestApi } from '../network/NavidromeRestApi';
 import { jellyfinApi } from '../network/JellyfinApi';
 import { embyApi } from '../network/EmbyApi';
+import { audioStationApi } from '../network/AudioStationApi';
+import { plexApi } from '../network/PlexApi';
 import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import { ServerLogUtil } from '../util/ServerLogUtil';
 import { StrUtil } from '@pura/harmony-utils';
@@ -161,6 +163,90 @@ class LyricService {
       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();

+ 4 - 0
entry/src/main/ets/common/util/RemoteDriveLabel.ets

@@ -20,6 +20,10 @@ export function getRemoteDriveProtocolLabel(type?: number): string {
       return 'Jellyfin';
     case RemoteDriveType.Emby:
       return 'Emby';
+    case RemoteDriveType.AudioStation:
+      return 'AudioStation';
+    case RemoteDriveType.Plex:
+      return 'Plex';
     case RemoteDriveType.Ftp:
       return 'FTP';
     case RemoteDriveType.Baidu:

+ 234 - 2
entry/src/main/ets/common/util/RemotePlayerUtil.ets

@@ -8,9 +8,13 @@ import { RemoteDriveManager } from './RemoteDriveManager';
 import { navidromeApi } from '../network/NavidromeApi';
 import { jellyfinApi } from '../network/JellyfinApi';
 import { embyApi } from '../network/EmbyApi';
+import { audioStationApi } from '../network/AudioStationApi';
+import { plexApi } from '../network/PlexApi';
 import { ensureNavidromeFileCached } from '../network/NavidromeFileCache';
 import { ensureJellyfinFileCached } from '../network/JellyfinFileCache';
 import { ensureEmbyFileCached } from '../network/EmbyFileCache';
+import { ensureAudioStationFileCached } from '../network/AudioStationFileCache';
+import { ensurePlexFileCached } from '../network/PlexFileCache';
 import FileManager from './FileManager';
 import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from '../network/RemoteSongCache';
 import { RemoteCacheManager } from '../network/RemoteCacheManager';
@@ -224,6 +228,46 @@ export async function preloadNextSongIfNeeded(
         })();
       }
     }
+
+    // AudioStation
+    if (isAudioStationType(nextSong.type) && nextSong.webdav_account_id) {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
+      if (account) {
+        const songId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
+        const streamUrl = await audioStationApi.buildStreamUrl(account, songId);
+        void (async () => {
+          try {
+            console.info(TAG, `后台提前缓存AudioStation下一首: ${songId}`);
+            await ensureAudioStationFileCached(account, songId, streamUrl, nextSong.videoSize);
+            console.info(TAG, `AudioStation下一首提前缓存完成`);
+          } catch (error) {
+            const err = error as Error;
+            console.warn(TAG, `AudioStation下一首提前缓存失败: ${err.message}`);
+          }
+        })();
+      }
+    }
+
+    // Plex
+    if (isPlexType(nextSong.type) && nextSong.webdav_account_id) {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
+      if (account) {
+        const songId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
+        const streamUrl = await plexApi.buildStreamUrl(account, songId);
+        void (async () => {
+          try {
+            console.info(TAG, `后台提前缓存Plex下一首: ${songId}`);
+            await ensurePlexFileCached(account, songId, streamUrl, nextSong.videoSize);
+            console.info(TAG, `Plex下一首提前缓存完成`);
+          } catch (error) {
+            const err = error as Error;
+            console.warn(TAG, `Plex下一首提前缓存失败: ${err.message}`);
+          }
+        })();
+      }
+    }
   } catch (error) {
     const err = error as Error;
     console.warn(TAG, `提前缓存下一首失败: ${err.message}`);
@@ -655,6 +699,136 @@ export async function setVideoUrlForSong(
     }
   }
 
+  // AudioStation 类型处理
+  if (isAudioStationType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('AudioStation账号不可用');
+      }
+      const audioSongId = song.remote_rel_path || song.id || song.filePath;
+
+      const normalizedRelative = normalizeCacheRelativePath(audioSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.AUDIOSTATION,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const cachePath = pathInfo.cachePath;
+      const exists = await FileManager.isExist(cachePath);
+      if (exists) {
+        const size = await FileManager.getFileSize(cachePath);
+        if (size > 0) {
+          if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+            Logger.warn(TAG, `AudioStation 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachePath);
+          } else {
+            Logger.info(TAG, `AudioStation 缓存命中,直接播放: ${cachePath}, size=${size}`);
+            void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+            return cachePath;
+          }
+        }
+      }
+
+      const streamUrl = await audioStationApi.buildStreamUrl(account, audioSongId);
+      void ServerLogUtil.info(TAG, `AudioStation 流地址构建成功: ${streamUrl}`);
+      void ServerLogUtil.debug(TAG, `播放songId=${audioSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+      const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存AudioStation文件: ${audioSongId}`);
+          void ServerLogUtil.info('AudioStationStream', `开始缓存: songId=${audioSongId}`);
+          const cachedFilePath = await ensureAudioStationFileCached(
+            account,
+            audioSongId,
+            streamUrl,
+            song.videoSize
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('AudioStationStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('AudioStationStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return sanitizedUrl;
+    } catch (error) {
+      const err = error as Error;
+      void ServerLogUtil.error(TAG, `AudioStation URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // Plex 类型处理
+  if (isPlexType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('Plex账号不可用');
+      }
+      const plexSongId = song.remote_rel_path || song.id || song.filePath;
+
+      const normalizedRelative = normalizeCacheRelativePath(plexSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.PLEX,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const cachePath = pathInfo.cachePath;
+      const exists = await FileManager.isExist(cachePath);
+      if (exists) {
+        const size = await FileManager.getFileSize(cachePath);
+        if (size > 0) {
+          if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+            Logger.warn(TAG, `Plex 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachePath);
+          } else {
+            Logger.info(TAG, `Plex 缓存命中,直接播放: ${cachePath}, size=${size}`);
+            void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+            return cachePath;
+          }
+        }
+      }
+
+      const streamUrl = await plexApi.buildStreamUrl(account, plexSongId);
+      void ServerLogUtil.info(TAG, `Plex 流地址构建成功: ${streamUrl}`);
+      void ServerLogUtil.debug(TAG, `播放songId=${plexSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+      const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存Plex文件: ${plexSongId}`);
+          void ServerLogUtil.info('PlexStream', `开始缓存: songId=${plexSongId}`);
+          const cachedFilePath = await ensurePlexFileCached(
+            account,
+            plexSongId,
+            streamUrl,
+            song.videoSize
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('PlexStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('PlexStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return sanitizedUrl;
+    } catch (error) {
+      const err = error as Error;
+      void ServerLogUtil.error(TAG, `Plex URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
   // SMB 类型处理
   if (isSmbType(song.type) && song.webdav_account_id) {
     try {
@@ -815,6 +989,12 @@ export async function setVideoUrlForSong(
   if (isEmbyType(song.type)) {
     throw new Error('Emby歌曲缺少webdav_account_id,无法构建播放链接');
   }
+  if (isAudioStationType(song.type)) {
+    throw new Error('AudioStation歌曲缺少webdav_account_id,无法构建播放链接');
+  }
+  if (isPlexType(song.type)) {
+    throw new Error('Plex歌曲缺少webdav_account_id,无法构建播放链接');
+  }
 
   Logger.info(TAG, `setVideoUrlForSong fallback直接返回原始路径: ${song.filePath}`);
   return song.filePath;
@@ -851,7 +1031,8 @@ function resolveRemoteStoragePathForMetadata(item: VideoItem): string | null {
     const relative = item.remote_rel_path || extractFtpRelativePath(item);
     return relative && relative.length > 0 ? relative : null;
   }
-  if (isNavidromeType(item.type) || isJellyfinType(item.type) || isEmbyType(item.type) || isBaiduType(item.type)) {
+  if (isNavidromeType(item.type) || isJellyfinType(item.type) || isEmbyType(item.type) || isAudioStationType(item.type)
+    || isPlexType(item.type) || isBaiduType(item.type)) {
     return item.remote_rel_path || item.id || item.filePath || null;
   }
   return item.filePath || null;
@@ -874,6 +1055,8 @@ export function getTypeOrder(type: number) {
     case CommonConstants.TYPE_BAIDU:
     case CommonConstants.TYPE_JELLYFIN:
     case CommonConstants.TYPE_EMBY:
+    case CommonConstants.TYPE_AUDIOSTATION:
+    case CommonConstants.TYPE_PLEX:
       return 3;
     default:
       return 4; // Unknown types, if any, go last
@@ -910,8 +1093,17 @@ export function isEmbyType(type: number | undefined): boolean {
   return type !== undefined && type === CommonConstants.TYPE_EMBY;
 }
 
+export function isAudioStationType(type: number | undefined): boolean {
+  return type !== undefined && type === CommonConstants.TYPE_AUDIOSTATION;
+}
+
+export function isPlexType(type: number | undefined): boolean {
+  return type !== undefined && type === CommonConstants.TYPE_PLEX;
+}
+
 export function isRemoteCloudType(type: number): boolean {
-  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type) || isBaiduType(type) || isJellyfinType(type) || isEmbyType(type);
+  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type) || isBaiduType(type)
+    || isJellyfinType(type) || isEmbyType(type) || isAudioStationType(type) || isPlexType(type);
 }
 
 export function getShareNameFromFilePath(filePath?: string): string | undefined {
@@ -1071,6 +1263,46 @@ export async function isRemoteSongCached(song: VideoItem): Promise<boolean> {
       return true;
     }
 
+    // AudioStation类型
+    if (isAudioStationType(song.type)) {
+      const audioSongId = song.remote_rel_path || song.id || song.filePath;
+      const normalizedRelative = normalizeCacheRelativePath(audioSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.AUDIOSTATION,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const exists = await FileManager.isExist(pathInfo.cachePath);
+      if (!exists) {
+        return false;
+      }
+      const size = await FileManager.getFileSize(pathInfo.cachePath);
+      if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+        return false;
+      }
+      return true;
+    }
+
+    // Plex类型
+    if (isPlexType(song.type)) {
+      const plexSongId = song.remote_rel_path || song.id || song.filePath;
+      const normalizedRelative = normalizeCacheRelativePath(plexSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.PLEX,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const exists = await FileManager.isExist(pathInfo.cachePath);
+      if (!exists) {
+        return false;
+      }
+      const size = await FileManager.getFileSize(pathInfo.cachePath);
+      if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+        return false;
+      }
+      return true;
+    }
+
     // 百度网盘类型
     if (isBaiduType(song.type)) {
       const relativePath = song.remote_rel_path || song.name || song.fileName || song.filePath;

+ 30 - 2
entry/src/main/ets/dialog/RemoteDriveAccountDialog.ets

@@ -455,7 +455,11 @@ export struct RemoteDriveAccountDialog {
       }
       .alignItems(VerticalAlign.Center);
 
-      if (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby) {
+      if (this.driveType === RemoteDriveType.WebDav
+        || this.driveType === RemoteDriveType.Jellyfin
+        || this.driveType === RemoteDriveType.Emby
+        || this.driveType === RemoteDriveType.AudioStation
+        || this.driveType === RemoteDriveType.Plex) {
         Row({ space: 12 }) {
           Text('启用HTTPS')
             .fontSize(14)
@@ -465,7 +469,11 @@ export struct RemoteDriveAccountDialog {
             .onChange((isOn: boolean) => {
               this.enableHttps = isOn;
               if (!this.portCustomized &&
-                (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby)) {
+                (this.driveType === RemoteDriveType.WebDav
+                  || this.driveType === RemoteDriveType.Jellyfin
+                  || this.driveType === RemoteDriveType.Emby
+                  || this.driveType === RemoteDriveType.AudioStation
+                  || this.driveType === RemoteDriveType.Plex)) {
                 this.updatePortState(this.getDefaultPort(), false);
               }
             });
@@ -555,6 +563,8 @@ export struct RemoteDriveAccountDialog {
         this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
         this.buildTypeButton('Jellyfin', RemoteDriveType.Jellyfin);
         this.buildTypeButton('Emby', RemoteDriveType.Emby);
+        this.buildTypeButton('AudioStation', RemoteDriveType.AudioStation);
+        this.buildTypeButton('Plex', RemoteDriveType.Plex);
         this.buildTypeButton('FTP', RemoteDriveType.Ftp);
         this.buildTypeButton('百度网盘', RemoteDriveType.Baidu);
       }
@@ -639,6 +649,10 @@ export struct RemoteDriveAccountDialog {
         return '新建Jellyfin';
       case RemoteDriveType.Emby:
         return '新建Emby';
+      case RemoteDriveType.AudioStation:
+        return '新建AudioStation';
+      case RemoteDriveType.Plex:
+        return '新建Plex';
       case RemoteDriveType.Ftp:
         return '新建FTP';
       case RemoteDriveType.Baidu:
@@ -662,6 +676,10 @@ export struct RemoteDriveAccountDialog {
         return '可粘贴 Jellyfin 连接(如 https://user:pass@host:8096/jellyfin),自动填充参数';
       case RemoteDriveType.Emby:
         return '可粘贴 Emby 连接(如 https://user:pass@host:8096/emby),自动填充参数';
+      case RemoteDriveType.AudioStation:
+        return '可粘贴 AudioStation 连接(如 https://user:pass@host:5001),自动填充参数';
+      case RemoteDriveType.Plex:
+        return '可粘贴 Plex 连接(如 http://user:pass@host:32400),自动填充参数';
       case RemoteDriveType.Ftp:
         return '支持 ftp://user:pass@host:21/path 输入,自动填充账户和目录';
       case RemoteDriveType.Baidu:
@@ -688,6 +706,12 @@ export struct RemoteDriveAccountDialog {
     if (this.driveType === RemoteDriveType.Emby) {
       return this.enableHttps ? 8920 : 8096;
     }
+    if (this.driveType === RemoteDriveType.AudioStation) {
+      return this.enableHttps ? 5001 : 5000;
+    }
+    if (this.driveType === RemoteDriveType.Plex) {
+      return 32400;
+    }
     if (this.driveType === RemoteDriveType.Ftp) {
       return 21;
     }
@@ -1516,6 +1540,10 @@ export function getCloudDiskIcon(type: number): ResourceStr {
       return $r('app.media.jellyfin');
     case RemoteDriveType.Emby:
       return $r('app.media.emby');
+    case RemoteDriveType.AudioStation:
+      return $r('app.media.cloudDisk');
+    case RemoteDriveType.Plex:
+      return $r('app.media.cloudDisk');
     case RemoteDriveType.Baidu:
       return $r('app.media.baiduwp');
     case RemoteDriveType.ALi:

+ 18 - 2
entry/src/main/ets/pages/NewIndex.ets

@@ -1639,6 +1639,20 @@ struct NewIndex {
         .onClick(async () => {
           this.showRemoteDriveAccountDialog(false, undefined, RemoteDriveType.Emby)
         })
+      MenuItem({
+        startIcon: $r('app.media.cloudDisk'),
+        content: 'AudioStation'
+      })
+        .onClick(async () => {
+          this.showRemoteDriveAccountDialog(false, undefined, RemoteDriveType.AudioStation)
+        })
+      MenuItem({
+        startIcon: $r('app.media.cloudDisk'),
+        content: 'Plex'
+      })
+        .onClick(async () => {
+          this.showRemoteDriveAccountDialog(false, undefined, RemoteDriveType.Plex)
+        })
       MenuItem({
         startIcon: $r('app.media.baiduwp'),
         content: '百度网盘'
@@ -1982,9 +1996,11 @@ struct NewIndex {
       // 根据账户类型切换到对应页面
       if (account.webType === RemoteDriveType.Navidrome
         || account.webType === RemoteDriveType.Jellyfin
-        || account.webType === RemoteDriveType.Emby) {
+        || account.webType === RemoteDriveType.Emby
+        || account.webType === RemoteDriveType.AudioStation
+        || account.webType === RemoteDriveType.Plex) {
         this.mType = 7
-        LogUtil.info('heanup NewIndex', `切换到 Navidrome/Jellyfin/Emby 页面,webType=${account.webType}`)
+        LogUtil.info('heanup NewIndex', `切换到 Navidrome/Jellyfin/Emby/AudioStation/Plex 页面,webType=${account.webType}`)
       } else {
         this.mType = 6
         LogUtil.info('heanup NewIndex', `切换到 WebDAV 页面,webType=${account.webType}`)

+ 14 - 3
entry/src/main/ets/view/LocalMusic.ets

@@ -101,6 +101,8 @@ import {
   isBaiduType,
   isJellyfinType,
   isEmbyType,
+  isAudioStationType,
+  isPlexType,
   isRemoteCloudType,
   WorkerEditMusicResult,
   WebDavMetadataUpdatePayload
@@ -6850,7 +6852,9 @@ export struct LocalMusic {
       case CommonConstants.TYPE_BAIDU:
       case CommonConstants.TYPE_JELLYFIN:
       case CommonConstants.TYPE_EMBY:
-        // 处理网络音频播放(WebDAV/SMB)
+      case CommonConstants.TYPE_AUDIOSTATION:
+      case CommonConstants.TYPE_PLEX:
+        // 处理网络音频播放(WebDAV/SMB/Navidrome/Jellyfin/Emby/AudioStation/Plex/Baidu)
         Logger.info(`heanup 处理云端音频播放: ${item.name}, URL: ${item.filePath}`)
 
         if (this.CONTROL_PlayStatus !== PlayStatus.INIT) {
@@ -9946,9 +9950,10 @@ export struct LocalMusic {
       return
     }
 
-    // 2. 尝试从服务器获取歌词 (Navidrome/Jellyfin/Emby)
+    // 2. 尝试从服务器获取歌词 (Navidrome/Jellyfin/Emby/AudioStation/Plex)
     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 getAccount = (accountId: string) => manager.getWebDavAccountById(accountId);
 
@@ -9972,6 +9977,12 @@ export struct LocalMusic {
       if (!serverLyric && isEmbyType(songData.type)) {
         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) {
         this.setLyricToControllers(serverLyric, lyricPath);

+ 588 - 8
entry/src/main/ets/view/NavidromePage.ets

@@ -25,6 +25,8 @@ import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
 import { SettingPage } from '../pages/SettingPage';
 import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../common/network/JellyfinApi';
 import { embyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../common/network/EmbyApi';
+import { audioStationApi, AudioStationAlbum, AudioStationArtist, AudioStationPlaylist, AudioStationSong } from '../common/network/AudioStationApi';
+import { plexApi, PlexAlbum, PlexArtist, PlexPlaylist, PlexSong } from '../common/network/PlexApi';
 import { getRemoteDriveDisplayLabel } from '../common/util/RemoteDriveLabel';
 import NavidromeListCache, { AllCacheData } from '../common/util/NavidromeListCache';
 import { taskpool } from '@kit.ArkTS';
@@ -58,6 +60,16 @@ interface LibraryInfo {
   scheme: string;
 }
 
+class AudioStationAlbumKey {
+  name: string;
+  artist: string;
+
+  constructor(name: string, artist: string) {
+    this.name = name;
+    this.artist = artist;
+  }
+}
+
 /**
  * taskpool 任务结果接口
  * 返回带封面的完整数据
@@ -165,6 +177,14 @@ export struct NavidromePage {
     return account.webType === RemoteDriveType.Emby;
   }
 
+  private isAudioStationAccount(account: WebDavAccount): boolean {
+    return account.webType === RemoteDriveType.AudioStation;
+  }
+
+  private isPlexAccount(account: WebDavAccount): boolean {
+    return account.webType === RemoteDriveType.Plex;
+  }
+
   private createTabOptions(): SegmentButtonOptions {
     const buttons = [
       { text: '全部' },
@@ -310,7 +330,9 @@ export struct NavidromePage {
     }
     if (!this.isNavidromeAccount(this.selectedAccount)
       && !this.isJellyfinAccount(this.selectedAccount)
-      && !this.isEmbyAccount(this.selectedAccount)) {
+      && !this.isEmbyAccount(this.selectedAccount)
+      && !this.isAudioStationAccount(this.selectedAccount)
+      && !this.isPlexAccount(this.selectedAccount)) {
       return undefined;
     }
     if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) {
@@ -352,6 +374,14 @@ export struct NavidromePage {
     }
     if (this.isEmbyAccount(account)) {
       await this.loadEmbyLibrary(account);
+      return;
+    }
+    if (this.isAudioStationAccount(account)) {
+      await this.loadAudioStationLibrary(account);
+      return;
+    }
+    if (this.isPlexAccount(account)) {
+      await this.loadPlexLibrary(account);
     }
   }
 
@@ -545,6 +575,70 @@ export struct NavidromePage {
     }
   }
 
+  private async loadAudioStationLibrary(account: WebDavAccount): Promise<void> {
+    const ticket = ++this.loadTicket;
+    this.loading = true;
+    this.resetData();
+    void ServerLogUtil.info('NavidromeLoad', '开始加载 AudioStation 媒体库');
+    void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`);
+    try {
+      this.songNextStart = 0;
+      this.artistNextStart = 0;
+      this.albumNextStart = 0;
+      this.playlistNextStart = 0;
+      await Promise.all([
+        this.loadNextAudioStationSongPage(account, ticket),
+        this.loadNextAudioStationArtistPage(account, ticket),
+        this.loadNextAudioStationAlbumPage(account, ticket),
+        this.loadNextAudioStationPlaylistPage(account, ticket)
+      ]);
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.info('NavidromeLoad', `AudioStation 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
+      }
+    } catch (error) {
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.error('NavidromeLoad', `AudioStation 数据加载失败: ${(error as Error).message}`);
+        ToastUtil.showToast((error as Error).message ?? 'AudioStation 数据加载失败');
+      }
+    } finally {
+      if (ticket === this.loadTicket) {
+        this.loading = false;
+      }
+    }
+  }
+
+  private async loadPlexLibrary(account: WebDavAccount): Promise<void> {
+    const ticket = ++this.loadTicket;
+    this.loading = true;
+    this.resetData();
+    void ServerLogUtil.info('NavidromeLoad', '开始加载 Plex 媒体库');
+    void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`);
+    try {
+      this.songNextStart = 0;
+      this.artistNextStart = 0;
+      this.albumNextStart = 0;
+      this.playlistNextStart = 0;
+      await Promise.all([
+        this.loadNextPlexSongPage(account, ticket),
+        this.loadNextPlexArtistPage(account, ticket),
+        this.loadNextPlexAlbumPage(account, ticket),
+        this.loadNextPlexPlaylistPage(account, ticket)
+      ]);
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.info('NavidromeLoad', `Plex 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
+      }
+    } catch (error) {
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.error('NavidromeLoad', `Plex 数据加载失败: ${(error as Error).message}`);
+        ToastUtil.showToast((error as Error).message ?? 'Plex 数据加载失败');
+      }
+    } finally {
+      if (ticket === this.loadTicket) {
+        this.loading = false;
+      }
+    }
+  }
+
   private async loadNextSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -835,6 +929,192 @@ export struct NavidromePage {
     }
   }
 
+  private async loadNextAudioStationSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.songNextStart === null || this.isSongPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isSongPageLoading = true;
+    try {
+      const response = await audioStationApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const restSongs = this.convertAudioStationSongsToRestSongs(response.items);
+      const videoItems = await this.convertSongsToVideoItems(restSongs, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]);
+      this.songNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `AudioStation 歌曲追加: 本次 ${videoItems.length} 首, 总数 ${this.allVideos.length}`);
+    } finally {
+      this.isSongPageLoading = false;
+    }
+  }
+
+  private async loadNextAudioStationArtistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.artistNextStart === null || this.isArtistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isArtistPageLoading = true;
+    try {
+      const response = await audioStationApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = this.convertAudioStationArtistsToRest(response.items);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.artists = [...this.artists, ...processed];
+      this.artistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `AudioStation 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`);
+    } finally {
+      this.isArtistPageLoading = false;
+    }
+  }
+
+  private async loadNextAudioStationAlbumPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.albumNextStart === null || this.isAlbumPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isAlbumPageLoading = true;
+    try {
+      const response = await audioStationApi.getAlbumsPage(account, undefined, this.albumNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertAudioStationAlbumsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.albums = [...this.albums, ...processed];
+      this.albumNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `AudioStation 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`);
+    } finally {
+      this.isAlbumPageLoading = false;
+    }
+  }
+
+  private async loadNextAudioStationPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isPlaylistPageLoading = true;
+    try {
+      const response = await audioStationApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = this.convertAudioStationPlaylistsToRest(response.items);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `AudioStation 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
+  private async loadNextPlexSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.songNextStart === null || this.isSongPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isSongPageLoading = true;
+    try {
+      const response = await plexApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const restSongs = this.convertPlexSongsToRestSongs(response.items);
+      const videoItems = await this.convertSongsToVideoItems(restSongs, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]);
+      this.songNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Plex 歌曲追加: 本次 ${videoItems.length} 首, 总数 ${this.allVideos.length}`);
+    } finally {
+      this.isSongPageLoading = false;
+    }
+  }
+
+  private async loadNextPlexArtistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.artistNextStart === null || this.isArtistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isArtistPageLoading = true;
+    try {
+      const response = await plexApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertPlexArtistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.artists = [...this.artists, ...processed];
+      this.artistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Plex 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`);
+    } finally {
+      this.isArtistPageLoading = false;
+    }
+  }
+
+  private async loadNextPlexAlbumPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.albumNextStart === null || this.isAlbumPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isAlbumPageLoading = true;
+    try {
+      const response = await plexApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertPlexAlbumsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.albums = [...this.albums, ...processed];
+      this.albumNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Plex 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`);
+    } finally {
+      this.isAlbumPageLoading = false;
+    }
+  }
+
+  private async loadNextPlexPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isPlaylistPageLoading = true;
+    try {
+      const response = await plexApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = this.convertPlexPlaylistsToRest(response.items);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Plex 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async loadNextPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
       return;
@@ -919,6 +1199,44 @@ export struct NavidromePage {
         default:
           break;
       }
+      return;
+    }
+    if (this.isAudioStationAccount(account)) {
+      switch (this.selectedTab) {
+        case 0:
+          await this.loadNextAudioStationSongPage(account);
+          break;
+        case 1:
+          await this.loadNextAudioStationArtistPage(account);
+          break;
+        case 2:
+          await this.loadNextAudioStationAlbumPage(account);
+          break;
+        case 3:
+          await this.loadNextAudioStationPlaylistPage(account);
+          break;
+        default:
+          break;
+      }
+      return;
+    }
+    if (this.isPlexAccount(account)) {
+      switch (this.selectedTab) {
+        case 0:
+          await this.loadNextPlexSongPage(account);
+          break;
+        case 1:
+          await this.loadNextPlexArtistPage(account);
+          break;
+        case 2:
+          await this.loadNextPlexAlbumPage(account);
+          break;
+        case 3:
+          await this.loadNextPlexPlaylistPage(account);
+          break;
+        default:
+          break;
+      }
     }
   }
 
@@ -1042,6 +1360,23 @@ export struct NavidromePage {
     return map;
   }
 
+  private buildAudioStationAlbumKey(name?: string, artist?: string): string {
+    const safeName = name ?? '';
+    const safeArtist = artist ?? '';
+    return `as:${safeName}|||${safeArtist}`;
+  }
+
+  private parseAudioStationAlbumKey(value: string): AudioStationAlbumKey {
+    let raw = value ?? '';
+    if (raw.startsWith('as:')) {
+      raw = raw.slice(3);
+    }
+    const parts = raw.split('|||');
+    const name = parts.length > 0 ? parts[0] : '';
+    const artist = parts.length > 1 ? parts.slice(1).join('|||') : '';
+    return new AudioStationAlbumKey(name, artist);
+  }
+
   private async convertJellyfinArtistsToRest(artists: JellyfinArtist[], account: WebDavAccount): Promise<NavidromeRestArtist[]> {
     const results: NavidromeRestArtist[] = [];
     for (let i = 0; i < artists.length; i++) {
@@ -1110,6 +1445,72 @@ export struct NavidromePage {
     return results;
   }
 
+  private convertAudioStationArtistsToRest(artists: AudioStationArtist[]): NavidromeRestArtist[] {
+    return artists.map(artist => {
+      const name = artist.name ?? '';
+      return {
+        id: name,
+        name: name
+      } as NavidromeRestArtist;
+    });
+  }
+
+  private async convertAudioStationAlbumsToRest(albums: AudioStationAlbum[], account: WebDavAccount): Promise<NavidromeRestAlbum[]> {
+    const results: NavidromeRestAlbum[] = [];
+    for (let i = 0; i < albums.length; i++) {
+      const album = albums[i];
+      const artistName = album.albumArtist ?? album.displayArtist ?? '';
+      const albumKey = this.buildAudioStationAlbumKey(album.name, artistName);
+      const coverUrl = await this.buildCoverUrl(account, albumKey, 300);
+      const restAlbum: NavidromeRestAlbum = {
+        id: albumKey,
+        name: album.name,
+        artist: artistName,
+        minYear: album.year,
+        coverArtId: albumKey,
+        coverUrl: coverUrl
+      };
+      results.push(restAlbum);
+    }
+    return results;
+  }
+
+  private async convertPlexArtistsToRest(artists: PlexArtist[], account: WebDavAccount): Promise<NavidromeRestArtist[]> {
+    const results: NavidromeRestArtist[] = [];
+    for (let i = 0; i < artists.length; i++) {
+      const artist = artists[i];
+      const coverId = artist.thumb ?? (artist.id ? `ar-${artist.id}` : '');
+      const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined;
+      const restArtist: NavidromeRestArtist = {
+        id: artist.id,
+        name: artist.name,
+        coverArtId: coverId,
+        coverUrl: coverUrl
+      };
+      results.push(restArtist);
+    }
+    return results;
+  }
+
+  private async convertPlexAlbumsToRest(albums: PlexAlbum[], account: WebDavAccount): Promise<NavidromeRestAlbum[]> {
+    const results: NavidromeRestAlbum[] = [];
+    for (let i = 0; i < albums.length; i++) {
+      const album = albums[i];
+      const coverId = album.thumb ?? (album.id ? `al-${album.id}` : '');
+      const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined;
+      const restAlbum: NavidromeRestAlbum = {
+        id: album.id,
+        name: album.name,
+        artist: album.artist,
+        minYear: album.year,
+        coverArtId: coverId,
+        coverUrl: coverUrl
+      };
+      results.push(restAlbum);
+    }
+    return results;
+  }
+
   private convertJellyfinSongsToRestSongs(songs: JellyfinSong[]): NavidromeRestSong[] {
     return songs.map(song => {
       const restSong: NavidromeRestSong = {
@@ -1152,6 +1553,78 @@ export struct NavidromePage {
     });
   }
 
+  private convertAudioStationSongsToRestSongs(songs: AudioStationSong[]): NavidromeRestSong[] {
+    return songs.map(song => {
+      const artistName = song.artist ?? song.albumArtist ?? '';
+      const albumKey = this.buildAudioStationAlbumKey(song.album, song.albumArtist ?? song.artist);
+      const songCoverId = song.id ? `as-song:${song.id}` : albumKey;
+      const restSong: NavidromeRestSong = {
+        id: song.id,
+        title: song.title,
+        album: song.album,
+        albumId: albumKey,
+        artist: artistName,
+        artistId: artistName,
+        duration: song.durationSeconds,
+        bitRate: song.bitRate,
+        suffix: song.container,
+        size: song.size,
+        track: song.track,
+        year: song.year,
+        contentType: song.container ? `audio/${song.container}` : undefined,
+        coverArtId: songCoverId
+      };
+      return restSong;
+    });
+  }
+
+  private convertPlexSongsToRestSongs(songs: PlexSong[]): NavidromeRestSong[] {
+    return songs.map(song => {
+      const coverId = song.albumThumb ?? song.thumb ?? song.albumId ?? song.id;
+      const restSong: NavidromeRestSong = {
+        id: song.id,
+        title: song.title,
+        album: song.album,
+        albumId: song.albumId,
+        artist: song.artist,
+        artistId: song.artistId,
+        duration: song.durationSeconds,
+        bitRate: song.bitRate,
+        suffix: song.suffix,
+        size: song.size,
+        track: song.track,
+        year: song.year,
+        contentType: song.mimeType,
+        coverArtId: coverId
+      };
+      return restSong;
+    });
+  }
+
+  private convertAudioStationPlaylistsToRest(playlists: AudioStationPlaylist[]): NavidromeRestPlaylist[] {
+    return playlists.map(playlist => {
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.name,
+        path: playlist.path
+      };
+      return restPlaylist;
+    });
+  }
+
+  private convertPlexPlaylistsToRest(playlists: PlexPlaylist[]): NavidromeRestPlaylist[] {
+    return playlists.map(playlist => {
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.title,
+        comment: playlist.summary,
+        duration: playlist.duration,
+        songCount: playlist.leafCount
+      };
+      return restPlaylist;
+    });
+  }
+
   private async fetchAllJellyfinArtistSongs(account: WebDavAccount, artistId: string): Promise<JellyfinSong[]> {
     const results: JellyfinSong[] = [];
     let startIndex = 0;
@@ -1180,6 +1653,60 @@ export struct NavidromePage {
     return results;
   }
 
+  private async fetchAllAudioStationArtistSongs(account: WebDavAccount, artistName: string): Promise<AudioStationSong[]> {
+    if (!artistName || artistName.trim().length === 0) {
+      return [];
+    }
+    const keyword = artistName.trim();
+    const songs = await audioStationApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
+    return songs.filter(song => {
+      const artist = song.artist ?? song.albumArtist ?? '';
+      return artist.trim() === keyword;
+    });
+  }
+
+  private async fetchAllAudioStationPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<AudioStationSong[]> {
+    const results: AudioStationSong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await audioStationApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
+  private async fetchAllPlexArtistSongs(account: WebDavAccount, artistId: string): Promise<PlexSong[]> {
+    const results: PlexSong[] = [];
+    const albums = await plexApi.getArtistAlbums(account, artistId);
+    for (let i = 0; i < albums.length; i++) {
+      const album = albums[i];
+      if (!album.id) {
+        continue;
+      }
+      const songs = await plexApi.getAlbumSongs(account, album.id);
+      results.push(...songs);
+    }
+    return results;
+  }
+
+  private async fetchAllPlexPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<PlexSong[]> {
+    const results: PlexSong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await plexApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
   private buildSongDedupKey(title?: string, artist?: string, album?: string, durationSeconds?: number): string {
     const safeTitle = title ?? '';
     const safeArtist = artist ?? '';
@@ -1291,7 +1818,7 @@ export struct NavidromePage {
     // }
 
     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}`);
       return this.buildCoverUrl(account, fallbackId);
     }
@@ -1350,6 +1877,19 @@ export struct NavidromePage {
       } else if (this.isEmbyAccount(account)) {
         void ServerLogUtil.debug('NavidromePageCover', `调用 embyApi.buildPrimaryImageUrl - coverId: ${normalizedId}, size: ${size}`);
         url = await embyApi.buildPrimaryImageUrl(account, normalizedId, size, size);
+      } else if (this.isAudioStationAccount(account)) {
+        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)) {
+        void ServerLogUtil.debug('NavidromePageCover', `调用 plexApi.buildImageUrl - coverId: ${normalizedId}`);
+        url = plexApi.buildImageUrl(account, normalizedId);
       }
 
       if (url && this.isNavidromeAccount(account)) {
@@ -1371,6 +1911,14 @@ export struct NavidromePage {
     return url;
   }
 
+  private isAudioStationSongCoverId(value: string): boolean {
+    return value.startsWith('as-song:');
+  }
+
+  private stripAudioStationSongCoverId(value: string): string {
+    return value.replace(/^as-song:/, '');
+  }
+
   /**
    * 记录账号缓存信息
    */
@@ -1440,6 +1988,12 @@ export struct NavidromePage {
     if (this.isEmbyAccount(account)) {
       return { type: CommonConstants.TYPE_EMBY, scheme: 'emby' } as LibraryInfo;
     }
+    if (this.isAudioStationAccount(account)) {
+      return { type: CommonConstants.TYPE_AUDIOSTATION, scheme: 'audiostation' } as LibraryInfo;
+    }
+    if (this.isPlexAccount(account)) {
+      return { type: CommonConstants.TYPE_PLEX, scheme: 'plex' } as LibraryInfo;
+    }
     return { type: CommonConstants.TYPE_NAVIDROME, scheme: 'navidrome' } as LibraryInfo;
   }
 
@@ -1836,6 +2390,12 @@ export struct NavidromePage {
       } else if (this.isEmbyAccount(account)) {
         const response = await embyApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
         restSongs = this.convertEmbySongsToRestSongs(response.items);
+      } else if (this.isAudioStationAccount(account)) {
+        const songs = await audioStationApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
+        restSongs = this.convertAudioStationSongsToRestSongs(songs);
+      } else if (this.isPlexAccount(account)) {
+        const response = await plexApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
+        restSongs = this.convertPlexSongsToRestSongs(response.items);
       }
       if (ticket !== this.searchTicket) {
         return;
@@ -2310,12 +2870,6 @@ export struct NavidromePage {
       throw new Error('媒体库账号不可用');
     }
     if (!this.isNavidromeAccount(account)) {
-      if (this.filterType === NavFilterType.Playlist) {
-        this.filterSongs = [];
-        this.isFilterLoading = false;
-        ToastUtil.showToast('当前媒体库不支持歌单');
-        return;
-      }
       let restSongs: NavidromeRestSong[] = [];
       if (this.filterType === NavFilterType.Artist) {
         if (this.isJellyfinAccount(account)) {
@@ -2324,6 +2878,12 @@ export struct NavidromePage {
         } else if (this.isEmbyAccount(account)) {
           const songs = await this.fetchAllEmbyArtistSongs(account, this.filterId);
           restSongs = this.convertEmbySongsToRestSongs(songs);
+        } else if (this.isAudioStationAccount(account)) {
+          const songs = await this.fetchAllAudioStationArtistSongs(account, this.filterId);
+          restSongs = this.convertAudioStationSongsToRestSongs(songs);
+        } else if (this.isPlexAccount(account)) {
+          const songs = await this.fetchAllPlexArtistSongs(account, this.filterId);
+          restSongs = this.convertPlexSongsToRestSongs(songs);
         }
       } else if (this.filterType === NavFilterType.Album) {
         if (this.isJellyfinAccount(account)) {
@@ -2332,6 +2892,26 @@ export struct NavidromePage {
         } else if (this.isEmbyAccount(account)) {
           const songs = await embyApi.getAlbumSongs(account, this.filterId);
           restSongs = this.convertEmbySongsToRestSongs(songs);
+        } else if (this.isAudioStationAccount(account)) {
+          const albumKey = this.parseAudioStationAlbumKey(this.filterId);
+          const songs = await audioStationApi.getAlbumSongs(account, albumKey.name, albumKey.artist);
+          restSongs = this.convertAudioStationSongsToRestSongs(songs);
+        } else if (this.isPlexAccount(account)) {
+          const songs = await plexApi.getAlbumSongs(account, this.filterId);
+          restSongs = this.convertPlexSongsToRestSongs(songs);
+        }
+      } else if (this.filterType === NavFilterType.Playlist) {
+        if (this.isAudioStationAccount(account)) {
+          const songs = await this.fetchAllAudioStationPlaylistSongs(account, this.filterId);
+          restSongs = this.convertAudioStationSongsToRestSongs(songs);
+        } else if (this.isPlexAccount(account)) {
+          const songs = await this.fetchAllPlexPlaylistSongs(account, this.filterId);
+          restSongs = this.convertPlexSongsToRestSongs(songs);
+        } else {
+          this.filterSongs = [];
+          this.isFilterLoading = false;
+          ToastUtil.showToast('当前媒体库不支持歌单');
+          return;
         }
       }
       const videoItems = await this.convertSongsToVideoItems(restSongs, account);