From e76dac87ae8ac3f11b33239910f128a227354d9b Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Wed, 3 Jun 2026 22:57:35 +0300 Subject: [PATCH] fix(home): scope Because you listened rail to sidebar library (#964) * fix(home): scope Because you listened rail to sidebar library Similar-artist album picks used getArtist without library filtering; session cache also ignored musicLibraryFilterVersion. Filter picks to the scoped album set and key cache/reserve by filter version. * docs(changelog): credit zunoz on Psysonic Discord for PR #964 * test: satisfy SubsonicAlbum required fields in new unit tests Fix tsc --noEmit CI: songCount and duration are required on SubsonicAlbum. --- CHANGELOG.md | 7 +++ src/api/subsonicLibrary.ts | 23 ++++++++ src/api/subsonicLibraryAlbumScope.test.ts | 24 ++++++++ src/components/BecauseYouLikeRail.tsx | 72 ++++++++++++++--------- src/pages/Home.tsx | 4 +- src/store/becauseYouLikeCache.test.ts | 20 +++++++ src/store/becauseYouLikeCache.ts | 4 +- 7 files changed, 124 insertions(+), 30 deletions(-) create mode 100644 src/api/subsonicLibraryAlbumScope.test.ts create mode 100644 src/store/becauseYouLikeCache.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e5246cc8..7042c6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -450,6 +450,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Navidrome's composer role list can include artists with zero composer album credits (e.g. Apollo 440 with performer albums only). Composers browse/search now drops rows where `stats.composer.albumCount` is zero so ghost composer cards no longer appear. +### Mainstage — Because you listened respects sidebar library + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#964](https://github.com/Psychotoxical/psysonic/pull/964)** + +* The recommendation rail picks albums from Last.fm similar artists via `getArtist`, which can ignore `musicFolderId` — picks are now filtered to the scoped library album set, and the rail cache invalidates when the sidebar library filter changes. + + ### In-page browse — virtual scroll and cover-art priority **By [@cucadmuh](https://github.com/cucadmuh), PR [#783](https://github.com/Psychotoxical/psysonic/pull/783)** diff --git a/src/api/subsonicLibrary.ts b/src/api/subsonicLibrary.ts index 6f50b8e5..dcb4445a 100644 --- a/src/api/subsonicLibrary.ts +++ b/src/api/subsonicLibrary.ts @@ -150,6 +150,29 @@ export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise return filterSongsToServerLibrary(songs, activeServerId); } +/** Client-side album scope filter — same album-id set as {@link filterSongsToServerLibrary}. */ +export function filterAlbumsByScopedAlbumIds( + albums: SubsonicAlbum[], + allowed: Set | null, +): SubsonicAlbum[] { + if (!allowed || allowed.size === 0) return albums; + return albums.filter(a => allowed.has(a.id)); +} + +export async function filterAlbumsToServerLibrary( + albums: SubsonicAlbum[], + serverId: string, +): Promise { + const allowed = await albumIdsInLibraryScope(serverId); + return filterAlbumsByScopedAlbumIds(albums, allowed); +} + +export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Promise { + const { activeServerId } = useAuthStore.getState(); + if (!activeServerId) return albums; + return filterAlbumsToServerLibrary(albums, activeServerId); +} + /** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */ export function similarSongsRequestCount(desired: number): number { const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); diff --git a/src/api/subsonicLibraryAlbumScope.test.ts b/src/api/subsonicLibraryAlbumScope.test.ts new file mode 100644 index 00000000..01cd9511 --- /dev/null +++ b/src/api/subsonicLibraryAlbumScope.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import type { SubsonicAlbum } from './subsonicTypes'; +import { filterAlbumsByScopedAlbumIds } from './subsonicLibrary'; + +const album = (id: string): SubsonicAlbum => ({ + id, + name: id, + artist: 'a', + artistId: '1', + songCount: 1, + duration: 1, +}); + +describe('filterAlbumsByScopedAlbumIds', () => { + it('returns all albums when scope is unset', () => { + const albums = [album('a'), album('b')]; + expect(filterAlbumsByScopedAlbumIds(albums, null)).toEqual(albums); + }); + + it('keeps only albums in the scoped id set', () => { + const albums = [album('a'), album('b'), album('c')]; + expect(filterAlbumsByScopedAlbumIds(albums, new Set(['a', 'c']))).toEqual([album('a'), album('c')]); + }); +}); diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index f05df470..6dd34c8d 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -1,5 +1,5 @@ import { getArtist, getArtistInfo } from '../api/subsonicArtists'; -import { getAlbum } from '../api/subsonicLibrary'; +import { filterAlbumsToActiveLibrary, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { shuffleArray } from '../utils/playback/shuffleArray'; @@ -56,6 +56,7 @@ const ROW_STAGGER_MS = 150; // ── Module-level reserve: next batch pre-fetched in background after each display ── type BecauseReserve = { serverId: string; + filterVersion: number; // poolKey intentionally omitted — reserve is valid for any pool state on the // same server. Pool (top-played artists) changes slowly; showing a slightly-off // anchor once before the next fill corrects it is far better than showing a @@ -96,9 +97,11 @@ async function resolvePicks( const picks: SubsonicAlbum[] = []; for (const r of results) { - if (!r || r.albums.length === 0) continue; - const fresh = r.albums.filter(a => !recentPicks.has(a.id)); - const choice = fresh.length > 0 ? fresh : r.albums; + if (!r) continue; + const albums = await filterAlbumsToActiveLibrary(r.albums); + if (albums.length === 0) continue; + const fresh = albums.filter(a => !recentPicks.has(a.id)); + const choice = fresh.length > 0 ? fresh : albums; const album = choice[Math.floor(Math.random() * choice.length)]; picks.push(album); if (picks.length >= SHOW_COUNT) break; @@ -182,6 +185,7 @@ async function fetchBecauseYouLike( async function fillBecauseReserve( pool: BecauseYouLikeAnchor[], serverId: string, + filterVersion: number, anchorHistKey: string | null, picksHistKey: string | null, ): Promise { @@ -190,10 +194,10 @@ async function fillBecauseReserve( try { const result = await fetchBecauseYouLike(pool, anchorHistKey, picksHistKey); if (result) { - _becauseReserve = { serverId, ...result }; + _becauseReserve = { serverId, filterVersion, ...result }; // Also refresh the session snapshot so a quick leave→return can pick up // newer cards even before the reserve is explicitly consumed. - writeBecauseYouLikeCache({ serverId, anchor: result.anchor, recs: result.recs }); + writeBecauseYouLikeCache({ serverId, filterVersion, anchor: result.anchor, recs: result.recs }); } } catch { /* Network failure — next visit falls back to a fresh fetch. */ @@ -312,8 +316,12 @@ function picksHistoryKey(serverId: string | null): string | null { return serverId ? `${PICKS_HISTORY_KEY_PREFIX}${serverId}` : null; } -function hasValidReserve(serverId: string | null): boolean { - return _becauseReserve != null && _becauseReserve.serverId === (serverId ?? ''); +function hasValidReserve(serverId: string | null, filterVersion: number): boolean { + return ( + _becauseReserve != null && + _becauseReserve.serverId === (serverId ?? '') && + _becauseReserve.filterVersion === filterVersion + ); } export default function BecauseYouLikeRail({ @@ -324,6 +332,7 @@ export default function BecauseYouLikeRail({ }: Props) { const { t } = useTranslation(); const activeServerId = useAuthStore(s => s.activeServerId); + const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const pool = useMemo( () => buildAnchorPool([mostPlayed, recentlyPlayed ?? [], starred ?? []], TOP_ARTIST_POOL), [mostPlayed, recentlyPlayed, starred], @@ -336,18 +345,18 @@ export default function BecauseYouLikeRail({ // revalidate) > skeleton. Both checks work without poolKey so they fire correctly on the // first render when pool is still [] (Home.tsx loads mostPlayed asynchronously). const [anchor, setAnchor] = useState(() => { - if (hasValidReserve(activeServerId)) return _becauseReserve!.anchor; - return readBecauseYouLikeCache(activeServerId)?.anchor ?? null; + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.anchor; + return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.anchor ?? null; }); const [recs, setRecs] = useState(() => { - if (hasValidReserve(activeServerId)) return _becauseReserve!.recs; - return readBecauseYouLikeCache(activeServerId)?.recs ?? []; + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return _becauseReserve!.recs; + return readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion)?.recs ?? []; }); const containerRef = useRef(null); const [narrow, setNarrow] = useState(false); const [refreshing, setRefreshing] = useState(() => { - if (hasValidReserve(activeServerId)) return false; - const snap = readBecauseYouLikeCache(activeServerId); + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) return false; + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); return !snap || snap.recs.length === 0; }); const skeletonSlots = useBecauseRowSlotCount(refreshing, SHOW_COUNT); @@ -358,12 +367,12 @@ export default function BecauseYouLikeRail({ * (synchronous, before browser paint) or fall back to session cache (stale- * while-revalidate), only clearing to skeleton when nothing is available. */ useLayoutEffect(() => { - if (hasValidReserve(activeServerId)) { + if (hasValidReserve(activeServerId, musicLibraryFilterVersion)) { setAnchor(_becauseReserve!.anchor); setRecs(_becauseReserve!.recs); setRefreshing(false); } else { - const snap = readBecauseYouLikeCache(activeServerId); + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); if (snap && snap.recs.length > 0) { setAnchor(snap.anchor); setRecs(snap.recs); @@ -374,7 +383,7 @@ export default function BecauseYouLikeRail({ setRecs([]); } } - }, [activeServerId, poolKey]); + }, [activeServerId, musicLibraryFilterVersion, poolKey]); // 696px ≙ exactly 2 BecauseCards side-by-side (2*340 + 16 gap). Below that // the hero-style cards stretch full-width and dwarf the rest of the page, @@ -410,11 +419,10 @@ export default function BecauseYouLikeRail({ const anchorHistKey = anchorHistoryKey(activeServerId); const picksHistKey = picksHistoryKey(activeServerId); - const snap = readBecauseYouLikeCache(activeServerId); + const snap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); - // Consume module-level reserve (keyed by serverId only — poolKey omitted so - // the reserve is usable even before pool has loaded on first render). - const reserved = hasValidReserve(activeServerId) ? _becauseReserve : null; + // Consume module-level reserve (keyed by server + library scope). + const reserved = hasValidReserve(activeServerId, musicLibraryFilterVersion) ? _becauseReserve : null; _becauseReserve = null; (async () => { @@ -433,11 +441,16 @@ export default function BecauseYouLikeRail({ setAnchor(reserved.anchor); setRecs(reserved.recs); if (activeServerId) { - writeBecauseYouLikeCache({ serverId: activeServerId, anchor: reserved.anchor, recs: reserved.recs }); + writeBecauseYouLikeCache({ + serverId: activeServerId, + filterVersion: musicLibraryFilterVersion, + anchor: reserved.anchor, + recs: reserved.recs, + }); } setRefreshing(false); // Pre-fetch the next batch so the next visit is also instant. - void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); return; } @@ -446,7 +459,7 @@ export default function BecauseYouLikeRail({ // for the next mount instead of swapping cards mid-visit. if (snap && snap.recs.length > 0) { setRefreshing(false); - void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); return; } @@ -476,11 +489,16 @@ export default function BecauseYouLikeRail({ setAnchor(result.anchor); setRecs(result.recs); if (activeServerId) { - writeBecauseYouLikeCache({ serverId: activeServerId, anchor: result.anchor, recs: result.recs }); + writeBecauseYouLikeCache({ + serverId: activeServerId, + filterVersion: musicLibraryFilterVersion, + anchor: result.anchor, + recs: result.recs, + }); } setRefreshing(false); // Pre-fetch next batch so the next visit is instant. - void fillBecauseReserve(pool, activeServerId, anchorHistKey, picksHistKey); + void fillBecauseReserve(pool, activeServerId, musicLibraryFilterVersion, anchorHistKey, picksHistKey); } else { // Network failed — restore session cache if available. if (snap) { @@ -500,7 +518,7 @@ export default function BecauseYouLikeRail({ })(); return () => { cancelled = true; }; - }, [pool, activeServerId, disableArtwork, poolKey]); + }, [pool, activeServerId, musicLibraryFilterVersion, disableArtwork, poolKey]); useLibraryCoverPrefetch( disableArtwork || recs.length === 0 ? [] : [{ albums: recs, priority: 'high' }], diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 1b28d21c..a2f9d7ce 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -162,7 +162,7 @@ export default function Home() { if (!wasPrePopulated) applyFeedSnapshot(cached); setLoading(false); void warmHomeMainstageCovers(cached); - const becauseSnap = readBecauseYouLikeCache(activeServerId); + const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, { limit: 6, }); @@ -193,7 +193,7 @@ export default function Home() { applyFeedSnapshot(snap); if (!cancelled) setLoading(false); void warmHomeMainstageCovers(snap); - const becauseSnap = readBecauseYouLikeCache(activeServerId); + const becauseSnap = readBecauseYouLikeCache(activeServerId, musicLibraryFilterVersion); void primeAlbumCoversForDisplay(becauseSnap?.recs ?? [], HOME_BECAUSE_CARD_COVER_CSS_PX, { limit: 6, }); diff --git a/src/store/becauseYouLikeCache.test.ts b/src/store/becauseYouLikeCache.test.ts new file mode 100644 index 00000000..7121f40f --- /dev/null +++ b/src/store/becauseYouLikeCache.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { + clearBecauseYouLikeCache, + readBecauseYouLikeCache, + writeBecauseYouLikeCache, +} from './becauseYouLikeCache'; + +describe('becauseYouLikeCache', () => { + it('invalidates when music library filter version changes', () => { + clearBecauseYouLikeCache(); + writeBecauseYouLikeCache({ + serverId: 'srv-1', + filterVersion: 1, + anchor: { id: 'a1', name: 'Artist' }, + recs: [{ id: 'alb-1', name: 'Album', artist: 'Artist', artistId: 'a1', songCount: 1, duration: 1 }], + }); + expect(readBecauseYouLikeCache('srv-1', 1)?.recs).toHaveLength(1); + expect(readBecauseYouLikeCache('srv-1', 2)).toBeNull(); + }); +}); diff --git a/src/store/becauseYouLikeCache.ts b/src/store/becauseYouLikeCache.ts index 21ac69cb..543a8176 100644 --- a/src/store/becauseYouLikeCache.ts +++ b/src/store/becauseYouLikeCache.ts @@ -4,6 +4,7 @@ export type BecauseYouLikeAnchor = { id: string; name: string }; export type BecauseYouLikeSnapshot = { serverId: string; + filterVersion: number; savedAt: number; anchor: BecauseYouLikeAnchor; recs: SubsonicAlbum[]; @@ -14,9 +15,10 @@ let snapshot: BecauseYouLikeSnapshot | null = null; export function readBecauseYouLikeCache( serverId: string | null | undefined, + filterVersion: number, ): BecauseYouLikeSnapshot | null { if (!serverId || !snapshot) return null; - if (snapshot.serverId !== serverId) return null; + if (snapshot.serverId !== serverId || snapshot.filterVersion !== filterVersion) return null; if (Date.now() - snapshot.savedAt > TTL_MS) return null; return snapshot; }