Răsfoiți Sursa

修复Jellyfin和emby的歌单和搜索问题

onecold 4 luni în urmă
părinte
comite
fbfe275632

+ 80 - 0
entry/src/main/ets/common/network/EmbyApi.ets

@@ -2,6 +2,7 @@ import { http } from '@kit.NetworkKit';
 import { PreferencesUtil } from '@pura/harmony-utils';
 import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import { ServerLogUtil } from '../util/ServerLogUtil';
+import { convertEmbyPlaylistEntries, EmbyPlaylistSummary, findEmbyMusicLibraryViewId } from '../util/EmbyPlaylistHelper';
 
 const TAG = 'heanup EmbyApi';
 const CLIENT_NAME = 'TTMusic';
@@ -34,6 +35,7 @@ interface EmbyItem {
   Id?: string;
   Name?: string;
   Type?: string;
+  CollectionType?: string;
   Album?: string;
   AlbumId?: string;
   Artists?: string[];
@@ -42,6 +44,7 @@ interface EmbyItem {
   RunTimeTicks?: number;
   ProductionYear?: number;
   IndexNumber?: number;
+  ChildCount?: number;
   ImageTags?: EmbyItemImageTags;
   MediaSources?: Array<EmbyMediaSource>;
 }
@@ -146,8 +149,12 @@ export interface EmbySong {
   lyricIndex?: number; // 歌词流索引
 }
 
+export interface EmbyPlaylist extends EmbyPlaylistSummary {
+}
+
 export class EmbyApi {
   private authCache: Map<string, EmbyAuthContext> = new Map();
+  private musicViewCache: Map<string, string> = new Map();
 
   async getArtists(account: WebDavAccount): Promise<EmbyArtist[]> {
     const auth = await this.ensureAuth(account);
@@ -372,6 +379,63 @@ export class EmbyApi {
     return result;
   }
 
+  async getPlaylistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbyPlaylist>> {
+    const auth = await this.ensureAuth(account);
+    const musicViewId = await this.getMusicLibraryViewId(account, auth.userId);
+    if (!musicViewId) {
+      throw new Error('Emby 未找到音乐媒体库视图');
+    }
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('ParentId', musicViewId),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('IncludeItemTypes', 'Playlist'),
+      new QueryParam('Fields', 'SortName,CanDelete,PrimaryImageAspectRatio,BasicSyncInfo,Container,ProductionYear,Status,EndDate,Prefix'),
+      new QueryParam('EnableImageTypes', 'Primary'),
+      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 playlists = convertEmbyPlaylistEntries(items);
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: EmbyPagedResponse<EmbyPlaylist> = { items: playlists, nextStart: nextStart, total: total };
+    return result;
+  }
+
+  async getPlaylistSongsPage(account: WebDavAccount, playlistId: string, startIndex: number = 0, limit: number = 200): Promise<EmbyPagedResponse<EmbySong>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'ListItemOrder,SortName,Album,ParentIndexNumber,IndexNumber'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('Fields', 'PrimaryImageAspectRatio,MediaSources,AudioInfo,DateCreated,ProductionYear'),
+      new QueryParam('ImageTypeLimit', '1'),
+      new QueryParam('IncludeItemTypes', 'Audio'),
+      new QueryParam('ParentId', playlistId),
+      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 songs: EmbySong[] = [];
+    for (let i = 0; i < items.length; i++) {
+      const song = this.toSong(items[i]);
+      if (song) {
+        songs.push(song);
+      }
+    }
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: EmbyPagedResponse<EmbySong> = { items: songs, nextStart: nextStart, total: total };
+    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> = [
@@ -537,6 +601,22 @@ export class EmbyApi {
   private invalidateAuth(account: WebDavAccount): void {
     const key = this.getAccountKey(account);
     this.authCache.delete(key);
+    this.musicViewCache.delete(key);
+  }
+
+  private async getMusicLibraryViewId(account: WebDavAccount, userId: string): Promise<string | undefined> {
+    const key = this.getAccountKey(account);
+    const cached = this.musicViewCache.get(key);
+    if (cached && cached.length > 0) {
+      return cached;
+    }
+    const response = await this.get<EmbyItemsResponse>(account, `/Users/${userId}/Views`);
+    const items = response.Items ?? [];
+    const viewId = findEmbyMusicLibraryViewId(items);
+    if (viewId && viewId.length > 0) {
+      this.musicViewCache.set(key, viewId);
+    }
+    return viewId;
   }
 
   private async login(account: WebDavAccount): Promise<EmbyAuthContext> {

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

@@ -42,6 +42,7 @@ interface JellyfinItem {
   RunTimeTicks?: number;
   ProductionYear?: number;
   IndexNumber?: number;
+  ChildCount?: number;
   ImageTags?: JellyfinItemImageTags;
   MediaSources?: Array<JellyfinMediaSource>;
 }
@@ -142,6 +143,13 @@ export interface JellyfinSong {
   year?: number;
 }
 
+export interface JellyfinPlaylist {
+  id: string;
+  name: string;
+  durationSeconds?: number;
+  songCount?: number;
+}
+
 export class JellyfinApi {
   private authCache: Map<string, JellyfinAuthContext> = new Map();
 
@@ -349,6 +357,57 @@ export class JellyfinApi {
     return result;
   }
 
+  async getPlaylistsPage(account: WebDavAccount, startIndex: number = 0, limit: number = 200): Promise<JellyfinPagedResponse<JellyfinPlaylist>> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('SortBy', 'SortName'),
+      new QueryParam('SortOrder', 'Ascending'),
+      new QueryParam('Recursive', 'true'),
+      new QueryParam('IncludeItemTypes', 'Playlist'),
+      new QueryParam('MediaTypes', 'Audio'),
+      new QueryParam('Fields', 'SortName,CanDelete,PrimaryImageAspectRatio'),
+      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 playlists: JellyfinPlaylist[] = items
+      .filter(item => item.Id && item.Name)
+      .map(item => {
+        const playlist: JellyfinPlaylist = {
+          id: item.Id as string,
+          name: item.Name as string,
+          durationSeconds: item.RunTimeTicks ? item.RunTimeTicks / 10000000 : undefined,
+          songCount: item.ChildCount
+        };
+        return playlist;
+      });
+    const total = response.TotalRecordCount;
+    const nextStart = total !== undefined
+      ? (startIndex + items.length < total ? startIndex + items.length : null)
+      : (items.length < limit ? null : startIndex + items.length);
+    const result: JellyfinPagedResponse<JellyfinPlaylist> = { items: playlists, nextStart: nextStart, total: total };
+    return result;
+  }
+
+  async getPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<JellyfinSong[]> {
+    const auth = await this.ensureAuth(account);
+    const params: Array<QueryParam> = [
+      new QueryParam('Fields', 'SortName,CanDelete,MediaSources,DateCreated,ProductionYear'),
+      new QueryParam('UserId', auth.userId)
+    ];
+    const response = await this.get<JellyfinItemsResponse>(account, `/Playlists/${playlistId}/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);
+      }
+    }
+    return songs;
+  }
+
   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> = [

+ 54 - 0
entry/src/main/ets/common/util/EmbyPlaylistHelper.ets

@@ -0,0 +1,54 @@
+export interface EmbyViewEntry {
+  Id?: string;
+  Name?: string;
+  CollectionType?: string;
+}
+
+export interface EmbyPlaylistEntry {
+  Id?: string;
+  Name?: string;
+  RunTimeTicks?: number;
+  ChildCount?: number;
+}
+
+export interface EmbyPlaylistSummary {
+  id: string;
+  name: string;
+  durationSeconds?: number;
+  songCount?: number;
+}
+
+export function findEmbyMusicLibraryViewId(items: EmbyViewEntry[]): string | undefined {
+  for (let i = 0; i < items.length; i++) {
+    const item = items[i];
+    if (!item.Id) {
+      continue;
+    }
+    if ((item.CollectionType ?? '').trim().toLowerCase() === 'music') {
+      return item.Id;
+    }
+  }
+  return undefined;
+}
+
+export function convertEmbyPlaylistEntries(items: EmbyPlaylistEntry[]): EmbyPlaylistSummary[] {
+  const results: EmbyPlaylistSummary[] = [];
+  for (let i = 0; i < items.length; i++) {
+    const item = items[i];
+    if (!item.Id || !item.Name) {
+      continue;
+    }
+    const summary: EmbyPlaylistSummary = {
+      id: item.Id,
+      name: item.Name
+    };
+    if (item.RunTimeTicks !== undefined && item.RunTimeTicks !== null && item.RunTimeTicks > 0) {
+      summary.durationSeconds = item.RunTimeTicks / 10000000;
+    }
+    if (item.ChildCount !== undefined && item.ChildCount !== null && item.ChildCount >= 0) {
+      summary.songCount = item.ChildCount;
+    }
+    results.push(summary);
+  }
+  return results;
+}

+ 195 - 35
entry/src/main/ets/view/RemoteMusicPage.ets

@@ -24,8 +24,8 @@ import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
 import { SettingPage } from '../pages/SettingPage';
 import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton';
-import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../common/network/JellyfinApi';
-import { embyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../common/network/EmbyApi';
+import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinPlaylist, JellyfinSong } from '../common/network/JellyfinApi';
+import { embyApi, EmbyAlbum, EmbyArtist, EmbyPlaylist, 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 { daoLiYuApi, DaoLiYuAlbum, DaoLiYuArtist, DaoLiYuTrack, DaoLiYuPlaylist } from '../common/network/DaoLiYuApi';
@@ -37,6 +37,7 @@ import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil';
 import { PlayingIndicator } from './PlayingIndicator';
 import { hdsEffect } from '@kit.UIDesignKit';
 import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore';
+import { filterAlbumsByKeyword, filterArtistsByKeyword, filterPlaylistsByKeyword } from '../common/util/RemoteMusicSearchHelper';
 
 const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
 const NAVIDROME_SEARCH_LIMIT = 500;
@@ -356,17 +357,17 @@ export struct RemoteMusicPage {
       this.clearFilter();
     }
 
-    // 从艺术家或专辑切换回全部时,清除筛选状态
-    if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
-      // 不清除筛选,保持筛选状态
-    } else if (this.selectedTab !== 0) {
-      // 切换到艺术家或专辑标签页时,清除筛选和搜索状态
+    if (this.selectedTab !== 0) {
       this.clearFilter();
-      this.isSearchMode = false;
-      this.searchText = '';
-      this.filteredList = [];
-      this.isSearchLoading = false;
-      this.searchTicket++;
+    }
+
+    if (this.isSearchMode && this.searchText.length > 0) {
+      if (this.selectedTab === 0) {
+        void this.onSearchInput(this.searchText);
+      } else {
+        this.isSearchLoading = false;
+        this.refreshVisibleDataSources();
+      }
     }
   }
 
@@ -591,10 +592,14 @@ export struct RemoteMusicPage {
 
   // 更新所有 LazyForEach 数据源
   private updateAllDataSources(): void {
+    this.refreshVisibleDataSources();
+  }
+
+  private refreshVisibleDataSources(): void {
     this.songDataSource.pushArrayData(this.getVisibleSongs());
-    this.artistDataSource.pushArrayData(this.artists);
-    this.albumDataSource.pushArrayData(this.albums);
-    this.playlistDataSource.pushArrayData(this.playlists);
+    this.artistDataSource.pushArrayData(this.getVisibleArtists());
+    this.albumDataSource.pushArrayData(this.getVisibleAlbums());
+    this.playlistDataSource.pushArrayData(this.getVisiblePlaylists());
   }
 
   private async loadMediaLibrary(account: WebDavAccount): Promise<void> {
@@ -771,14 +776,21 @@ export struct RemoteMusicPage {
       this.songNextStart = 0;
       this.artistNextStart = 0;
       this.albumNextStart = 0;
-      this.playlistNextStart = null;
+      this.playlistNextStart = 0;
+      const playlistLoad = this.loadNextJellyfinPlaylistPage(account, ticket).catch((error: Error) => {
+        if (ticket === this.loadTicket) {
+          this.playlistNextStart = null;
+          void ServerLogUtil.warn('NavidromeLoad', `Jellyfin 歌单首屏加载失败,已跳过: ${error.message}`);
+        }
+      });
       await Promise.all([
         this.loadNextJellyfinSongPage(account, ticket),
         this.loadNextJellyfinArtistPage(account, ticket),
-        this.loadNextJellyfinAlbumPage(account, ticket)
+        this.loadNextJellyfinAlbumPage(account, ticket),
+        playlistLoad
       ]);
       if (ticket === this.loadTicket) {
-        void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+        void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
       }
     } catch (error) {
       if (ticket === this.loadTicket) {
@@ -802,14 +814,21 @@ export struct RemoteMusicPage {
       this.songNextStart = 0;
       this.artistNextStart = 0;
       this.albumNextStart = 0;
-      this.playlistNextStart = null;
+      this.playlistNextStart = 0;
+      const playlistLoad = this.loadNextEmbyPlaylistPage(account, ticket).catch((error: Error) => {
+        if (ticket === this.loadTicket) {
+          this.playlistNextStart = null;
+          void ServerLogUtil.warn('NavidromeLoad', `Emby 歌单首屏加载失败,已跳过: ${error.message}`);
+        }
+      });
       await Promise.all([
         this.loadNextEmbySongPage(account, ticket),
         this.loadNextEmbyArtistPage(account, ticket),
-        this.loadNextEmbyAlbumPage(account, ticket)
+        this.loadNextEmbyAlbumPage(account, ticket),
+        playlistLoad
       ]);
       if (ticket === this.loadTicket) {
-        void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+        void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
       }
     } catch (error) {
       if (ticket === this.loadTicket) {
@@ -1136,6 +1155,30 @@ export struct RemoteMusicPage {
     }
   }
 
+  private async loadNextJellyfinPlaylistPage(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 jellyfinApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertJellyfinPlaylistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.updateAllDataSources();
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Jellyfin 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async loadNextEmbySongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -1229,6 +1272,30 @@ export struct RemoteMusicPage {
     }
   }
 
+  private async loadNextEmbyPlaylistPage(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 embyApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const processed = await this.convertEmbyPlaylistsToRest(response.items, account);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      this.playlists = [...this.playlists, ...processed];
+      this.updateAllDataSources();
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `Emby 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async loadNextAudioStationSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
     if (this.songNextStart === null || this.isSongPageLoading) {
       return;
@@ -1608,6 +1675,9 @@ export struct RemoteMusicPage {
         case 2:
           await this.loadNextJellyfinAlbumPage(account);
           break;
+        case 3:
+          await this.loadNextJellyfinPlaylistPage(account);
+          break;
         default:
           break;
       }
@@ -1624,6 +1694,9 @@ export struct RemoteMusicPage {
         case 2:
           await this.loadNextEmbyAlbumPage(account);
           break;
+        case 3:
+          await this.loadNextEmbyPlaylistPage(account);
+          break;
         default:
           break;
       }
@@ -1892,6 +1965,40 @@ export struct RemoteMusicPage {
     return results;
   }
 
+  private async convertJellyfinPlaylistsToRest(playlists: JellyfinPlaylist[], account: WebDavAccount): Promise<NavidromeRestPlaylist[]> {
+    const results: NavidromeRestPlaylist[] = [];
+    for (let i = 0; i < playlists.length; i++) {
+      const playlist = playlists[i];
+      const coverUrl = await this.buildCoverUrl(account, playlist.id, 300);
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.name,
+        duration: playlist.durationSeconds,
+        songCount: playlist.songCount,
+        coverUrl: coverUrl
+      };
+      results.push(restPlaylist);
+    }
+    return results;
+  }
+
+  private async convertEmbyPlaylistsToRest(playlists: EmbyPlaylist[], account: WebDavAccount): Promise<NavidromeRestPlaylist[]> {
+    const results: NavidromeRestPlaylist[] = [];
+    for (let i = 0; i < playlists.length; i++) {
+      const playlist = playlists[i];
+      const coverUrl = await this.buildCoverUrl(account, playlist.id, 300);
+      const restPlaylist: NavidromeRestPlaylist = {
+        id: playlist.id,
+        name: playlist.name,
+        duration: playlist.durationSeconds,
+        songCount: playlist.songCount,
+        coverUrl: coverUrl
+      };
+      results.push(restPlaylist);
+    }
+    return results;
+  }
+
   private convertAudioStationArtistsToRest(artists: AudioStationArtist[]): NavidromeRestArtist[] {
     return artists.map(artist => {
       const name = artist.name ?? '';
@@ -2187,6 +2294,20 @@ export struct RemoteMusicPage {
     return results;
   }
 
+  private async fetchAllEmbyPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<EmbySong[]> {
+    const results: EmbySong[] = [];
+    let startIndex = 0;
+    while (true) {
+      const response = await embyApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE);
+      results.push(...response.items);
+      if (response.nextStart === null) {
+        break;
+      }
+      startIndex = response.nextStart;
+    }
+    return results;
+  }
+
   private async fetchAllAudioStationArtistSongs(account: WebDavAccount, artistName: string): Promise<AudioStationSong[]> {
     if (!artistName || artistName.trim().length === 0) {
       return [];
@@ -2199,6 +2320,10 @@ export struct RemoteMusicPage {
     });
   }
 
+  private async fetchAllJellyfinPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<JellyfinSong[]> {
+    return jellyfinApi.getPlaylistSongs(account, playlistId);
+  }
+
   private async fetchAllAudioStationPlaylistSongs(account: WebDavAccount, playlistId: string): Promise<AudioStationSong[]> {
     const results: AudioStationSong[] = [];
     let startIndex = 0;
@@ -2722,7 +2847,7 @@ export struct RemoteMusicPage {
               }
             }
           })
-          .visibility(this.selectedTab==0?Visibility.Visible:Visibility.None)
+          .visibility(this.isDetailView ? Visibility.None : Visibility.Visible)
 
           // 排序按钮
           TitleBarPointLightButton({
@@ -2923,7 +3048,7 @@ export struct RemoteMusicPage {
       this.loadSearchHistory();
       this.filteredList = [];
       this.isSearchLoading = false;
-      this.songDataSource.pushArrayData(this.getVisibleSongs());
+      this.refreshVisibleDataSources();
       void ServerLogUtil.info('NavidromeSearch', '搜索已清空,显示所有歌曲');
       return;
     }
@@ -2931,6 +3056,14 @@ export struct RemoteMusicPage {
     if (!this.isSearchMode) {
       this.isSearchMode = true;
     }
+
+    if (this.selectedTab !== 0) {
+      this.isSearchLoading = false;
+      this.refreshVisibleDataSources();
+      void ServerLogUtil.info('NavidromeSearch', `本地名称过滤完成: "${keyword}"`);
+      return;
+    }
+
     const account = this.resolveActiveAccount();
     if (!account) {
       this.filteredList = [];
@@ -2971,7 +3104,7 @@ export struct RemoteMusicPage {
       const searchTime = Date.now() - startTime;
       if (!restSongs || restSongs.length === 0) {
         this.filteredList = [];
-        this.songDataSource.pushArrayData(this.getVisibleSongs());
+        this.refreshVisibleDataSources();
         void ServerLogUtil.warn('NavidromeSearch', `搜索无结果: "${keyword}"`);
         void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`);
         return;
@@ -2991,7 +3124,7 @@ export struct RemoteMusicPage {
         return;
       }
       this.filteredList = [];
-      this.songDataSource.pushArrayData(this.getVisibleSongs());
+      this.refreshVisibleDataSources();
       const message = (error as Error).message ?? 'Navidrome 搜索失败';
       ToastUtil.showToast(message);
       void ServerLogUtil.error('NavidromeSearch', `搜索失败: ${message}`);
@@ -3006,11 +3139,11 @@ export struct RemoteMusicPage {
   private getCurrentCount(): number {
     switch (this.selectedTab) {
       case 1:
-        return this.artists.length;
+        return this.getVisibleArtists().length;
       case 2:
-        return this.albums.length;
+        return this.getVisibleAlbums().length;
       case 3:
-        return this.playlists.length;
+        return this.getVisiblePlaylists().length;
       default:
         return this.getVisibleSongs().length;
     }
@@ -3019,11 +3152,11 @@ export struct RemoteMusicPage {
   private getEmptyTitle(): string {
     switch (this.selectedTab) {
       case 1:
-        return '暂无艺术家';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的艺术家' : '暂无艺术家';
       case 2:
-        return '暂无专辑';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的专辑' : '暂无专辑';
       case 3:
-        return '暂无歌单';
+        return this.isSearchMode && this.searchText.length > 0 ? '没有匹配的歌单' : '暂无歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '正在搜索远程歌曲' : '没有匹配的远程歌曲';
@@ -3038,11 +3171,11 @@ export struct RemoteMusicPage {
   private getEmptySubtitle(): string {
     switch (this.selectedTab) {
       case 1:
-        return '当前筛选没有找到艺术家';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个艺术家关键词再试试吧' : '当前筛选没有找到艺术家';
       case 2:
-        return '当前筛选没有找到专辑';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个专辑关键词再试试吧' : '当前筛选没有找到专辑';
       case 3:
-        return '当前筛选没有找到歌单';
+        return this.isSearchMode && this.searchText.length > 0 ? '换个歌单关键词再试试吧' : '当前筛选没有找到歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '请稍候,正在通过 API 搜索' : '换个关键词再试试吧';
@@ -3466,6 +3599,27 @@ export struct RemoteMusicPage {
     return this.allVideos;
   }
 
+  private getVisibleArtists(): NavidromeRestArtist[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterArtistsByKeyword(this.artists, this.searchText);
+    }
+    return this.artists;
+  }
+
+  private getVisibleAlbums(): NavidromeRestAlbum[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterAlbumsByKeyword(this.albums, this.searchText);
+    }
+    return this.albums;
+  }
+
+  private getVisiblePlaylists(): NavidromeRestPlaylist[] {
+    if (this.isSearchMode && this.searchText.length > 0 && !this.isDetailView) {
+      return filterPlaylistsByKeyword(this.playlists, this.searchText);
+    }
+    return this.playlists;
+  }
+
   private onArtistSelected(artist: NavidromeRestArtist): void {
     if (!artist || !artist.id) {
       return;
@@ -3587,7 +3741,13 @@ export struct RemoteMusicPage {
           restSongs = this.convertDaoLiYuSongsToRestSongs(songs);
         }
       } else if (this.filterType === NavFilterType.Playlist) {
-        if (this.isAudioStationAccount(account)) {
+        if (this.isJellyfinAccount(account)) {
+          const songs = await this.fetchAllJellyfinPlaylistSongs(account, this.filterId);
+          restSongs = this.convertJellyfinSongsToRestSongs(songs);
+        } else if (this.isEmbyAccount(account)) {
+          const songs = await this.fetchAllEmbyPlaylistSongs(account, this.filterId);
+          restSongs = this.convertEmbySongsToRestSongs(songs);
+        } else if (this.isAudioStationAccount(account)) {
           const songs = await this.fetchAllAudioStationPlaylistSongs(account, this.filterId);
           restSongs = this.convertAudioStationSongsToRestSongs(songs);
         } else if (this.isPlexAccount(account)) {