mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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)**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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')]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user