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.
This commit is contained in:
cucadmuh
2026-06-03 22:57:35 +03:00
committed by GitHub
parent be21f7834f
commit e76dac87ae
7 changed files with 124 additions and 30 deletions
+23
View File
@@ -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<string> | 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<SubsonicAlbum[]> {
const allowed = await albumIdsInLibraryScope(serverId);
return filterAlbumsByScopedAlbumIds(albums, allowed);
}
export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Promise<SubsonicAlbum[]> {
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();
+24
View File
@@ -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')]);
});
});
+45 -27
View File
@@ -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<void> {
@@ -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<BecauseYouLikeAnchor | null>(() => {
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<SubsonicAlbum[]>(() => {
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<HTMLDivElement>(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' }],
+2 -2
View File
@@ -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,
});
+20
View File
@@ -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();
});
});
+3 -1
View File
@@ -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;
}