From 90b7b67a10eb14d4c58b8927f1af976c76b11113 Mon Sep 17 00:00:00 2001 From: Arno <46051866+arnolicious@users.noreply.github.com> Date: Tue, 14 Apr 2026 00:34:49 +0200 Subject: [PATCH] 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 --- backend/services/artist_service.py | 7 +- frontend/eslint.config.js | 6 + .../src/lib/components/HomeSection.svelte | 1 + .../lib/components/LastFmEnrichment.svelte | 2 +- frontend/src/lib/components/PageHeader.svelte | 2 +- .../settings/SettingsPreferences.svelte | 10 +- frontend/src/lib/constants.ts | 17 +- frontend/src/lib/queries/HomeQuery.svelte.ts | 2 +- .../lib/queries/IndexedDbPersister.svelte.ts | 1 - frontend/src/lib/queries/QueryClient.ts | 11 + .../queries/artist/ArtistQueries.svelte.ts | 154 ++++ .../queries/artist/ArtistQueryKeyFactory.ts | 15 + frontend/src/lib/types.ts | 14 +- frontend/src/lib/utils/artistDetailCache.ts | 28 - frontend/src/lib/utils/typeHelpers.ts | 3 - .../album/[id]/albumPageState.svelte.ts | 6 +- frontend/src/routes/artist/[id]/+page.svelte | 706 ++++-------------- frontend/src/routes/artist/[id]/+page.ts | 4 + 18 files changed, 389 insertions(+), 600 deletions(-) create mode 100644 frontend/src/lib/queries/artist/ArtistQueries.svelte.ts create mode 100644 frontend/src/lib/queries/artist/ArtistQueryKeyFactory.ts delete mode 100644 frontend/src/lib/utils/artistDetailCache.ts delete mode 100644 frontend/src/lib/utils/typeHelpers.ts diff --git a/backend/services/artist_service.py b/backend/services/artist_service.py index 8e72598..1448a0a 100644 --- a/backend/services/artist_service.py +++ b/backend/services/artist_service.py @@ -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, ) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 3b131e7..e098b7f 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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." } ] } diff --git a/frontend/src/lib/components/HomeSection.svelte b/frontend/src/lib/components/HomeSection.svelte index 547c2dd..fdfa297 100644 --- a/frontend/src/lib/components/HomeSection.svelte +++ b/frontend/src/lib/components/HomeSection.svelte @@ -167,6 +167,7 @@
-
+
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 = ''; diff --git a/frontend/src/lib/constants.ts b/frontend/src/lib/constants.ts index d737d6a..899944a 100644 --- a/frontend/src/lib/constants.ts +++ b/frontend/src/lib/constants.ts @@ -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}`, diff --git a/frontend/src/lib/queries/HomeQuery.svelte.ts b/frontend/src/lib/queries/HomeQuery.svelte.ts index 330330b..abc4d2d 100644 --- a/frontend/src/lib/queries/HomeQuery.svelte.ts +++ b/frontend/src/lib/queries/HomeQuery.svelte.ts @@ -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 diff --git a/frontend/src/lib/queries/IndexedDbPersister.svelte.ts b/frontend/src/lib/queries/IndexedDbPersister.svelte.ts index 51636fd..4fff9bb 100644 --- a/frontend/src/lib/queries/IndexedDbPersister.svelte.ts +++ b/frontend/src/lib/queries/IndexedDbPersister.svelte.ts @@ -34,7 +34,6 @@ export function createIDBStorage(): AsyncStorage { 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) { diff --git a/frontend/src/lib/queries/QueryClient.ts b/frontend/src/lib/queries/QueryClient.ts index d79f9ec..ef3075d 100644 --- a/frontend/src/lib/queries/QueryClient.ts +++ b/frontend/src/lib/queries/QueryClient.ts @@ -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 ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions +) => { + await queryPersister.removeQueries(filters); + // eslint-disable-next-line no-restricted-syntax + await queryClient.invalidateQueries(filters, options); +}; + /** * Global query client, used to manage all queries in the application. */ diff --git a/frontend/src/lib/queries/artist/ArtistQueries.svelte.ts b/frontend/src/lib/queries/artist/ArtistQueries.svelte.ts new file mode 100644 index 0000000..0616bd6 --- /dev/null +++ b/frontend/src/lib/queries/artist/ArtistQueries.svelte.ts @@ -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(API.artist.basic(artistId), { + signal + }) + }); + +export const getBasicArtistQuery = (getArtistId: Getter) => + createQuery(() => getBasicArtistQueryOptions(getArtistId())); + +export const getExtendedArtistQuery = (getArtistId: Getter) => + createQuery(() => ({ + staleTime: CACHE_TTL.ARTIST_DETAIL_EXTENDED, + queryKey: ArtistQueryKeyFactory.extended(getArtistId()), + queryFn: ({ signal }) => + api.global.get(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(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(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(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(API.artist.lastFmEnrichment(artistId, artistName!), { + signal + }), + enabled: () => !!artistName && get(integrationStore).lastfm + }; + }); + +const BATCH_SIZE = 50; + +export const getArtistReleasesInfiniteQuery = (getArtistId: Getter) => + createInfiniteQuery(() => ({ + queryKey: ArtistQueryKeyFactory.releases(getArtistId()), + initialPageParam: 0, + queryFn: async ({ pageParam = 0, signal }) => { + const response = await api.global.get( + 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; + +export const updateArtistReleaseInCache = ( + artistId: string, + updatedData: Partial & Pick +) => { + 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 }; + }); +}; diff --git a/frontend/src/lib/queries/artist/ArtistQueryKeyFactory.ts b/frontend/src/lib/queries/artist/ArtistQueryKeyFactory.ts new file mode 100644 index 0000000..be8b6ee --- /dev/null +++ b/frontend/src/lib/queries/artist/ArtistQueryKeyFactory.ts @@ -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 +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 538b470..79066e8 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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[]; diff --git a/frontend/src/lib/utils/artistDetailCache.ts b/frontend/src/lib/utils/artistDetailCache.ts deleted file mode 100644 index d1b6aee..0000000 --- a/frontend/src/lib/utils/artistDetailCache.ts +++ /dev/null @@ -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( - CACHE_KEYS.ARTIST_BASIC_CACHE, - CACHE_TTL.ARTIST_DETAIL_BASIC, - { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } -); - -export const artistExtendedCache = createLocalStorageCache( - CACHE_KEYS.ARTIST_EXTENDED_CACHE, - CACHE_TTL.ARTIST_DETAIL_EXTENDED, - { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } -); - -export const artistLastFmCache = createLocalStorageCache( - CACHE_KEYS.ARTIST_LASTFM_CACHE, - CACHE_TTL.ARTIST_DETAIL_LASTFM, - { maxEntries: MAX_ARTIST_DETAIL_CACHE_ENTRIES } -); diff --git a/frontend/src/lib/utils/typeHelpers.ts b/frontend/src/lib/utils/typeHelpers.ts deleted file mode 100644 index 61b7b77..0000000 --- a/frontend/src/lib/utils/typeHelpers.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type Getter = () => T; - -export type MaybeGetter = T | Getter; diff --git a/frontend/src/routes/album/[id]/albumPageState.svelte.ts b/frontend/src/routes/album/[id]/albumPageState.svelte.ts index 3d61f3a..c8bc728 100644 --- a/frontend/src/routes/album/[id]/albumPageState.svelte.ts +++ b/frontend/src/routes/album/[id]/albumPageState.svelte.ts @@ -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) }); } } }); diff --git a/frontend/src/routes/artist/[id]/+page.svelte b/frontend/src/routes/artist/[id]/+page.svelte index 2ddcee3..54577b5 100644 --- a/frontend/src/routes/artist/[id]/+page.svelte +++ b/frontend/src/routes/artist/[id]/+page.svelte @@ -1,17 +1,5 @@ @@ -637,14 +241,17 @@ {#if artist.life_span?.begin} 📅 - {artist.life_span.begin}{#if artist.life_span.end} – {artist.life_span - .end}{/if} + {artist.life_span.begin} + {#if artist.life_span.end} +  –  + {artist.life_span.end} + {/if} {/if} - {#if artist.albums.length + artist.eps.length + artist.singles.length > 0} + {#if releases.albums.length + releases.eps.length + releases.singles.length > 0} 💿 - {artist.albums.length + artist.eps.length + artist.singles.length} releases + {releases.albums.length + releases.eps.length + releases.singles.length} releases {/if}
@@ -677,13 +284,18 @@
- + { + activeSource.current = newSource; + }} + />
@@ -729,19 +341,19 @@ >Loading releases... Loaded {loadedReleaseCount} of {totalReleaseCount} releasesLoaded {loadedReleaseCount} of {totalReleaseCount} potential releases
{/if} - {#if artist.albums.length > 0} + {#if releases.albums.length > 0}
{/if} - {#if artist.eps.length > 0} + {#if releases.eps.length > 0}
{/if} - {#if artist.singles.length > 0} + {#if releases.singles.length > 0}
{ + queryClient.prefetchQuery(getBasicArtistQueryOptions(params.id)); + return { artistId: params.id };