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;