refactor: tanstack query - artist page (#42)

* refactored artist page

* remove unnecessary types

* remove log

* remove old artistCache

* fixed reviewed suggestions

* invalidate artist queries when changing release prefs
This commit is contained in:
Arno
2026-04-14 00:34:49 +02:00
committed by GitHub
parent e523f2ccca
commit 90b7b67a10
18 changed files with 389 additions and 600 deletions
+4 -3
View File
@@ -393,13 +393,14 @@ class ArtistService:
artist_id: str,
library_artist_mbids: set[str] = None,
library_album_mbids: dict[str, Any] = None,
include_extended: bool = True
include_extended: bool = True,
include_releases: bool = True,
) -> ArtistInfo:
mb_artist, library_mbids, album_mbids, requested_mbids = await self._fetch_artist_data(
artist_id, library_artist_mbids, library_album_mbids
)
in_library = artist_id.lower() in library_mbids
albums, singles, eps = await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids)
albums, singles, eps = (await self._get_categorized_releases(mb_artist, album_mbids, requested_mbids)) if include_releases else ([], [], [])
description, image = (await self._fetch_wikidata_info(mb_artist)) if include_extended else (None, None)
info = build_base_artist_info(
mb_artist, artist_id, in_library,
@@ -426,7 +427,7 @@ class ArtistService:
self._artist_basic_in_flight[artist_id] = future
try:
logger.debug(f"Cache MISS (Disk): Artist {artist_id[:8]}... - fetching from API")
artist_info = await self._build_artist_from_musicbrainz(artist_id, include_extended=False)
artist_info = await self._build_artist_from_musicbrainz(artist_id, include_extended=False, include_releases=False)
artist_info = await self._apply_audiodb_artist_images(
artist_info, artist_id, artist_info.name, allow_fetch=False,
)
+6
View File
@@ -62,6 +62,12 @@ export default defineConfig(
"CallExpression[callee.object.name='queryClient'][callee.property.name='setQueryData']",
message:
"Direct use of 'queryClient.setQueryData' is forbidden. Please use the 'setQueryDataWithPersister' function instead."
},
{
selector:
"CallExpression[callee.object.name='queryClient'][callee.property.name='invalidateQueries']",
message:
"Direct use of 'queryClient.invalidateQueries' is forbidden. Please use the custom 'invalidateQueriesWithPersister' hook instead."
}
]
}
@@ -167,6 +167,7 @@
<svelte:element
this={artistHref ? 'a' : 'div'}
href={artistHref ?? undefined}
data-sveltekit-preload-data={artistHref ? 'hover' : undefined}
class="card bg-base-100 w-full shadow-sm transition-all {artistHref
? 'cursor-pointer hover:scale-105 active:scale-95 hover:shadow-[0_0_20px_rgba(174,213,242,0.15)]'
: 'cursor-default opacity-80'}"
@@ -3,7 +3,7 @@
import LastFmInfoCard from './LastFmInfoCard.svelte';
interface Props {
enrichment: LastFmArtistEnrichment | null;
enrichment: LastFmArtistEnrichment | null | undefined;
loading?: boolean;
enabled?: boolean;
}
@@ -29,7 +29,7 @@
</script>
<div class="relative mb-6 overflow-hidden {gradientClass}">
<div class="absolute inset-0 bg-gradient-to-t from-base-100 to-transparent"></div>
<div class="absolute inset-0 bg-linear-to-t from-base-100 to-transparent"></div>
<div
class="absolute inset-0 opacity-[0.03]"
style="background-image: url('data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 200 200%22><filter id=%22n%22><feTurbulence type=%22fractalNoise%22 baseFrequency=%220.9%22 numOctaves=%224%22 stitchTiles=%22stitch%22/></filter><rect width=%22100%25%22 height=%22100%25%22 filter=%22url(%23n)%22 opacity=%220.5%22/></svg>'); background-size: 200px;"
@@ -1,6 +1,5 @@
<script lang="ts">
import { getApiUrl } from '$lib/api/api-utils';
import { browser } from '$app/environment';
import { preferencesStore } from '$lib/stores/preferences';
import { integrationStore } from '$lib/stores/integration';
import type {
@@ -9,6 +8,8 @@
LidarrMetadataProfilePreferences,
MetadataProfile
} from '$lib/types';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
let preferences: UserPreferences = $state({
primary_types: [],
@@ -200,10 +201,9 @@
if (success) {
saveMessage = 'Settings saved Artist pages and search results will refresh automatically.';
if (browser) {
window.dispatchEvent(new CustomEvent('artist-refresh'));
window.dispatchEvent(new CustomEvent('search-refresh'));
}
// Invalidate artist queries since these preferences affect which releases are shown on artist pages and search results
invalidateQueriesWithPersister({ queryKey: ArtistQueryKeyFactory.prefix });
window.dispatchEvent(new CustomEvent('search-refresh'));
setTimeout(() => {
saveMessage = '';
+15 -2
View File
@@ -1,3 +1,5 @@
import type { MusicSource } from './stores/musicSource';
export const CACHE_KEY_GROUPS = {
core: {
LIBRARY_MBIDS: 'musicseerr_library_mbids',
@@ -76,7 +78,8 @@ export const CACHE_TTL_GROUPS = {
ALBUM_DETAIL_SOURCE_MATCH: 5 * 60 * 1000,
ARTIST_DETAIL_BASIC: 5 * 60 * 1000,
ARTIST_DETAIL_EXTENDED: 30 * 60 * 1000,
ARTIST_DETAIL_LASTFM: 30 * 60 * 1000
ARTIST_DETAIL_LASTFM: 30 * 60 * 1000,
ARTIST_DISCOVERY: 5 * 60 * 1000
},
charts: {
TIME_RANGE_OVERVIEW: 2 * 60 * 1000,
@@ -136,7 +139,17 @@ export const API = {
basic: (id: string) => `/api/v1/artists/${id}`,
extended: (id: string) => `/api/v1/artists/${id}/extended`,
releases: (id: string, offset: number, limit: number) =>
`/api/v1/artists/${id}/releases?offset=${offset}&limit=${limit}`
`/api/v1/artists/${id}/releases?offset=${offset}&limit=${limit}`,
similarArtists: (id: string, source: MusicSource, count: number = 15) =>
`/api/v1/artists/${id}/similar?count=${count}&source=${source}`,
topSongs: (id: string, source: MusicSource, count: number = 10) =>
`/api/v1/artists/${id}/top-songs?count=${count}&source=${source}`,
topAlbums: (id: string, source: MusicSource, count: number = 10) =>
`/api/v1/artists/${id}/top-albums?count=${count}&source=${source}`,
lastFmEnrichment: (id: string, artistName: string) => {
const params = new URLSearchParams({ artist_name: artistName });
return `/api/v1/artists/${id}/lastfm?${params.toString()}`;
}
},
album: {
basic: (id: string) => `/api/v1/albums/${id}`,
+1 -1
View File
@@ -2,8 +2,8 @@ import { api } from '$lib/api/client';
import { API, CACHE_TTL } from '$lib/constants';
import type { MusicSource } from '$lib/stores/musicSource';
import type { HomeResponse } from '$lib/types';
import type { Getter } from '$lib/utils/typeHelpers';
import { createQuery } from '@tanstack/svelte-query';
import type { Getter } from 'runed';
const keyFactory = {
home: (source: MusicSource) => ['home', source] as const
@@ -34,7 +34,6 @@ export function createIDBStorage(): AsyncStorage<PersistedQuery> {
setItem: async (key: string, value: PersistedQuery) => {
// In some cases, a svelte state proxy value appears in the query state, which cannot be stored in IndexedDB.
// To work around this, we can snapshot the value before storing it.
console.debug('Setting item in IndexedDB', key, value);
try {
await set(key, $state.snapshot(value));
} catch (e) {
+11
View File
@@ -1,6 +1,8 @@
import { browser } from '$app/environment';
import {
type InferDataFromTag,
type InvalidateOptions,
type InvalidateQueryFilters,
QueryClient,
type QueryFilters,
type QueryKey,
@@ -55,6 +57,15 @@ export const setQueryDataWithPersister = async <
await queryPersister.persistQueryByKey(queryKey, queryClient);
};
export const invalidateQueriesWithPersister = async <TTaggedQueryKey extends QueryKey = QueryKey>(
filters?: InvalidateQueryFilters<TTaggedQueryKey>,
options?: InvalidateOptions
) => {
await queryPersister.removeQueries(filters);
// eslint-disable-next-line no-restricted-syntax
await queryClient.invalidateQueries<TTaggedQueryKey>(filters, options);
};
/**
* Global query client, used to manage all queries in the application.
*/
@@ -0,0 +1,154 @@
import { API, CACHE_TTL } from '$lib/constants';
import { createInfiniteQuery, createQuery, queryOptions } from '@tanstack/svelte-query';
import type { Getter } from 'runed';
import { ArtistQueryKeyFactory } from './ArtistQueryKeyFactory';
import { api } from '$lib/api/client';
import type {
ArtistInfoBasic,
ArtistInfoExtended,
ArtistReleases,
LastFmArtistEnrichment,
ReleaseGroup,
SimilarArtistsResponse,
TopAlbumsResponse,
TopSongsResponse
} from '$lib/types';
import type { MusicSource } from '$lib/stores/musicSource';
import { integrationStore } from '$lib/stores/integration';
import { get } from 'svelte/store';
import { setQueryDataWithPersister } from '../QueryClient';
export const getBasicArtistQueryOptions = (artistId: string) =>
queryOptions({
staleTime: CACHE_TTL.ARTIST_DETAIL_BASIC,
queryKey: ArtistQueryKeyFactory.basic(artistId),
queryFn: ({ signal }) =>
api.global.get<ArtistInfoBasic>(API.artist.basic(artistId), {
signal
})
});
export const getBasicArtistQuery = (getArtistId: Getter<string>) =>
createQuery(() => getBasicArtistQueryOptions(getArtistId()));
export const getExtendedArtistQuery = (getArtistId: Getter<string>) =>
createQuery(() => ({
staleTime: CACHE_TTL.ARTIST_DETAIL_EXTENDED,
queryKey: ArtistQueryKeyFactory.extended(getArtistId()),
queryFn: ({ signal }) =>
api.global.get<ArtistInfoExtended>(API.artist.extended(getArtistId()), {
signal
})
}));
export const getSimilarArtistsQuery = (
getParams: Getter<{ artistId: string; source: MusicSource }>
) =>
createQuery(() => {
const { artistId, source } = getParams();
return {
staleTime: CACHE_TTL.ARTIST_DISCOVERY,
queryKey: ArtistQueryKeyFactory.similarArtists(artistId, source),
queryFn: ({ signal }) =>
api.global.get<SimilarArtistsResponse>(API.artist.similarArtists(artistId, source), {
signal
})
};
});
export const getArtistTopAlbumsQuery = (
getParams: Getter<{ artistId: string; source: MusicSource }>
) =>
createQuery(() => {
const { artistId, source } = getParams();
return {
staleTime: CACHE_TTL.ARTIST_DISCOVERY,
queryKey: ArtistQueryKeyFactory.topAlbums(artistId, source),
queryFn: ({ signal }) =>
api.global.get<TopAlbumsResponse>(API.artist.topAlbums(artistId, source), {
signal
})
};
});
export const getArtistTopSongsQuery = (
getParams: Getter<{ artistId: string; source: MusicSource }>
) =>
createQuery(() => {
const { artistId, source } = getParams();
return {
staleTime: CACHE_TTL.ARTIST_DISCOVERY,
queryKey: ArtistQueryKeyFactory.topSongs(artistId, source),
queryFn: ({ signal }) =>
api.global.get<TopSongsResponse>(API.artist.topSongs(artistId, source), {
signal
})
};
});
export const getArtistLastFmEnrichmentQuery = (
getParams: Getter<{ artistId: string; artistName?: string }>
) =>
createQuery(() => {
const { artistId, artistName } = getParams();
return {
staleTime: CACHE_TTL.ARTIST_DETAIL_LASTFM,
queryKey: ArtistQueryKeyFactory.lastFmEnrichment(artistId, artistName),
queryFn: ({ signal }) =>
api.global.get<LastFmArtistEnrichment>(API.artist.lastFmEnrichment(artistId, artistName!), {
signal
}),
enabled: () => !!artistName && get(integrationStore).lastfm
};
});
const BATCH_SIZE = 50;
export const getArtistReleasesInfiniteQuery = (getArtistId: Getter<string>) =>
createInfiniteQuery(() => ({
queryKey: ArtistQueryKeyFactory.releases(getArtistId()),
initialPageParam: 0,
queryFn: async ({ pageParam = 0, signal }) => {
const response = await api.global.get<ArtistReleases>(
API.artist.releases(getArtistId(), pageParam, BATCH_SIZE),
{ signal }
);
return response;
},
getNextPageParam: (lastPage, allPages) => {
const wasLastPageEmpty =
lastPage.albums.length === 0 && lastPage.singles.length === 0 && lastPage.eps.length === 0;
if (!lastPage.has_more || wasLastPageEmpty) {
return undefined;
}
return allPages.length * BATCH_SIZE;
}
}));
type ArtistReleasesInfiniteQuery = ReturnType<typeof getArtistReleasesInfiniteQuery>;
export const updateArtistReleaseInCache = (
artistId: string,
updatedData: Partial<ReleaseGroup> & Pick<ReleaseGroup, 'id'>
) => {
const queryKey = ArtistQueryKeyFactory.releases(artistId);
return setQueryDataWithPersister(queryKey, (prevData: ArtistReleasesInfiniteQuery['data']) => {
if (!prevData) return prevData;
const updatedPages = prevData.pages.map((page) => {
const updateRelease = (originalRelease: ReleaseGroup) => {
if (originalRelease.id === updatedData.id) {
return { ...originalRelease, ...updatedData };
}
return originalRelease;
};
return {
...page,
albums: page.albums.map(updateRelease),
singles: page.singles.map(updateRelease),
eps: page.eps.map(updateRelease)
};
});
return { ...prevData, pages: updatedPages };
});
};
@@ -0,0 +1,15 @@
import type { MusicSource } from '$lib/stores/musicSource';
export const ArtistQueryKeyFactory = {
prefix: ['artist'] as const,
basic: (id: string) => [...ArtistQueryKeyFactory.prefix, id] as const,
extended: (id: string) => [...ArtistQueryKeyFactory.prefix, id, 'extended'] as const,
topAlbums: (id: string, source: MusicSource) =>
[...ArtistQueryKeyFactory.prefix, id, 'top-albums', { source }] as const,
topSongs: (id: string, source: MusicSource) =>
[...ArtistQueryKeyFactory.prefix, id, 'top-songs', { source }] as const,
lastFmEnrichment: (id: string, artistName?: string) =>
[...ArtistQueryKeyFactory.prefix, id, 'lastfm-enrichment', { artistName }] as const,
releases: (id: string) => [...ArtistQueryKeyFactory.prefix, id, 'releases'] as const,
similarArtists: (id: string, source: MusicSource) => ['similar-artists', id, { source }] as const
};
+8 -6
View File
@@ -120,7 +120,7 @@ export type ExternalLink = {
category?: string;
};
export type ArtistInfo = {
export type ArtistInfoBasic = {
name: string;
musicbrainz_id: string;
disambiguation?: string | null;
@@ -131,8 +131,6 @@ export type ArtistInfo = {
end?: string | null;
ended?: boolean;
} | null;
description?: string | null;
image?: string | null;
fanart_url?: string | null;
banner_url?: string | null;
thumb_url?: string | null;
@@ -150,12 +148,16 @@ export type ArtistInfo = {
in_lidarr?: boolean;
monitored?: boolean;
auto_download?: boolean;
albums: ReleaseGroup[];
singles: ReleaseGroup[];
eps: ReleaseGroup[];
release_group_count?: number;
};
export type ArtistInfoExtended = {
description?: string | null;
image?: string | null;
};
export type ArtistInfo = ArtistInfoBasic & ArtistInfoExtended;
export type ArtistReleases = {
albums: ReleaseGroup[];
singles: ReleaseGroup[];
@@ -1,28 +0,0 @@
import { CACHE_KEYS, CACHE_TTL } from '$lib/constants';
import type { ArtistInfo, LastFmArtistEnrichment } from '$lib/types';
import { createLocalStorageCache } from '$lib/utils/localStorageCache';
const MAX_ARTIST_DETAIL_CACHE_ENTRIES = 120;
export type ArtistExtendedCachePayload = {
description: string | null;
image: string | null;
};
export const artistBasicCache = createLocalStorageCache<ArtistInfo>(
CACHE_KEYS.ARTIST_BASIC_CACHE,
CACHE_TTL.ARTIST_DETAIL_BASIC,
{ maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES }
);
export const artistExtendedCache = createLocalStorageCache<ArtistExtendedCachePayload>(
CACHE_KEYS.ARTIST_EXTENDED_CACHE,
CACHE_TTL.ARTIST_DETAIL_EXTENDED,
{ maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES }
);
export const artistLastFmCache = createLocalStorageCache<LastFmArtistEnrichment>(
CACHE_KEYS.ARTIST_LASTFM_CACHE,
CACHE_TTL.ARTIST_DETAIL_LASTFM,
{ maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES }
);
-3
View File
@@ -1,3 +0,0 @@
export type Getter<T> = () => T;
export type MaybeGetter<T> = T | Getter<T>;
@@ -32,7 +32,6 @@ import {
albumSourceMatchCache
} from '$lib/utils/albumDetailCache';
import { hydrateDetailCacheEntry } from '$lib/utils/detailCacheHydration';
import { artistBasicCache } from '$lib/utils/artistDetailCache';
import { compareDiscTrack, getDiscTrackKey } from '$lib/player/queueHelpers';
import type { QueueItem } from '$lib/player/types';
import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
@@ -59,6 +58,8 @@ import {
getTrackContextMenuItems as getTrackContextMenuItemsImpl,
buildSourceCallbacks
} from './albumPlaybackHandlers';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
export interface SourceCallbacks {
onPlayAll: () => void;
@@ -541,7 +542,8 @@ export function createAlbumPageState(albumIdGetter: () => string) {
if (aid && abortController) void fetchArtistMonitoringState(aid, abortController.signal);
if (opts?.monitorArtist && aid) {
monitoredArtistsStore.addPendingMonitor(aid, opts.autoDownloadArtist ?? false);
artistBasicCache.remove(aid);
invalidateQueriesWithPersister({ queryKey: ArtistQueryKeyFactory.basic(aid) });
invalidateQueriesWithPersister({ queryKey: ArtistQueryKeyFactory.releases(aid) });
}
}
});
+159 -547
View File
@@ -1,17 +1,5 @@
<script lang="ts">
import { run } from 'svelte/legacy';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import type {
ArtistInfo,
ArtistReleases,
SimilarArtistsResponse,
TopSongsResponse,
TopAlbumsResponse,
LastFmArtistEnrichment,
ReleaseGroup
} from '$lib/types';
import type { ReleaseGroup } from '$lib/types';
import { colors } from '$lib/colors';
import ArtistHeaderSkeleton from '$lib/components/ArtistHeaderSkeleton.svelte';
import AlbumGridSkeleton from '$lib/components/AlbumGridSkeleton.svelte';
@@ -23,83 +11,137 @@
import TopSongsList from '$lib/components/TopSongsList.svelte';
import TopAlbumsList from '$lib/components/TopAlbumsList.svelte';
import ArtistRemovedModal from '$lib/components/ArtistRemovedModal.svelte';
import SourceSwitcher from '$lib/components/SourceSwitcher.svelte';
import LastFmEnrichment from '$lib/components/LastFmEnrichment.svelte';
import LibraryAlbumsCarousel from '$lib/components/LibraryAlbumsCarousel.svelte';
import ArtistPageToc from '$lib/components/ArtistPageToc.svelte';
import { requestAlbum } from '$lib/utils/albumRequest';
import { getArtistDiscoveryCache, setArtistDiscoveryCache } from '$lib/stores/discoveryCache';
import { integrationStore } from '$lib/stores/integration';
import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource';
import { monitoredArtistsStore } from '$lib/stores/monitoredArtists';
import { type MusicSource } from '$lib/stores/musicSource';
import {
artistBasicCache,
artistExtendedCache,
artistLastFmCache
} from '$lib/utils/artistDetailCache';
import { hydrateDetailCacheEntry } from '$lib/utils/detailCacheHydration';
import { isAbortError } from '$lib/utils/errorHandling';
import { api } from '$lib/api/client';
import { extractServiceStatus } from '$lib/utils/serviceStatus';
getArtistLastFmEnrichmentQuery,
getArtistReleasesInfiniteQuery,
getArtistTopAlbumsQuery,
getArtistTopSongsQuery,
getBasicArtistQuery,
getExtendedArtistQuery,
getSimilarArtistsQuery,
updateArtistReleaseInCache
} from '$lib/queries/artist/ArtistQueries.svelte';
import type { PageProps } from './$types';
import { invalidateQueriesWithPersister } from '$lib/queries/QueryClient';
import { ArtistQueryKeyFactory } from '$lib/queries/artist/ArtistQueryKeyFactory';
import { PAGE_SOURCE_KEYS } from '$lib/constants';
import { PersistedState } from 'runed';
import SimpleSourceSwitcher from '$lib/components/SimpleSourceSwitcher.svelte';
interface Props {
data: { artistId: string };
}
let { data }: PageProps = $props();
let { data }: Props = $props();
// svelte-ignore state_referenced_locally
let activeSource = new PersistedState<MusicSource>(
PAGE_SOURCE_KEYS['artist'],
data.primarySource
);
let artist: ArtistInfo | null = $state(null);
let loadingBasic = $state(true);
let loadingExtended = $state(true);
let refreshingArtist = $state(false);
let error: string | null = $state(null);
let showToast = $state(false);
let toastMessage = 'Added to Library';
let showArtistRemovedModal = $state(false);
let removedArtistName = $state('');
let requestingAlbums = $state(new Set<string>());
let abortController: AbortController | null = null;
let requestedReleaseIds = $state(new Set<string>());
let albumsCollapsed = $state(false);
let epsCollapsed = $state(false);
let singlesCollapsed = $state(false);
let loadingMoreReleases = $state(false);
let currentOffset = 50;
let hasMoreReleases = $state(false);
let totalReleaseCount = $state(0);
let loadedReleaseCount = $state(0);
const BATCH_SIZE = 50;
let fetchMoreTimeoutId: ReturnType<typeof setTimeout> | null = null;
let similarArtists: SimilarArtistsResponse | null = $state(null);
let topSongs: TopSongsResponse | null = $state(null);
let topAlbums: TopAlbumsResponse | null = $state(null);
let loadingSimilar = $state(true);
let loadingTopSongs = $state(true);
let loadingTopAlbums = $state(true);
let lastfmEnrichment: LastFmArtistEnrichment | null = $state(null);
let loadingLastfm = $state(true);
type ArtistReleasePaginationState = {
loadedReleaseCount: number;
hasMoreReleases: boolean;
totalReleaseCount: number;
currentOffset: number;
};
type ArtistReleaseSortingResult = {
artistInfo: ArtistInfo;
pagination: ArtistReleasePaginationState;
};
type ArtistTocSection = {
id: string;
label: string;
};
let tocSections: ArtistTocSection[] = $state([]);
const artistBasicQuery = getBasicArtistQuery(() => data.artistId);
const artistBasic = $derived(artistBasicQuery.data);
const loadingBasic = $derived(artistBasicQuery.isLoading);
function sortReleasesByYear(releases: ArtistInfo['albums']) {
const artistExtendedQuery = getExtendedArtistQuery(() => data.artistId);
const artistExtended = $derived(artistExtendedQuery.data);
const loadingExtended = $derived(artistExtendedQuery.isLoading);
const similarArtistsQuery = getSimilarArtistsQuery(() => ({
artistId: data.artistId,
source: activeSource.current
}));
const similarArtists = $derived(similarArtistsQuery.data);
const loadingSimilar = $derived(similarArtistsQuery.isLoading);
const topSongsQuery = getArtistTopSongsQuery(() => ({
artistId: data.artistId,
source: activeSource.current
}));
const topSongs = $derived(topSongsQuery.data);
const loadingTopSongs = $derived(topSongsQuery.isLoading);
const topAlbumsQuery = getArtistTopAlbumsQuery(() => ({
artistId: data.artistId,
source: activeSource.current
}));
const topAlbums = $derived(topAlbumsQuery.data);
const loadingTopAlbums = $derived(topAlbumsQuery.isLoading);
const lastFmEnrichmentQuery = getArtistLastFmEnrichmentQuery(() => ({
artistId: data.artistId,
artistName: artistBasic?.name
}));
const lastfmEnrichment = $derived(lastFmEnrichmentQuery.data);
const loadingLastfm = $derived(lastFmEnrichmentQuery.isLoading);
let error: string | null = $derived.by(() => {
if (artistBasicQuery.error) {
return 'Failed to load artist information.';
}
if (artistExtendedQuery.error) {
return 'Failed to load extended artist information.';
}
return null;
});
const artist = $derived.by(() => {
if (!artistBasic) return null;
return {
...artistBasic,
description: artistExtended?.description,
image: artistExtended?.image
};
});
const releasesQuery = getArtistReleasesInfiniteQuery(() => data.artistId);
const loadingMoreReleases = $derived(releasesQuery.isFetchingNextPage);
const hasMoreReleases = $derived(releasesQuery.hasNextPage);
const releases = $derived.by(() => {
const albums = releasesQuery.data?.pages.flatMap((page) => page.albums) || [];
const singles = releasesQuery.data?.pages.flatMap((page) => page.singles) || [];
const eps = releasesQuery.data?.pages.flatMap((page) => page.eps) || [];
return {
albums: sortReleasesByYear(albums),
singles: sortReleasesByYear(singles),
eps: sortReleasesByYear(eps)
};
});
const loadedReleaseCount = $derived(
releasesQuery.data?.pages.flatMap((page) => [...page.albums, ...page.singles, ...page.eps])
.length || 0
);
// Total count is not reliable, since it contains items that are filtered out
const totalReleaseCount = $derived(releasesQuery.data?.pages[0].total_count || 0);
$effect(() => {
if (hasMoreReleases && !releasesQuery.isFetchingNextPage) {
releasesQuery.fetchNextPage();
}
});
const refreshingArtist = $derived(
artistBasicQuery.isRefetching || artistExtendedQuery.isRefetching
);
function sortReleasesByYear(releases: ReleaseGroup[]) {
return [...releases].sort((a, b) => {
const yearA = a.year;
const yearB = b.year;
@@ -109,464 +151,32 @@
});
}
function applyArtistReleaseSorting(artistInfo: ArtistInfo): ArtistReleaseSortingResult {
const sortedArtistInfo: ArtistInfo = {
...artistInfo,
albums: sortReleasesByYear(artistInfo.albums),
singles: sortReleasesByYear(artistInfo.singles),
eps: sortReleasesByYear(artistInfo.eps)
};
const nextLoadedReleaseCount =
sortedArtistInfo.albums.length +
sortedArtistInfo.singles.length +
sortedArtistInfo.eps.length;
const releaseGroupCount = sortedArtistInfo.release_group_count || 0;
const nextHasMoreReleases =
releaseGroupCount > nextLoadedReleaseCount ||
(releaseGroupCount === 0 && nextLoadedReleaseCount >= BATCH_SIZE);
const nextTotalReleaseCount = nextHasMoreReleases
? releaseGroupCount || nextLoadedReleaseCount
: nextLoadedReleaseCount;
const nextOffset = nextHasMoreReleases ? BATCH_SIZE : 0;
return {
artistInfo: sortedArtistInfo,
pagination: {
loadedReleaseCount: nextLoadedReleaseCount,
hasMoreReleases: nextHasMoreReleases,
totalReleaseCount: nextTotalReleaseCount,
currentOffset: nextOffset
}
};
}
function applyArtistReleasePaginationState(pagination: ArtistReleasePaginationState): void {
loadedReleaseCount = pagination.loadedReleaseCount;
hasMoreReleases = pagination.hasMoreReleases;
totalReleaseCount = pagination.totalReleaseCount;
currentOffset = pagination.currentOffset;
}
function hydrateFromCache(artistId: string): {
refreshBasic: boolean;
refreshExtended: boolean;
refreshLastfm: boolean;
} {
const refreshBasic = hydrateDetailCacheEntry({
cache: artistBasicCache,
cacheKey: artistId,
onHydrate: (cachedArtist) => {
const sortedResult = applyArtistReleaseSorting(cachedArtist);
artist = sortedResult.artistInfo;
applyArtistReleasePaginationState(sortedResult.pagination);
loadingBasic = false;
}
});
const refreshExtended = artist
? hydrateDetailCacheEntry({
cache: artistExtendedCache,
cacheKey: artistId,
onHydrate: (cachedExtended) => {
artist = {
...artist!,
description: cachedExtended.description,
image: cachedExtended.image
};
loadingExtended = false;
}
})
: true;
const refreshLastfm = hydrateDetailCacheEntry({
cache: artistLastFmCache,
cacheKey: artistId,
onHydrate: (cachedLastFmEnrichment) => {
lastfmEnrichment = cachedLastFmEnrichment;
loadingLastfm = false;
}
});
return { refreshBasic, refreshExtended, refreshLastfm };
}
async function fetchArtist(force = false) {
const hasPendingMonitor = !!monitoredArtistsStore.getPendingMonitor(data.artistId);
const {
refreshBasic: cacheRefreshBasic,
refreshExtended,
refreshLastfm
} = force
? { refreshBasic: true, refreshExtended: true, refreshLastfm: true }
: hydrateFromCache(data.artistId);
const staleLibraryFlags = artist && artist.in_library && !artist.in_lidarr;
const refreshBasic = cacheRefreshBasic || hasPendingMonitor || !!staleLibraryFlags;
if (!artist || refreshBasic) loadingBasic = true;
if (!artist || refreshExtended) loadingExtended = true;
if (refreshLastfm) loadingLastfm = true;
if (!similarArtists && !topSongs && !topAlbums) {
loadingSimilar = true;
loadingTopSongs = true;
loadingTopAlbums = true;
}
error = null;
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
// Fire discovery immediately — only needs artistId (from URL), not basic data
const sourceLoadPromise = musicSourceStore.load();
const currentController = abortController;
sourceLoadPromise.then(() => {
if (currentController.signal.aborted) return;
void fetchDiscoveryData(musicSourceStore.getPageSource('artist'));
});
const forceBasic = force || !!staleLibraryFlags || hasPendingMonitor;
if (refreshBasic || !artist) {
await Promise.all([fetchBasicInfo(forceBasic), sourceLoadPromise]);
} else {
await sourceLoadPromise;
}
if (artist) {
const secondaryPromises: Promise<void>[] = [];
if (refreshExtended) {
secondaryPromises.push(fetchExtendedInfo(force, artist));
}
if (refreshLastfm) {
secondaryPromises.push(fetchLastFmEnrichment());
}
// Start background release loading after all other data has settled
if (hasMoreReleases) {
Promise.allSettled(secondaryPromises).then(() => {
if (abortController && !abortController.signal.aborted) {
void fetchMoreReleases();
}
});
}
}
}
async function fetchBasicInfo(force = false) {
try {
const now = Date.now();
const cacheBuster = force ? `?t=${now}` : '';
const artistData: ArtistInfo = await api.get(
`/api/v1/artists/${data.artistId}${cacheBuster}`,
{
signal: abortController?.signal,
cache: force ? 'no-cache' : 'default'
}
);
if (artistData) {
extractServiceStatus(artistData);
const sortedResult = applyArtistReleaseSorting(artistData);
artist = sortedResult.artistInfo;
applyArtistReleasePaginationState(sortedResult.pagination);
artistBasicCache.set(artist, data.artistId);
if (artistData.in_lidarr) {
monitoredArtistsStore.removePendingMonitor(data.artistId);
}
}
} catch (e) {
if (isAbortError(e)) {
return;
}
error = 'Error loading artist';
} finally {
loadingBasic = false;
}
}
async function fetchExtendedInfo(force = false, artistRef: ArtistInfo) {
try {
const now = Date.now();
const cacheBuster = force ? `?t=${now}` : '';
const extendedInfo = await api.get<{ description?: string; image?: string }>(
`/api/v1/artists/${data.artistId}/extended${cacheBuster}`,
{
signal: abortController?.signal,
cache: force ? 'no-cache' : 'default'
}
);
if (artist && artist === artistRef) {
artist.description = extendedInfo.description;
artist.image = extendedInfo.image;
artist = artist;
artistExtendedCache.set(
{
description: extendedInfo.description ?? null,
image: extendedInfo.image ?? null
},
data.artistId
);
artistBasicCache.set(artist, data.artistId);
}
} catch (e) {
if (isAbortError(e)) {
return;
}
} finally {
loadingExtended = false;
}
}
async function fetchDiscoveryData(sourceOverride?: MusicSource) {
const activeSource = sourceOverride ?? musicSourceStore.getPageSource('artist');
const cacheKey = `${data.artistId}:${activeSource}`;
const cached = getArtistDiscoveryCache(cacheKey);
if (cached) {
similarArtists = cached.similarArtists;
topSongs = cached.topSongs;
topAlbums = cached.topAlbums;
loadingSimilar = false;
loadingTopSongs = false;
loadingTopAlbums = false;
return;
}
similarArtists = null;
topSongs = null;
topAlbums = null;
loadingSimilar = true;
loadingTopSongs = true;
loadingTopAlbums = true;
const signal = abortController?.signal;
const similarPromise = api
.get<SimilarArtistsResponse>(
`/api/v1/artists/${data.artistId}/similar?count=15&source=${activeSource}`,
{ signal }
)
.then((data) => {
similarArtists = data;
})
.catch((e) => {
if (!isAbortError(e)) {
/* ignore */
}
})
.finally(() => {
loadingSimilar = false;
});
const songsPromise = api
.get<TopSongsResponse>(
`/api/v1/artists/${data.artistId}/top-songs?count=10&source=${activeSource}`,
{ signal }
)
.then((data) => {
topSongs = data;
})
.catch((e) => {
if (!isAbortError(e)) {
/* ignore */
}
})
.finally(() => {
loadingTopSongs = false;
});
const albumsPromise = api
.get<TopAlbumsResponse>(
`/api/v1/artists/${data.artistId}/top-albums?count=10&source=${activeSource}`,
{ signal }
)
.then((data) => {
topAlbums = data;
})
.catch((e) => {
if (!isAbortError(e)) {
/* ignore */
}
})
.finally(() => {
loadingTopAlbums = false;
});
await Promise.all([similarPromise, songsPromise, albumsPromise]);
const sa = similarArtists as SimilarArtistsResponse | null;
const ts = topSongs as TopSongsResponse | null;
const ta = topAlbums as TopAlbumsResponse | null;
if (sa?.similar_artists?.length || ts?.songs?.length || ta?.albums?.length) {
setArtistDiscoveryCache(cacheKey, { similarArtists, topSongs, topAlbums });
}
}
async function fetchLastFmEnrichment() {
if (!artist) {
loadingLastfm = false;
return;
}
await integrationStore.ensureLoaded();
if (!$integrationStore.lastfm) {
loadingLastfm = false;
return;
}
loadingLastfm = true;
try {
const params = new URLSearchParams({ artist_name: artist.name });
lastfmEnrichment = await api.get<LastFmArtistEnrichment>(
`/api/v1/artists/${data.artistId}/lastfm?${params.toString()}`,
{ signal: abortController?.signal }
);
if (lastfmEnrichment) {
artistLastFmCache.set(lastfmEnrichment, data.artistId);
}
} catch (e) {
if (isAbortError(e)) return;
} finally {
loadingLastfm = false;
}
}
function handleSourceChange(source: MusicSource) {
similarArtists = null;
topSongs = null;
topAlbums = null;
loadingSimilar = true;
loadingTopSongs = true;
loadingTopAlbums = true;
fetchDiscoveryData(source);
}
async function fetchMoreReleases() {
if (!artist || loadingMoreReleases || !hasMoreReleases) {
return;
}
loadingMoreReleases = true;
try {
const url = `/api/v1/artists/${data.artistId}/releases?offset=${currentOffset}&limit=${BATCH_SIZE}`;
const moreReleases: ArtistReleases = await api.get(url, { signal: abortController?.signal });
if (artist) {
const newAlbums = moreReleases.albums.filter(
(a: ReleaseGroup) =>
!artist!.albums.some((existing: ReleaseGroup) => existing.id === a.id)
);
const newSingles = moreReleases.singles.filter(
(s: ReleaseGroup) =>
!artist!.singles.some((existing: ReleaseGroup) => existing.id === s.id)
);
const newEps = moreReleases.eps.filter(
(e: ReleaseGroup) => !artist!.eps.some((existing: ReleaseGroup) => existing.id === e.id)
);
artist.albums = sortReleasesByYear([...artist.albums, ...newAlbums]);
artist.singles = sortReleasesByYear([...artist.singles, ...newSingles]);
artist.eps = sortReleasesByYear([...artist.eps, ...newEps]);
artist = artist;
currentOffset += BATCH_SIZE;
hasMoreReleases = moreReleases.has_more;
loadedReleaseCount = artist.albums.length + artist.singles.length + artist.eps.length;
if (hasMoreReleases) {
if (fetchMoreTimeoutId) clearTimeout(fetchMoreTimeoutId);
fetchMoreTimeoutId = setTimeout(() => fetchMoreReleases(), 500);
}
}
} catch (e) {
if (isAbortError(e)) {
return;
}
hasMoreReleases = false;
} finally {
loadingMoreReleases = false;
}
}
let currentArtistId: string | null = $state(null);
function resetState() {
artist = null;
loadingBasic = true;
loadingExtended = true;
loadingSimilar = true;
loadingTopSongs = true;
loadingTopAlbums = true;
loadingLastfm = true;
similarArtists = null;
topSongs = null;
topAlbums = null;
lastfmEnrichment = null;
error = null;
currentOffset = 50;
hasMoreReleases = false;
loadedReleaseCount = 0;
if (abortController) {
abortController.abort();
abortController = null;
}
}
async function handleRefreshClick() {
refreshingArtist = true;
try {
await fetchArtist(true);
} finally {
refreshingArtist = false;
}
// Will also invalidate the extended query
invalidateQueriesWithPersister({ queryKey: ArtistQueryKeyFactory.basic(data.artistId) });
}
onMount(() => {
if (browser) {
const handleRefresh = () => fetchArtist(true);
window.addEventListener('artist-refresh', handleRefresh);
return () => {
window.removeEventListener('artist-refresh', handleRefresh);
};
}
});
onDestroy(() => {
if (abortController) {
abortController.abort();
abortController = null;
}
if (fetchMoreTimeoutId) {
clearTimeout(fetchMoreTimeoutId);
fetchMoreTimeoutId = null;
}
});
async function handleRequest(albumId: string, albumTitle?: string) {
requestingAlbums.add(albumId);
requestingAlbums = requestingAlbums;
async function handleRequest(releaseId: string, releaseTitle?: string) {
requestedReleaseIds.add(releaseId);
requestedReleaseIds = requestedReleaseIds;
try {
const result = await requestAlbum(albumId, {
const result = await requestAlbum(releaseId, {
artist: artist?.name,
album: albumTitle
album: releaseTitle
});
if (result.success && artist) {
const allReleases = [...artist.albums, ...artist.singles, ...artist.eps];
const release = allReleases.find((rg) => rg.id === albumId);
if (release) {
release.requested = true;
artist = artist;
artistBasicCache.set(artist, data.artistId);
}
await updateArtistReleaseInCache(data.artistId, {
id: releaseId,
requested: true
});
showToast = true;
}
} finally {
requestingAlbums.delete(albumId);
requestingAlbums = requestingAlbums;
requestedReleaseIds.delete(releaseId);
requestedReleaseIds = requestedReleaseIds;
}
}
@@ -578,27 +188,21 @@
removedArtistName = result.artist_name || artist.name;
showArtistRemovedModal = true;
}
artist = artist;
artistBasicCache.set(artist, data.artistId);
invalidateQueriesWithPersister({ queryKey: ArtistQueryKeyFactory.basic(data.artistId) });
}
run(() => {
tocSections = artist
? [
{ id: 'section-overview', label: 'Overview' },
{ id: 'section-about', label: 'About' },
{ id: 'section-similar', label: 'Similar Artists' },
...(artist.albums.length > 0 ? [{ id: 'section-albums', label: 'Albums' }] : []),
...(artist.eps.length > 0 ? [{ id: 'section-eps', label: 'EPs' }] : []),
...(artist.singles.length > 0 ? [{ id: 'section-singles', label: 'Singles' }] : [])
]
: [];
});
run(() => {
if (browser && data.artistId && data.artistId !== currentArtistId) {
currentArtistId = data.artistId;
resetState();
fetchArtist();
const tocSections = $derived.by<ArtistTocSection[]>(() => {
if (!artist) {
return [];
}
return [
{ id: 'section-overview', label: 'Overview' },
{ id: 'section-about', label: 'About' },
{ id: 'section-similar', label: 'Similar Artists' },
...(releases.albums.length > 0 ? [{ id: 'section-albums', label: 'Albums' }] : []),
...(releases.eps.length > 0 ? [{ id: 'section-eps', label: 'EPs' }] : []),
...(releases.singles.length > 0 ? [{ id: 'section-singles', label: 'Singles' }] : [])
];
});
</script>
@@ -637,14 +241,17 @@
{#if artist.life_span?.begin}
<span class="text-sm text-base-content/80 flex items-center gap-1.5">
<span>📅</span>
{artist.life_span.begin}{#if artist.life_span.end}&nbsp;&nbsp;{artist.life_span
.end}{/if}
{artist.life_span.begin}
{#if artist.life_span.end}
&nbsp;&nbsp;
{artist.life_span.end}
{/if}
</span>
{/if}
{#if artist.albums.length + artist.eps.length + artist.singles.length > 0}
{#if releases.albums.length + releases.eps.length + releases.singles.length > 0}
<span class="text-sm text-base-content/80 flex items-center gap-1.5">
<span>💿</span>
{artist.albums.length + artist.eps.length + artist.singles.length} releases
{releases.albums.length + releases.eps.length + releases.singles.length} releases
</span>
{/if}
</div>
@@ -677,13 +284,18 @@
</section>
<LibraryAlbumsCarousel
releases={[...artist.albums, ...artist.eps, ...artist.singles]}
releases={[...releases.albums, ...releases.eps, ...releases.singles]}
artistName={artist.name}
loading={loadingBasic}
/>
<div class="flex items-center justify-end mt-8 mb-4">
<SourceSwitcher pageKey="artist" onSourceChange={handleSourceChange} />
<SimpleSourceSwitcher
currentSource={activeSource.current}
onSourceChange={(newSource) => {
activeSource.current = newSource;
}}
/>
</div>
<div class="flex flex-col md:flex-row gap-6 md:items-stretch">
@@ -729,19 +341,19 @@
>Loading releases...</span
>
<span class="text-sm text-base-content/70"
>Loaded {loadedReleaseCount} of {totalReleaseCount} releases</span
>Loaded {loadedReleaseCount} of {totalReleaseCount} potential releases</span
>
</div>
</div>
{/if}
{#if artist.albums.length > 0}
{#if releases.albums.length > 0}
<section id="section-albums" class="scroll-mt-24">
<ReleaseList
title="Albums"
releases={artist.albums}
releases={releases.albums}
collapsed={albumsCollapsed}
requestingIds={requestingAlbums}
requestingIds={requestedReleaseIds}
showLoadingIndicator={hasMoreReleases || loadingMoreReleases}
artistName={artist.name}
onRequest={handleRequest}
@@ -751,13 +363,13 @@
</section>
{/if}
{#if artist.eps.length > 0}
{#if releases.eps.length > 0}
<section id="section-eps" class="scroll-mt-24">
<ReleaseList
title="EPs"
releases={artist.eps}
releases={releases.eps}
collapsed={epsCollapsed}
requestingIds={requestingAlbums}
requestingIds={requestedReleaseIds}
showLoadingIndicator={hasMoreReleases || loadingMoreReleases}
artistName={artist.name}
onRequest={handleRequest}
@@ -767,13 +379,13 @@
</section>
{/if}
{#if artist.singles.length > 0}
{#if releases.singles.length > 0}
<section id="section-singles" class="scroll-mt-24">
<ReleaseList
title="Singles"
releases={artist.singles}
releases={releases.singles}
collapsed={singlesCollapsed}
requestingIds={requestingAlbums}
requestingIds={requestedReleaseIds}
showLoadingIndicator={hasMoreReleases || loadingMoreReleases}
artistName={artist.name}
onRequest={handleRequest}
+4
View File
@@ -1,6 +1,10 @@
import { getBasicArtistQueryOptions } from '$lib/queries/artist/ArtistQueries.svelte';
import { queryClient } from '$lib/queries/QueryClient';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
queryClient.prefetchQuery(getBasicArtistQueryOptions(params.id));
return {
artistId: params.id
};