Просмотр исходного кода

feat(media): 支持 Jellyfin 和 Emby 媒体库

- 添加了 EmbyApi 和 JellyfinApi 的分页响应接口
- 实现了 Emby 和 Jellyfin 的专辑、艺术家、歌曲分页加载功能
- 添加了 Emby 和 Jellyfin 的艺术家歌曲和搜索功能
- 更新了媒体库页面以支持多种媒体服务器类型
- 修改了封面图片生成逻辑以适配不同媒体服务器
- 添加了 Emby 和 Jellyfin 专辑、艺术家、歌曲的转换函数
- 实现了基于不同媒体服务器类型的加载和分页逻辑
- 更新了页面跳转逻辑以支持 Jellyfin 和 Emby 类型
- 修复了图片加载时缺少 api_key 参数的问题
chendeben 7 месяцев назад
Родитель
Сommit
44376c936f

+ 159 - 1
entry/src/main/ets/common/network/EmbyApi.ets

@@ -51,6 +51,11 @@ interface EmbyItemsResponse {
   TotalRecordCount?: number;
 }
 
+export interface EmbyPagedResponse<T> {
+  items: T[];
+  nextStart: number | null;
+}
+
 interface EmbyPerson {
   Id?: string;
   Name?: string;
@@ -203,6 +208,29 @@ export class EmbyApi {
       });
   }
 
+  async getAlbums(account: WebDavAccount): Promise<EmbyAlbum[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'MusicAlbum'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    return items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const album: EmbyAlbum = {
+          id: item.Id as string,
+          name: item.Name as string,
+          artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+          year: item.ProductionYear
+        };
+        return album;
+      });
+  }
+
   async getAlbum(account: WebDavAccount, albumId: string): Promise<EmbyAlbum | null> {
     const auth = await this.ensureAuth(account);
     const item = await this.get<EmbyItem>(account, `/Users/${auth.userId}/Items/${albumId}`);
@@ -239,6 +267,134 @@ export class EmbyApi {
     return songs;
   }
 
+  async getArtistSongs(account: WebDavAccount, artistId: string, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('ArtistIds', artistId),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: EmbyPagedResponse<EmbySong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
+  async getSongsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: EmbyPagedResponse<EmbySong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
+  async getArtistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbyArtist>> {
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, '/Artists', params);
+    const items = response.Items ?? [];
+    const artists: EmbyArtist[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const artist: EmbyArtist = {
+          id: item.Id as string,
+          name: item.Name as string
+        };
+        return artist;
+      });
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: EmbyPagedResponse<EmbyArtist> = { items: artists, nextStart: nextStart };
+    return result;
+  }
+
+  async getAlbumsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbyAlbum>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'MusicAlbum'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const albums: EmbyAlbum[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const album: EmbyAlbum = {
+          id: item.Id as string,
+          name: item.Name as string,
+          artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+          year: item.ProductionYear
+        };
+        return album;
+      });
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: EmbyPagedResponse<EmbyAlbum> = { items: albums, nextStart: nextStart };
+    return result;
+  }
+
+  async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SearchTerm', keyword),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: EmbyPagedResponse<EmbySong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
   async getAllSongs(account: WebDavAccount): Promise<EmbySong[]> {
     const auth = await this.ensureAuth(account);
     const params: Array<QueryParam> = [
@@ -266,8 +422,10 @@ export class EmbyApi {
   }
 
   async buildPrimaryImageUrl(account: WebDavAccount, itemId: string, width: number = 600, height: number = 600): Promise<string> {
+    const auth = await this.ensureAuth(account);
     const baseUrl = this.buildBaseUrl(account);
-    return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=${height}&fillWidth=${width}`;
+    const apiKey = encodeURIComponent(auth.token);
+    return `${baseUrl}/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=${height}&fillWidth=${width}&api_key=${apiKey}`;
   }
 
   async getLyric(account: WebDavAccount, itemId: string, lyricIndex: number): Promise<string> {

+ 156 - 0
entry/src/main/ets/common/network/JellyfinApi.ets

@@ -50,6 +50,11 @@ interface JellyfinItemsResponse {
   Items?: JellyfinItem[];
 }
 
+export interface JellyfinPagedResponse<T> {
+  items: T[];
+  nextStart: number | null;
+}
+
 interface JellyfinPerson {
   Id?: string;
   Name?: string;
@@ -170,6 +175,29 @@ export class JellyfinApi {
       });
   }
 
+  async getAlbums(account: WebDavAccount): Promise<JellyfinAlbum[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'MusicAlbum'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending')
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    return items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const album: JellyfinAlbum = {
+          id: item.Id as string,
+          name: item.Name as string,
+          artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+          year: item.ProductionYear
+        };
+        return album;
+      });
+  }
+
   async getAlbum(account: WebDavAccount, albumId: string): Promise<JellyfinAlbum | null> {
     const auth = await this.ensureAuth(account);
     const item = await this.get<JellyfinItem>(account, `/Users/${auth.userId}/Items/${albumId}`);
@@ -206,6 +234,134 @@ export class JellyfinApi {
     return songs;
   }
 
+  async getArtistSongs(account: WebDavAccount, artistId: string, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('ArtistIds', artistId),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: JellyfinSong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
+  async getSongsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: JellyfinSong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
+  async getArtistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinArtist>> {
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, '/Artists', params);
+    const items = response.Items ?? [];
+    const artists: JellyfinArtist[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const artist: JellyfinArtist = {
+          id: item.Id as string,
+          name: item.Name as string
+        };
+        return artist;
+      });
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: JellyfinPagedResponse<JellyfinArtist> = { items: artists, nextStart: nextStart };
+    return result;
+  }
+
+  async getAlbumsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinAlbum>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'MusicAlbum'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString())
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const albums: JellyfinAlbum[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const album: JellyfinAlbum = {
+          id: item.Id as string,
+          name: item.Name as string,
+          artist: item.AlbumArtists?.[0]?.Name ?? item.Artists?.[0],
+          year: item.ProductionYear
+        };
+        return album;
+      });
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: JellyfinPagedResponse<JellyfinAlbum> = { items: albums, nextStart: nextStart };
+    return result;
+  }
+
+  async searchSongs(account: WebDavAccount, keyword: string, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinSong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('SearchTerm', keyword),
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('StartIndex', startIndex.toString()),
+      new QueryParam('Limit', limit.toString()),
+      new QueryParam('Fields', 'MediaSources,AudioInfo,PrimaryImageAspectRatio')
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Users/${auth.userId}/Items`, params);
+    const items = response.Items ?? [];
+    const songs: JellyfinSong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const nextStart = items.length < limit ? null : startIndex + items.length;
+    const result: JellyfinPagedResponse<JellyfinSong> = { items: songs, nextStart: nextStart };
+    return result;
+  }
+
   async getAllSongs(account: WebDavAccount): Promise<JellyfinSong[]> {
     const auth = await this.ensureAuth(account);
     const params: Array<QueryParam> = [

+ 3 - 1
entry/src/main/ets/pages/NewIndex.ets

@@ -1948,7 +1948,9 @@ struct NewIndex {
       this.selectedAccount = account
       console.info('onecold selectWebDavAccount account.webType='+account.webType)
       // 切换到WebDAV页面
-      if(account.webType==RemoteDriveType.Navidrome){
+      if (account.webType === RemoteDriveType.Navidrome
+        || account.webType === RemoteDriveType.Jellyfin
+        || account.webType === RemoteDriveType.Emby) {
         this.mType = 7
       }else{
         this.mType = 6

+ 516 - 31
entry/src/main/ets/view/NavidromePage.ets

@@ -23,6 +23,9 @@ import { navidromeApi, NavidromeSong } from '../common/network/NavidromeApi';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 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 { getRemoteDriveDisplayLabel } from '../common/util/RemoteDriveLabel';
 
 const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
 const NAVIDROME_SEARCH_LIMIT = 500;
@@ -47,6 +50,11 @@ interface CachePreviewInfo {
   examplePath: string;
 }
 
+interface LibraryInfo {
+  type: number;
+  scheme: string;
+}
+
 @Component
 export struct NavidromePage {
   @State isShowTitleBar: boolean = true //是否显示分类导航条
@@ -92,6 +100,19 @@ export struct NavidromePage {
   private coverUrlCache: Map<string, string> = new Map();
   private albumCoverLookup: Map<string, string> = new Map();
   private searchTicket: number = 0;
+  private readonly REMOTE_PAGE_SIZE: number = 200;
+
+  private isNavidromeAccount(account: WebDavAccount): boolean {
+    return account.webType === RemoteDriveType.Navidrome;
+  }
+
+  private isJellyfinAccount(account: WebDavAccount): boolean {
+    return account.webType === RemoteDriveType.Jellyfin;
+  }
+
+  private isEmbyAccount(account: WebDavAccount): boolean {
+    return account.webType === RemoteDriveType.Emby;
+  }
 
   private createTabOptions(): SegmentButtonOptions {
     const buttons = [
@@ -141,6 +162,7 @@ export struct NavidromePage {
   @State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
   @State sortType: number = 0; // 排序类型 0:名称升序 1:名称降序 2:时间升序 3:时间降序 4:大小升序 5:大小降序
   @State isSearchLoading: boolean = false;
+  private readonly REMOTE_SEARCH_LIMIT: number = 500;
 
   // SegmentButton选项
   @State tabOptions: SegmentButtonOptions = this.createTabOptions();
@@ -196,7 +218,7 @@ export struct NavidromePage {
     if (!account) {
       this.resetData();
       if (showToastWhenMissing) {
-        ToastUtil.showToast('请先选择 Navidrome 账号');
+        ToastUtil.showToast('请先选择媒体库账号');
       }
       return;
     }
@@ -207,7 +229,7 @@ export struct NavidromePage {
     // 记录账号信息和缓存路径
     await this.logAccountCacheInfo(account);
 
-    await this.loadNavidromeLibrary(account);
+    await this.loadMediaLibrary(account);
     this.doSortType(this.sortType)
   }
 
@@ -215,7 +237,9 @@ export struct NavidromePage {
     if (!this.selectedAccount) {
       return undefined;
     }
-    if (this.selectedAccount.webType !== RemoteDriveType.Navidrome) {
+    if (!this.isNavidromeAccount(this.selectedAccount)
+      && !this.isJellyfinAccount(this.selectedAccount)
+      && !this.isEmbyAccount(this.selectedAccount)) {
       return undefined;
     }
     if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) {
@@ -239,6 +263,20 @@ export struct NavidromePage {
     this.isAlbumPageLoading = false;
   }
 
+  private async loadMediaLibrary(account: WebDavAccount): Promise<void> {
+    if (this.isNavidromeAccount(account)) {
+      await this.loadNavidromeLibrary(account);
+      return;
+    }
+    if (this.isJellyfinAccount(account)) {
+      await this.loadJellyfinLibrary(account);
+      return;
+    }
+    if (this.isEmbyAccount(account)) {
+      await this.loadEmbyLibrary(account);
+    }
+  }
+
   private async loadNavidromeLibrary(account: WebDavAccount): Promise<void> {
     const ticket = ++this.loadTicket;
     this.loading = true;
@@ -273,6 +311,66 @@ export struct NavidromePage {
     }
   }
 
+  private async loadJellyfinLibrary(account: WebDavAccount): Promise<void> {
+    const ticket = ++this.loadTicket;
+    this.loading = true;
+    this.resetData();
+    void ServerLogUtil.info('NavidromeLoad', '开始加载 Jellyfin 媒体库');
+    void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`);
+    try {
+      this.songNextStart = 0;
+      this.artistNextStart = 0;
+      this.albumNextStart = 0;
+      await Promise.all([
+        this.loadNextJellyfinSongPage(account, ticket),
+        this.loadNextJellyfinArtistPage(account, ticket),
+        this.loadNextJellyfinAlbumPage(account, ticket)
+      ]);
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+      }
+    } catch (error) {
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.error('NavidromeLoad', `Jellyfin 数据加载失败: ${(error as Error).message}`);
+        ToastUtil.showToast((error as Error).message ?? 'Jellyfin 数据加载失败');
+      }
+    } finally {
+      if (ticket === this.loadTicket) {
+        this.loading = false;
+      }
+    }
+  }
+
+  private async loadEmbyLibrary(account: WebDavAccount): Promise<void> {
+    const ticket = ++this.loadTicket;
+    this.loading = true;
+    this.resetData();
+    void ServerLogUtil.info('NavidromeLoad', '开始加载 Emby 媒体库');
+    void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`);
+    try {
+      this.songNextStart = 0;
+      this.artistNextStart = 0;
+      this.albumNextStart = 0;
+      await Promise.all([
+        this.loadNextEmbySongPage(account, ticket),
+        this.loadNextEmbyArtistPage(account, ticket),
+        this.loadNextEmbyAlbumPage(account, ticket)
+      ]);
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+      }
+    } catch (error) {
+      if (ticket === this.loadTicket) {
+        void ServerLogUtil.error('NavidromeLoad', `Emby 数据加载失败: ${(error as Error).message}`);
+        ToastUtil.showToast((error as Error).message ?? 'Emby 数据加载失败');
+      }
+    } finally {
+      if (ticket === this.loadTicket) {
+        this.loading = false;
+      }
+    }
+  }
+
   private async loadNextSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -370,6 +468,146 @@ export struct NavidromePage {
     }
   }
 
+  private async loadNextJellyfinSongPage(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 jellyfinApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const restSongs = this.convertJellyfinSongsToRestSongs(response.items);
+      const videoItems = await this.convertSongsToVideoItems(restSongs, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.allVideos = [...this.allVideos, ...videoItems];
+      this.songNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Jellyfin 歌曲追加: 本次 ${videoItems.length} 首, 总数 ${this.allVideos.length}`);
+    } finally {
+      this.isSongPageLoading = false;
+    }
+  }
+
+  private async loadNextJellyfinArtistPage(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 jellyfinApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertJellyfinArtistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.artists = [...this.artists, ...processed];
+      this.artistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Jellyfin 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`);
+    } finally {
+      this.isArtistPageLoading = false;
+    }
+  }
+
+  private async loadNextJellyfinAlbumPage(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 jellyfinApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertJellyfinAlbumsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.albums = [...this.albums, ...processed];
+      this.albumNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Jellyfin 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`);
+    } finally {
+      this.isAlbumPageLoading = false;
+    }
+  }
+
+  private async loadNextEmbySongPage(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 embyApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const restSongs = this.convertEmbySongsToRestSongs(response.items);
+      const videoItems = await this.convertSongsToVideoItems(restSongs, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.allVideos = [...this.allVideos, ...videoItems];
+      this.songNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Emby 歌曲追加: 本次 ${videoItems.length} 首, 总数 ${this.allVideos.length}`);
+    } finally {
+      this.isSongPageLoading = false;
+    }
+  }
+
+  private async loadNextEmbyArtistPage(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 embyApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertEmbyArtistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.artists = [...this.artists, ...processed];
+      this.artistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Emby 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`);
+    } finally {
+      this.isArtistPageLoading = false;
+    }
+  }
+
+  private async loadNextEmbyAlbumPage(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 embyApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertEmbyAlbumsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.albums = [...this.albums, ...processed];
+      this.albumNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Emby 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`);
+    } finally {
+      this.isAlbumPageLoading = false;
+    }
+  }
+
   private async handleReachEnd(): Promise<void> {
     if (this.loading) {
       return;
@@ -381,18 +619,52 @@ export struct NavidromePage {
     if (!account) {
       return;
     }
-    switch (this.selectedTab) {
-      case 0:
-        await this.loadNextSongPage(account);
-        break;
-      case 1:
-        await this.loadNextArtistPage(account);
-        break;
-      case 2:
-        await this.loadNextAlbumPage(account);
-        break;
-      default:
-        break;
+    if (this.isNavidromeAccount(account)) {
+      switch (this.selectedTab) {
+        case 0:
+          await this.loadNextSongPage(account);
+          break;
+        case 1:
+          await this.loadNextArtistPage(account);
+          break;
+        case 2:
+          await this.loadNextAlbumPage(account);
+          break;
+        default:
+          break;
+      }
+      return;
+    }
+    if (this.isJellyfinAccount(account)) {
+      switch (this.selectedTab) {
+        case 0:
+          await this.loadNextJellyfinSongPage(account);
+          break;
+        case 1:
+          await this.loadNextJellyfinArtistPage(account);
+          break;
+        case 2:
+          await this.loadNextJellyfinAlbumPage(account);
+          break;
+        default:
+          break;
+      }
+      return;
+    }
+    if (this.isEmbyAccount(account)) {
+      switch (this.selectedTab) {
+        case 0:
+          await this.loadNextEmbySongPage(account);
+          break;
+        case 1:
+          await this.loadNextEmbyArtistPage(account);
+          break;
+        case 2:
+          await this.loadNextEmbyAlbumPage(account);
+          break;
+        default:
+          break;
+      }
     }
   }
 
@@ -488,6 +760,159 @@ export struct NavidromePage {
     return map;
   }
 
+  private async buildRemoteCoverMap(ids: string[], account: WebDavAccount): Promise<Map<string, string>> {
+    const map = new Map<string, string>();
+    const tasks = ids.map(async (id) => {
+      if (!id) {
+        return;
+      }
+      try {
+        const url = await this.buildCoverUrl(account, id, 300);
+        if (url) {
+          map.set(id, url);
+        }
+      } catch (error) {
+        void ServerLogUtil.warn('CoverCache', `远程封面生成失败: ${id} ${(error as Error).message}`);
+      }
+    });
+    await Promise.all(tasks);
+    return map;
+  }
+
+  private async convertJellyfinArtistsToRest(artists: JellyfinArtist[], account: WebDavAccount): Promise<NavidromeRestArtist[]> {
+    const results: NavidromeRestArtist[] = [];
+    for (let i = 0; i < artists.length; i++) {
+      const artist = artists[i];
+      const coverUrl = await this.buildCoverUrl(account, artist.id, 300);
+      const restArtist: NavidromeRestArtist = {
+        id: artist.id,
+        name: artist.name,
+        coverUrl: coverUrl
+      };
+      results.push(restArtist);
+    }
+    return results;
+  }
+
+  private async convertEmbyArtistsToRest(artists: EmbyArtist[], account: WebDavAccount): Promise<NavidromeRestArtist[]> {
+    const results: NavidromeRestArtist[] = [];
+    for (let i = 0; i < artists.length; i++) {
+      const artist = artists[i];
+      const coverUrl = await this.buildCoverUrl(account, artist.id, 300);
+      const restArtist: NavidromeRestArtist = {
+        id: artist.id,
+        name: artist.name,
+        coverUrl: coverUrl
+      };
+      results.push(restArtist);
+    }
+    return results;
+  }
+
+  private async convertJellyfinAlbumsToRest(albums: JellyfinAlbum[], account: WebDavAccount): Promise<NavidromeRestAlbum[]> {
+    const results: NavidromeRestAlbum[] = [];
+    for (let i = 0; i < albums.length; i++) {
+      const album = albums[i];
+      const coverUrl = await this.buildCoverUrl(account, album.id, 300);
+      const restAlbum: NavidromeRestAlbum = {
+        id: album.id,
+        name: album.name,
+        artist: album.artist,
+        minYear: album.year,
+        coverUrl: coverUrl
+      };
+      results.push(restAlbum);
+    }
+    return results;
+  }
+
+  private async convertEmbyAlbumsToRest(albums: EmbyAlbum[], account: WebDavAccount): Promise<NavidromeRestAlbum[]> {
+    const results: NavidromeRestAlbum[] = [];
+    for (let i = 0; i < albums.length; i++) {
+      const album = albums[i];
+      const coverUrl = await this.buildCoverUrl(account, album.id, 300);
+      const restAlbum: NavidromeRestAlbum = {
+        id: album.id,
+        name: album.name,
+        artist: album.artist,
+        minYear: album.year,
+        coverUrl: coverUrl
+      };
+      results.push(restAlbum);
+    }
+    return results;
+  }
+
+  private convertJellyfinSongsToRestSongs(songs: JellyfinSong[]): NavidromeRestSong[] {
+    return songs.map(song => {
+      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,
+        coverArt: song.albumId ?? song.id
+      };
+      return restSong;
+    });
+  }
+
+  private convertEmbySongsToRestSongs(songs: EmbySong[]): NavidromeRestSong[] {
+    return songs.map(song => {
+      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,
+        coverArt: song.albumId ?? song.id
+      };
+      return restSong;
+    });
+  }
+
+  private async fetchAllJellyfinArtistSongs(account: WebDavAccount, artistId: string): Promise<JellyfinSong[]> {
+    const results: JellyfinSong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await jellyfinApi.getArtistSongs(account, artistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
+  private async fetchAllEmbyArtistSongs(account: WebDavAccount, artistId: string): Promise<EmbySong[]> {
+    const results: EmbySong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await embyApi.getArtistSongs(account, artistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
   private convertApiSongsToRestSongs(songs: NavidromeSong[]): NavidromeRestSong[] {
     return songs.map((song: NavidromeSong): NavidromeRestSong => ({
       id: song.id,
@@ -551,12 +976,16 @@ export struct NavidromePage {
   }
 
   private async resolveSongCover(song: NavidromeRestSong, account: WebDavAccount): Promise<string | undefined> {
-    if (song.albumId) {
+    if (this.isNavidromeAccount(account) && song.albumId) {
       const albumCover = song.albumId ? this.albumCoverLookup.get(song.albumId) : undefined;
       if (albumCover) {
         return albumCover;
       }
     }
+    if (!this.isNavidromeAccount(account)) {
+      const fallbackId = song.albumId ?? song.id;
+      return this.buildCoverUrl(account, fallbackId);
+    }
     const directUrl = this.resolveEmbedCover(account, song.embedArtPath ?? song.coverArtPath);
     if (directUrl) {
       return directUrl;
@@ -571,15 +1000,24 @@ export struct NavidromePage {
     }
     const normalizedId = coverId.trim();
     const cacheKey = `${normalizedId}_${size}`;
-    const cached = this.coverUrlCache.get(cacheKey);
-    if (cached) {
-      void ServerLogUtil.debug('CoverCache', `封面URL缓存命中: ${cacheKey} -> ${cached}`);
-      return cached;
+    if (this.isNavidromeAccount(account)) {
+      const cached = this.coverUrlCache.get(cacheKey);
+      if (cached) {
+        void ServerLogUtil.debug('CoverCache', `封面URL缓存命中: ${cacheKey} -> ${cached}`);
+        return cached;
+      }
     }
 
     void ServerLogUtil.debug('CoverCache', `生成封面URL: ${coverId} (尺寸: ${size})`);
-    const url = await navidromeApi.buildCoverArtUrl(account, normalizedId, size);
-    if (url) {
+    let url: string | undefined = undefined;
+    if (this.isNavidromeAccount(account)) {
+      url = await navidromeApi.buildCoverArtUrl(account, normalizedId, size);
+    } else if (this.isJellyfinAccount(account)) {
+      url = await jellyfinApi.buildPrimaryImageUrl(account, normalizedId, size, size);
+    } else if (this.isEmbyAccount(account)) {
+      url = await embyApi.buildPrimaryImageUrl(account, normalizedId, size, size);
+    }
+    if (url && this.isNavidromeAccount(account)) {
       this.coverUrlCache.set(cacheKey, url);
       void ServerLogUtil.debug('CoverCache', `封面URL已缓存: ${cacheKey} -> ${url}`);
     } else {
@@ -644,16 +1082,30 @@ export struct NavidromePage {
   }
 
   private resolveEmbedCover(account: WebDavAccount, path?: string): string | undefined {
+    if (!this.isNavidromeAccount(account)) {
+      return undefined;
+    }
     return navidromeRestApi.resolveResourceUrl(account, path);
   }
 
+  private resolveLibraryInfo(account: WebDavAccount): LibraryInfo {
+    if (this.isJellyfinAccount(account)) {
+      return { type: CommonConstants.TYPE_JELLYFIN, scheme: 'jellyfin' } as LibraryInfo;
+    }
+    if (this.isEmbyAccount(account)) {
+      return { type: CommonConstants.TYPE_EMBY, scheme: 'emby' } as LibraryInfo;
+    }
+    return { type: CommonConstants.TYPE_NAVIDROME, scheme: 'navidrome' } as LibraryInfo;
+  }
+
   private convertSongToVideoItem(song: NavidromeRestSong, account: WebDavAccount, coverUrl?: string): VideoItem {
     const title = song.title ?? Constants.UNKNOWN_TITLE;
+    const libraryInfo = this.resolveLibraryInfo(account);
     const videoItem = new VideoItem(
       title,
       song.id,
-      `navidrome://${account.id ?? 0}/${song.id}`,
-      CommonConstants.TYPE_NAVIDROME,
+      `${libraryInfo.scheme}://${account.id ?? 0}/${song.id}`,
+      libraryInfo.type,
       song.size ?? 0,
       song.createdAt ?? '',
       Utility.formatFSize(song.size ?? 0),
@@ -978,7 +1430,7 @@ export struct NavidromePage {
     if (!account) {
       this.filteredList = [];
       this.isSearchLoading = false;
-      ToastUtil.showToast('Navidrome账号不可用');
+      ToastUtil.showToast('媒体库账号不可用');
       return;
     }
 
@@ -988,18 +1440,27 @@ export struct NavidromePage {
     void ServerLogUtil.info('NavidromeSearch', `开始远程搜索: "${keyword}"`);
 
     try {
-      const songs = await navidromeApi.searchSongs(account, keyword, NAVIDROME_SEARCH_LIMIT);
+      let restSongs: NavidromeRestSong[] = [];
+      if (this.isNavidromeAccount(account)) {
+        const songs = await navidromeApi.searchSongs(account, keyword, NAVIDROME_SEARCH_LIMIT);
+        restSongs = this.convertApiSongsToRestSongs(songs);
+      } else if (this.isJellyfinAccount(account)) {
+        const response = await jellyfinApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
+        restSongs = this.convertJellyfinSongsToRestSongs(response.items);
+      } else if (this.isEmbyAccount(account)) {
+        const response = await embyApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT);
+        restSongs = this.convertEmbySongsToRestSongs(response.items);
+      }
       if (ticket !== this.searchTicket) {
         return;
       }
       const searchTime = Date.now() - startTime;
-      if (!songs || songs.length === 0) {
+      if (!restSongs || restSongs.length === 0) {
         this.filteredList = [];
         void ServerLogUtil.warn('NavidromeSearch', `搜索无结果: "${keyword}"`);
         void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`);
         return;
       }
-      const restSongs = this.convertApiSongsToRestSongs(songs);
       const videoItems = await this.convertSongsToVideoItems(restSongs, account);
       if (ticket !== this.searchTicket) {
         return;
@@ -1355,7 +1816,31 @@ export struct NavidromePage {
     if (!account) {
       this.filterSongs = [];
       this.isFilterLoading = false;
-      throw new Error('Navidrome账号不可用');
+      throw new Error('媒体库账号不可用');
+    }
+    if (!this.isNavidromeAccount(account)) {
+      let restSongs: NavidromeRestSong[] = [];
+      if (this.filterType === NavFilterType.Artist) {
+        if (this.isJellyfinAccount(account)) {
+          const songs = await this.fetchAllJellyfinArtistSongs(account, this.filterId);
+          restSongs = this.convertJellyfinSongsToRestSongs(songs);
+        } else if (this.isEmbyAccount(account)) {
+          const songs = await this.fetchAllEmbyArtistSongs(account, this.filterId);
+          restSongs = this.convertEmbySongsToRestSongs(songs);
+        }
+      } else if (this.filterType === NavFilterType.Album) {
+        if (this.isJellyfinAccount(account)) {
+          const songs = await jellyfinApi.getAlbumSongs(account, this.filterId);
+          restSongs = this.convertJellyfinSongsToRestSongs(songs);
+        } else if (this.isEmbyAccount(account)) {
+          const songs = await embyApi.getAlbumSongs(account, this.filterId);
+          restSongs = this.convertEmbySongsToRestSongs(songs);
+        }
+      }
+      const videoItems = await this.convertSongsToVideoItems(restSongs, account);
+      this.filterSongs = videoItems;
+      this.isFilterLoading = false;
+      return;
     }
     const expectedFilterId = this.filterId;
     const expectedFilterType = this.filterType;
@@ -1397,7 +1882,7 @@ export struct NavidromePage {
       }
       const account = this.resolveActiveAccount();
       if (!account || !account.id) {
-        ToastUtil.showToast('Navidrome账号信息不完整,无法播放');
+        ToastUtil.showToast('媒体库账号信息不完整,无法播放');
         return;
       }
 
@@ -1422,7 +1907,7 @@ export struct NavidromePage {
 
       const playlistData: PlaylistEventData = {
         playlistId: NAVIDROME_PLAYLIST_ID,
-        playlistName: `Navidrome - ${account.name ?? '未知账户'}`,
+        playlistName: `${getRemoteDriveDisplayLabel(account.webType)} - ${account.name ?? '未知账户'}`,
         songCount: playlistSource.length,
         startIndex,
         isJump: isJump,//设置true会弹出播放页