From 9e08157517d3fbd9d61ba2b71dcc8ce5d1edfbe0 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Fri, 6 Mar 2026 18:03:10 -0800 Subject: [PATCH] add has_rating filter for Navidrome song list --- .../api/navidrome/navidrome-controller.ts | 6 ++++ .../components/navidrome-song-filters.tsx | 35 +++++++++++++++++-- .../songs/hooks/use-song-list-filters.ts | 30 +++++++++++++++- src/shared/api/navidrome/navidrome-types.ts | 1 + src/shared/types/domain-types.ts | 1 + src/shared/types/features-types.ts | 2 ++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index cf9a07f8d..81ccd8c37 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -30,6 +30,7 @@ import { ServerFeature } from '/@/shared/types/features-types'; const VERSION_INFO: VersionInfo = [ // Why 2? Subsonic controller will return 1 for its own implementation // Use 2 to denote that Navidrome's own API has a different endpoint + ['0.60.4', { [ServerFeature.TRACK_YES_NO_RATING_FILTER]: [1] }], ['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }], ['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }], ['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }], @@ -669,6 +670,7 @@ export const NavidromeController: InternalControllerEndpoint = { ...subsonicArgs.features, ...navidromeFeatures, publicPlaylist: [1], + [ServerFeature.ALBUM_YES_NO_RATING_FILTER]: [1], [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], }; @@ -741,6 +743,10 @@ export const NavidromeController: InternalControllerEndpoint = { album_id: query.albumIds, genre_id: query.genreIds, [getArtistSongKey(apiClientProps.server)]: query.artistIds ?? query.albumArtistIds, + ...(hasFeature(apiClientProps.server, ServerFeature.TRACK_YES_NO_RATING_FILTER) && + query.hasRating !== undefined + ? { has_rating: query.hasRating } + : {}), library_id: getLibraryId(query.musicFolderId), starred: query.favorite, title: query.searchTerm, diff --git a/src/renderer/features/songs/components/navidrome-song-filters.tsx b/src/renderer/features/songs/components/navidrome-song-filters.tsx index ccc5309d3..5ef46a147 100644 --- a/src/renderer/features/songs/components/navidrome-song-filters.tsx +++ b/src/renderer/features/songs/components/navidrome-song-filters.tsx @@ -13,6 +13,7 @@ import { TagFilters } from '/@/renderer/features/shared/components/tag-filter'; import { useSongListFilters } from '/@/renderer/features/songs/hooks/use-song-list-filters'; import { useCurrentServer } from '/@/renderer/store'; import { useAppStore, useAppStoreActions } from '/@/renderer/store/app.store'; +import { hasFeature } from '/@/shared/api/utils'; import { Divider } from '/@/shared/components/divider/divider'; import { Group } from '/@/shared/components/group/group'; import { VirtualMultiSelect } from '/@/shared/components/multi-select/virtual-multi-select'; @@ -27,6 +28,7 @@ import { LibraryItem, SortOrder, } from '/@/shared/types/domain-types'; +import { ServerFeature } from '/@/shared/types/features-types'; interface NavidromeSongFiltersProps { disableArtistFilter?: boolean; @@ -40,8 +42,18 @@ export const NavidromeSongFilters = ({ const { t } = useTranslation(); const server = useCurrentServer(); const serverId = server.id; - const { query, setArtistIds, setCustom, setFavorite, setGenreId, setMaxYear, setMinYear } = - useSongListFilters(); + const { + query, + setArtistIds, + setCustom, + setFavorite, + setGenreId, + setHasRating, + setMaxYear, + setMinYear, + } = useSongListFilters(); + + const showRatingFilter = hasFeature(server, ServerFeature.TRACK_YES_NO_RATING_FILTER); const genreListQuery = useQuery( genresQueries.list({ @@ -278,6 +290,25 @@ export const NavidromeSongFilters = ({ w="100%" /> + {showRatingFilter && ( + <> + + + + {t('filter.isRated', { postProcess: 'sentenceCase' })} + + { + setHasRating(segmentValueToBoolean(value)); + }} + size="sm" + value={booleanToSegmentValue(query.hasRating)} + w="100%" + /> + + + )} {!disableArtistFilter && ( <> diff --git a/src/renderer/features/songs/hooks/use-song-list-filters.ts b/src/renderer/features/songs/hooks/use-song-list-filters.ts index 757404780..1f957f8d9 100644 --- a/src/renderer/features/songs/hooks/use-song-list-filters.ts +++ b/src/renderer/features/songs/hooks/use-song-list-filters.ts @@ -53,6 +53,11 @@ export const useSongListFilters = (listKey?: ItemListKey) => { [searchParams], ); + const hasRating = useMemo( + () => parseBooleanParam(searchParams, FILTER_KEYS.SONG.HAS_RATING), + [searchParams], + ); + const custom = useMemo( () => parseCustomFiltersParam(searchParams, FILTER_KEYS.SONG._CUSTOM), [searchParams], @@ -103,6 +108,15 @@ export const useSongListFilters = (listKey?: ItemListKey) => { [setSearchParams], ); + const setHasRating = useCallback( + (value: boolean | null) => { + setSearchParams((prev) => setSearchParam(prev, FILTER_KEYS.SONG.HAS_RATING, value), { + replace: true, + }); + }, + [setSearchParams], + ); + const setCustom = useCallback( ( value: @@ -142,6 +156,7 @@ export const useSongListFilters = (listKey?: ItemListKey) => { [FILTER_KEYS.SONG.ARTIST_IDS]: null, [FILTER_KEYS.SONG.FAVORITE]: null, [FILTER_KEYS.SONG.GENRE_ID]: null, + [FILTER_KEYS.SONG.HAS_RATING]: null, [FILTER_KEYS.SONG.MAX_YEAR]: null, [FILTER_KEYS.SONG.MIN_YEAR]: null, }, @@ -160,10 +175,22 @@ export const useSongListFilters = (listKey?: ItemListKey) => { [FILTER_KEYS.SONG.ARTIST_IDS]: artistIds ?? undefined, [FILTER_KEYS.SONG.FAVORITE]: favorite ?? undefined, [FILTER_KEYS.SONG.GENRE_ID]: genreId ?? undefined, + [FILTER_KEYS.SONG.HAS_RATING]: hasRating ?? undefined, [FILTER_KEYS.SONG.MAX_YEAR]: maxYear ?? undefined, [FILTER_KEYS.SONG.MIN_YEAR]: minYear ?? undefined, }), - [searchTerm, sortBy, sortOrder, custom, artistIds, favorite, genreId, maxYear, minYear], + [ + searchTerm, + sortBy, + sortOrder, + custom, + artistIds, + favorite, + genreId, + hasRating, + maxYear, + minYear, + ], ); return { @@ -173,6 +200,7 @@ export const useSongListFilters = (listKey?: ItemListKey) => { setCustom, setFavorite, setGenreId, + setHasRating, setMaxYear, setMinYear, setSearchTerm, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index f9846672c..16788a271 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -580,6 +580,7 @@ const songListParameters = paginationParameters.extend({ artist_id: z.array(z.string()).optional(), artists_id: z.array(z.string()).optional(), genre_id: z.array(z.string()).optional(), + has_rating: z.boolean().optional(), library_id: z.array(z.string()).optional(), path: z.string().optional(), starred: z.boolean().optional(), diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 913aa70b7..179cda88e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -623,6 +623,7 @@ export interface SongListQuery extends BaseQuery { artistIds?: string[]; favorite?: boolean; genreIds?: string[]; + hasRating?: boolean; imageSize?: number; limit?: number; maxYear?: number; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 640312b5e..33a722a53 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -1,6 +1,7 @@ // Should follow a strict naming convention: "_" // For example: : "Playlists", : "Smart" = "PLAYLISTS_SMART" export enum ServerFeature { + ALBUM_YES_NO_RATING_FILTER = 'albumYesNoRatingFilter', BFR = 'bfr', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', @@ -13,6 +14,7 @@ export enum ServerFeature { SIMILAR_SONGS_MUSIC_FOLDER = 'similarSongsMusicFolder', TAGS = 'tags', TRACK_ALBUM_ARTIST_SEARCH = 'trackAlbumArtistSearch', + TRACK_YES_NO_RATING_FILTER = 'trackYesNoRatingFilter', } export type ServerFeatures = Partial>;