feat: adds option to view top rated songs in the Favorite Songs section (#2289)

* Adds Top Rating option songs to Artist's favorite songs section
This commit is contained in:
Derek Simonds
2026-08-03 19:36:08 -07:00
committed by GitHub
parent 71fc632501
commit fd9380a149
9 changed files with 239 additions and 28 deletions
+12
View File
@@ -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);
@@ -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;
@@ -557,6 +557,57 @@ export const NavidromeController: InternalControllerEndpoint = {
);
},
getDownloadUrl: SubsonicController.getDownloadUrl,
getFavoriteSongs: async (args) => {
const { apiClientProps, query } = args;
// if user selects 'rating'
if (query.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,
};
}
// else if user selects 'favorite'
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;
+4 -3
View File
@@ -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;
@@ -976,6 +976,56 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin'
);
},
getFavoriteSongs: async (args) => {
const { apiClientProps, query } = args;
// if user selects 'rating'
if (query.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,
};
}
// else if user selects 'favorites'
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';
@@ -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<FavoriteSongListQuery>) => {
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<TopSongListQuery>) => {
@@ -52,6 +52,8 @@ import {
useCurrentServer,
useCurrentServerId,
usePlayerSong,
useShowFavorites,
useShowRatings,
} from '/@/renderer/store';
import {
useArtistItems,
@@ -605,6 +607,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 +625,24 @@ const AlbumArtistMetadataFavoriteSongs = ({
const currentSong = usePlayerSong();
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: favoriteSongsQueryTypeFilter,
},
serverId: serverId,
}),
@@ -795,6 +816,27 @@ const AlbumArtistMetadataFavoriteSongs = ({
}}
value={searchTerm}
/>
{showFavoriteAndRatingSegmentControl && (
<SegmentedControl
data={[
{
label: t('common.favorite'),
value: 'favorite',
},
{
label: t('common.rating'),
value: 'rating',
},
]}
onChange={(value) =>
setFavoriteSongsQueryType(
value as 'favorite' | 'rating',
)
}
size="xs"
value={favoriteSongsQueryType}
/>
)}
<ListSortByDropdownControlled
filters={CLIENT_SIDE_SONG_FILTERS}
itemType={LibraryItem.SONG}
@@ -1,4 +1,4 @@
import { useSuspenseQueries } from '@tanstack/react-query';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useParams } from 'react-router';
@@ -24,6 +24,7 @@ import { useAppStore } from '/@/renderer/store/app.store';
import { useCurrentServer } from '/@/renderer/store/auth.store';
import { useSettingsStore } from '/@/renderer/store/settings.store';
import { sortSongList } from '/@/shared/api/utils';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types';
@@ -36,19 +37,28 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
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],
+15
View File
@@ -1329,6 +1329,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<Song[]>;
export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
lyrics: LyricsResponse;
offsetMs?: number;
@@ -1566,6 +1577,7 @@ export type ControllerEndpoint = {
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getArtistRadio: (args: ArtistRadioArgs) => Promise<Song[]>;
getDownloadUrl: (args: DownloadArgs) => string;
getFavoriteSongs: (args: FavoriteSongListArgs) => Promise<FavoriteSongListResponse>;
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getImageRequest: (args: ImageArgs) => ImageRequest | null;
@@ -1719,6 +1731,9 @@ export type InternalControllerEndpoint = {
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getArtistRadio: (args: ReplaceApiClientProps<ArtistRadioArgs>) => Promise<Song[]>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFavoriteSongs: (
args: ReplaceApiClientProps<FavoriteSongListArgs>,
) => Promise<FavoriteSongListResponse>;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;