diff --git a/CHANGELOG.md b/CHANGELOG.md index 6690ebc7..ad53d8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -421,6 +421,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * With multiple music libraries, narrowing the sidebar to one library no longer leaves the Genre filter showing server-wide genres — options now come from the scoped local index catalog (same scope as the album grid). +### Now Playing — multi-artist links and About the Artist tabs + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#960](https://github.com/Psychotoxical/psysonic/pull/960)** + +* Tracks with OpenSubsonic `artists[]` (e.g. Navidrome `feat.` splits) now expose per-artist links on the Now Playing hero and in the queue current-track row — same interaction as player bar and album track lists. +* About the Artist loads bio for each performer; when multiple artist ids are present, tabs switch between their bios, images, and similar artists instead of showing one joined name with a single profile. + + ### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx index b63bee41..4803f734 100644 --- a/src/components/NowPlayingInfo.tsx +++ b/src/components/NowPlayingInfo.tsx @@ -11,6 +11,7 @@ import { usePlaybackServerId } from '../hooks/usePlaybackServerId'; import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown'; import CachedImage from './CachedImage'; import OverlayScrollArea from './OverlayScrollArea'; +import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs'; const TOUR_LIMIT = 5; const BIO_CLAMP_LINES = 4; @@ -88,8 +89,9 @@ export default function NowPlayingInfo() { const subsonicServerId = usePlaybackServerId(); const subsonicReady = Boolean(subsonicServerId); - const artistName = currentTrack?.artist || ''; - const artistId = currentTrack?.artistId || ''; + const primaryArtist = currentTrack ? primaryTrackArtistRef(currentTrack) : null; + const artistName = primaryArtist?.name ?? currentTrack?.artist ?? ''; + const artistId = primaryArtist?.id ?? ''; const songId = currentTrack?.id || ''; // Tuple { id, info } gates rendering on "info matches the current artistId" so @@ -175,8 +177,8 @@ export default function NowPlayingInfo() { }, [bioClean]); const contributorRows = useMemo( - () => buildContributorRows(matchedSongDetail, artistName), - [matchedSongDetail, artistName], + () => buildContributorRows(matchedSongDetail, currentTrack?.artist ?? ''), + [matchedSongDetail, currentTrack?.artist], ); if (!currentTrack) { diff --git a/src/components/nowPlaying/ArtistCard.test.tsx b/src/components/nowPlaying/ArtistCard.test.tsx index 0ef3d03c..490c8ce2 100644 --- a/src/components/nowPlaying/ArtistCard.test.tsx +++ b/src/components/nowPlaying/ArtistCard.test.tsx @@ -5,6 +5,7 @@ * /artist/:id and the previously-divergent rendering). */ import { describe, it, expect, vi } from 'vitest'; +import { fireEvent } from '@testing-library/react'; import { renderWithProviders } from '@/test/helpers/renderWithProviders'; import ArtistCard from './ArtistCard'; import type { SubsonicArtistInfo } from '../../api/subsonicTypes'; @@ -47,6 +48,28 @@ describe('ArtistCard — hideArtistName / hideSimilar', () => { }); }); +describe('ArtistCard — artistTabs', () => { + it('renders tabs and switches bio when a second artist tab is selected', () => { + const { container, getByRole } = renderWithProviders( + , + ); + + expect(container.querySelectorAll('.np-artist-tab')).toHaveLength(2); + expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio A'); + + fireEvent.click(getByRole('tab', { name: 'Katerina Begu' })); + expect(container.querySelector('.np-bio-text')?.textContent).toContain('Bio B'); + }); +}); + describe('ArtistCard — coverFallback', () => { it('uses coverFallback src + cacheKey when artistInfo has no hero image', () => { const noImageInfo = { biography: 'b', similarArtist: [] } as unknown as SubsonicArtistInfo; @@ -60,8 +83,6 @@ describe('ArtistCard — coverFallback', () => { ); const img = container.querySelector('img.np-dash-artist-image'); expect(img).not.toBeNull(); - // CachedImage swaps src to a blob URL asynchronously; the initial render - // shows the fetch URL — assert the *configured* fallback src is in play. expect(img!.getAttribute('src') || '').toContain('fallback.test'); }); diff --git a/src/components/nowPlaying/ArtistCard.tsx b/src/components/nowPlaying/ArtistCard.tsx index 5ac4ae4a..059158cb 100644 --- a/src/components/nowPlaying/ArtistCard.tsx +++ b/src/components/nowPlaying/ArtistCard.tsx @@ -5,10 +5,18 @@ import type { SubsonicArtistInfo } from '../../api/subsonicTypes'; import { isRealArtistImage, sanitizeHtml } from '../../utils/componentHelpers/nowPlayingHelpers'; import CachedImage from '../CachedImage'; +export interface ArtistCardTab { + id?: string; + name: string; + artistInfo: SubsonicArtistInfo | null; +} + interface ArtistCardProps { artistName: string; artistId?: string; artistInfo: SubsonicArtistInfo | null; + /** When more than one entry, render picker tabs (Now Playing multi-artist tracks). */ + artistTabs?: ArtistCardTab[]; /** When omitted the "Go to Artist" link and similar-artist chip click handlers do nothing — used on /artist/:id where the user is already there. */ onNavigate?: (path: string) => void; /** Render fallback cover when artistInfo has no hero image (ArtistDetail's coverArt fallback). */ @@ -19,28 +27,13 @@ interface ArtistCardProps { hideSimilar?: boolean; } -const ArtistCard = memo(function ArtistCard({ - artistName, artistId, artistInfo, onNavigate, coverFallback, - hideArtistName = false, hideSimilar = false, -}: ArtistCardProps) { - const { t } = useTranslation(); - const [bioExpanded, setBioExpanded] = useState(false); - const [bioOverflows, setBioOverflows] = useState(false); - const bioRef = useRef(null); - - useEffect(() => { setBioExpanded(false); }, [artistId]); - - const bioHtml = useMemo(() => artistInfo?.biography ? sanitizeHtml(artistInfo.biography) : '', [artistInfo?.biography]); - - useLayoutEffect(() => { - const el = bioRef.current; - if (!el) { setBioOverflows(false); return; } - setBioOverflows(el.scrollHeight - el.clientHeight > 1); - }, [bioHtml]); - - const similar = hideSimilar ? [] : (artistInfo?.similarArtist ?? []); +function heroForEntry( + artistId: string | undefined, + artistInfo: SubsonicArtistInfo | null, + coverFallback?: { src: string; cacheKey: string }, +): { heroImage: string; heroCacheKey: string } { const rawLarge = artistInfo?.largeImageUrl; - const rawMed = artistInfo?.mediumImageUrl; + const rawMed = artistInfo?.mediumImageUrl; const heroFromInfo = isRealArtistImage(rawLarge) ? rawLarge! : isRealArtistImage(rawMed) ? rawMed! : ''; @@ -48,32 +41,104 @@ const ArtistCard = memo(function ArtistCard({ const heroCacheKey = heroFromInfo ? (artistId ? `artistInfo:${artistId}:hero` : '') : (coverFallback?.cacheKey ?? ''); + return { heroImage, heroCacheKey }; +} - if (!bioHtml && similar.length === 0 && !heroImage) return null; +function entryHasContent( + entry: ArtistCardTab, + hideSimilar: boolean, + coverFallback?: { src: string; cacheKey: string }, +): boolean { + const bioHtml = entry.artistInfo?.biography ? sanitizeHtml(entry.artistInfo.biography) : ''; + const similar = hideSimilar ? [] : (entry.artistInfo?.similarArtist ?? []); + const { heroImage } = heroForEntry(entry.id, entry.artistInfo, coverFallback); + return Boolean(bioHtml || similar.length > 0 || heroImage); +} + +const ArtistCard = memo(function ArtistCard({ + artistName, artistId, artistInfo, artistTabs, onNavigate, coverFallback, + hideArtistName = false, hideSimilar = false, +}: ArtistCardProps) { + const { t } = useTranslation(); + const [bioExpanded, setBioExpanded] = useState(false); + const [bioOverflows, setBioOverflows] = useState(false); + const bioRef = useRef(null); + + const tabs = artistTabs && artistTabs.length > 1 ? artistTabs : null; + const tabsKey = tabs?.map(tab => tab.id ?? tab.name).join('\x1e') ?? ''; + const [activeTabIdx, setActiveTabIdx] = useState(0); + + useEffect(() => { setActiveTabIdx(0); }, [tabsKey]); + useEffect(() => { setBioExpanded(false); }, [artistId, tabsKey, activeTabIdx]); + + const activeEntry = tabs + ? tabs[Math.min(activeTabIdx, tabs.length - 1)] + : { id: artistId, name: artistName, artistInfo }; + + const activeArtistId = activeEntry.id; + const activeArtistName = activeEntry.name; + const activeArtistInfo = activeEntry.artistInfo; + + const bioHtml = useMemo( + () => activeArtistInfo?.biography ? sanitizeHtml(activeArtistInfo.biography) : '', + [activeArtistInfo?.biography], + ); + + useLayoutEffect(() => { + const el = bioRef.current; + if (!el) { setBioOverflows(false); return; } + setBioOverflows(el.scrollHeight - el.clientHeight > 1); + }, [bioHtml]); + + const similar = hideSimilar ? [] : (activeArtistInfo?.similarArtist ?? []); + const { heroImage, heroCacheKey } = heroForEntry(activeArtistId, activeArtistInfo, coverFallback); + + const visible = tabs + ? tabs.some(tab => entryHasContent(tab, hideSimilar, coverFallback)) + : entryHasContent({ id: artistId, name: artistName, artistInfo }, hideSimilar, coverFallback); + + if (!visible) return null; return (

{t('nowPlaying.aboutArtist')}

- {artistId && onNavigate && ( - )}
+ {tabs && ( +
+ {tabs.map((tab, idx) => ( + + ))} +
+ )} +
{heroImage && heroCacheKey && ( { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} /> )}
- {!hideArtistName &&
{artistName}
} + {!hideArtistName && !tabs &&
{activeArtistName}
} {bioHtml && ( <>
16 || (track.samplingRate ?? 0) > 48000; @@ -67,11 +71,22 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
{track.title}
- track.artistId && onNavigate(`/artist/${track.artistId}`)} - style={{ cursor: track.artistId ? 'pointer' : 'default' }}> - {track.artist} - + {artistRefs && artistRefs.length > 0 ? ( + onNavigate(`/artist/${id}`)} + as="none" + linkTag="span" + linkClassName="np-link" + /> + ) : ( + track.artistId && onNavigate(`/artist/${track.artistId}`)} + style={{ cursor: track.artistId ? 'pointer' : 'default' }}> + {track.artist} + + )} · track.albumId && onNavigate(`/album/${track.albumId}`)} diff --git a/src/components/queuePanel/QueueCurrentTrack.tsx b/src/components/queuePanel/QueueCurrentTrack.tsx index b4d99cc5..0533af8e 100644 --- a/src/components/queuePanel/QueueCurrentTrack.tsx +++ b/src/components/queuePanel/QueueCurrentTrack.tsx @@ -15,8 +15,10 @@ import { useQueueTrackEnrichment } from '../../hooks/useQueueTrackEnrichment'; import { QueueLufsTargetMenu } from './QueueLufsTargetMenu'; import { PlaybackBufferingOverlay } from '../playback/PlaybackBufferingOverlay'; import { CoverArtImage } from '../../cover/CoverArtImage'; +import { OpenArtistRefInline } from '../OpenArtistRefInline'; import { usePlaybackTrackCoverRef } from '../../cover/useLibraryCoverRef'; import { usePlayerStore } from '../../store/playerStore'; +import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs'; interface Props { currentTrack: Track; @@ -52,6 +54,7 @@ export function QueueCurrentTrack({ }: Props) { const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering); const coverRef = usePlaybackTrackCoverRef(currentTrack); + const artistRefs = resolveTrackArtistRefs(currentTrack); const enrichment = useQueueTrackEnrichment(currentTrack.id); const bpmTech = formatQueueBpmTech(enrichment, t); const moodLine = formatQueueMoodLabels(enrichment.moodLabels, t); @@ -219,10 +222,16 @@ export function QueueCurrentTrack({

{currentTrack.title}

-
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)} - >{currentTrack.artist}
+
+ navigate(`/artist/${id}`)} + as="none" + linkTag="span" + linkClassName="is-link" + /> +
currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)} diff --git a/src/hooks/useArtistInfoBatch.ts b/src/hooks/useArtistInfoBatch.ts new file mode 100644 index 00000000..4bd2aedf --- /dev/null +++ b/src/hooks/useArtistInfoBatch.ts @@ -0,0 +1,85 @@ +import { useEffect, useMemo, useState } from 'react'; +import { getArtistInfoForServer } from '../api/subsonicArtists'; +import type { SubsonicArtistInfo, SubsonicOpenArtistRef } from '../api/subsonicTypes'; +import { makeCache } from '../utils/cache/nowPlayingCache'; + +const artistInfoCache = makeCache(); + +function cacheKey(serverId: string, artistId: string): string { + return `${serverId}:${artistId}`; +} + +/** + * Fetches `getArtistInfo` for each ref with an id. Returns `undefined` for ids + * still loading, `null` when fetch finished with no info. + */ +export function useArtistInfoBatch( + serverId: string | undefined, + refs: SubsonicOpenArtistRef[], + similarArtistCount?: number, +): Record { + const ids = useMemo( + () => [...new Set(refs.map(r => r.id?.trim()).filter((id): id is string => Boolean(id)))], + [refs], + ); + const idsKey = ids.join('\x1e'); + + const [byId, setById] = useState>(() => { + if (!serverId || ids.length === 0) return {}; + const seed: Record = {}; + for (const id of ids) { + const cached = artistInfoCache.get(cacheKey(serverId, id)); + if (cached !== undefined) seed[id] = cached; + } + return seed; + }); + + useEffect(() => { + if (!serverId || ids.length === 0) { + setById({}); + return; + } + + const next: Record = {}; + const pending: string[] = []; + for (const id of ids) { + const cached = artistInfoCache.get(cacheKey(serverId, id)); + if (cached !== undefined) { + next[id] = cached; + } else { + next[id] = undefined; + pending.push(id); + } + } + setById(next); + + if (pending.length === 0) return; + + let cancelled = false; + void Promise.all( + pending.map(async id => { + try { + const info = await getArtistInfoForServer(serverId, id, { + similarArtistCount: similarArtistCount, + }); + artistInfoCache.set(cacheKey(serverId, id), info ?? null); + return [id, info ?? null] as const; + } catch { + artistInfoCache.set(cacheKey(serverId, id), null); + return [id, null] as const; + } + }), + ).then(results => { + if (cancelled) return; + setById(prev => { + const merged = { ...prev }; + for (const [id, info] of results) merged[id] = info; + return merged; + }); + }); + + return () => { cancelled = true; }; + }, [serverId, idsKey, similarArtistCount]); + + return byId; +} diff --git a/src/hooks/useNowPlayingPrewarm.ts b/src/hooks/useNowPlayingPrewarm.ts index 9b6203f4..62a0b255 100644 --- a/src/hooks/useNowPlayingPrewarm.ts +++ b/src/hooks/useNowPlayingPrewarm.ts @@ -12,6 +12,7 @@ import { prewarmNowPlayingFetchers } from './useNowPlayingFetchers'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import { usePlaybackServerId } from './usePlaybackServerId'; +import { primaryTrackArtistRef } from '../utils/playback/trackArtistRefs'; const NOW_PLAYING_COVER_CSS_PX = 800; @@ -52,11 +53,12 @@ export function useNowPlayingPrewarm(): void { useEffect(() => { if (!currentTrack || !playbackServerId) return; + const primary = primaryTrackArtistRef(currentTrack); void prewarmNowPlayingFetchers({ songId: currentTrack.id, - artistId: currentTrack.artistId, + artistId: primary.id, albumId: currentTrack.albumId, - artistName: currentTrack.artist, + artistName: primary.name ?? currentTrack.artist, enableBandsintown, audiomuseNavidromeEnabled, lastfmUsername, @@ -81,6 +83,7 @@ export function useNowPlayingPrewarm(): void { }, [ currentTrack?.id, currentTrack?.artistId, + currentTrack?.artists, currentTrack?.albumId, currentTrack?.coverArt, currentTrack?.artist, diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index e2e97314..44a87bb4 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -40,6 +40,9 @@ import TourCard from '../components/nowPlaying/TourCard'; import DiscographyCard from '../components/nowPlaying/DiscographyCard'; import { useNowPlayingFetchers } from '../hooks/useNowPlayingFetchers'; import { useNowPlayingStarLove } from '../hooks/useNowPlayingStarLove'; +import { useArtistInfoBatch } from '../hooks/useArtistInfoBatch'; +import { primaryTrackArtistRef, resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs'; +import type { ArtistCardTab } from '../components/nowPlaying/ArtistCard'; // ─── Main Page ──────────────────────────────────────────────────────────────── @@ -68,9 +71,17 @@ export default function NowPlaying() { const radioMeta = useRadioMetadata(currentRadio ?? null); const songId = currentTrack?.id; - const artistId = currentTrack?.artistId; const albumId = currentTrack?.albumId; - const artistName = currentTrack?.artist ?? ''; + const trackArtistRefs = useMemo( + () => (currentTrack ? resolveTrackArtistRefs(currentTrack) : []), + [currentTrack], + ); + const primaryArtist = useMemo( + () => (currentTrack ? primaryTrackArtistRef(currentTrack) : null), + [currentTrack], + ); + const artistId = primaryArtist?.id; + const artistName = primaryArtist?.name ?? currentTrack?.artist ?? ''; // Entity fetchers (8 cached useEffects + their state) const { @@ -107,8 +118,8 @@ export default function NowPlaying() { const resolvedRadioCover = radioCover.src; const contributorRows = useMemo( - () => buildContributorRows(songMeta, artistName), - [songMeta, artistName], + () => buildContributorRows(songMeta, currentTrack?.artist ?? ''), + [songMeta, currentTrack?.artist], ); // Merge Subsonic artistInfo with Last.fm fallback: if Subsonic has no bio, @@ -123,6 +134,28 @@ export default function NowPlaying() { }; }, [artistInfo, lfmArtist]); + const artistInfoById = useArtistInfoBatch( + playbackServerId, + trackArtistRefs, + audiomuseNavidromeEnabled ? 24 : undefined, + ); + + const artistTabs = useMemo((): ArtistCardTab[] | undefined => { + const tabs = trackArtistRefs.filter(r => r.id?.trim()); + if (tabs.length <= 1) return undefined; + return tabs.map((ref, index) => { + const id = ref.id!.trim(); + const info = index === 0 + ? (effectiveArtistInfo ?? artistInfoById[id] ?? null) + : (artistInfoById[id] ?? null); + return { + id, + name: ref.name ?? id, + artistInfo: info, + }; + }); + }, [trackArtistRefs, artistInfoById, effectiveArtistInfo]); + const handleEnableBandsintown = useCallback(() => setEnableBandsintown(true), [setEnableBandsintown]); const handlePlayTopSong = useCallback((song: SubsonicSong) => { @@ -237,6 +270,7 @@ export default function NowPlaying() { albumId: currentTrack.albumId, userRating: currentTrack.userRating, }} + artistRefs={trackArtistRefs.length > 0 ? trackArtistRefs : undefined} genre={songMeta?.genre ?? undefined} playCount={(songMeta as (SubsonicSong & { playCount?: number }) | null)?.playCount} userRatingOverride={userRatingOverrides[currentTrack.id]} @@ -283,6 +317,7 @@ export default function NowPlaying() { artistName={artistName} artistId={artistId} artistInfo={effectiveArtistInfo} + artistTabs={artistTabs} onNavigate={stableNavigate} /> ); diff --git a/src/styles/components/np-dash.css b/src/styles/components/np-dash.css index b976a193..dc283779 100644 --- a/src/styles/components/np-dash.css +++ b/src/styles/components/np-dash.css @@ -328,6 +328,40 @@ } /* Artist card */ +.np-artist-tab-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 12px; +} + +.np-artist-tab { + font-size: 12px; + font-weight: 600; + padding: 5px 11px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.72); + cursor: pointer; + transition: background 0.15s, color 0.15s, border-color 0.15s; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; +} + +.np-artist-tab:hover { + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.92); +} + +.np-artist-tab.is-active { + background: color-mix(in srgb, var(--accent) 28%, transparent); + border-color: color-mix(in srgb, var(--accent) 45%, transparent); + color: white; +} + .np-dash-artist-body { display: flex; gap: 16px; diff --git a/src/utils/playback/trackArtistRefs.test.ts b/src/utils/playback/trackArtistRefs.test.ts new file mode 100644 index 00000000..a973e163 --- /dev/null +++ b/src/utils/playback/trackArtistRefs.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { primaryTrackArtistRef, resolveTrackArtistRefs } from './trackArtistRefs'; + +describe('resolveTrackArtistRefs', () => { + it('prefers OpenSubsonic artists[] when present', () => { + const refs = [{ id: 'a1', name: 'Dan Balan' }, { id: 'a2', name: 'Katerina Begu' }]; + expect(resolveTrackArtistRefs({ + artist: 'Dan Balan feat. Katerina Begu', + artistId: 'legacy', + artists: refs, + })).toEqual(refs); + }); + + it('falls back to legacy artistId + artist', () => { + expect(resolveTrackArtistRefs({ + artist: 'Solo', + artistId: 'ar-solo', + })).toEqual([{ id: 'ar-solo', name: 'Solo' }]); + }); + + it('returns name-only ref when no id', () => { + expect(resolveTrackArtistRefs({ artist: 'Unknown' })).toEqual([{ name: 'Unknown' }]); + }); +}); + +describe('primaryTrackArtistRef', () => { + it('returns the first structured ref', () => { + expect(primaryTrackArtistRef({ + artist: 'A feat. B', + artists: [{ id: 'a1', name: 'A' }, { id: 'a2', name: 'B' }], + })).toEqual({ id: 'a1', name: 'A' }); + }); +}); diff --git a/src/utils/playback/trackArtistRefs.ts b/src/utils/playback/trackArtistRefs.ts new file mode 100644 index 00000000..82269003 --- /dev/null +++ b/src/utils/playback/trackArtistRefs.ts @@ -0,0 +1,21 @@ +import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes'; +import type { Track } from '../../store/playerStoreTypes'; + +type TrackArtistFields = Pick; + +/** OpenSubsonic `artists` when present; else legacy `artistId` + `artist` (album track rows). */ +export function resolveTrackArtistRefs(track: TrackArtistFields): SubsonicOpenArtistRef[] { + if (track.artists && track.artists.length > 0) { + return track.artists; + } + const id = track.artistId?.trim(); + if (id) { + return [{ id, name: track.artist }]; + } + return [{ name: track.artist }]; +} + +/** First performer ref — used for artist bio / discography / top songs on Now Playing. */ +export function primaryTrackArtistRef(track: TrackArtistFields): SubsonicOpenArtistRef { + return resolveTrackArtistRefs(track)[0]; +}