decouple AlbumArtistInfo from AlbumArtistDetail (#1809)

This commit is contained in:
jeffvli
2026-03-08 22:06:18 -07:00
parent 7dbf8dd9fe
commit 17deac8d65
14 changed files with 386 additions and 156 deletions
+12
View File
@@ -200,6 +200,18 @@ export const controller: GeneralController = {
server.type, server.type,
)?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } })); )?.(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }));
}, },
getAlbumArtistInfo(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
return Promise.resolve(null);
}
const fn = apiController('getAlbumArtistInfo', server.type);
return fn
? fn(addContext({ ...args, apiClientProps: { ...args.apiClientProps, server } }))
: Promise.resolve(null);
},
getAlbumArtistList(args) { getAlbumArtistList(args) {
const server = getServerById(args.apiClientProps.serverId); const server = getServerById(args.apiClientProps.serverId);
@@ -258,34 +258,54 @@ export const JellyfinController: InternalControllerEndpoint = {
throw new Error('No userId found'); throw new Error('No userId found');
} }
const [res, similarArtistsRes] = await Promise.all([ const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
jfApiClient(apiClientProps).getAlbumArtistDetail({
params: { params: {
id: query.id, id: query.id,
userId: apiClientProps.server?.userId, userId: apiClientProps.server?.userId,
}, },
query: { query: {
Fields: JF_FIELDS.ALBUM_ARTIST_DETAIL, Fields: ['Genres', 'Overview', 'SortName'],
}, },
}), });
jfApiClient(apiClientProps).getSimilarArtistList({
if (res.status !== 200) {
throw new Error('Failed to get album artist detail');
}
return jfNormalize.albumArtist(res.body, apiClientProps.server);
},
getAlbumArtistInfo: async (args) => {
const { apiClientProps, query } = args;
const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
params: { params: {
id: query.id, id: query.id,
}, },
query: { query: {
Limit: 10, Limit: query.limit ?? 10,
}, },
}), });
]);
if (res.status !== 200 || similarArtistsRes.status !== 200) { if (similarArtistsRes.status !== 200) {
throw new Error('Failed to get album artist detail'); return null;
} }
return jfNormalize.albumArtist( const items = similarArtistsRes.body?.Items?.filter(
{ ...res.body, similarArtists: similarArtistsRes.body }, (entry) => entry.Name !== 'Various Artists',
apiClientProps.server,
); );
const similarArtists =
items?.map((entry) => ({
id: entry.Id,
imageId: entry.ImageTags?.Primary ? entry.Id : null,
imageUrl: null,
name: entry.Name,
userFavorite: entry.UserData?.IsFavorite || false,
userRating: null,
})) ?? null;
return {
similarArtists,
};
}, },
getAlbumArtistList: async (args) => { getAlbumArtistList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -190,19 +190,11 @@ export const NavidromeController: InternalControllerEndpoint = {
getAlbumArtistDetail: async (args) => { getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
const [res, artistInfoRes] = await Promise.all([ const res = await ndApiClient(apiClientProps).getAlbumArtistDetail({
ndApiClient(apiClientProps).getAlbumArtistDetail({
params: { params: {
id: query.id, id: query.id,
}, },
}), });
ssApiClient(apiClientProps).getArtistInfo({
query: {
count: 10,
id: query.id,
},
}),
]);
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get album artist detail'); throw new Error('Failed to get album artist detail');
@@ -212,22 +204,42 @@ export const NavidromeController: InternalControllerEndpoint = {
throw new Error('Server is required'); throw new Error('Server is required');
} }
// Prefer images from getArtistInfo first (which should be proxied) return ndNormalize.albumArtist(res.body.data, apiClientProps.server);
// Prioritize large > medium > small
return ndNormalize.albumArtist(
{
...res.body.data,
...(artistInfoRes.status === 200 && {
largeImageUrl:
artistInfoRes.body.artistInfo.largeImageUrl ||
artistInfoRes.body.artistInfo.mediumImageUrl ||
artistInfoRes.body.artistInfo.smallImageUrl ||
res.body.data.largeImageUrl,
similarArtists: artistInfoRes.body.artistInfo.similarArtist,
}),
}, },
apiClientProps.server, getAlbumArtistInfo: async (args) => {
); const { apiClientProps, query } = args;
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
id: query.id,
...(query.limit != null && { count: query.limit }),
},
});
if (artistInfoRes.status !== 200) {
return null;
}
const artistInfo = artistInfoRes.body.artistInfo;
const imageUrl =
artistInfo?.largeImageUrl ||
artistInfo?.mediumImageUrl ||
artistInfo?.smallImageUrl ||
null;
return {
biography: artistInfo?.biography || null,
imageUrl,
similarArtists:
artistInfo?.similarArtist?.map((artist) => ({
id: artist.id,
imageId: null,
imageUrl: artist?.artistImageUrl?.replace(/\?size=\d+/, '') ?? null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null,
})) ?? null,
};
}, },
getAlbumArtistList: async (args) => { getAlbumArtistList: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
+8
View File
@@ -1,5 +1,6 @@
import type { import type {
AlbumArtistDetailQuery, AlbumArtistDetailQuery,
AlbumArtistInfoQuery,
AlbumArtistListQuery, AlbumArtistListQuery,
AlbumDetailQuery, AlbumDetailQuery,
AlbumListQuery, AlbumListQuery,
@@ -93,6 +94,13 @@ export const queryKeys: Record<
return [serverId, 'albumArtists', 'infiniteList'] as const; return [serverId, 'albumArtists', 'infiniteList'] as const;
}, },
info: (serverId: string, query?: AlbumArtistInfoQuery) => {
if (query) {
return [serverId, 'albumArtists', 'info', query] as const;
}
return [serverId, 'albumArtists', 'info'] as const;
},
list: (serverId: string, query?: AlbumArtistListQuery) => { list: (serverId: string, query?: AlbumArtistListQuery) => {
const { filter, pagination } = splitPaginatedQuery(query); const { filter, pagination } = splitPaginatedQuery(query);
if (query && pagination) { if (query && pagination) {
@@ -258,18 +258,11 @@ export const SubsonicController: InternalControllerEndpoint = {
getAlbumArtistDetail: async (args) => { getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
const [artistInfoRes, res] = await Promise.all([ const res = await ssApiClient(apiClientProps).getArtist({
ssApiClient(apiClientProps).getArtistInfo({
query: { query: {
id: query.id, id: query.id,
}, },
}), });
ssApiClient(apiClientProps).getArtist({
query: {
id: query.id,
},
}),
]);
if (res.status !== 200) { if (res.status !== 200) {
throw new Error('Failed to get album artist detail'); throw new Error('Failed to get album artist detail');
@@ -277,11 +270,6 @@ export const SubsonicController: InternalControllerEndpoint = {
const artist = res.body.artist; const artist = res.body.artist;
let artistInfo;
if (artistInfoRes.status === 200) {
artistInfo = artistInfoRes.body.artistInfo;
}
return { return {
...ssNormalize.albumArtist(artist, apiClientProps.server), ...ssNormalize.albumArtist(artist, apiClientProps.server),
albums: artist.album?.map((album) => albums: artist.album?.map((album) =>
@@ -292,10 +280,36 @@ export const SubsonicController: InternalControllerEndpoint = {
args.context?.pathReplaceWith, args.context?.pathReplaceWith,
), ),
), ),
similarArtists: null,
};
},
getAlbumArtistInfo: async (args) => {
const { apiClientProps, query } = args;
const artistInfoRes = await ssApiClient(apiClientProps).getArtistInfo({
query: {
id: query.id,
...(query.limit != null && { count: query.limit }),
},
});
if (artistInfoRes.status !== 200) {
return null;
}
const artistInfo = artistInfoRes.body.artistInfo;
return {
biography: artistInfo?.biography || null,
similarArtists: similarArtists:
artistInfo?.similarArtist?.map((artist) => artistInfo?.similarArtist?.map((artist) => ({
ssNormalize.albumArtist(artist, apiClientProps.server), id: artist.id,
) || null, imageId: null,
imageUrl: null,
name: artist.name,
userFavorite: Boolean(artist.starred) || false,
userRating: artist.userRating ?? null,
})) ?? null,
}; };
}, },
getAlbumArtistList: async (args) => { getAlbumArtistList: async (args) => {
@@ -365,6 +365,7 @@ interface GridCarouselSkeletonProps {
containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>; containerQuery?: ReturnType<typeof useGridCarouselContainerQuery>;
enableRefresh?: boolean; enableRefresh?: boolean;
placeholderItemType: LibraryItem; placeholderItemType: LibraryItem;
placeholderRound?: boolean;
placeholderRows: DataRow[]; placeholderRows: DataRow[];
rowCount?: number; rowCount?: number;
title?: ReactNode | string; title?: ReactNode | string;
@@ -375,12 +376,15 @@ const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
containerQuery: providedContainerQuery, containerQuery: providedContainerQuery,
enableRefresh = false, enableRefresh = false,
placeholderItemType, placeholderItemType,
placeholderRound = false,
placeholderRows, placeholderRows,
rowCount = 1, rowCount = 1,
title, title,
} = props; } = props;
const { ...cq } = providedContainerQuery; const defaultContainerQuery = useGridCarouselContainerQuery();
const containerQuery = providedContainerQuery ?? defaultContainerQuery;
const { ...cq } = containerQuery;
const cardsToShow = cq.isCalculated const cardsToShow = cq.isCalculated
? getCardsToShow({ ? getCardsToShow({
@@ -399,6 +403,7 @@ const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
content: ( content: (
<MemoizedItemCard <MemoizedItemCard
data={undefined} data={undefined}
isRound={placeholderRound}
itemType={placeholderItemType} itemType={placeholderItemType}
rows={placeholderRows} rows={placeholderRows}
type="poster" type="poster"
@@ -406,12 +411,12 @@ const GridCarouselSkeleton = (props: GridCarouselSkeletonProps) => {
), ),
id: `skeleton-${index}`, id: `skeleton-${index}`,
})); }));
}, [cardsToShow, rowCount, placeholderItemType, placeholderRows]); }, [cardsToShow, placeholderRound, rowCount, placeholderItemType, placeholderRows]);
return ( return (
<GridCarousel <GridCarousel
cards={placeholderCards} cards={placeholderCards}
containerQuery={providedContainerQuery} containerQuery={containerQuery}
enableRefresh={enableRefresh} enableRefresh={enableRefresh}
hasNextPage={false} hasNextPage={false}
isFetchingNextPage={false} isFetchingNextPage={false}
@@ -7,6 +7,7 @@ import { getOptimizedListCount } from '/@/renderer/api/utils-list-count';
import { QueryHookArgs } from '/@/renderer/lib/react-query'; import { QueryHookArgs } from '/@/renderer/lib/react-query';
import { import {
AlbumArtistDetailQuery, AlbumArtistDetailQuery,
AlbumArtistInfoQuery,
AlbumArtistListQuery, AlbumArtistListQuery,
ArtistListQuery, ArtistListQuery,
ListCountQuery, ListCountQuery,
@@ -28,6 +29,20 @@ export const artistsQueries = {
...args.options, ...args.options,
}); });
}, },
albumArtistInfo: (args: QueryHookArgs<AlbumArtistInfoQuery>) => {
return queryOptions({
queryFn: ({ signal }) => {
return (
api.controller.getAlbumArtistInfo?.({
apiClientProps: { serverId: args.serverId, signal },
query: args.query,
}) ?? Promise.resolve(null)
);
},
queryKey: queryKeys.albumArtists.info(args.serverId, args.query),
...args.options,
});
},
albumArtistList: (args: QueryHookArgs<AlbumArtistListQuery>) => { albumArtistList: (args: QueryHookArgs<AlbumArtistListQuery>) => {
return queryOptions({ return queryOptions({
queryFn: ({ signal }) => { queryFn: ({ signal }) => {
@@ -5,8 +5,7 @@ import {
UseSuspenseQueryResult, UseSuspenseQueryResult,
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { LayoutGroup, motion } from 'motion/react'; import { LayoutGroup, motion } from 'motion/react';
import { Suspense } from 'react'; import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router'; import { createSearchParams, generatePath, Link, useLocation, useParams } from 'react-router';
@@ -70,6 +69,7 @@ import { Grid } from '/@/shared/components/grid/grid';
import { Group } from '/@/shared/components/group/group'; import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon'; import { Icon } from '/@/shared/components/icon/icon';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control'; import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { Spoiler } from '/@/shared/components/spoiler/spoiler'; import { Spoiler } from '/@/shared/components/spoiler/spoiler';
import { Stack } from '/@/shared/components/stack/stack'; import { Stack } from '/@/shared/components/stack/stack';
@@ -201,18 +201,57 @@ const AlbumArtistMetadataGenres = ({ genres }: AlbumArtistMetadataGenresProps) =
interface AlbumArtistMetadataBiographyProps { interface AlbumArtistMetadataBiographyProps {
artistName?: string; artistName?: string;
biography: null | string | undefined; routeId: string;
} }
const AlbumArtistMetadataBiography = ({ const AlbumArtistMetadataBiography = ({
artistName, artistName,
biography, routeId,
}: AlbumArtistMetadataBiographyProps) => { }: AlbumArtistMetadataBiographyProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer();
if (!biography) return null; const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
const sanitizedBiography = sanitize(biography); const detailQuery = useQuery({
...artistsQueries.albumArtistDetail({
query: { id: routeId },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
const biography = artistInfoQuery.data?.biography || detailQuery.data?.biography;
const isLoading = !biography && (artistInfoQuery.isLoading || detailQuery.isLoading);
const sanitizedBiography = biography ? sanitize(biography) : '';
if (isLoading) {
return (
<section style={{ maxWidth: '1280px' }}>
<TextTitle fw={700} order={3}>
{t('page.albumArtistDetail.about', {
artist: artistName,
})}
</TextTitle>
<Stack gap="xs">
<Skeleton enableAnimation height="1rem" width="100%" />
<Skeleton enableAnimation height="1rem" width="98%" />
<Skeleton enableAnimation height="1rem" width="60%" />
</Stack>
</section>
);
}
if (!biography) {
return null;
}
return ( return (
<section style={{ maxWidth: '1280px' }}> <section style={{ maxWidth: '1280px' }}>
@@ -850,20 +889,25 @@ const AlbumArtistMetadataExternalLinks = ({
}; };
interface AlbumArtistMetadataSimilarArtistsProps { interface AlbumArtistMetadataSimilarArtistsProps {
detailQuery: ReturnType<typeof useSuspenseQuery<AlbumArtistDetailResponse>>;
routeId: string; routeId: string;
} }
const AlbumArtistMetadataSimilarArtists = ({ const AlbumArtistMetadataSimilarArtists = ({ routeId }: AlbumArtistMetadataSimilarArtistsProps) => {
detailQuery,
routeId,
}: AlbumArtistMetadataSimilarArtistsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const server = useCurrentServer(); const server = useCurrentServer();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
const relatedArtists = artistInfoQuery.data?.similarArtists ?? null;
const similarArtists = useMemo(() => { const similarArtists = useMemo(() => {
const relatedArtists = detailQuery.data?.similarArtists;
if (!relatedArtists || relatedArtists.length === 0) { if (!relatedArtists || relatedArtists.length === 0) {
return []; return [];
} }
@@ -890,7 +934,7 @@ const AlbumArtistMetadataSimilarArtists = ({
userRating: relatedArtist.userRating, userRating: relatedArtist.userRating,
}), }),
); );
}, [detailQuery.data?.similarArtists, server?.type, serverId]); }, [relatedArtists, server?.type, serverId]);
const carouselTitle = useMemo( const carouselTitle = useMemo(
() => ( () => (
@@ -908,7 +952,7 @@ const AlbumArtistMetadataSimilarArtists = ({
[t], [t],
); );
if (similarArtists.length === 0) { if (!artistInfoQuery.isLoading && similarArtists.length === 0) {
return null; return null;
} }
@@ -916,6 +960,7 @@ const AlbumArtistMetadataSimilarArtists = ({
<AlbumArtistGridCarousel <AlbumArtistGridCarousel
data={similarArtists} data={similarArtists}
excludeIds={[routeId]} excludeIds={[routeId]}
isLoading={artistInfoQuery.isLoading}
rowCount={1} rowCount={1}
title={carouselTitle} title={carouselTitle}
/> />
@@ -977,8 +1022,6 @@ export const AlbumArtistDetailContent = ({
[routeId, detailQuery.data?.name], [routeId, detailQuery.data?.name],
); );
const biography =
detailQuery.data?.biography && enabledItem.biography ? detailQuery.data.biography : null;
const showGenres = detailQuery.data?.genres ? detailQuery.data.genres.length !== 0 : false; const showGenres = detailQuery.data?.genres ? detailQuery.data.genres.length !== 0 : false;
const mbzId = detailQuery.data?.mbz; const mbzId = detailQuery.data?.mbz;
@@ -1034,11 +1077,11 @@ export const AlbumArtistDetailContent = ({
/> />
</Grid.Col> </Grid.Col>
)} )}
{biography && ( {enabledItem.biography && (
<Grid.Col order={itemOrder.biography} span={12}> <Grid.Col order={itemOrder.biography} span={12}>
<AlbumArtistMetadataBiography <AlbumArtistMetadataBiography
artistName={detailQuery.data?.name} artistName={detailQuery.data?.name}
biography={biography} routeId={routeId}
/> />
</Grid.Col> </Grid.Col>
)} )}
@@ -1047,10 +1090,7 @@ export const AlbumArtistDetailContent = ({
</Grid.Col> </Grid.Col>
{enabledItem.similarArtists && ( {enabledItem.similarArtists && (
<Grid.Col order={itemOrder.similarArtists} span={12}> <Grid.Col order={itemOrder.similarArtists} span={12}>
<AlbumArtistMetadataSimilarArtists <AlbumArtistMetadataSimilarArtists routeId={routeId} />
detailQuery={detailQuery}
routeId={routeId}
/>
</Grid.Col> </Grid.Col>
)} )}
{enabledItem.topSongs && ( {enabledItem.topSongs && (
@@ -1,4 +1,4 @@
import { useSuspenseQuery } from '@tanstack/react-query'; import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react'; import { forwardRef, Fragment, Ref, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
@@ -69,9 +69,9 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
]; ];
const { addToQueueByFetch } = usePlayer(); const { addToQueueByFetch } = usePlayer();
const playButtonBehavior = usePlayButtonBehavior();
const setFavorite = useSetFavorite(); const setFavorite = useSetFavorite();
const setRating = useSetRating(); const setRating = useSetRating();
const playButtonBehavior = usePlayButtonBehavior();
const handlePlay = useCallback( const handlePlay = useCallback(
(type?: Play) => { (type?: Play) => {
@@ -137,11 +137,19 @@ export const AlbumArtistDetailHeader = forwardRef((_props, ref: Ref<HTMLDivEleme
type: 'itemCard', type: 'itemCard',
}); });
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME; const showRating = showRatings && detailQuery?.data?._serverType === ServerType.NAVIDROME;
const selectedImageUrl = useMemo(() => { const selectedImageUrl = useMemo(() => {
return detailQuery.data?.imageUrl || imageUrl; return detailQuery.data?.imageUrl || artistInfoQuery.data?.imageUrl || imageUrl;
}, [detailQuery.data?.imageUrl, imageUrl]); }, [artistInfoQuery.data?.imageUrl, detailQuery.data?.imageUrl, imageUrl]);
return ( return (
<LibraryHeader <LibraryHeader
@@ -1,6 +1,9 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { GridCarousel } from '/@/renderer/components/grid-carousel/grid-carousel-v2'; import {
GridCarousel,
GridCarouselSkeletonFallback,
} from '/@/renderer/components/grid-carousel/grid-carousel-v2';
import { MemoizedItemCard } from '/@/renderer/components/item-card/item-card'; import { MemoizedItemCard } from '/@/renderer/components/item-card/item-card';
import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls'; import { useDefaultItemListControls } from '/@/renderer/components/item-list/helpers/item-list-controls';
import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows'; import { useGridRows } from '/@/renderer/components/item-list/helpers/use-grid-rows';
@@ -10,12 +13,13 @@ import { ItemListKey } from '/@/shared/types/types';
interface AlbumArtistGridCarouselProps { interface AlbumArtistGridCarouselProps {
data: AlbumArtist[]; data: AlbumArtist[];
excludeIds?: string[]; excludeIds?: string[];
isLoading?: boolean;
rowCount?: number; rowCount?: number;
title: React.ReactNode | string; title: React.ReactNode | string;
} }
export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) { export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
const { data, excludeIds, rowCount = 1, title } = props; const { data, excludeIds, isLoading = false, rowCount = 1, title } = props;
const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST); const rows = useGridRows(LibraryItem.ALBUM_ARTIST, ItemListKey.ALBUM_ARTIST);
const controls = useDefaultItemListControls(); const controls = useDefaultItemListControls();
@@ -41,6 +45,18 @@ export function AlbumArtistGridCarousel(props: AlbumArtistGridCarouselProps) {
})); }));
}, [data, excludeIds, controls, rows]); }, [data, excludeIds, controls, rows]);
if (isLoading) {
return (
<GridCarouselSkeletonFallback
placeholderItemType={LibraryItem.ALBUM_ARTIST}
placeholderRound
placeholderRows={rows}
rowCount={rowCount}
title={title}
/>
);
}
const handleNextPage = () => {}; const handleNextPage = () => {};
const handlePrevPage = () => {}; const handlePrevPage = () => {};
@@ -1,7 +1,8 @@
import { useSuspenseQueries } from '@tanstack/react-query'; import { useQuery, useQueryClient, useSuspenseQueries } from '@tanstack/react-query';
import { Suspense, useRef } from 'react'; import { Suspense, useEffect, useRef } from 'react';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { queryKeys } from '/@/renderer/api/query-keys';
import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { useItemImageUrl } from '/@/renderer/components/item-image/item-image';
import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area'; import { NativeScrollArea } from '/@/renderer/components/native-scroll-area/native-scroll-area';
import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { albumQueries } from '/@/renderer/features/albums/api/album-api';
@@ -19,7 +20,12 @@ import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-e
import { useFastAverageColor } from '/@/renderer/hooks'; import { useFastAverageColor } from '/@/renderer/hooks';
import { useArtistBackground, useCurrentServer, useCurrentServerId } from '/@/renderer/store'; import { useArtistBackground, useCurrentServer, useCurrentServerId } from '/@/renderer/store';
import { Spinner } from '/@/shared/components/spinner/spinner'; import { Spinner } from '/@/shared/components/spinner/spinner';
import { AlbumListSort, LibraryItem, SortOrder } from '/@/shared/types/domain-types'; import {
AlbumArtistDetailResponse,
AlbumListSort,
LibraryItem,
SortOrder,
} from '/@/shared/types/domain-types';
const AlbumArtistDetailRouteContent = () => { const AlbumArtistDetailRouteContent = () => {
const scrollAreaRef = useRef<HTMLDivElement>(null); const scrollAreaRef = useRef<HTMLDivElement>(null);
@@ -27,6 +33,7 @@ const AlbumArtistDetailRouteContent = () => {
const server = useCurrentServer(); const server = useCurrentServer();
const serverId = useCurrentServerId(); const serverId = useCurrentServerId();
const { artistBackground, artistBackgroundBlur } = useArtistBackground(); const { artistBackground, artistBackgroundBlur } = useArtistBackground();
const queryClient = useQueryClient();
const { albumArtistId, artistId } = useParams() as { const { albumArtistId, artistId } = useParams() as {
albumArtistId?: string; albumArtistId?: string;
@@ -35,6 +42,26 @@ const AlbumArtistDetailRouteContent = () => {
const routeId = (artistId || albumArtistId) as string; const routeId = (artistId || albumArtistId) as string;
const artistInfoQuery = useQuery({
...artistsQueries.albumArtistInfo({
query: { id: routeId, limit: 10 },
serverId: server?.id,
}),
enabled: Boolean(server?.id && routeId),
});
useEffect(() => {
const data = artistInfoQuery.data;
if (!data?.imageUrl || !server?.id || !routeId) return;
queryClient.setQueryData(
queryKeys.albumArtists.detail(server.id, { id: routeId }),
(prev: AlbumArtistDetailResponse | undefined) => {
if (!prev) return prev;
return { ...prev, imageUrl: data.imageUrl ?? prev.imageUrl };
},
);
}, [artistInfoQuery.data, queryClient, routeId, server?.id]);
const [detailQuery, albumsQuery] = useSuspenseQueries({ const [detailQuery, albumsQuery] = useSuspenseQueries({
queries: [ queries: [
artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }), artistsQueries.albumArtistDetail({ query: { id: routeId }, serverId: server?.id }),
@@ -60,13 +87,14 @@ const AlbumArtistDetailRouteContent = () => {
const libraryBackgroundImageUrl = useItemImageUrl({ const libraryBackgroundImageUrl = useItemImageUrl({
id: detailQuery.data?.imageId || undefined, id: detailQuery.data?.imageId || undefined,
imageUrl: detailQuery.data?.imageUrl,
itemType: LibraryItem.ALBUM_ARTIST, itemType: LibraryItem.ALBUM_ARTIST,
type: 'itemCard', type: 'itemCard',
}); });
const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl; const selectedImageUrl = imageUrl || detailQuery.data?.imageUrl;
const { background: backgroundColor, isLoading: isColorLoading } = useFastAverageColor({ const { background: backgroundColor } = useFastAverageColor({
id: artistId, id: artistId,
src: selectedImageUrl, src: selectedImageUrl,
srcLoaded: true, srcLoaded: true,
@@ -76,9 +104,9 @@ const AlbumArtistDetailRouteContent = () => {
const showBlurredImage = artistBackground; const showBlurredImage = artistBackground;
if (isColorLoading) { // if (isColorLoading) {
return <Spinner container />; // return <Spinner container />;
} // }
return ( return (
<AnimatedPage key={`album-artist-detail-${routeId}`}> <AnimatedPage key={`album-artist-detail-${routeId}`}>
@@ -6,6 +6,7 @@ import {
Album, Album,
AlbumArtist, AlbumArtist,
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
AlbumArtistInfoResponse,
AlbumArtistListResponse, AlbumArtistListResponse,
AlbumDetailResponse, AlbumDetailResponse,
AlbumListResponse, AlbumListResponse,
@@ -240,12 +241,31 @@ export const applyFavoriteOptimisticUpdates = (
return { ...prev, userFavorite: isFavorite }; return { ...prev, userFavorite: isFavorite };
} }
if (prev.similarArtists && prev.similarArtists.length > 0) { return prev;
const hasMatchingSimilarArtist = prev.similarArtists.some( },
(artist) => itemIdSet.has(artist.id), });
); }
});
const infoQueryKey = queryKeys.albumArtists.info(variables.apiClientProps.serverId);
const infoQueries = queryClient.getQueriesData({
exact: false,
queryKey: infoQueryKey,
});
infoQueries.forEach(([queryKey, data]) => {
if (data) {
pendingUpdates.push({
previousData: data,
queryKey,
updater: (prev: AlbumArtistInfoResponse | null | undefined) => {
if (!prev?.similarArtists?.length) return prev;
const hasMatching = prev.similarArtists.some((artist) =>
itemIdSet.has(artist.id),
);
if (!hasMatching) return prev;
if (hasMatchingSimilarArtist) {
return { return {
...prev, ...prev,
similarArtists: prev.similarArtists.map((artist) => similarArtists: prev.similarArtists.map((artist) =>
@@ -254,10 +274,6 @@ export const applyFavoriteOptimisticUpdates = (
: artist, : artist,
), ),
}; };
}
}
return prev;
}, },
}); });
} }
@@ -654,6 +670,10 @@ export const applyFavoriteOptimisticUpdatesDeferred = (
queryKeys.albumArtists.detail(variables.apiClientProps.serverId), queryKeys.albumArtists.detail(variables.apiClientProps.serverId),
'album-artist-detail', 'album-artist-detail',
); );
collectQueries(
queryKeys.albumArtists.info(variables.apiClientProps.serverId),
'album-artist-info',
);
collectQueries( collectQueries(
queryKeys.albumArtists.list(variables.apiClientProps.serverId), queryKeys.albumArtists.list(variables.apiClientProps.serverId),
'album-artist-list', 'album-artist-list',
@@ -7,6 +7,7 @@ import {
Album, Album,
AlbumArtist, AlbumArtist,
AlbumArtistDetailResponse, AlbumArtistDetailResponse,
AlbumArtistInfoResponse,
AlbumArtistListResponse, AlbumArtistListResponse,
AlbumDetailResponse, AlbumDetailResponse,
AlbumListResponse, AlbumListResponse,
@@ -235,12 +236,31 @@ export const applyRatingOptimisticUpdates = (
return { ...prev, userRating: rating }; return { ...prev, userRating: rating };
} }
if (prev.similarArtists && prev.similarArtists.length > 0) { return prev;
const hasMatchingSimilarArtist = prev.similarArtists.some( },
(artist) => itemIdSet.has(artist.id), });
); }
});
const infoQueryKey = queryKeys.albumArtists.info(variables.apiClientProps.serverId);
const infoQueries = queryClient.getQueriesData({
exact: false,
queryKey: infoQueryKey,
});
infoQueries.forEach(([queryKey, data]) => {
if (data) {
pendingUpdates.push({
previousData: data,
queryKey,
updater: (prev: AlbumArtistInfoResponse | null | undefined) => {
if (!prev?.similarArtists?.length) return prev;
const hasMatching = prev.similarArtists.some((artist) =>
itemIdSet.has(artist.id),
);
if (!hasMatching) return prev;
if (hasMatchingSimilarArtist) {
return { return {
...prev, ...prev,
similarArtists: prev.similarArtists.map((artist) => similarArtists: prev.similarArtists.map((artist) =>
@@ -249,10 +269,6 @@ export const applyRatingOptimisticUpdates = (
: artist, : artist,
), ),
}; };
}
}
return prev;
}, },
}); });
} }
@@ -626,6 +642,10 @@ export const applyRatingOptimisticUpdatesDeferred = (
queryKeys.albumArtists.detail(variables.apiClientProps.serverId), queryKeys.albumArtists.detail(variables.apiClientProps.serverId),
'album-artist-detail', 'album-artist-detail',
); );
collectQueries(
queryKeys.albumArtists.info(variables.apiClientProps.serverId),
'album-artist-info',
);
collectQueries( collectQueries(
queryKeys.albumArtists.list(variables.apiClientProps.serverId), queryKeys.albumArtists.list(variables.apiClientProps.serverId),
'album-artist-list', 'album-artist-list',
@@ -695,19 +715,6 @@ export const applyRatingOptimisticUpdatesDeferred = (
if (itemIdSet.has(prev.id)) { if (itemIdSet.has(prev.id)) {
return { ...prev, userRating: rating }; return { ...prev, userRating: rating };
} }
if (prev.similarArtists) {
const hasMatch = prev.similarArtists.some((a: any) =>
itemIdSet.has(a.id),
);
if (hasMatch) {
return {
...prev,
similarArtists: prev.similarArtists.map((a: any) =>
itemIdSet.has(a.id) ? { ...a, userRating: rating } : a,
),
};
}
}
return prev; return prev;
} }
case 'album-artist-infinite-list': case 'album-artist-infinite-list':
@@ -732,6 +739,17 @@ export const applyRatingOptimisticUpdatesDeferred = (
} }
return prev; return prev;
} }
case 'album-artist-info': {
if (!prev?.similarArtists?.length) return prev;
const hasMatch = prev.similarArtists.some((a: any) => itemIdSet.has(a.id));
if (!hasMatch) return prev;
return {
...prev,
similarArtists: prev.similarArtists.map((a: any) =>
itemIdSet.has(a.id) ? { ...a, userRating: rating } : a,
),
};
}
case 'album-artist-list': case 'album-artist-list':
case 'album-list': case 'album-list':
case 'artist-list': case 'artist-list':
+14
View File
@@ -820,6 +820,16 @@ export type AlbumArtistDetailQuery = { id: string };
export type AlbumArtistDetailResponse = AlbumArtist | null; export type AlbumArtistDetailResponse = AlbumArtist | null;
export type AlbumArtistInfoArgs = BaseEndpointArgs & { query: AlbumArtistInfoQuery };
export type AlbumArtistInfoQuery = { id: string; limit?: number };
export type AlbumArtistInfoResponse = {
biography?: null | string;
imageUrl?: null | string;
similarArtists: null | RelatedArtist[];
};
export type ArtistListArgs = BaseEndpointArgs & { query: ArtistListQuery }; export type ArtistListArgs = BaseEndpointArgs & { query: ArtistListQuery };
export type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery<ArtistListQuery> }; export type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery<ArtistListQuery> };
@@ -1373,6 +1383,7 @@ export type ControllerEndpoint = {
) => Promise<DeleteInternetRadioStationResponse>; ) => Promise<DeleteInternetRadioStationResponse>;
deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>; deletePlaylist: (args: DeletePlaylistArgs) => Promise<DeletePlaylistResponse>;
getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistInfo?: (args: AlbumArtistInfoArgs) => Promise<AlbumArtistInfoResponse | null>;
getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise<AlbumArtistListResponse>;
getAlbumArtistListCount: (args: AlbumArtistListCountArgs) => Promise<number>; getAlbumArtistListCount: (args: AlbumArtistListCountArgs) => Promise<number>;
getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>; getAlbumDetail: (args: AlbumDetailArgs) => Promise<AlbumDetailResponse>;
@@ -1491,6 +1502,9 @@ export type InternalControllerEndpoint = {
getAlbumArtistDetail: ( getAlbumArtistDetail: (
args: ReplaceApiClientProps<AlbumArtistDetailArgs>, args: ReplaceApiClientProps<AlbumArtistDetailArgs>,
) => Promise<AlbumArtistDetailResponse>; ) => Promise<AlbumArtistDetailResponse>;
getAlbumArtistInfo?: (
args: ReplaceApiClientProps<AlbumArtistInfoArgs>,
) => Promise<AlbumArtistInfoResponse | null>;
getAlbumArtistList: ( getAlbumArtistList: (
args: ReplaceApiClientProps<AlbumArtistListArgs>, args: ReplaceApiClientProps<AlbumArtistListArgs>,
) => Promise<AlbumArtistListResponse>; ) => Promise<AlbumArtistListResponse>;