From 4fc346ac90d5753bf307f4678f2148ed4feb4d51 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Wed, 19 Nov 2025 02:10:22 -0800 Subject: [PATCH] add fuse search utils --- .../now-playing/components/play-queue.tsx | 4 +- src/renderer/features/shared/utils.ts | 156 ++++++++++++++++++ src/renderer/utils/search-songs.ts | 20 --- 3 files changed, 158 insertions(+), 22 deletions(-) delete mode 100644 src/renderer/utils/search-songs.ts diff --git a/src/renderer/features/now-playing/components/play-queue.tsx b/src/renderer/features/now-playing/components/play-queue.tsx index 1fecc91c6..31120d998 100644 --- a/src/renderer/features/now-playing/components/play-queue.tsx +++ b/src/renderer/features/now-playing/components/play-queue.tsx @@ -14,6 +14,7 @@ import { ItemListHandle } from '/@/renderer/components/item-list/types'; import { eventEmitter } from '/@/renderer/events/event-emitter'; import { UserFavoriteEventPayload, UserRatingEventPayload } from '/@/renderer/events/events'; import { useIsPlayerFetching, usePlayer } from '/@/renderer/features/player/context/player-context'; +import { searchLibraryItems } from '/@/renderer/features/shared/utils'; import { useDragDrop } from '/@/renderer/hooks/use-drag-drop'; import { subscribeCurrentTrack, @@ -26,7 +27,6 @@ import { usePlayerQueueType, usePlayerSong, } from '/@/renderer/store'; -import { searchSongs } from '/@/renderer/utils/search-songs'; import { Flex } from '/@/shared/components/flex/flex'; import { LoadingOverlay } from '/@/shared/components/loading-overlay/loading-overlay'; import { Text } from '/@/shared/components/text/text'; @@ -140,7 +140,7 @@ export const PlayQueue = forwardRef(({ listKey, sear const filteredData: QueueSong[] = useMemo(() => { if (debouncedSearchTerm) { - const searched = searchSongs(data, debouncedSearchTerm); + const searched = searchLibraryItems(data, debouncedSearchTerm, LibraryItem.SONG); return searched; } diff --git a/src/renderer/features/shared/utils.ts b/src/renderer/features/shared/utils.ts index c61e80938..07356d154 100644 --- a/src/renderer/features/shared/utils.ts +++ b/src/renderer/features/shared/utils.ts @@ -1,6 +1,17 @@ +import Fuse from 'fuse.js'; import z from 'zod'; import i18n from '/@/i18n/i18n'; +import { + Album, + AlbumArtist, + Artist, + Genre, + LibraryItem, + Playlist, + QueueSong, + Song, +} from '/@/shared/types/domain-types'; import { Play } from '/@/shared/types/types'; export const PLAY_TYPES = [ @@ -69,3 +80,148 @@ export const FILTER_KEYS = { SHARED: SharedFilterKeys, SONG: SongFilterKeys, }; + +interface CreateFuseOptions { + fieldNormWeight?: number; + ignoreLocation?: boolean; + threshold?: number; +} + +type FuseSearchableItem = Album | AlbumArtist | Artist | Genre | Playlist | QueueSong | Song; + +export const createFuseForLibraryItem = ( + items: T[], + itemType: LibraryItem, + options: CreateFuseOptions = {}, +): Fuse => { + const { fieldNormWeight = 1, ignoreLocation = true, threshold = 0.3 } = options; + + if (items.length === 0) { + return new Fuse(items, { + fieldNormWeight, + ignoreLocation, + keys: [], + threshold, + }); + } + + const sampleItem = items[0]; + + const stringKeys = Object.keys(sampleItem).filter( + (key) => + typeof sampleItem[key as keyof T] === 'string' && + !key.startsWith('_') && + key !== 'id' && + key !== 'albumId' && + key !== 'streamUrl' && + key !== 'serverId' && + key !== 'ownerId', + ) as string[]; + + const nestedKeys: Array<{ getFn: (item: T) => string; name: string }> = []; + + switch (itemType) { + case LibraryItem.ALBUM: { + nestedKeys.push( + { + getFn: (item) => { + const a = item as Album; + return a.artists?.map((artist) => artist.name).join(' ') || ''; + }, + name: 'artists', + }, + { + getFn: (item) => { + const a = item as Album; + return a.albumArtists?.map((artist) => artist.name).join(' ') || ''; + }, + name: 'albumArtists', + }, + { + getFn: (item) => { + const a = item as Album; + return a.genres?.map((genre) => genre.name).join(' ') || ''; + }, + name: 'genres', + }, + ); + break; + } + + case LibraryItem.ALBUM_ARTIST: { + nestedKeys.push({ + getFn: (item) => { + const aa = item as AlbumArtist; + return aa.genres?.map((genre) => genre.name).join(' ') || ''; + }, + name: 'genres', + }); + break; + } + + case LibraryItem.ARTIST: + case LibraryItem.GENRE: + break; + + case LibraryItem.PLAYLIST: { + nestedKeys.push({ + getFn: (item) => { + const p = item as Playlist; + return p.genres?.map((genre) => genre.name).join(' ') || ''; + }, + name: 'genres', + }); + break; + } + + case LibraryItem.PLAYLIST_SONG: + case LibraryItem.QUEUE_SONG: + case LibraryItem.SONG: { + nestedKeys.push( + { + getFn: (item) => { + const s = item as QueueSong | Song; + return s.artists?.map((artist) => artist.name).join(' ') || ''; + }, + name: 'artists', + }, + { + getFn: (item) => { + const s = item as QueueSong | Song; + return s.albumArtists?.map((artist) => artist.name).join(' ') || ''; + }, + name: 'albumArtists', + }, + { + getFn: (item) => { + const s = item as QueueSong | Song; + return s.genres?.map((genre) => genre.name).join(' ') || ''; + }, + name: 'genres', + }, + ); + break; + } + } + + return new Fuse(items, { + fieldNormWeight, + ignoreLocation, + keys: [...stringKeys, ...nestedKeys], + threshold, + }); +}; + +export const searchLibraryItems = ( + items: T[], + searchTerm: string, + itemType: LibraryItem, + options?: CreateFuseOptions, +): T[] => { + if (!searchTerm.trim()) { + return items; + } + + const fuse = createFuseForLibraryItem(items, itemType, options); + return fuse.search(searchTerm).map((result) => result.item); +}; diff --git a/src/renderer/utils/search-songs.ts b/src/renderer/utils/search-songs.ts deleted file mode 100644 index e0c6a4692..000000000 --- a/src/renderer/utils/search-songs.ts +++ /dev/null @@ -1,20 +0,0 @@ -import Fuse from 'fuse.js'; - -import { QueueSong } from '/@/shared/types/domain-types'; - -export const searchSongs = (songs: QueueSong[], searchTerm: string) => { - const fuse = new Fuse(songs, { - fieldNormWeight: 1, - ignoreLocation: true, - keys: [ - 'name', - 'album', - { - getFn: (song) => song.artists.map((artist) => artist.name), - name: 'artist', - }, - ], - threshold: 0, - }); - return fuse.search(searchTerm).map((item) => item.item); -};