fix(ui): split album and track artists (OpenSubsonic) (#696)

* fix(ui): split OpenSubsonic album and track artists in header and player

Album detail header uses albumArtists from album or child songs; player bar,
mobile player, and mini player use structured track artists with per-id links.
Adds deriveAlbumHeaderArtistRefs helper and OpenArtistRefInline.

Fixes #552

* docs: changelog and credits for OpenSubsonic artist links (PR #696)
This commit is contained in:
cucadmuh
2026-05-14 21:56:39 +03:00
committed by GitHub
parent ecdbe0cf2a
commit 3cc172723d
20 changed files with 315 additions and 29 deletions
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs';
import type { SubsonicAlbum } from '../../api/subsonicTypes';
import { makeSubsonicSong } from '@/test/helpers/factories';
const baseAlbum = (): SubsonicAlbum => ({
id: 'al-1',
name: 'Test Album',
artist: 'Joined A / B',
artistId: 'ar-first',
songCount: 2,
duration: 100,
});
describe('deriveAlbumHeaderArtistRefs', () => {
it('prefers album-level albumArtists when present', () => {
const album: SubsonicAlbum = {
...baseAlbum(),
albumArtists: [{ id: 'a1', name: 'One' }, { id: 'a2', name: 'Two' }],
};
expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual(album.albumArtists);
});
it('falls back to the first song with albumArtists', () => {
const album = baseAlbum();
const songs = [
makeSubsonicSong({
albumId: album.id,
album: album.name,
albumArtists: [{ id: 'b1', name: 'Beta' }, { name: 'Gamma' }],
}),
];
expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual(songs[0].albumArtists);
});
it('uses legacy artist + artistId when no structured refs', () => {
const album = baseAlbum();
const songs = [makeSubsonicSong({ albumId: album.id, album: album.name })];
expect(deriveAlbumHeaderArtistRefs(album, songs)).toEqual([{ id: 'ar-first', name: 'Joined A / B' }]);
});
it('omits id when artistId is blank', () => {
const album: SubsonicAlbum = { ...baseAlbum(), artistId: ' ', artist: 'Solo' };
expect(deriveAlbumHeaderArtistRefs(album, [])).toEqual([{ name: 'Solo' }]);
});
});
@@ -0,0 +1,24 @@
import type { SubsonicAlbum, SubsonicOpenArtistRef, SubsonicSong } from '../../api/subsonicTypes';
function nonEmpty(refs: SubsonicOpenArtistRef[] | undefined): refs is SubsonicOpenArtistRef[] {
return !!refs && refs.length > 0;
}
/**
* 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.
*/
export function deriveAlbumHeaderArtistRefs(
album: SubsonicAlbum,
songs: SubsonicSong[],
): SubsonicOpenArtistRef[] {
if (nonEmpty(album.albumArtists)) return album.albumArtists;
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 }];
}
+5
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
export const MINI_WINDOW_LABEL = 'mini';
@@ -9,6 +10,8 @@ export interface MiniTrackInfo {
id: string;
title: string;
artist: string;
/** OpenSubsonic performer refs when the main queue carried them. */
artists?: SubsonicOpenArtistRef[];
album: string;
albumId?: string;
artistId?: string;
@@ -41,6 +44,7 @@ function toMini(t: any): MiniTrackInfo {
id: t.id,
title: t.title,
artist: t.artist,
artists: Array.isArray(t.artists) && t.artists.length > 0 ? t.artists : undefined,
album: t.album,
albumId: t.albumId,
artistId: t.artistId,
@@ -87,6 +91,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
payload.track?.id ?? '',
payload.isPlaying,
payload.track?.starred ?? '',
(payload.track?.artists ?? []).map((a: SubsonicOpenArtistRef) => a.id ?? a.name).join('|'),
payload.queueIndex,
payload.volume,
payload.gaplessEnabled,
+9
View File
@@ -85,6 +85,14 @@ describe('songToTrack', () => {
expect(t.replayGainPeak).toBeUndefined();
});
it('copies OpenSubsonic artists when present', () => {
const song = makeSubsonicSong({
artists: [{ id: 'a1', name: 'Feat' }, { id: 'a2', name: 'Main' }],
});
const t = songToTrack(song);
expect(t.artists).toEqual(song.artists);
});
it('does not invent fields that the Subsonic song lacks', () => {
const song = makeSubsonicSong({});
const t = songToTrack(song);
@@ -92,5 +100,6 @@ describe('songToTrack', () => {
expect(t.autoAdded).toBeUndefined();
expect(t.radioAdded).toBeUndefined();
expect(t.playNextAdded).toBeUndefined();
expect(t.artists).toBeUndefined();
});
});
+1
View File
@@ -8,6 +8,7 @@ export function songToTrack(song: SubsonicSong): Track {
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
artists: song.artists && song.artists.length > 0 ? song.artists : undefined,
duration: song.duration,
coverArt: song.coverArt,
track: song.track,