mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(cover): playlist and radio custom covers (fetch-only getCoverArt ids) (#1295)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user