mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(mix): rating filter across mixes and Lucky Mix queue fill (#714)
* fix(mix): apply rating filter across mixes and fix Lucky Mix queue fill Invalidate entity rating cache on setRating and stop negative-caching unrated artists/albums so filters see fresh stars. Honor UI rating overrides, wire Instant Mix and CLI paths, fix Random Mix filter order, and align Lucky Mix progress with the real player queue length. * docs(changelog): document mix rating filter and Lucky Mix queue fix
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('./subsonicArtists', () => ({ getArtist: vi.fn() }));
|
||||
vi.mock('./subsonicLibrary', () => ({ getAlbum: vi.fn() }));
|
||||
|
||||
import { getArtist } from './subsonicArtists';
|
||||
import { invalidateEntityUserRatingCaches, prefetchArtistUserRatings } from './subsonicRatings';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getArtist).mockReset();
|
||||
invalidateEntityUserRatingCaches('art-1');
|
||||
});
|
||||
|
||||
describe('prefetchArtistUserRatings cache', () => {
|
||||
it('does not negative-cache unrated artists', async () => {
|
||||
vi.mocked(getArtist).mockResolvedValue({ artist: { id: 'art-1', name: 'Artist' }, albums: [] });
|
||||
|
||||
const first = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(first.size).toBe(0);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 1 },
|
||||
albums: [],
|
||||
});
|
||||
const second = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(second.get('art-1')).toBe(1);
|
||||
expect(getArtist).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-fetches after invalidateEntityUserRatingCaches', async () => {
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 2 },
|
||||
albums: [],
|
||||
});
|
||||
|
||||
const first = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(first.get('art-1')).toBe(2);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.mocked(getArtist).mockResolvedValue({
|
||||
artist: { id: 'art-1', name: 'Artist', userRating: 4 },
|
||||
albums: [],
|
||||
});
|
||||
const cached = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(cached.get('art-1')).toBe(2);
|
||||
expect(getArtist).toHaveBeenCalledTimes(1);
|
||||
|
||||
invalidateEntityUserRatingCaches('art-1');
|
||||
const fresh = await prefetchArtistUserRatings(['art-1']);
|
||||
expect(fresh.get('art-1')).toBe(4);
|
||||
expect(getArtist).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -3,19 +3,25 @@ import { getAlbum } from './subsonicLibrary';
|
||||
|
||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
||||
const ratingCache = new Map<string, { value: number | undefined; expiresAt: number }>();
|
||||
const ratingCache = new Map<string, { value: number; expiresAt: number }>();
|
||||
|
||||
function getCachedRating(key: string): number | undefined | null {
|
||||
function getCachedRating(key: string): number | null {
|
||||
const entry = ratingCache.get(key);
|
||||
if (!entry) return null; // cache miss
|
||||
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function setCachedRating(key: string, value: number | undefined): void {
|
||||
function setCachedRating(key: string, value: number): void {
|
||||
ratingCache.set(key, { value, expiresAt: Date.now() + RATING_CACHE_TTL });
|
||||
}
|
||||
|
||||
/** Drop cached entity ratings after `setRating` so mixes see fresh stars. */
|
||||
export function invalidateEntityUserRatingCaches(id: string): void {
|
||||
ratingCache.delete(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
ratingCache.delete(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
}
|
||||
|
||||
function parseEntityUserRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
@@ -45,7 +51,7 @@ export async function prefetchArtistUserRatings(
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
if (cached !== null) out.set(id, cached);
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
@@ -58,8 +64,10 @@ export async function prefetchArtistUserRatings(
|
||||
try {
|
||||
const { artist } = await getArtist(id);
|
||||
const r = parseSubsonicEntityStarRating(artist);
|
||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
if (r !== undefined && r > 0) {
|
||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
out.set(id, r);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -81,7 +89,7 @@ export async function prefetchAlbumUserRatings(
|
||||
const uncached: string[] = [];
|
||||
for (const id of unique) {
|
||||
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
||||
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
|
||||
if (cached !== null) out.set(id, cached);
|
||||
else uncached.push(id);
|
||||
}
|
||||
if (!uncached.length) return out;
|
||||
@@ -94,8 +102,10 @@ export async function prefetchAlbumUserRatings(
|
||||
try {
|
||||
const { album } = await getAlbum(id);
|
||||
const r = parseSubsonicEntityStarRating(album);
|
||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
if (r !== undefined) out.set(id, r);
|
||||
if (r !== undefined && r > 0) {
|
||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||
out.set(id, r);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function setRating(id: string, rating: number): Promise<void> {
|
||||
// subsonic ← navidromeBrowse and avoid pulling Tauri internals into shared
|
||||
// type-only consumers.
|
||||
void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
|
||||
void import('./subsonicRatings').then(m => m.invalidateEntityUserRatingCaches(id)).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user