mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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 }];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user