From 41eb401a7a8b0cb23b981147a4579c66af2e2994 Mon Sep 17 00:00:00 2001 From: Derek Simonds Date: Sun, 26 Jul 2026 13:53:06 -0700 Subject: [PATCH 1/3] Adds Top Rating option songs to Artist's favorite songs section --- src/renderer/api/controller.ts | 12 +++++ .../api/jellyfin/jellyfin-controller.ts | 37 ++++++++++++++ .../api/navidrome/navidrome-controller.ts | 50 +++++++++++++++++++ src/renderer/api/query-keys.ts | 7 +-- .../api/subsonic/subsonic-controller.ts | 50 +++++++++++++++++++ .../features/artists/api/artists-api.ts | 19 +++---- .../album-artist-detail-content.tsx | 30 +++++++++++ ...rtist-detail-favorite-songs-list-route.tsx | 34 ++++++++----- src/shared/types/domain-types.ts | 15 ++++++ 9 files changed, 226 insertions(+), 28 deletions(-) diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index c0cc1dee5e..06ca8a49df 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -535,6 +535,18 @@ export const controller: GeneralController = { server.type, )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); }, + getFavoriteSongs(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error(`${i18n.t('error.apiRouteError')}: getFavoriteSongs`); + } + + return apiController( + 'getFavoriteSongs', + server.type, + )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); + }, getFolder(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index e957fb0065..15653a2f80 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -679,6 +679,43 @@ export const JellyfinController: InternalControllerEndpoint = { return `${apiClientProps.server?.url}/items/${query.id}/download?apiKey=${apiClientProps.server?.credential}`; }, + getFavoriteSongs: async (args) => { + const { apiClientProps, query } = args; + + if (!apiClientProps.server?.userId) { + throw new Error('No userId found'); + } + + // Gets songs sorted by play count and filters favorited songs + const res = await jfApiClient(apiClientProps).getTopSongsList({ + params: { + userId: apiClientProps.server?.userId, + }, + query: { + ArtistIds: query.artistId, + Fields: JF_FIELDS.SONG, + IncludeItemTypes: 'Audio', + IsFavorite: true, + Limit: query.limit, + Recursive: true, + SortBy: JFSongListSort.PLAY_COUNT, + SortOrder: 'Descending', + UserId: apiClientProps.server?.userId, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get top song list'); + } + + const items = res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server)); + + return { + items, + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + }, getFolder: async (args) => { const { apiClientProps, query } = args; const userId = apiClientProps.server?.userId; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b9642a69df..79aca7c0b4 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -557,6 +557,56 @@ export const NavidromeController: InternalControllerEndpoint = { ); }, getDownloadUrl: SubsonicController.getDownloadUrl, + getFavoriteSongs: async (args) => { + const { apiClientProps, query } = args; + + const type = query.type === 'favorite' ? 'favorite' : 'rating'; + + if (type === 'rating') { + const res = await NavidromeController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.RATING, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + + const songsWithHighRating = orderBy( + res.items.filter((song) => song.userRating !== null && song.userRating > 2), + ['userRating', 'userFavorite', 'playCount', 'albumId', 'trackNumber'], + ['desc', 'desc', 'desc', 'asc', 'asc'], + ); + + return { + items: songsWithHighRating, + startIndex: 0, + totalRecordCount: res.totalRecordCount, + }; + } + + const res = await NavidromeController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.FAVORITED, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + const songsWithFavorite = orderBy( + res.items.filter((song) => song.userFavorite), + ['userFavorite', 'userRating', 'playCount', 'albumId', 'trackNumber'], + ['desc', 'desc', 'desc', 'asc', 'asc'], + ); + + return { + items: songsWithFavorite, + startIndex: 0, + totalRecordCount: res.totalRecordCount, + }; + }, getFolder: SubsonicController.getFolder, getGenreList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 686a62c747..85227b91b0 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -7,6 +7,7 @@ import type { AlbumRadioQuery, ArtistListQuery, ArtistRadioQuery, + FavoriteSongListQuery, FolderQuery, GenreListQuery, LyricSearchQuery, @@ -75,9 +76,9 @@ export const queryKeys: Record< return [serverId, 'albumArtists', 'detail'] as const; }, - favoriteSongs: (serverId: string, artistId?: string) => { - if (artistId) { - return [serverId, 'albumArtists', 'favoriteSongs', artistId] as const; + favoriteSongs: (serverId: string, query?: FavoriteSongListQuery) => { + if (query) { + return [serverId, 'albumArtists', 'favoriteSongs', query] as const; } return [serverId, 'albumArtists', 'favoriteSongs'] as const; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 572908bb6d..f3f771080b 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -976,6 +976,56 @@ export const SubsonicController: InternalControllerEndpoint = { '&c=Feishin' ); }, + getFavoriteSongs: async (args) => { + const { apiClientProps, query } = args; + + const type = query.type === 'favorite' ? 'favorite' : 'rating'; + + if (type === 'rating') { + const res = await SubsonicController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.RATING, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + + const songsWithHighRating = orderBy( + res.items.filter((song) => song.userRating !== null && song.userRating > 2), + ['userRating', 'userFavorite', 'playCount', 'albumId', 'trackNumber'], + ['desc', 'desc', 'desc', 'asc', 'asc'], + ); + + return { + items: songsWithHighRating, + startIndex: 0, + totalRecordCount: res.totalRecordCount, + }; + } + + const res = await SubsonicController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.FAVORITED, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + const songsWithFavorite = orderBy( + res.items.filter((song) => song.userFavorite), + ['userFavorite', 'userRating', 'playCount', 'albumId', 'trackNumber'], + ['desc', 'desc', 'desc', 'asc', 'asc'], + ); + + return { + items: songsWithFavorite, + startIndex: 0, + totalRecordCount: res.totalRecordCount, + }; + }, getFolder: async ({ apiClientProps, query }) => { const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc'; diff --git a/src/renderer/features/artists/api/artists-api.ts b/src/renderer/features/artists/api/artists-api.ts index 7f42c95547..df153fca97 100644 --- a/src/renderer/features/artists/api/artists-api.ts +++ b/src/renderer/features/artists/api/artists-api.ts @@ -10,9 +10,8 @@ import { AlbumArtistInfoQuery, AlbumArtistListQuery, ArtistListQuery, + FavoriteSongListQuery, ListCountQuery, - SongListSort, - SortOrder, TopSongListQuery, } from '/@/shared/types/domain-types'; @@ -137,22 +136,16 @@ export const artistsQueries = { ...args.options, }); }, - favoriteSongs: (args: QueryHookArgs<{ artistId: string }>) => { + favoriteSongs: (args: QueryHookArgs) => { return queryOptions({ queryFn: ({ signal }) => { - return api.controller.getSongList({ + return api.controller.getFavoriteSongs({ apiClientProps: { serverId: args.serverId, signal }, - query: { - artistIds: [args.query.artistId], - favorite: true, - limit: -1, - sortBy: SongListSort.RELEASE_DATE, - sortOrder: SortOrder.ASC, - startIndex: 0, - }, + query: args.query, }); }, - queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query.artistId), + queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query), + ...args.options, }); }, topSongs: (args: QueryHookArgs) => { diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index 3830851042..67fd5021e1 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -605,6 +605,12 @@ const AlbumArtistMetadataFavoriteSongs = ({ const { t } = useTranslation(); const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); + const [favoriteSongsQueryType, setFavoriteSongsQueryType] = useLocalStorage< + 'favorite' | 'rating' + >({ + defaultValue: 'favorite', + key: 'album-artist-favorite-songs-query-type', + }); const albumArtistDetailFavoriteSongsSort = useAppStore( (state) => state.albumArtistDetailFavoriteSongsSort, ); @@ -617,11 +623,13 @@ const AlbumArtistMetadataFavoriteSongs = ({ const currentSong = usePlayerSong(); const player = usePlayer(); const serverId = useCurrentServerId(); + const server = useCurrentServer(); const favoriteSongsQuery = useQuery({ ...artistsQueries.favoriteSongs({ query: { artistId: routeId, + type: favoriteSongsQueryType, }, serverId: serverId, }), @@ -795,6 +803,28 @@ const AlbumArtistMetadataFavoriteSongs = ({ }} value={searchTerm} /> + {/* Don't include SegmentedControl for JELLYFIN, since it only supports Favorites and not Ratings */} + {server?.type !== ServerType.JELLYFIN && ( + + setFavoriteSongsQueryType( + value as 'favorite' | 'rating', + ) + } + size="xs" + value={favoriteSongsQueryType} + /> + )} { const server = useCurrentServer(); const pageKey = LibraryItem.SONG; - const [detailQuery, favoriteSongsQuery] = useSuspenseQueries({ - queries: [ - artistsQueries.albumArtistDetail({ - query: { id: routeId }, - serverId: server?.id, - }), - artistsQueries.favoriteSongs({ - query: { artistId: routeId }, - serverId: server?.id, - }), - ], + const [favoriteSongsQueryType] = useLocalStorage<'favorite' | 'rating'>({ + defaultValue: 'favorite', + key: 'album-artist-favorite-songs-query-type', }); + const detailQuery = useSuspenseQuery( + artistsQueries.albumArtistDetail({ + query: { id: routeId }, + serverId: server?.id, + }), + ); + + const favoriteSongsQuery = useSuspenseQuery( + artistsQueries.favoriteSongs({ + query: { + artistId: routeId, + type: favoriteSongsQueryType, + }, + serverId: server?.id, + }), + ); + const songs = useMemo( () => favoriteSongsQuery?.data?.items || [], [favoriteSongsQuery?.data?.items], diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index ccf9a63e67..af092b3f5e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1325,6 +1325,17 @@ export type ArtistInfoQuery = { musicFolderId?: string | string[]; }; +export type FavoriteSongListArgs = BaseEndpointArgs & { query: FavoriteSongListQuery }; + +export type FavoriteSongListQuery = { + artistId: string; + limit?: number; + type?: 'favorite' | 'rating'; +}; + +// Favorite Songs List +export type FavoriteSongListResponse = BasePaginatedResponse; + export type FullLyricsMetadata = Omit & { lyrics: LyricsResponse; offsetMs?: number; @@ -1562,6 +1573,7 @@ export type ControllerEndpoint = { getArtistListCount: (args: ArtistListCountArgs) => Promise; getArtistRadio: (args: ArtistRadioArgs) => Promise; getDownloadUrl: (args: DownloadArgs) => string; + getFavoriteSongs: (args: FavoriteSongListArgs) => Promise; getFolder: (args: FolderArgs) => Promise; getGenreList: (args: GenreListArgs) => Promise; getImageRequest: (args: ImageArgs) => ImageRequest | null; @@ -1714,6 +1726,9 @@ export type InternalControllerEndpoint = { getArtistListCount: (args: ReplaceApiClientProps) => Promise; getArtistRadio: (args: ReplaceApiClientProps) => Promise; getDownloadUrl: (args: ReplaceApiClientProps) => string; + getFavoriteSongs: ( + args: ReplaceApiClientProps, + ) => Promise; getFolder: (args: ReplaceApiClientProps) => Promise; getGenreList: (args: ReplaceApiClientProps) => Promise; getImageRequest: (args: ReplaceApiClientProps) => ImageRequest | null; From b0e0f235b0b382497eef968309cc066d5b323b00 Mon Sep 17 00:00:00 2001 From: Derek Simonds Date: Mon, 27 Jul 2026 12:52:17 -0700 Subject: [PATCH 2/3] Adds support for 07ef323 to optionally only show ratings or favorites --- .../components/album-artist-detail-content.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/renderer/features/artists/components/album-artist-detail-content.tsx b/src/renderer/features/artists/components/album-artist-detail-content.tsx index 67fd5021e1..487d702d14 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -52,6 +52,8 @@ import { useCurrentServer, useCurrentServerId, usePlayerSong, + useShowFavorites, + useShowRatings, } from '/@/renderer/store'; import { useArtistItems, @@ -624,12 +626,23 @@ const AlbumArtistMetadataFavoriteSongs = ({ const player = usePlayer(); const serverId = useCurrentServerId(); const server = useCurrentServer(); + const showRatings = useShowRatings(); + const showFavorites = useShowFavorites(); + const showFavoriteAndRatingSegmentControl = + server?.type !== ServerType.JELLYFIN && showFavorites && showRatings; + + let favoriteSongsQueryTypeFilter = favoriteSongsQueryType; + if (showRatings && !showFavorites) { + favoriteSongsQueryTypeFilter = 'rating'; + } else if (!showRatings && showFavorites) { + favoriteSongsQueryTypeFilter = 'favorite'; + } const favoriteSongsQuery = useQuery({ ...artistsQueries.favoriteSongs({ query: { artistId: routeId, - type: favoriteSongsQueryType, + type: favoriteSongsQueryTypeFilter, }, serverId: serverId, }), @@ -803,8 +816,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ }} value={searchTerm} /> - {/* Don't include SegmentedControl for JELLYFIN, since it only supports Favorites and not Ratings */} - {server?.type !== ServerType.JELLYFIN && ( + {showFavoriteAndRatingSegmentControl && ( Date: Fri, 31 Jul 2026 10:17:46 -0700 Subject: [PATCH 3/3] Removes redundant type variable --- src/renderer/api/navidrome/navidrome-controller.ts | 7 ++++--- src/renderer/api/subsonic/subsonic-controller.ts | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 79aca7c0b4..ef2fad1e85 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -560,9 +560,8 @@ export const NavidromeController: InternalControllerEndpoint = { getFavoriteSongs: async (args) => { const { apiClientProps, query } = args; - const type = query.type === 'favorite' ? 'favorite' : 'rating'; - - if (type === 'rating') { + // if user selects 'rating' + if (query.type === 'rating') { const res = await NavidromeController.getSongList({ apiClientProps, query: { @@ -586,6 +585,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; } + // else if user selects 'favorite' const res = await NavidromeController.getSongList({ apiClientProps, query: { @@ -595,6 +595,7 @@ export const NavidromeController: InternalControllerEndpoint = { startIndex: 0, }, }); + const songsWithFavorite = orderBy( res.items.filter((song) => song.userFavorite), ['userFavorite', 'userRating', 'playCount', 'albumId', 'trackNumber'], diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index f3f771080b..07f0437872 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -979,9 +979,8 @@ export const SubsonicController: InternalControllerEndpoint = { getFavoriteSongs: async (args) => { const { apiClientProps, query } = args; - const type = query.type === 'favorite' ? 'favorite' : 'rating'; - - if (type === 'rating') { + // if user selects 'rating' + if (query.type === 'rating') { const res = await SubsonicController.getSongList({ apiClientProps, query: { @@ -1005,6 +1004,7 @@ export const SubsonicController: InternalControllerEndpoint = { }; } + // else if user selects 'favorites' const res = await SubsonicController.getSongList({ apiClientProps, query: {