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, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(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) { getFolder(args) {
const server = getServerById(args.apiClientProps.serverId); 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}`; 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) => { getFolder: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
const userId = apiClientProps.server?.userId; const userId = apiClientProps.server?.userId;
@@ -557,6 +557,57 @@ export const NavidromeController: InternalControllerEndpoint = {
); );
}, },
getDownloadUrl: SubsonicController.getDownloadUrl, 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, getFolder: SubsonicController.getFolder,
getGenreList: async (args) => { getGenreList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+4 -3
View File
@@ -7,6 +7,7 @@ import type {
AlbumRadioQuery, AlbumRadioQuery,
ArtistListQuery, ArtistListQuery,
ArtistRadioQuery, ArtistRadioQuery,
FavoriteSongListQuery,
FolderQuery, FolderQuery,
GenreListQuery, GenreListQuery,
LyricSearchQuery, LyricSearchQuery,
@@ -75,9 +76,9 @@ export const queryKeys: Record<
return [serverId, 'albumArtists', 'detail'] as const; return [serverId, 'albumArtists', 'detail'] as const;
}, },
favoriteSongs: (serverId: string, artistId?: string) => { favoriteSongs: (serverId: string, query?: FavoriteSongListQuery) => {
if (artistId) { if (query) {
return [serverId, 'albumArtists', 'favoriteSongs', artistId] as const; return [serverId, 'albumArtists', 'favoriteSongs', query] as const;
} }
return [serverId, 'albumArtists', 'favoriteSongs'] as const; return [serverId, 'albumArtists', 'favoriteSongs'] as const;
@@ -976,6 +976,56 @@ export const SubsonicController: InternalControllerEndpoint = {
'&c=Feishin' '&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 }) => { getFolder: async ({ apiClientProps, query }) => {
const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc'; const sortOrder = (query.sortOrder?.toLowerCase() ?? 'asc') as 'asc' | 'desc';
@@ -10,9 +10,8 @@ import {
AlbumArtistInfoQuery, AlbumArtistInfoQuery,
AlbumArtistListQuery, AlbumArtistListQuery,
ArtistListQuery, ArtistListQuery,
FavoriteSongListQuery,
ListCountQuery, ListCountQuery,
SongListSort,
SortOrder,
TopSongListQuery, TopSongListQuery,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
@@ -137,22 +136,16 @@ export const artistsQueries = {
...args.options, ...args.options,
}); });
}, },
favoriteSongs: (args: QueryHookArgs<{ artistId: string }>) => { favoriteSongs: (args: QueryHookArgs<FavoriteSongListQuery>) => {
return queryOptions({ return queryOptions({
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
return api.controller.getSongList({ return api.controller.getFavoriteSongs({
apiClientProps: { serverId: args.serverId, signal }, apiClientProps: { serverId: args.serverId, signal },
query: { query: args.query,
artistIds: [args.query.artistId],
favorite: true,
limit: -1,
sortBy: SongListSort.RELEASE_DATE,
sortOrder: SortOrder.ASC,
startIndex: 0,
},
}); });
}, },
queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query.artistId), queryKey: queryKeys.albumArtists.favoriteSongs(args.serverId, args.query),
...args.options,
}); });
}, },
topSongs: (args: QueryHookArgs<TopSongListQuery>) => { topSongs: (args: QueryHookArgs<TopSongListQuery>) => {
@@ -52,6 +52,8 @@ import {
useCurrentServer, useCurrentServer,
useCurrentServerId, useCurrentServerId,
usePlayerSong, usePlayerSong,
useShowFavorites,
useShowRatings,
} from '/@/renderer/store'; } from '/@/renderer/store';
import { import {
useArtistItems, useArtistItems,
@@ -605,6 +607,12 @@ const AlbumArtistMetadataFavoriteSongs = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
const [favoriteSongsQueryType, setFavoriteSongsQueryType] = useLocalStorage<
'favorite' | 'rating'
>({
defaultValue: 'favorite',
key: 'album-artist-favorite-songs-query-type',
});
const albumArtistDetailFavoriteSongsSort = useAppStore( const albumArtistDetailFavoriteSongsSort = useAppStore(
(state) => state.albumArtistDetailFavoriteSongsSort, (state) => state.albumArtistDetailFavoriteSongsSort,
); );
@@ -617,11 +625,24 @@ const AlbumArtistMetadataFavoriteSongs = ({
const currentSong = usePlayerSong(); const currentSong = usePlayerSong();
const player = usePlayer(); const player = usePlayer();
const serverId = useCurrentServerId(); 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({ const favoriteSongsQuery = useQuery({
...artistsQueries.favoriteSongs({ ...artistsQueries.favoriteSongs({
query: { query: {
artistId: routeId, artistId: routeId,
type: favoriteSongsQueryTypeFilter,
}, },
serverId: serverId, serverId: serverId,
}), }),
@@ -795,6 +816,27 @@ const AlbumArtistMetadataFavoriteSongs = ({
}} }}
value={searchTerm} 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 <ListSortByDropdownControlled
filters={CLIENT_SIDE_SONG_FILTERS} filters={CLIENT_SIDE_SONG_FILTERS}
itemType={LibraryItem.SONG} itemType={LibraryItem.SONG}
@@ -1,4 +1,4 @@
import { useSuspenseQueries } from '@tanstack/react-query'; import { useSuspenseQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -24,6 +24,7 @@ import { useAppStore } from '/@/renderer/store/app.store';
import { useCurrentServer } from '/@/renderer/store/auth.store'; import { useCurrentServer } from '/@/renderer/store/auth.store';
import { useSettingsStore } from '/@/renderer/store/settings.store'; import { useSettingsStore } from '/@/renderer/store/settings.store';
import { sortSongList } from '/@/shared/api/utils'; import { sortSongList } from '/@/shared/api/utils';
import { useLocalStorage } from '/@/shared/hooks/use-local-storage';
import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { LibraryItem, Song } from '/@/shared/types/domain-types';
import { ItemListKey } from '/@/shared/types/types'; import { ItemListKey } from '/@/shared/types/types';
@@ -36,18 +37,27 @@ const AlbumArtistDetailFavoriteSongsListRoute = () => {
const server = useCurrentServer(); const server = useCurrentServer();
const pageKey = LibraryItem.SONG; const pageKey = LibraryItem.SONG;
const [detailQuery, favoriteSongsQuery] = useSuspenseQueries({ const [favoriteSongsQueryType] = useLocalStorage<'favorite' | 'rating'>({
queries: [ defaultValue: 'favorite',
key: 'album-artist-favorite-songs-query-type',
});
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({ artistsQueries.albumArtistDetail({
query: { id: routeId }, query: { id: routeId },
serverId: server?.id, serverId: server?.id,
}), }),
);
const favoriteSongsQuery = useSuspenseQuery(
artistsQueries.favoriteSongs({ artistsQueries.favoriteSongs({
query: { artistId: routeId }, query: {
artistId: routeId,
type: favoriteSongsQueryType,
},
serverId: server?.id, serverId: server?.id,
}), }),
], );
});
const songs = useMemo( const songs = useMemo(
() => favoriteSongsQuery?.data?.items || [], () => favoriteSongsQuery?.data?.items || [],
+15
View File
@@ -1329,6 +1329,17 @@ export type ArtistInfoQuery = {
musicFolderId?: string | string[]; 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'> & { export type FullLyricsMetadata = Omit<InternetProviderLyricResponse, 'id' | 'lyrics' | 'source'> & {
lyrics: LyricsResponse; lyrics: LyricsResponse;
offsetMs?: number; offsetMs?: number;
@@ -1566,6 +1577,7 @@ export type ControllerEndpoint = {
getArtistListCount: (args: ArtistListCountArgs) => Promise<number>; getArtistListCount: (args: ArtistListCountArgs) => Promise<number>;
getArtistRadio: (args: ArtistRadioArgs) => Promise<Song[]>; getArtistRadio: (args: ArtistRadioArgs) => Promise<Song[]>;
getDownloadUrl: (args: DownloadArgs) => string; getDownloadUrl: (args: DownloadArgs) => string;
getFavoriteSongs: (args: FavoriteSongListArgs) => Promise<FavoriteSongListResponse>;
getFolder: (args: FolderArgs) => Promise<FolderResponse>; getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>; getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getImageRequest: (args: ImageArgs) => ImageRequest | null; getImageRequest: (args: ImageArgs) => ImageRequest | null;
@@ -1719,6 +1731,9 @@ export type InternalControllerEndpoint = {
getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>; getArtistListCount: (args: ReplaceApiClientProps<ArtistListCountArgs>) => Promise<number>;
getArtistRadio: (args: ReplaceApiClientProps<ArtistRadioArgs>) => Promise<Song[]>; getArtistRadio: (args: ReplaceApiClientProps<ArtistRadioArgs>) => Promise<Song[]>;
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string; getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFavoriteSongs: (
args: ReplaceApiClientProps<FavoriteSongListArgs>,
) => Promise<FavoriteSongListResponse>;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>; getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>; getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null; getImageRequest: (args: ReplaceApiClientProps<ImageArgs>) => ImageRequest | null;