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',
})}
-