From fab6ff19bf2d453b4b33699b832b88d2dda11bf9 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 27 May 2026 08:49:06 +0300 Subject: [PATCH] fix(home): Discover Songs covers for local-index tracks (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(home): pre-warm and prefetch Discover Songs row covers The Discover Songs raблин. il came out of the cover pipeline merge with two gaps that left its cards stuck on the placeholder disc icon on cold caches. - `warmHomeMainstageCovers` walked `heroAlbums` / `recent` / `random` through `ensureAlbumCoverMisses` + `predecodeWarmAlbums` but skipped `discoverSongs`, so songs that were peeked but missed on disk had to wait for lazy per-card ensure - `Home.tsx`'s `coverPrefetchRegister` lumped `songRefs` into a `cappedRest` slice already saturated by 48 album refs + 16 artist refs at a 24-entry cap, so the song row's background prefetch was discarded entirely Fix: ensure + decode-warm the Discover Songs cells alongside the album rails, and register the song refs in their own bucket with a sane cap and `middle` priority. Both follow the same shape as the working album rails — no behavior change for surfaces that were already painting. * fix(library): resolve track cover art from albumId for local index songs Discover Songs uses runLocalRandomSongs; trackToSong only mapped coverArtId, so rows with empty cover_art_id but a valid album_id showed the disc placeholder. Mirror Rust COALESCE(cover_art_id, album_id) and Live Search's coverArt ?? albumId in trackToSong, SongCard, and Home prefetch. * docs(release): note Discover Songs cover fix in CHANGELOG and credits (PR #874) * docs(release): credit PR #874 to Psychotoxical and cucadmuh jointly * chore(credits): drop PR #874 from settingsCredits — minor fix --------- Co-authored-by: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> --- CHANGELOG.md | 9 ++++++ src/components/SongCard.tsx | 7 +++-- src/cover/ids.ts | 5 +-- src/cover/warmDiskPeek.ts | 7 +++++ src/pages/Home.tsx | 16 ++++++++-- src/utils/library/advancedSearchLocal.test.ts | 31 ++++++++++++++++++- src/utils/library/advancedSearchLocal.ts | 19 +++++++++++- 7 files changed, 85 insertions(+), 9 deletions(-) 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;