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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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} – {artist.life_span
|
||||
.end}{/if}
|
||||
{artist.life_span.begin}
|
||||
{#if artist.life_span.end}
|
||||
–
|
||||
{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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user