fix(artists): per-artist links on song rails and shared OpenSubsonic refs (#1023)

* fix(artists): per-artist links on song rails and shared OpenSubsonic refs

Song cards in Random Picks and Discover Songs showed joined artist
credits but navigated to a single artistId. Route track surfaces through
resolveTrackArtistRefs and coerce single-object Subsonic JSON payloads.

* docs(changelog): note song-rail multi-artist link fix (PR #1023)
This commit is contained in:
cucadmuh
2026-06-08 00:45:32 +03:00
committed by GitHub
parent 49ad3618a8
commit 30e9db1a2b
16 changed files with 167 additions and 56 deletions
+9
View File
@@ -144,6 +144,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Song rails — multi-artist credits link to each artist
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
## [1.47.0] ## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
+4 -2
View File
@@ -28,6 +28,8 @@ import PlaybackScheduleBadge from './PlaybackScheduleBadge';
import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat'; import { usePlaybackScheduleRemaining } from '../utils/format/playbackScheduleFormat';
import { usePreviewStore } from '../store/previewStore'; import { usePreviewStore } from '../store/previewStore';
import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { coerceOpenArtistRefs } from '../utils/openArtistRefs';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
import { formatTrackTime } from '../utils/format/formatDuration'; import { formatTrackTime } from '../utils/format/formatDuration';
import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo'; import { PlayerTrackInfo } from './playerBar/PlayerTrackInfo';
import { PlayerTransportControls } from './playerBar/PlayerTransportControls'; import { PlayerTransportControls } from './playerBar/PlayerTransportControls';
@@ -146,8 +148,8 @@ export default function PlayerBar() {
const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null; const showPreviewMeta = isPreviewing && !isRadio && previewingTrack !== null;
const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle')); const displayTitle = showPreviewMeta ? previewingTrack!.title : (currentTrack?.title ?? t('player.noTitle'));
const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—'); const displayArtist = showPreviewMeta ? previewingTrack!.artist : (currentTrack?.artist ?? '—');
const displayArtistRefs = !showPreviewMeta && currentTrack?.artists && currentTrack.artists.length > 0 const displayArtistRefs = !showPreviewMeta && currentTrack && coerceOpenArtistRefs(currentTrack.artists).length > 0
? currentTrack.artists ? resolveTrackArtistRefs(currentTrack)
: undefined; : undefined;
const coverArtId = showPreviewMeta const coverArtId = showPreviewMeta
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/helpers/renderWithProviders';
import SongCard from './SongCard';
import type { SubsonicSong } from '../api/subsonicTypes';
const navigateToArtist = vi.fn();
vi.mock('../hooks/useNavigateToArtist', () => ({
useNavigateToArtist: () => navigateToArtist,
}));
vi.mock('../cover/useLibraryCoverRef', () => ({
useTrackCoverRef: () => undefined,
}));
function song(overrides: Partial<SubsonicSong>): SubsonicSong {
return {
id: 's1', title: 'Track', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100,
...overrides,
} as SubsonicSong;
}
describe('SongCard', () => {
it('splits OpenSubsonic artists into individual links', async () => {
navigateToArtist.mockClear();
const user = userEvent.setup();
renderWithProviders(
<SongCard
disableArtwork
song={song({
artist: 'Apocalyptica', artistId: 'a1',
artists: [{ id: 'a1', name: 'Apocalyptica' }, { id: 'a2', name: 'Joe Duplantier' }],
})}
/>,
);
expect(screen.getByText('Apocalyptica')).toHaveClass('track-artist-link');
expect(screen.getByText('Joe Duplantier')).toHaveClass('track-artist-link');
await user.click(screen.getByText('Joe Duplantier'));
expect(navigateToArtist).toHaveBeenCalledWith('a2');
});
});
+16 -15
View File
@@ -1,7 +1,6 @@
import type { SubsonicSong } from '../api/subsonicTypes'; import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack'; import { songToTrack } from '../utils/playback/songToTrack';
import React, { memo } from 'react'; import React, { memo, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, ListPlus, Star, Disc3 } from 'lucide-react'; import { Play, ListPlus, Star, Disc3 } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
@@ -13,6 +12,9 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum'; import { useNavigateToAlbum } from '../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../hooks/useNavigateToArtist';
import { OpenArtistRefInline } from './OpenArtistRefInline';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
interface SongCardProps { interface SongCardProps {
song: SubsonicSong; song: SubsonicSong;
@@ -31,7 +33,7 @@ function SongCard({
}: SongCardProps) { }: SongCardProps) {
const layoutPx = artworkSize ?? displayCssPx; const layoutPx = artworkSize ?? displayCssPx;
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigateToArtist = useNavigateToArtist();
const openContextMenu = usePlayerStore(s => s.openContextMenu); const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false }); const coverRef = useTrackCoverRef(song, undefined, { libraryResolve: false });
@@ -55,12 +57,7 @@ function SongCard({
}; };
const handleClick = handlePlay; const handleClick = handlePlay;
const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
const handleArtistClick = (e: React.MouseEvent) => {
if (!song.artistId) return;
e.stopPropagation();
navigate(`/artist/${song.artistId}`);
};
const handleAlbumClick = (e: React.MouseEvent) => { const handleAlbumClick = (e: React.MouseEvent) => {
if (!song.albumId) return; if (!song.albumId) return;
@@ -142,12 +139,16 @@ function SongCard({
</div> </div>
<div className="song-card-info"> <div className="song-card-info">
<p className="song-card-title truncate" title={song.title}>{song.title}</p> <p className="song-card-title truncate" title={song.title}>{song.title}</p>
<p <p className="song-card-artist truncate" title={song.artist}>
className={`song-card-artist truncate${song.artistId ? ' track-artist-link' : ''}`} <OpenArtistRefInline
style={{ cursor: song.artistId ? 'pointer' : 'default' }} refs={artistRefs}
onClick={handleArtistClick} fallbackName={song.artist}
title={song.artist} onGoArtist={id => navigateToArtist(id)}
>{song.artist}</p> as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{song.albumId && ( {song.albumId && (
<button <button
type="button" type="button"
+2 -6
View File
@@ -10,6 +10,7 @@ import { enqueueAndPlay } from '../utils/playback/playSong';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior'; import { useOrbitSongRowBehavior } from '../hooks/useOrbitSongRowBehavior';
import { formatTrackTime } from '../utils/format/formatDuration'; import { formatTrackTime } from '../utils/format/formatDuration';
import { resolveTrackArtistRefs } from '../utils/playback/trackArtistRefs';
import { tooltipAttrs } from './tooltipAttrs'; import { tooltipAttrs } from './tooltipAttrs';
interface Props { interface Props {
@@ -39,12 +40,7 @@ function SongRow({ song, showBpm }: Props) {
enqueue([songToTrack(song)]); enqueue([songToTrack(song)]);
}; };
// Split multi-artist tracks into individually clickable links (OpenSubsonic const artistRefs = resolveTrackArtistRefs(song);
// `artists[]`), falling back to the single flat artist when absent — mirrors
// the album tracklist so a "A · B" credit isn't one link to a single artist.
const artistRefs = song.artists && song.artists.length > 0
? song.artists
: [{ id: song.artistId, name: song.artist }];
const bpmTooltip = const bpmTooltip =
song.localBpmSource === 'analysis' song.localBpmSource === 'analysis'
+2 -3
View File
@@ -15,6 +15,7 @@ import { formatLongDuration } from '../../utils/format/formatDuration';
import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers'; import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy'; import { offlineActionPolicy, type OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
type ContextMenuFn = ( type ContextMenuFn = (
x: number, x: number,
@@ -137,9 +138,7 @@ export const TrackRow = React.memo(function TrackRow({
</div> </div>
); );
case 'artist': { case 'artist': {
const artistRefs = song.artists && song.artists.length > 0 const artistRefs = resolveTrackArtistRefs(song);
? song.artists
: [{ id: song.artistId, name: song.artist }];
return ( return (
<div key="artist" className="track-artist-cell"> <div key="artist" className="track-artist-cell">
{artistRefs.map((a, i) => ( {artistRefs.map((a, i) => (
+11 -1
View File
@@ -8,6 +8,8 @@ import { formatLastSeen } from '../../utils/componentHelpers/userMgmtHelpers';
import i18n from '../../i18n'; import i18n from '../../i18n';
import { formatTrackTime } from '../../utils/format/formatDuration'; import { formatTrackTime } from '../../utils/format/formatDuration';
import StarRating from '../StarRating'; import StarRating from '../StarRating';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
export interface FavoriteSongRowCallbacks { export interface FavoriteSongRowCallbacks {
activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void; activate: (song: SubsonicSong, index: number, e: React.MouseEvent) => void;
@@ -100,7 +102,15 @@ function FavoriteSongRow({
); );
case 'artist': return ( case 'artist': return (
<div key="artist" className="track-artist-cell"> <div key="artist" className="track-artist-cell">
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); cb.navArtist(song.artistId, song.serverId); } }}>{song.artist}</span> <OpenArtistRefInline
refs={resolveTrackArtistRefs(song)}
fallbackName={song.artist}
onGoArtist={id => cb.navArtist(id, song.serverId)}
as="none"
linkTag="span"
linkClassName="track-artist track-artist-link"
separatorClassName="track-artist-sep"
/>
</div> </div>
); );
case 'album': return ( case 'album': return (
@@ -1,6 +1,7 @@
import React from 'react'; import React, { useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
/** /**
* Multi-artist credit for playlist track rows (main list + suggestions). * Multi-artist credit for playlist track rows (main list + suggestions).
@@ -11,9 +12,7 @@ import type { SubsonicSong } from '../../api/subsonicTypes';
*/ */
export function PlaylistArtistCell({ song }: { song: SubsonicSong }) { export function PlaylistArtistCell({ song }: { song: SubsonicSong }) {
const navigate = useNavigate(); const navigate = useNavigate();
const artistRefs = song.artists && song.artists.length > 0 const artistRefs = useMemo(() => resolveTrackArtistRefs(song), [song]);
? song.artists
: [{ id: song.artistId, name: song.artist }];
return ( return (
<div className="track-artist-cell"> <div className="track-artist-cell">
{artistRefs.map((a, i) => ( {artistRefs.map((a, i) => (
+12 -17
View File
@@ -13,6 +13,8 @@ import { ndListSongs, ndInvalidateSongsCache } from '../../api/navidromeBrowse';
import { usePerfProbeFlags } from '../../utils/perf/perfFlags'; import { usePerfProbeFlags } from '../../utils/perf/perfFlags';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum'; import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist'; import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
import { OpenArtistRefInline } from '../OpenArtistRefInline';
import { resolveTrackArtistRefs } from '../../utils/playback/trackArtistRefs';
const RANDOM_RAIL_SIZE = 18; const RANDOM_RAIL_SIZE = 18;
const RATED_RAIL_FETCH = 60; const RATED_RAIL_FETCH = 60;
@@ -105,13 +107,7 @@ export default function TracksPageChrome({
[random, hero], [random, hero],
); );
// Split a multi-artist feature into individually clickable links const heroArtistRefs = hero ? resolveTrackArtistRefs(hero) : [];
// (OpenSubsonic `artists[]`), falling back to the single flat artist.
const heroArtistRefs = hero
? (hero.artists && hero.artists.length > 0
? hero.artists
: [{ id: hero.artistId, name: hero.artist }])
: [];
return ( return (
<> <>
@@ -148,16 +144,15 @@ export default function TracksPageChrome({
</span> </span>
<h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2> <h2 className="tracks-hero-title" title={hero.title}>{hero.title}</h2>
<p className="tracks-hero-meta"> <p className="tracks-hero-meta">
{heroArtistRefs.map((a, i) => ( <OpenArtistRefInline
<React.Fragment key={a.id ?? a.name ?? i}> refs={heroArtistRefs}
{i > 0 && <span className="track-artist-sep">&nbsp;·&nbsp;</span>} fallbackName={hero.artist}
<span onGoArtist={id => navigateToArtist(id)}
className={a.id ? 'track-artist-link' : ''} as="none"
style={{ cursor: a.id ? 'pointer' : 'default' }} linkTag="span"
onClick={() => a.id && navigateToArtist(a.id)} linkClassName="track-artist-link"
>{a.name ?? hero.artist}</span> separatorClassName="track-artist-sep"
</React.Fragment> />
))}
{hero.album && ( {hero.album && (
<> <>
<span className="tracks-hero-meta-dot">·</span> <span className="tracks-hero-meta-dot">·</span>
@@ -29,6 +29,14 @@ describe('deriveAlbumArtistRefs', () => {
expect(deriveAlbumArtistRefs({ ...baseAlbum(), artistId: ' ', artist: 'Solo' })) expect(deriveAlbumArtistRefs({ ...baseAlbum(), artistId: ' ', artist: 'Solo' }))
.toEqual([{ name: 'Solo' }]); .toEqual([{ name: 'Solo' }]);
}); });
it('coerces a single-object OpenSubsonic artists payload', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
artists: { id: 'a1', name: 'Solo' } as unknown as SubsonicAlbum['artists'],
};
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'a1', name: 'Solo' }]);
});
}); });
describe('deriveAlbumHeaderArtistRefs', () => { describe('deriveAlbumHeaderArtistRefs', () => {
@@ -1,7 +1,8 @@
import type { SubsonicAlbum, SubsonicOpenArtistRef, SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicAlbum, SubsonicOpenArtistRef, SubsonicSong } from '../../api/subsonicTypes';
import { coerceOpenArtistRefs } from '../openArtistRefs';
function nonEmpty(refs: SubsonicOpenArtistRef[] | undefined): refs is SubsonicOpenArtistRef[] { function nonEmpty(refs: SubsonicOpenArtistRef[]): refs is SubsonicOpenArtistRef[] {
return !!refs && refs.length > 0; return refs.length > 0;
} }
/** /**
@@ -10,7 +11,8 @@ function nonEmpty(refs: SubsonicOpenArtistRef[] | undefined): refs is SubsonicOp
* OpenSubsonic `artists` array; falls back to legacy `artist` + `artistId`. * OpenSubsonic `artists` array; falls back to legacy `artist` + `artistId`.
*/ */
export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] { export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] {
if (nonEmpty(album.artists)) return album.artists; const albumArtists = coerceOpenArtistRefs(album.artists);
if (nonEmpty(albumArtists)) return albumArtists;
const name = album.artist?.trim() || '—'; const name = album.artist?.trim() || '—';
const id = album.artistId?.trim(); const id = album.artistId?.trim();
return id ? [{ id, name }] : [{ name }]; return id ? [{ id, name }] : [{ name }];
@@ -26,9 +28,11 @@ export function deriveAlbumHeaderArtistRefs(
album: SubsonicAlbum, album: SubsonicAlbum,
songs: SubsonicSong[], songs: SubsonicSong[],
): SubsonicOpenArtistRef[] { ): SubsonicOpenArtistRef[] {
if (nonEmpty(album.artists)) return album.artists; const albumArtists = coerceOpenArtistRefs(album.artists);
if (nonEmpty(albumArtists)) return albumArtists;
for (const s of songs) { for (const s of songs) {
if (nonEmpty(s.albumArtists)) return s.albumArtists; const songAlbumArtists = coerceOpenArtistRefs(s.albumArtists);
if (nonEmpty(songAlbumArtists)) return songAlbumArtists;
} }
return deriveAlbumArtistRefs(album); return deriveAlbumArtistRefs(album);
} }
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';
import { coerceOpenArtistRefs } from './openArtistRefs';
describe('coerceOpenArtistRefs', () => {
it('returns an empty array for nullish input', () => {
expect(coerceOpenArtistRefs(undefined)).toEqual([]);
expect(coerceOpenArtistRefs(null)).toEqual([]);
});
it('passes through arrays', () => {
const refs = [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }];
expect(coerceOpenArtistRefs(refs)).toBe(refs);
});
it('wraps a single ref object from Subsonic JSON', () => {
const ref = { id: 'a1', name: 'Solo' };
expect(coerceOpenArtistRefs(ref)).toEqual([ref]);
});
});
+11
View File
@@ -0,0 +1,11 @@
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
/** Subsonic JSON may return one ref object instead of a one-element array. */
export function coerceOpenArtistRefs(
refs: SubsonicOpenArtistRef[] | SubsonicOpenArtistRef | undefined | null,
): SubsonicOpenArtistRef[] {
if (refs == null) return [];
if (Array.isArray(refs)) return refs;
if (typeof refs === 'object') return [refs];
return [];
}
+5 -1
View File
@@ -1,5 +1,6 @@
import type { SubsonicSong } from '../../api/subsonicTypes'; import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { Track } from '../../store/playerStoreTypes';
import { coerceOpenArtistRefs } from '../openArtistRefs';
import { activeServerProfileId } from './trackServerScope'; import { activeServerProfileId } from './trackServerScope';
export function songToTrack(song: SubsonicSong): Track { export function songToTrack(song: SubsonicSong): Track {
@@ -10,7 +11,10 @@ export function songToTrack(song: SubsonicSong): Track {
album: song.album, album: song.album,
albumId: song.albumId, albumId: song.albumId,
artistId: song.artistId, artistId: song.artistId,
artists: song.artists && song.artists.length > 0 ? song.artists : undefined, artists: (() => {
const artists = coerceOpenArtistRefs(song.artists);
return artists.length > 0 ? artists : undefined;
})(),
duration: song.duration, duration: song.duration,
coverArt: song.coverArt, coverArt: song.coverArt,
discNumber: song.discNumber, discNumber: song.discNumber,
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { Track } from '../../store/playerStoreTypes';
import { primaryTrackArtistRef, resolveTrackArtistRefs } from './trackArtistRefs'; import { primaryTrackArtistRef, resolveTrackArtistRefs } from './trackArtistRefs';
describe('resolveTrackArtistRefs', () => { describe('resolveTrackArtistRefs', () => {
@@ -21,6 +22,14 @@ describe('resolveTrackArtistRefs', () => {
it('returns name-only ref when no id', () => { it('returns name-only ref when no id', () => {
expect(resolveTrackArtistRefs({ artist: 'Unknown' })).toEqual([{ name: 'Unknown' }]); expect(resolveTrackArtistRefs({ artist: 'Unknown' })).toEqual([{ name: 'Unknown' }]);
}); });
it('coerces a single-object OpenSubsonic artists payload', () => {
expect(resolveTrackArtistRefs({
artist: 'Joined',
artistId: 'legacy',
artists: { id: 'a1', name: 'Solo' } as unknown as Track['artists'],
})).toEqual([{ id: 'a1', name: 'Solo' }]);
});
}); });
describe('primaryTrackArtistRef', () => { describe('primaryTrackArtistRef', () => {
+4 -2
View File
@@ -1,12 +1,14 @@
import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes'; import type { SubsonicOpenArtistRef } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes'; import type { Track } from '../../store/playerStoreTypes';
import { coerceOpenArtistRefs } from '../openArtistRefs';
type TrackArtistFields = Pick<Track, 'artist' | 'artistId' | 'artists'>; type TrackArtistFields = Pick<Track, 'artist' | 'artistId' | 'artists'>;
/** OpenSubsonic `artists` when present; else legacy `artistId` + `artist` (album track rows). */ /** OpenSubsonic `artists` when present; else legacy `artistId` + `artist` (album track rows). */
export function resolveTrackArtistRefs(track: TrackArtistFields): SubsonicOpenArtistRef[] { export function resolveTrackArtistRefs(track: TrackArtistFields): SubsonicOpenArtistRef[] {
if (track.artists && track.artists.length > 0) { const structured = coerceOpenArtistRefs(track.artists);
return track.artists; if (structured.length > 0) {
return structured;
} }
const id = track.artistId?.trim(); const id = track.artistId?.trim();
if (id) { if (id) {