feat: Support role filters for (album) artist role for Navidrome

- Adds an optional (Navidrome only, currently) field `roles` to type `AlbumArtist`
- `roles` is populated using artist.stats (excludign maincredit)
- `getAlbumList` supports a role filter; if specified, only filter artists with that role (ND only)
- Album list on artist page can filter by role if specified (only one)
- Album query is no longer suspend, as it can change multiple times
This commit is contained in:
Kendall Garner
2026-06-04 21:02:06 -07:00
parent b9312d86fd
commit e7830afc86
9 changed files with 70 additions and 25 deletions
+1
View File
@@ -143,6 +143,7 @@
"resetToDefault": "Reset to default",
"restartRequired": "Restart required",
"right": "Right",
"role": "Role",
"sampleRate": "Sample rate",
"save": "Save",
"saveAndReplace": "Save and replace",
@@ -428,16 +428,18 @@ export const NavidromeController: InternalControllerEndpoint = {
? query.artistIds
: query.artistIds?.[0];
const key = query.role ? `role_${query.role}_id` : 'artist_id';
const res = await ndApiClient(apiClientProps).getAlbumList({
query: {
_end: query.startIndex + (query.limit || 0),
_order: sortOrderMap.navidrome[query.sortOrder],
_sort: albumListSortMap.navidrome[query.sortBy],
_start: query.startIndex,
artist_id: artistIds,
compilation: query.compilation,
genre_id: genres,
has_rating: query.hasRating,
[key]: artistIds,
library_id: getLibraryId(query.musicFolderId),
name: query.searchTerm,
recently_played: query.isRecentlyPlayed,
@@ -1,6 +1,7 @@
import {
useQuery,
useQueryClient,
UseQueryResult,
useSuspenseQuery,
UseSuspenseQueryResult,
} from '@tanstack/react-query';
@@ -1061,6 +1062,7 @@ const AlbumArtistMetadataSimilarArtists = ({
mbz: null,
name: relatedArtist.name,
playCount: null,
roles: null,
similarArtists: null,
songCount: null,
userFavorite: relatedArtist.userFavorite,
@@ -1101,13 +1103,17 @@ const AlbumArtistMetadataSimilarArtists = ({
};
interface AlbumArtistDetailContentProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
albumsQuery: UseQueryResult<AlbumListResponse, Error>;
detailQuery: UseSuspenseQueryResult<AlbumArtistDetailResponse, Error>;
role: null | string;
setRole: (role: null | string) => void;
}
export const AlbumArtistDetailContent = ({
albumsQuery,
detailQuery,
role,
setRole,
}: AlbumArtistDetailContentProps) => {
const artistItems = useArtistItems();
const artistRadioCount = useArtistRadioCount();
@@ -1220,7 +1226,13 @@ export const AlbumArtistDetailContent = ({
routeId={routeId}
/>
)}
<ArtistAlbums albumsQuery={albumsQuery} order={itemOrder.recentAlbums} />
<ArtistAlbums
albumsQuery={albumsQuery}
order={itemOrder.recentAlbums}
role={role}
roles={detailQuery.data?.roles}
setRole={setRole}
/>
{enabledItem.similarArtists && (
<AlbumArtistMetadataSimilarArtists
order={itemOrder.similarArtists}
@@ -1420,13 +1432,17 @@ const AlbumSection = memo(function AlbumSection({
});
import { useArtistAlbumsGrouped } from '/@/renderer/features/artists/hooks/use-artist-albums-grouped';
import { Select } from '/@/shared/components/select/select';
interface ArtistAlbumsProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
albumsQuery: UseQueryResult<AlbumListResponse, Error>;
order?: number;
role: null | string;
roles?: null | string[];
setRole: (role: null | string) => void;
}
const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
const ArtistAlbums = ({ albumsQuery, order, role, roles, setRole }: ArtistAlbumsProps) => {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearchTerm] = useDebouncedValue(searchTerm, 300);
@@ -1521,6 +1537,17 @@ const ArtistAlbums = ({ albumsQuery, order }: ArtistAlbumsProps) => {
}}
value={searchTerm}
/>
{roles?.length && (
<Select
aria-label="role"
clearable
data={roles}
onChange={setRole}
placeholder={t('common.role')}
value={role}
w={200}
/>
)}
<ListSortByDropdownControlled
filters={CLIENT_SIDE_ALBUM_FILTERS}
itemType={LibraryItem.ALBUM}
@@ -1,4 +1,4 @@
import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query';
import { UseQueryResult, useSuspenseQuery } from '@tanstack/react-query';
import { forwardRef, Fragment, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router';
@@ -38,7 +38,7 @@ import { ServerFeature } from '/@/shared/types/features-types';
import { Play } from '/@/shared/types/types';
interface AlbumArtistDetailHeaderProps {
albumsQuery: UseSuspenseQueryResult<AlbumListResponse, Error>;
albumsQuery: UseQueryResult<AlbumListResponse, Error>;
}
function ArtistImageUploadOverlay({
@@ -1,5 +1,5 @@
import { useSuspenseQueries } from '@tanstack/react-query';
import { Suspense, useRef } from 'react';
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { Suspense, useRef, useState } from 'react';
import { useParams } from 'react-router';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
@@ -34,22 +34,24 @@ const AlbumArtistDetailRouteContent = () => {
};
const routeId = (artistId || albumArtistId) as string;
const [role, setRole] = useState<null | string>(null);
const [detailQuery, albumsQuery] = useSuspenseQueries({
queries: [
artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),
albumQueries.list({
query: {
artistIds: [routeId],
limit: -1,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId,
}),
],
});
const detailQuery = useSuspenseQuery(
artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),
);
const albumsQuery = useQuery(
albumQueries.list({
query: {
artistIds: [routeId],
limit: -1,
role: role || undefined,
sortBy: AlbumListSort.RELEASE_DATE,
sortOrder: SortOrder.DESC,
startIndex: 0,
},
serverId,
}),
);
const imageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined,
@@ -117,7 +119,12 @@ const AlbumArtistDetailRouteContent = () => {
albumsQuery={albumsQuery}
ref={headerRef as React.Ref<HTMLDivElement>}
/>
<AlbumArtistDetailContent albumsQuery={albumsQuery} detailQuery={detailQuery} />
<AlbumArtistDetailContent
albumsQuery={albumsQuery}
detailQuery={detailQuery}
role={role}
setRole={setRole}
/>
</LibraryContainer>
</NativeScrollArea>
</AnimatedPage>
@@ -395,6 +395,7 @@ const normalizeAlbumArtist = (
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
playCount: item.UserData?.PlayCount || 0,
roles: null,
similarArtists,
songCount: item.SongCount ?? null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
@@ -410,10 +410,12 @@ const normalizeAlbumArtist = (
if (item.stats) {
albumCount = Math.max(
item.stats.maincredit?.albumCount ?? 0,
item.stats.albumartist?.albumCount ?? 0,
item.stats.artist?.albumCount ?? 0,
);
songCount = Math.max(
item.stats.maincredit?.songCount ?? 0,
item.stats.albumartist?.songCount ?? 0,
item.stats.artist?.songCount ?? 0,
);
@@ -453,6 +455,8 @@ const normalizeAlbumArtist = (
mbz: item.mbzArtistId || null,
name: item.name,
playCount: item.playCount || 0,
// filter out specifically maincredit. This is not filterable properly
roles: item.stats ? Object.keys(item.stats).filter((key) => key !== 'maincredit') : null,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
@@ -274,6 +274,7 @@ const normalizeAlbumArtist = (
mbz: null,
name: item.name,
playCount: null,
roles: null,
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
+2
View File
@@ -223,6 +223,7 @@ export type AlbumArtist = {
mbz: null | string;
name: string;
playCount: null | number;
roles: null | string[];
similarArtists: null | RelatedArtist[];
songCount: null | number;
uploadedImage?: string;
@@ -500,6 +501,7 @@ export interface AlbumListQuery extends AlbumListNavidromeQuery, BaseQuery<Album
maxYear?: number;
minYear?: number;
musicFolderId?: string | string[];
role?: string;
searchTerm?: string;
startIndex: number;
}