diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f1ed209f3..cfabc7aab 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -425,7 +425,9 @@ "viewDiscography": "view discography", "relatedArtists": "related $t(entity.artist, {\"count\": 2})", "topSongs": "top songs", + "topSongsCommunity": "community", "topSongsFrom": "top songs from {{title}}", + "topSongsPersonal": "personal", "favoriteSongsFrom": "favorite songs from {{title}}", "viewAll": "view all", "viewAllTracks": "view all $t(entity.track, {\"count\": 2})" diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 68732acff..f668581a7 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1281,17 +1281,26 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('No userId found'); } + const type = query.type === 'personal' ? 'personal' : 'community'; + const res = await jfApiClient(apiClientProps).getTopSongsList({ params: { userId: apiClientProps.server?.userId, }, query: { ArtistIds: query.artistId, - Fields: 'Genres, DateCreated, MediaSources, ParentId, SortName', + Fields: + type === 'personal' + ? 'Genres, DateCreated, MediaSources, ParentId, SortName, UserData' + : 'Genres, DateCreated, MediaSources, ParentId, SortName', + IncludeItemTypes: 'Audio', Limit: query.limit, Recursive: true, - SortBy: 'CommunityRating,SortName', + SortBy: + type === 'personal' + ? JFSongListSort.PLAY_COUNT + : JFSongListSort.COMMUNITY_RATING, SortOrder: 'Descending', UserId: apiClientProps.server?.userId, }, @@ -1301,15 +1310,31 @@ export const JellyfinController: InternalControllerEndpoint = { throw new Error('Failed to get top song list'); } - return { - items: res.body.Items.map((item) => - jfNormalize.song( - item, - apiClientProps.server, - args.context?.pathReplace, - args.context?.pathReplaceWith, - ), + const items = res.body.Items.map((item) => + jfNormalize.song( + item, + apiClientProps.server, + args.context?.pathReplace, + args.context?.pathReplaceWith, ), + ); + + if (type === 'personal') { + const sorted = orderBy( + items, + ['playCount', 'albumId', 'trackNumber'], + ['desc', 'asc', 'asc'], + ); + + return { + items: sorted, + startIndex: 0, + totalRecordCount: res.body.TotalRecordCount, + }; + } + + return { + items, startIndex: 0, totalRecordCount: res.body.TotalRecordCount, }; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index b41c28c6f..73c382d88 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -1,4 +1,5 @@ import { set } from 'idb-keyval'; +import orderBy from 'lodash/orderBy'; import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api'; import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; @@ -17,7 +18,9 @@ import { PlaylistSongListArgs, PlaylistSongListResponse, ServerListItemWithCredential, + SongListSort, songListSortMap, + SortOrder, sortOrderMap, tagListSortMap, userListSortMap, @@ -807,7 +810,59 @@ export const NavidromeController: InternalControllerEndpoint = { tags, }; }, - getTopSongs: SubsonicController.getTopSongs, + getTopSongs: async (args) => { + const { apiClientProps, query } = args; + + const type = query.type === 'personal' ? 'personal' : 'community'; + + if (type === 'community') { + const res = await ssApiClient(apiClientProps).getTopSongsList({ + query: { + artist: query.artist, + count: query.limit, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to get top songs'); + } + + return { + items: (res.body.topSongs?.song || []).map((song) => + ssNormalize.song( + song, + apiClientProps.server, + args.context?.pathReplace, + args.context?.pathReplaceWith, + ), + ), + startIndex: 0, + totalRecordCount: res.body.topSongs?.song?.length || 0, + }; + } + + const res = await NavidromeController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.PLAY_COUNT, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + + const songsWithPlayCount = orderBy( + res.items.filter((song) => song.playCount > 0), + ['playCount', 'albumId', 'trackNumber'], + ['desc', 'asc', 'asc'], + ); + + return { + items: songsWithPlayCount, + startIndex: 0, + totalRecordCount: res.totalRecordCount, + }; + }, getUserInfo: SubsonicController.getUserInfo, getUserList: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 64d8ba5f2..10e81bc55 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1794,29 +1794,54 @@ export const SubsonicController: InternalControllerEndpoint = { getTopSongs: async (args) => { const { apiClientProps, context, query } = args; - const res = await ssApiClient(apiClientProps).getTopSongsList({ - query: { - artist: query.artist, - count: query.limit, - }, - }); + const type = query.type === 'personal' ? 'personal' : 'community'; - if (res.status !== 200) { - throw new Error('Failed to get top songs'); - } + if (type === 'community') { + const res = await ssApiClient(apiClientProps).getTopSongsList({ + query: { + artist: query.artist, + count: query.limit, + }, + }); - return { - items: - res.body.topSongs?.song?.map((song) => + if (res.status !== 200) { + throw new Error('Failed to get top songs'); + } + + return { + items: (res.body.topSongs?.song || []).map((song) => ssNormalize.song( song, apiClientProps.server, context?.pathReplace, context?.pathReplaceWith, ), - ) || [], + ), + startIndex: 0, + totalRecordCount: res.body.topSongs?.song?.length || 0, + }; + } + + const res = await SubsonicController.getSongList({ + apiClientProps, + query: { + artistIds: [query.artistId], + sortBy: SongListSort.PLAY_COUNT, + sortOrder: SortOrder.DESC, + startIndex: 0, + }, + }); + + const songsWithPlayCount = orderBy( + res.items.filter((song) => song.playCount > 0), + ['playCount', 'albumId', 'trackNumber'], + ['desc', 'asc', 'asc'], + ); + + return { + items: songsWithPlayCount, startIndex: 0, - totalRecordCount: res.body.topSongs?.song?.length || 0, + totalRecordCount: res.totalRecordCount, }; }, getUserInfo: async (args) => { 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 f8c43b1fb..1c26e6b57 100644 --- a/src/renderer/features/artists/components/album-artist-detail-content.tsx +++ b/src/renderer/features/artists/components/album-artist-detail-content.tsx @@ -66,6 +66,7 @@ import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Grid } from '/@/shared/components/grid/grid'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; +import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Stack } from '/@/shared/components/stack/stack'; @@ -74,6 +75,7 @@ import { TextTitle } from '/@/shared/components/text-title/text-title'; import { Text } from '/@/shared/components/text/text'; import { useDebouncedValue } from '/@/shared/hooks/use-debounced-value'; import { useHotkeys } from '/@/shared/hooks/use-hotkeys'; +import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { Album, AlbumArtist, @@ -236,6 +238,10 @@ const AlbumArtistMetadataTopSongsContent = ({ const [searchTerm, setSearchTerm] = useState(''); const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300); const [showAll, setShowAll] = useState(false); + const [topSongsQueryType, setTopSongsQueryType] = useLocalStorage<'community' | 'personal'>({ + defaultValue: 'community', + key: 'album-artist-top-songs-query-type', + }); const tableConfig = useSettingsStore((state) => state.lists[ItemListKey.SONG]?.table); const currentSong = usePlayerSong(); const player = usePlayer(); @@ -249,6 +255,7 @@ const AlbumArtistMetadataTopSongsContent = ({ query: { artist: detailQuery.data?.name || '', artistId: routeId, + type: topSongsQueryType, }, serverId: serverId, }), @@ -316,15 +323,9 @@ const AlbumArtistMetadataTopSongsContent = ({ onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]), }); - if (topSongsQuery.isLoading || !topSongsQuery.data) { - return null; - } + const isLoading = topSongsQuery.isLoading || !topSongsQuery.data; - if (!topSongsQuery?.data?.items?.length) return null; - - if (!tableConfig) { - return null; - } + if (!isLoading && !tableConfig) return null; const currentSongId = currentSong?.id; @@ -338,7 +339,7 @@ const AlbumArtistMetadataTopSongsContent = ({ postProcess: 'sentenceCase', })} - {songs.length} + {!isLoading && {songs.length}}
@@ -365,6 +366,7 @@ const AlbumArtistMetadataTopSongsContent = ({ variant="subtle" {...handlePlayNow.handlers} {...handlePlayNow.props} + disabled={isLoading} /> @@ -375,6 +377,7 @@ const AlbumArtistMetadataTopSongsContent = ({ variant="subtle" {...handlePlayNext.handlers} {...handlePlayNext.props} + disabled={isLoading} /> @@ -385,78 +388,108 @@ const AlbumArtistMetadataTopSongsContent = ({ variant="subtle" {...handlePlayLast.handlers} {...handlePlayLast.props} + disabled={isLoading} /> )}
- - } - onChange={(e) => setSearchTerm(e.target.value)} - placeholder={t('common.search', { postProcess: 'sentenceCase' })} - radius="xl" - rightSection={ - searchTerm ? ( - setSearchTerm('')} - size="sm" - variant="transparent" - /> - ) : null - } - styles={{ - input: { - background: 'transparent', - border: '1px solid rgba(255, 255, 255, 0.05)', - }, - }} - value={searchTerm} - /> - - - - {!searchTerm.trim() && songs.length > 5 && !showAll && ( - - + {isLoading ? ( + + - )} + ) : tableConfig ? ( + <> + + } + onChange={(e) => setSearchTerm(e.target.value)} + placeholder={t('common.search', { postProcess: 'sentenceCase' })} + radius="xl" + rightSection={ + searchTerm ? ( + setSearchTerm('')} + size="sm" + variant="transparent" + /> + ) : null + } + styles={{ + input: { + background: 'transparent', + border: '1px solid rgba(255, 255, 255, 0.05)', + }, + }} + value={searchTerm} + /> + + setTopSongsQueryType(value as 'community' | 'personal') + } + size="xs" + value={topSongsQueryType} + /> + + + + {!searchTerm.trim() && songs.length > 5 && !showAll && ( + + + + )} + + ) : null} ); @@ -569,15 +602,9 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori onLongPress: () => handlePlay(LONG_PRESS_PLAY_BEHAVIOR[Play.LAST]), }); - if (favoriteSongsQuery.isLoading || !favoriteSongsQuery.data) { - return null; - } + const isLoading = favoriteSongsQuery.isLoading || !favoriteSongsQuery.data; - if (!favoriteSongsQuery?.data?.items?.length) return null; - - if (!tableConfig) { - return null; - } + if (!isLoading && !tableConfig) return null; const currentSongId = currentSong?.id; @@ -591,7 +618,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori postProcess: 'sentenceCase', })} - {favoriteSongsQuery.data?.items?.length} + {!isLoading && {songs.length}}
@@ -618,6 +645,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori variant="subtle" {...handlePlayNow.handlers} {...handlePlayNow.props} + disabled={isLoading} /> @@ -628,6 +656,7 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori variant="subtle" {...handlePlayNext.handlers} {...handlePlayNext.props} + disabled={isLoading} /> @@ -638,78 +667,87 @@ const AlbumArtistMetadataFavoriteSongs = ({ routeId }: AlbumArtistMetadataFavori variant="subtle" {...handlePlayLast.handlers} {...handlePlayLast.props} + disabled={isLoading} /> )}
- - } - onChange={(e) => setSearchTerm(e.target.value)} - placeholder={t('common.search', { postProcess: 'sentenceCase' })} - radius="xl" - rightSection={ - searchTerm ? ( - setSearchTerm('')} - size="sm" - variant="transparent" - /> - ) : null - } - styles={{ - input: { - background: 'transparent', - border: '1px solid rgba(255, 255, 255, 0.05)', - }, - }} - value={searchTerm} - /> - - - - {!searchTerm.trim() && songs.length > 5 && !showAll && ( - - + {isLoading ? ( + + - )} + ) : tableConfig ? ( + <> + + } + onChange={(e) => setSearchTerm(e.target.value)} + placeholder={t('common.search', { postProcess: 'sentenceCase' })} + radius="xl" + rightSection={ + searchTerm ? ( + setSearchTerm('')} + size="sm" + variant="transparent" + /> + ) : null + } + styles={{ + input: { + background: 'transparent', + border: '1px solid rgba(255, 255, 255, 0.05)', + }, + }} + value={searchTerm} + /> + + + + {!searchTerm.trim() && songs.length > 5 && !showAll && ( + + + + )} + + ) : null} ); diff --git a/src/renderer/features/artists/routes/album-artist-detail-top-songs-list-route.tsx b/src/renderer/features/artists/routes/album-artist-detail-top-songs-list-route.tsx index 27b3f9a66..4ba014299 100644 --- a/src/renderer/features/artists/routes/album-artist-detail-top-songs-list-route.tsx +++ b/src/renderer/features/artists/routes/album-artist-detail-top-songs-list-route.tsx @@ -16,6 +16,7 @@ import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-e import { usePlayerSong } from '/@/renderer/store'; import { useCurrentServer } from '/@/renderer/store/auth.store'; import { useSettingsStore } from '/@/renderer/store/settings.store'; +import { useLocalStorage } from '/@/shared/hooks/use-local-storage'; import { LibraryItem, Song } from '/@/shared/types/domain-types'; import { ItemListKey, Play } from '/@/shared/types/types'; @@ -28,6 +29,11 @@ const AlbumArtistDetailTopSongsListRoute = () => { const server = useCurrentServer(); const pageKey = LibraryItem.SONG; + const [topSongsQueryType] = useLocalStorage<'community' | 'personal'>({ + defaultValue: 'community', + key: 'album-artist-top-songs-query-type', + }); + const detailQuery = useQuery( artistsQueries.albumArtistDetail({ query: { id: routeId }, @@ -38,7 +44,11 @@ const AlbumArtistDetailTopSongsListRoute = () => { const topSongsQuery = useQuery( artistsQueries.topSongs({ options: { enabled: !!detailQuery?.data?.name }, - query: { artist: detailQuery?.data?.name || '', artistId: routeId }, + query: { + artist: detailQuery?.data?.name || '', + artistId: routeId, + type: topSongsQueryType, + }, serverId: server?.id, }), ); diff --git a/src/renderer/features/player/mutations/scrobble-mutation.ts b/src/renderer/features/player/mutations/scrobble-mutation.ts index ef9aa21ad..b6464f1c6 100644 --- a/src/renderer/features/player/mutations/scrobble-mutation.ts +++ b/src/renderer/features/player/mutations/scrobble-mutation.ts @@ -41,6 +41,16 @@ export const useSendScrobble = (options?: MutationOptions) => { queryClient.invalidateQueries({ queryKey: ['home', 'mostPlayed'], }); + + // Invalidate album artist top songs + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.topSongs(serverId), + }); + + // Invalidate album artist favorite songs + queryClient.invalidateQueries({ + queryKey: queryKeys.albumArtists.favoriteSongs(serverId), + }); } }, ...options, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index a4309ffbd..ad137943e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1316,6 +1316,7 @@ export type TopSongListQuery = { artist: string; artistId: string; limit?: number; + type?: 'community' | 'personal'; }; // Top Songs List