fix(album-card): per-artist click on multi-artist albums (#767)

* refactor(album): rename SubsonicAlbum.albumArtists to artists per OpenSubsonic spec

The OpenSubsonic AlbumID3 object exposes structured album-artist credits as
`artists` (not `albumArtists`); psysonic's internal type used the wrong name,
so `album.albumArtists` was always undefined on the Subsonic responses
Navidrome returns. `deriveAlbumHeaderArtistRefs` therefore never hit its
album-level branch — only the song-level fallback was carrying the multi-
artist split on the album-detail header.

- Rename the field on `SubsonicAlbum` and add the spec-defined `displayArtist`
  string alongside it.
- Extract `deriveAlbumArtistRefs(album)` for the common case where only the
  album object is available (cards, rails); `deriveAlbumHeaderArtistRefs`
  now uses it after the song-level fallback. The song-level `albumArtists`
  field is unchanged (the spec does name it that way on child songs).
- Tests cover both helpers and the legacy `artist` + `artistId` fallback.

* fix(album-card): per-artist click on multi-artist albums

The artist subtitle under an album card rendered the full `album.artist`
string ("Melvins • Napalm Death") as a single link that always navigated to
`album.artistId`. On the artist-detail page that id is the page's own
artist — so the click resolved to the URL the user was already on and the
router silently no-op'd, with no visible effect.

Render through the existing `OpenArtistRefInline` component instead, fed
from the structured `artists` array via `deriveAlbumArtistRefs`. Each
artist becomes its own ·-separated link; the single-artist fallback is the
same legacy `artist` + `artistId` pair as before, so the behaviour on
servers that don't expose the structured field is unchanged.

* docs(changelog): note PR #767 under Fixed in v1.46.0
This commit is contained in:
Frank Stellmacher
2026-05-18 02:03:01 +02:00
committed by GitHub
parent 48a69754d6
commit 6e0f076f43
5 changed files with 67 additions and 20 deletions
+8
View File
@@ -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
+4 -2
View File
@@ -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[];
}
+13 -5
View File
@@ -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({
</div>
<div className="album-card-info">
<p className="album-card-title truncate">{album.name}</p>
<p
className={`album-card-artist truncate${album.artistId ? ' track-artist-link' : ''}`}
style={{ cursor: album.artistId ? 'pointer' : 'default' }}
onClick={e => { if (album.artistId) { e.stopPropagation(); navigate(`/artist/${album.artistId}`); } }}
>{album.artist}</p>
<p className="album-card-artist truncate">
<OpenArtistRefInline
refs={artistRefs}
fallbackName={album.artist}
onGoArtist={id => navigate(`/artist/${id}`)}
as="none"
linkTag="span"
linkClassName="track-artist-link"
/>
</p>
{album.year && <p className="album-card-year">{album.year}</p>}
{showRating && (album.userRating ?? 0) > 0 && (
<div className="album-card-rating-row">
@@ -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({
+17 -7
View File
@@ -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);
}