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