diff --git a/CHANGELOG.md b/CHANGELOG.md index c87353c9..76ded711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -695,6 +695,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * Same scroll-margin bug as the one fixed by [#764](https://github.com/Psychotoxical/psysonic/pull/764) for the Album Detail "More by …" rail, on four more virtual lists: **Artists grid**, **Artists list**, **Composers list** and the **Tracks** virtual song browser. The virtual wrapper sat below the sticky page header but TanStack measured row positions from the scroll-element top — rows still on screen could unmount, and at larger header offsets the list refused to render at all. * The measurement is now a shared `useVirtualizerScrollMargin` hook used by every virtual-list call-site (including the existing `VirtualCardGrid` fix from #764). +### Album cards — per-artist click on multi-artist albums + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#767](https://github.com/Psychotoxical/psysonic/pull/767)** + +* The artist subtitle under an album card rendered a multi-artist string as a single link to the album's primary `artistId`. On an artist-detail page that id is the page's own artist, so the click resolved to the current URL and the router silently no-op'd — the cursor said clickable, nothing happened. +* Album cards now use the same `OpenArtistRefInline` component the album-detail header uses: each artist becomes its own ·-separated link. Behaviour on servers that don't expose the structured list is unchanged. +* Root cause was a stale field name: psysonic's internal type called the OpenSubsonic album-artist array `albumArtists`, but the spec (and Navidrome) returns it as `artists`, so the structured branch never fired and the song-level fallback was carrying the album-detail header on its own. + ## [1.45.0] - 2026-05-04 ## Added diff --git a/src/api/subsonicTypes.ts b/src/api/subsonicTypes.ts index 1c6f5b82..15e2258a 100644 --- a/src/api/subsonicTypes.ts +++ b/src/api/subsonicTypes.ts @@ -20,8 +20,10 @@ export interface SubsonicAlbum { isCompilation?: boolean; /** OpenSubsonic: release types from MusicBrainz tags (e.g. "Album", "EP", "Single", "Compilation", "Live"). */ releaseTypes?: string[]; - /** OpenSubsonic: album-level credits (Navidrome may attach on album and/or child songs). */ - albumArtists?: SubsonicOpenArtistRef[]; + /** OpenSubsonic: structured album-artist credits (e.g. featured guests on the album). */ + artists?: SubsonicOpenArtistRef[]; + /** OpenSubsonic: single-string album-artist for display (mirrors `artists` joined). */ + displayArtist?: string; /** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */ discTitles?: SubsonicDiscTitle[]; } diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx index 6578d635..01ba050f 100644 --- a/src/components/AlbumCard.tsx +++ b/src/components/AlbumCard.tsx @@ -10,9 +10,11 @@ import { usePlayerStore } from '../store/playerStore'; import { useOfflineStore } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import CachedImage from './CachedImage'; +import { OpenArtistRefInline } from './OpenArtistRefInline'; import { playAlbum } from '../utils/playback/playAlbum'; import { useDragDrop } from '../contexts/DragDropContext'; import { isAlbumRecentlyAdded } from '../utils/albumRecency'; +import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs'; interface AlbumCardProps { album: SubsonicAlbum; @@ -56,6 +58,7 @@ function AlbumCard({ ); const psyDrag = useDragDrop(); const isNewAlbum = isAlbumRecentlyAdded(album.created); + const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]); const handleClick = (opts?: { shiftKey?: boolean }) => { if (selectionMode) { onToggleSelect?.(album.id, opts); return; } @@ -163,11 +166,16 @@ function AlbumCard({

{album.name}

-

{ if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }} - >{album.artist}

+

+ navigate(`/artist/${id}`)} + as="none" + linkTag="span" + linkClassName="track-artist-link" + /> +

{album.year &&

{album.year}

} {showRating && (album.userRating ?? 0) > 0 && (
diff --git a/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts b/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts index 0cd68636..8ab03ed7 100644 --- a/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts +++ b/src/utils/album/deriveAlbumHeaderArtistRefs.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs'; +import { deriveAlbumArtistRefs, deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; import { makeSubsonicSong } from '@/test/helpers/factories'; @@ -12,16 +12,35 @@ const baseAlbum = (): SubsonicAlbum => ({ duration: 100, }); -describe('deriveAlbumHeaderArtistRefs', () => { - it('prefers album-level albumArtists when present', () => { +describe('deriveAlbumArtistRefs', () => { + it('prefers the OpenSubsonic `artists` array when present', () => { const album: SubsonicAlbum = { ...baseAlbum(), - albumArtists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }], + artists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }], }; - expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual(album.albumArtists); + expect(deriveAlbumArtistRefs(album)).toEqual(album.artists); }); - it('falls back to the first song with albumArtists', () => { + it('uses legacy artist + artistId when no structured refs', () => { + expect(deriveAlbumArtistRefs(baseAlbum())).toEqual([{ id: 'ar-first', name: 'Joined A / B' }]); + }); + + it('omits id when artistId is blank', () => { + expect(deriveAlbumArtistRefs({ ...baseAlbum(), artistId: ' ', artist: 'Solo' })) + .toEqual([{ name: 'Solo' }]); + }); +}); + +describe('deriveAlbumHeaderArtistRefs', () => { + it('prefers the album-level `artists` array when present', () => { + const album: SubsonicAlbum = { + ...baseAlbum(), + artists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }], + }; + expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual(album.artists); + }); + + it('falls back to the first song with `albumArtists`', () => { const album = baseAlbum(); const songs = [ makeSubsonicSong({ diff --git a/src/utils/album/deriveAlbumHeaderArtistRefs.ts b/src/utils/album/deriveAlbumHeaderArtistRefs.ts index 9543a056..e2c88bab 100644 --- a/src/utils/album/deriveAlbumHeaderArtistRefs.ts +++ b/src/utils/album/deriveAlbumHeaderArtistRefs.ts @@ -4,21 +4,31 @@ function nonEmpty(refs: SubsonicOpenArtistRef[] | undefined): refs is SubsonicOp return !!refs && refs.length > 0; } +/** + * Structured album-artist credits without the album-detail Song fallback. + * Used wherever only the album object is available (cards, rails). Prefers the + * OpenSubsonic `artists` array; falls back to legacy `artist` + `artistId`. + */ +export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] { + if (nonEmpty(album.artists)) return album.artists; + const name = album.artist?.trim() || '—'; + const id = album.artistId?.trim(); + return id ? [{ id, name }] : [{ name }]; +} + /** * OpenSubsonic album credits for the album-detail header. - * Prefer `albumArtists` on the album payload, then on any child song (Navidrome - * often attaches the structured list only on songs); fall back to legacy - * `artist` + `artistId` strings. + * Prefer the album's `artists` array, then any child song's `albumArtists` + * (some servers only attach the structured list at song level); fall back to + * the legacy `artist` + `artistId` strings. */ export function deriveAlbumHeaderArtistRefs( album: SubsonicAlbum, songs: SubsonicSong[], ): SubsonicOpenArtistRef[] { - if (nonEmpty(album.albumArtists)) return album.albumArtists; + if (nonEmpty(album.artists)) return album.artists; for (const s of songs) { if (nonEmpty(s.albumArtists)) return s.albumArtists; } - const name = album.artist?.trim() || '—'; - const id = album.artistId?.trim(); - return id ? [{ id, name }] : [{ name }]; + return deriveAlbumArtistRefs(album); }