diff --git a/CHANGELOG.md b/CHANGELOG.md index ba580a13..65f77a96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -374,6 +374,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Home — Discover Songs cover art with local index + +**By [@Psychotoxical](https://github.com/Psychotoxical) + [@cucadmuh](https://github.com/cucadmuh), PR [#874](https://github.com/Psychotoxical/psysonic/pull/874)** + +* **Mainstage → Discover Songs** no longer shows disc placeholders when the local library index returns tracks without `coverArt` but with a valid `albumId` — cover resolution matches the Rust backfill rule (`COALESCE(cover_art_id, album_id)`). +* Discover Songs row gets dedicated mainstage cover prefetch and warmup so song cards are not crowded out by album rails on cold caches. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/components/SongCard.tsx b/src/components/SongCard.tsx index de52ba08..e5f0ca3c 100644 --- a/src/components/SongCard.tsx +++ b/src/components/SongCard.tsx @@ -32,7 +32,8 @@ function SongCard({ const navigate = useNavigate(); const openContextMenu = usePlayerStore(s => s.openContextMenu); const enqueue = usePlayerStore(s => s.enqueue); - const coverHandle = useCoverArt(song.coverArt, layoutPx, { + const coverArtId = song.coverArt ?? song.albumId; + const coverHandle = useCoverArt(coverArtId, layoutPx, { surface: 'dense', ensurePriority: 'middle', }); @@ -92,9 +93,9 @@ function SongCard({ }} >
- {!disableArtwork && song.coverArt ? ( + {!disableArtwork && coverArtId ? ( coverArtRef(coverArtIdFromArtist(a))); - const songRefs = discoverSongs.flatMap(s => (s.coverArt ? [coverArtRef(s.coverArt)] : [])); + const songRefs = discoverSongs.flatMap(s => { + const id = s.coverArt ?? s.albumId; + return id ? [coverArtRef(id)] : []; + }); const unregHero = coverPrefetchRegister(heroRefs, { surface: 'dense', priority: 'high' }); const unregRecent = coverPrefetchRegister(recentRefs, { surface: 'dense', priority: 'high' }); - const cappedRest = [...restAlbumRefs, ...artistRefs, ...songRefs].slice(0, 24); + // The album-and-artist `cappedRest` bucket is sized for the visible album + // rails (random + mostPlayed + recentlyPlayed + starred = 48 refs, plus + // 16 artist refs) and would otherwise crowd `songRefs` out entirely at + // the 24-entry slice. Register the Discover Songs rail on its own with + // its own modest cap so the song row gets a fair share of background + // bandwidth without inflating the 'low' bucket. + const cappedRest = [...restAlbumRefs, ...artistRefs].slice(0, 24); const unregRest = coverPrefetchRegister(cappedRest, { surface: 'dense', priority: 'low' }); + const cappedSongs = songRefs.slice(0, 16); + const unregSongs = coverPrefetchRegister(cappedSongs, { surface: 'dense', priority: 'middle' }); return () => { unregHero(); unregRecent(); unregRest(); + unregSongs(); }; }, [heroAlbums, recent, random, mostPlayed, recentlyPlayed, starred, randomArtists, discoverSongs]); diff --git a/src/utils/library/advancedSearchLocal.test.ts b/src/utils/library/advancedSearchLocal.test.ts index e98c1ec6..3f3b2a99 100644 --- a/src/utils/library/advancedSearchLocal.test.ts +++ b/src/utils/library/advancedSearchLocal.test.ts @@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { onInvoke } from '@/test/mocks/tauri'; import { useAuthStore } from '@/store/authStore'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; -import { runLocalAdvancedSearch, runLocalSongBrowse, trackToSong } from './advancedSearchLocal'; +import { + resolveTrackCoverArtId, + runLocalAdvancedSearch, + runLocalSongBrowse, + trackToSong, +} from './advancedSearchLocal'; const opts = (over: Partial[1]> = {}) => ({ query: '', @@ -100,6 +105,30 @@ describe('runLocalAdvancedSearch', () => { }); }); + it('resolveTrackCoverArtId falls back to albumId when coverArtId is empty', () => { + expect( + resolveTrackCoverArtId( + { coverArtId: undefined, albumId: 'al-42' }, + { coverArt: '', albumId: 'al-42' }, + ), + ).toBe('al-42'); + expect(resolveTrackCoverArtId({ coverArtId: 'cv1', albumId: 'al-42' })).toBe('cv1'); + }); + + it('trackToSong sets coverArt from albumId when the index row has no cover_art_id', () => { + const song = trackToSong({ + serverId: 's1', + id: 't1', + title: 'T', + album: 'Alb', + albumId: 'al-42', + durationSec: 100, + syncedAt: 0, + rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al-42', duration: 100 }, + }); + expect(song.coverArt).toBe('al-42'); + }); + it('trackToSong keeps resolved bpm and source over rawJson tag', () => { const song = trackToSong({ serverId: 's1', diff --git a/src/utils/library/advancedSearchLocal.ts b/src/utils/library/advancedSearchLocal.ts index ff492b84..de5986b9 100644 --- a/src/utils/library/advancedSearchLocal.ts +++ b/src/utils/library/advancedSearchLocal.ts @@ -138,6 +138,21 @@ function buildRequest( }; } +/** + * Cover art id for a library track — mirrors Rust cover backfill + * (`COALESCE(cover_art_id, album_id)`). Many servers only expose album art. + */ +export function resolveTrackCoverArtId( + hot: Pick, + song: Partial = {}, +): string | undefined { + for (const c of [hot.coverArtId, song.coverArt, hot.albumId, song.albumId]) { + const id = typeof c === 'string' ? c.trim() : ''; + if (id) return id; + } + return undefined; +} + export function trackToSong(t: LibraryTrackDto): SubsonicSong { const raw = isObject(t.rawJson) ? t.rawJson : {}; const resolvedBpm = t.bpm != null && t.bpm > 0 ? t.bpm : undefined; @@ -151,7 +166,7 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong { duration: t.durationSec, track: t.trackNumber ?? undefined, discNumber: t.discNumber ?? undefined, - coverArt: t.coverArtId ?? undefined, + coverArt: resolveTrackCoverArtId(t), year: t.year ?? undefined, genre: t.genre ?? undefined, suffix: t.suffix ?? undefined, @@ -167,6 +182,8 @@ export function trackToSong(t: LibraryTrackDto): SubsonicSong { // `rawJson` is the authoritative original song — let it override the // hot-column fallbacks (it carries OpenSubsonic extras too). const merged: SubsonicSong = { ...base, ...(raw as Partial) }; + const coverArt = resolveTrackCoverArtId(t, merged); + if (coverArt) merged.coverArt = coverArt; if (resolvedBpm != null) merged.bpm = resolvedBpm; if (t.bpmSource === 'analysis' || t.bpmSource === 'tag') { merged.localBpmSource = t.bpmSource;