fix(now-playing): split artist links and About the Artist tabs (#960)

* fix(now-playing): split artist links and About the Artist tabs

OpenSubsonic artists[] now drives per-artist navigation on Now Playing
hero and the queue current-track row (matching player bar). About the
Artist loads bio for each performer via tabs when a track has multiple
artist ids; queue Info uses the primary ref for bio/tour fetch.

Reported by zunoz on the Psysonic Discord (v1.47.0-rc.3).

* docs: note Now Playing multi-artist fix in CHANGELOG (PR #960)
This commit is contained in:
cucadmuh
2026-06-03 22:16:06 +03:00
committed by GitHub
parent a142bb1dab
commit 4c70408bd6
12 changed files with 379 additions and 48 deletions
+6 -4
View File
@@ -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) {
+23 -2
View File
@@ -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(
<ArtistCard
artistName="A"
artistId="a1"
artistInfo={infoWithImage}
artistTabs={[
{ id: 'a1', name: 'Dan Balan', artistInfo: { biography: 'Bio A' } as SubsonicArtistInfo },
{ id: 'a2', name: 'Katerina Begu', artistInfo: { biography: 'Bio B' } as SubsonicArtistInfo },
]}
/>,
);
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<HTMLImageElement>('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');
});
+91 -26
View File
@@ -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<HTMLDivElement | null>(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<HTMLDivElement | null>(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 (
<div className="np-info-card np-dash-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.aboutArtist')}</h3>
{artistId && onNavigate && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${artistId}`)}>
{activeArtistId && onNavigate && (
<button className="np-card-link" onClick={() => onNavigate(`/artist/${activeArtistId}`)}>
{t('nowPlaying.goToArtist')} <ExternalLink size={12} />
</button>
)}
</div>
{tabs && (
<div className="np-artist-tab-row" role="tablist" aria-label={t('nowPlaying.aboutArtist')}>
{tabs.map((tab, idx) => (
<button
key={tab.id ?? tab.name}
type="button"
role="tab"
aria-selected={idx === activeTabIdx}
className={`np-artist-tab${idx === activeTabIdx ? ' is-active' : ''}`}
onClick={() => setActiveTabIdx(idx)}
>
{tab.name}
</button>
))}
</div>
)}
<div className="np-dash-artist-body">
{heroImage && heroCacheKey && (
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={artistName}
alt={activeArtistName}
className="np-dash-artist-image"
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="np-dash-artist-text">
{!hideArtistName && <div className="np-dash-artist-name">{artistName}</div>}
{!hideArtistName && !tabs && <div className="np-dash-artist-name">{activeArtistName}</div>}
{bioHtml && (
<>
<div
+21 -6
View File
@@ -4,7 +4,9 @@ import { Headphones, Heart, MicVocal, Music, Star } from 'lucide-react';
import { CoverArtImage } from '../../cover/CoverArtImage';
import type { CoverArtRef } from '../../cover/types';
import type { LastfmArtistStats, LastfmTrackInfo } from '../../api/lastfm';
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
import LastfmIcon from '../LastfmIcon';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import { formatTrackTime } from '../../utils/format/formatDuration';
interface HeroProps {
@@ -12,6 +14,8 @@ interface HeroProps {
duration: number; suffix?: string; bitRate?: number; samplingRate?: number;
bitDepth?: number; artistId?: string; albumId?: string; id: string;
userRating?: number; };
/** OpenSubsonic `artists` on the playing track — per-artist links in the hero subline. */
artistRefs?: SubsonicOpenArtistRef[];
genre?: string;
playCount?: number;
userRatingOverride?: number;
@@ -42,7 +46,7 @@ function renderStars(rating?: number) {
);
}
const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, lfmTrack, lfmArtist, starred, lfmLoved, lfmLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleLfmLove, onOpenLyrics }: HeroProps) {
const { t } = useTranslation();
const rating = userRatingOverride ?? track.userRating;
const hiRes = (track.bitDepth ?? 0) > 16 || (track.samplingRate ?? 0) > 48000;
@@ -67,11 +71,22 @@ const Hero = memo(function Hero({ track, genre, playCount, userRatingOverride, l
<div className="np-dash-hero-body">
<div className="np-dash-hero-title">{track.title}</div>
<div className="np-dash-hero-sub">
<span className="np-link"
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
{track.artist}
</span>
{artistRefs && artistRefs.length > 0 ? (
<OpenArtistRefInline
refs={artistRefs}
fallbackName={track.artist}
onGoArtist={id => onNavigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="np-link"
/>
) : (
<span className="np-link"
onClick={() => track.artistId && onNavigate(`/artist/${track.artistId}`)}
style={{ cursor: track.artistId ? 'pointer' : 'default' }}>
{track.artist}
</span>
)}
<span className="np-sep">·</span>
<span className="np-link"
onClick={() => track.albumId && onNavigate(`/album/${track.albumId}`)}
@@ -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({
</div>
<div className="queue-current-info">
<h3 className="truncate">{currentTrack.title}</h3>
<div
className={`queue-current-sub truncate${currentTrack.artistId ? ' is-link' : ''}`}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div className="queue-current-sub truncate">
<OpenArtistRefInline
refs={artistRefs}
fallbackName={currentTrack.artist}
onGoArtist={id => navigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="is-link"
/>
</div>
<div
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
+85
View File
@@ -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<SubsonicArtistInfo | null>();
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<string, SubsonicArtistInfo | null | undefined> {
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<Record<string, SubsonicArtistInfo | null | undefined>>(() => {
if (!serverId || ids.length === 0) return {};
const seed: Record<string, SubsonicArtistInfo | null | undefined> = {};
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<string, SubsonicArtistInfo | null | undefined> = {};
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;
}
+5 -2
View File
@@ -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,
+39 -4
View File
@@ -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}
/>
);
+34
View File
@@ -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;
@@ -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' });
});
});
+21
View File
@@ -0,0 +1,21 @@
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
type TrackArtistFields = Pick<Track, 'artist' | 'artistId' | 'artists'>;
/** 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];
}