fix(cover): playlist and radio custom covers (fetch-only getCoverArt ids) (#1295)

This commit is contained in:
cucadmuh
2026-07-14 08:47:08 +03:00
committed by GitHub
parent fd19419ac2
commit 398adaf214
9 changed files with 98 additions and 2 deletions
+6
View File
@@ -157,6 +157,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Fixed
### Playlist and radio custom covers blank
**By [@cucadmuh](https://github.com/cucadmuh), reported by VirtualWolf, PR [#1295](https://github.com/Psychotoxical/psysonic/pull/1295)**
* Custom playlist and internet radio covers uploaded in Navidrome stayed blank in Psysonic (cards and detail headers) while album and track art worked. The cover resolver rewrote Navidrome's `pl-*` and `ra-*` getCoverArt ids into invalid `al-pl-*_0` / `al-ra-*_0` forms; fetch-only prefixes are now preserved in TS and Rust.
### Album detail — no duplicate cover thumbs in tracklist
**By [@cucadmuh](https://github.com/cucadmuh), reported by mikmik on the Psysonic Discord, PR [#1291](https://github.com/Psychotoxical/psysonic/pull/1291)**
@@ -20,6 +20,7 @@ use std::path::{Path, PathBuf};
pub const LAYOUT_STAMP: &str = "canonical-segment-v5";
/// True for ids that are only valid as `getCoverArt` targets, not library entity keys.
/// Prefixes mirror Navidrome `model.Kind` (`pl`, `ra`, `dc`, `mf`, …) — not bare album hashes.
pub fn is_fetch_only_cover_id(id: &str) -> bool {
let id = id.trim();
id.starts_with("mf-")
@@ -119,7 +120,7 @@ pub fn resolve_album_cover(
fetch_cover_art_id: fetch.to_string(),
});
}
let fetch_id = if !distinct_disc_covers && fetch == album {
let fetch_id = if !distinct_disc_covers && fetch == album && !is_fetch_only_cover_id(fetch) {
format!("al-{album}_0")
} else {
fetch.to_string()
@@ -298,6 +299,28 @@ mod tests {
assert_eq!(e.fetch_cover_art_id, "al-2lsdR1ogDKiFcAD6Pcvk4f_0");
}
#[test]
fn resolve_album_playlist_cover_keeps_pl_prefix() {
let e = resolve_album_cover("pl-abc123", Some("pl-abc123"), false).unwrap();
assert_eq!(e.cache_entity_id, "pl-abc123");
assert_eq!(e.fetch_cover_art_id, "pl-abc123");
}
#[test]
fn resolve_album_playlist_cover_keeps_navidrome_pl_suffix() {
let id = "pl-18690de0-151b-4d86-81cb-f418a907315a_0";
let e = resolve_album_cover(id, Some(id), false).unwrap();
assert_eq!(e.cache_entity_id, id);
assert_eq!(e.fetch_cover_art_id, id);
}
#[test]
fn resolve_album_radio_cover_keeps_ra_prefix() {
let e = resolve_album_cover("ra-rd-1_0", Some("ra-rd-1_0"), false).unwrap();
assert_eq!(e.cache_entity_id, "ra-rd-1_0");
assert_eq!(e.fetch_cover_art_id, "ra-rd-1_0");
}
fn test_server_dir(label: &str) -> std::path::PathBuf {
let base = std::env::temp_dir().join(format!("psysonic-cover-layout-{label}"));
let _ = std::fs::remove_dir_all(&base);
+1
View File
@@ -204,6 +204,7 @@ const CONTRIBUTOR_ENTRIES = [
'Internet Radio — Web Audio EQ on HTML5 streams; presets apply without reconnect (PR #1284)',
'Player bar — hideable stop button, optional album line, drag-reorderable action buttons (request: mikmik on Psysonic Discord, PR #1287)',
'Persistent shuffle mode — queue-reordering shuffle with restore, survives restart, in sync with other devices and Orbit (request: mikmik on Psysonic Discord, PR #1288)',
'Playlist and radio custom covers — preserve Navidrome pl-/ra-* getCoverArt ids (fixes blank uploaded covers; PR #1295)',
],
},
{
+41
View File
@@ -1,12 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
albumHasDistinctDiscCovers,
isFetchOnlyCoverId,
normalizeAlbumLibraryEntry,
resolveAlbumCoverEntry,
resolveArtistCoverEntry,
resolveSongFetchCoverArtId,
resolveTrackCoverEntry,
} from './resolveEntry';
import { albumCoverRef } from './ref';
describe('resolveAlbumCoverEntry', () => {
it('uses bare Navidrome album id on disk', () => {
@@ -27,6 +29,45 @@ describe('resolveAlbumCoverEntry', () => {
'al-2lsdR1ogDKiFcAD6Pcvk4f_0',
);
});
it('keeps pl-* playlist cover ids for getCoverArt (no al- prefix)', () => {
const e = resolveAlbumCoverEntry('pl-abc123', 'pl-abc123');
expect(e?.cacheEntityId).toBe('pl-abc123');
expect(e?.fetchCoverArtId).toBe('pl-abc123');
});
it('keeps Navidrome pl-{uuid}_0 playlist coverArt from Subsonic API', () => {
const id = 'pl-18690de0-151b-4d86-81cb-f418a907315a_0';
const e = resolveAlbumCoverEntry(id, id);
expect(e?.fetchCoverArtId).toBe(id);
});
it('keeps ra-* internet radio cover ids (no al- prefix)', () => {
const e = resolveAlbumCoverEntry('ra-rd-1_0', 'ra-rd-1_0');
expect(e?.fetchCoverArtId).toBe('ra-rd-1_0');
});
});
describe('isFetchOnlyCoverId', () => {
it('matches Navidrome getCoverArt-only prefixes', () => {
expect(isFetchOnlyCoverId('pl-abc')).toBe(true);
expect(isFetchOnlyCoverId('ra-rd-1_0')).toBe(true);
expect(isFetchOnlyCoverId('mf-track')).toBe(true);
expect(isFetchOnlyCoverId('dc-album:2')).toBe(true);
});
it('does not match bare album hashes', () => {
expect(isFetchOnlyCoverId('2lsdR1ogDKiFcAD6Pcvk4f')).toBe(false);
expect(isFetchOnlyCoverId('al-2lsd_0')).toBe(false);
});
});
describe('albumCoverRef fetch-only ids', () => {
it('preserves pl-* for playlist hero/card covers', () => {
const id = 'pl-18690de0-151b-4d86-81cb-f418a907315a_0';
const ref = albumCoverRef(id, id);
expect(ref.fetchCoverArtId).toBe(id);
});
});
describe('resolveArtistCoverEntry', () => {
+17 -1
View File
@@ -22,6 +22,21 @@ export type CoverArtResolvableSong = Pick<SubsonicSong, 'id' | 'coverArt'> & {
albumId?: string | null;
};
/**
* True for ids that are only valid as `getCoverArt` targets, not library entity keys.
* Keep in sync with Rust `psysonic_core::cover_cache_layout::is_fetch_only_cover_id`.
*/
export function isFetchOnlyCoverId(id: string): boolean {
const trimmed = id.trim();
return (
trimmed.startsWith('mf-')
|| trimmed.startsWith('tr-')
|| trimmed.startsWith('pl-')
|| trimmed.startsWith('dc-')
|| trimmed.startsWith('ra-')
);
}
/** Navidrome `getCoverArt` id for a song row (ignores echo of track id with no art). */
export function resolveSongFetchCoverArtId(song: CoverArtResolvableSong): string | undefined {
const albumId = song.albumId?.trim();
@@ -92,7 +107,8 @@ export function resolveAlbumCoverEntry(
return { cacheKind: 'album', cacheEntityId: album, fetchCoverArtId: fetch };
}
// Bare album ids need `al-<albumId>_0` on Navidrome when no mf id is available.
if (!distinctDiscCovers && fetch === album) {
// Playlist / radio / other fetch-only ids must keep their native prefix (e.g. `pl-*`).
if (!distinctDiscCovers && fetch === album && !isFetchOnlyCoverId(fetch)) {
fetch = `al-${album}_0`;
}
const cacheEntityId =
@@ -26,6 +26,7 @@ export function PlaylistCardMainCover({ coverArt, alt }: { coverArt: string; alt
coverArt={coverArt}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt={alt}
className="album-card-cover-img"
/>
@@ -84,6 +84,7 @@ export default function PlaylistEditModal({
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt=""
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
@@ -95,6 +95,7 @@ export default function PlaylistHero({
coverArt={customCoverId}
displayCssPx={PLAYLIST_MAIN_COVER_CSS_PX}
surface="dense"
libraryResolve={false}
alt=""
className="playlist-cover-grid"
style={{ objectFit: 'cover', display: 'block' }}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { CoverArtId, CoverArtRef } from '@/cover/types';
import { albumCoverRef } from '@/cover/ref';
import { coverPrefetchRegister } from '@/cover/prefetchRegistry';
import { resolveAlbumCoverRefFromLibrary } from '@/cover/resolveEntryLibrary';
import { useCoverArt } from '@/cover/useCoverArt';
@@ -18,6 +19,11 @@ async function playlistCoverRefFromLibrary(
coverId: string,
songs: SubsonicSong[],
): Promise<CoverArtRef> {
const trimmed = coverId.trim();
// Custom playlist / radio covers only — not track mf-* ids used in quad collages.
if (trimmed.startsWith('pl-') || trimmed.startsWith('ra-')) {
return albumCoverRef(trimmed, trimmed);
}
const song = songs.find(s => s.coverArt === coverId || s.albumId === coverId);
if (song?.albumId) {
return resolveAlbumCoverRefFromLibrary(song.albumId, coverId);