fix(player): Lucky Mix + mix rating filter (Navidrome / OpenSubsonic) (#332)

* fix(player): Lucky Mix respects mix rating filter and harden rating reads

Wire Lucky Mix through the same Settings → Ratings filter as Random Mix
(enrich + passesMixMinRatings). Prefetch artist/album ratings via
getArtist/getAlbum using both userRating and Navidrome-style rating,
and bump the in-memory prefetch cache key so stale misses are not reused.
Resolve artist entity id from OpenSubsonic artists[] or contributors[]
when top-level artistId is missing; read rating on child song and artist
refs so thresholds apply consistently.

* chore: clarify JSDoc for mix rating enrich vs Lucky Mix filter
This commit is contained in:
cucadmuh
2026-04-27 01:30:41 +03:00
committed by GitHub
parent 8a97a35ce6
commit cf09fd4bd3
3 changed files with 173 additions and 70 deletions
+19 -6
View File
@@ -82,6 +82,8 @@ export interface SubsonicOpenArtistRef {
id?: string;
name?: string;
userRating?: number;
/** Navidrome / alternate OpenSubsonic payloads (same meaning as `userRating`). */
rating?: number;
}
export interface SubsonicSong {
@@ -561,6 +563,17 @@ function parseEntityUserRating(v: unknown): number | undefined {
return n;
}
/** Navidrome and some JSON shapes use `rating` where Subsonic docs say `userRating`. */
export function parseSubsonicEntityStarRating(entity: {
userRating?: unknown;
rating?: unknown;
}): number | undefined {
return parseEntityUserRating(entity.userRating ?? entity.rating);
}
/** Bump when rating parse keys change so stale cache entries are not reused. */
const ENTITY_RATING_CACHE_KEY_VER = 'v2';
/** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings(
ids: string[],
@@ -571,7 +584,7 @@ export async function prefetchArtistUserRatings(
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`artist:${id}`);
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
@@ -584,8 +597,8 @@ export async function prefetchArtistUserRatings(
const id = uncached[i];
try {
const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating);
setCachedRating(`artist:${id}`, r);
const r = parseSubsonicEntityStarRating(artist);
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
@@ -607,7 +620,7 @@ export async function prefetchAlbumUserRatings(
if (!unique.length) return out;
const uncached: string[] = [];
for (const id of unique) {
const cached = getCachedRating(`album:${id}`);
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id);
}
@@ -620,8 +633,8 @@ export async function prefetchAlbumUserRatings(
const id = uncached[i];
try {
const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating);
setCachedRating(`album:${id}`, r);
const r = parseSubsonicEntityStarRating(album);
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r);
} catch {
/* ignore */
+70 -44
View File
@@ -15,6 +15,11 @@ import { songToTrack, usePlayerStore, type Track } from '../store/playerStore';
import { useLuckyMixStore } from '../store/luckyMixStore';
import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { showToast } from './toast';
import {
filterSongsForLuckyMixRatings,
getMixMinRatingsConfigFromAuth,
type MixMinRatingsConfig,
} from './mixRatingFilter';
/**
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
@@ -92,30 +97,47 @@ async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
return out;
}
async function pickSongsForArtist(artist: TopArtist, need: number): Promise<SubsonicSong[]> {
async function pickSongsForArtist(
artist: TopArtist,
need: number,
mixRatings: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
if (primary.length >= need) return sampleRandom(primary, need);
const extra: SubsonicSong[] = [];
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
for (const s of rnd) {
if (s.artistId === artist.id || s.artist === artist.name) {
extra.push(s);
let pool = primary;
if (primary.length < need) {
const extra: SubsonicSong[] = [];
for (let i = 0; i < 8 && primary.length + extra.length < need * 4; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
for (const s of rnd) {
if (s.artistId === artist.id || s.artist === artist.name) {
extra.push(s);
}
}
}
pool = uniqueBySongId([...primary, ...extra]);
}
return sampleRandom(uniqueBySongId([...primary, ...extra]), need);
const filtered = await filterSongsForLuckyMixRatings(pool, mixRatings);
return sampleRandom(filtered, Math.min(need, filtered.length));
}
async function pickSongsForAlbum(albumId: string, need: number): Promise<SubsonicSong[]> {
async function pickSongsForAlbum(
albumId: string,
need: number,
mixRatings: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
const full = await getAlbum(albumId).catch(() => null);
if (!full?.songs?.length) return [];
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
return sampleRandom(uniqueBySongId(scopedSongs), need);
const unique = uniqueBySongId(scopedSongs);
const filtered = await filterSongsForLuckyMixRatings(unique, mixRatings);
return sampleRandom(filtered, Math.min(need, filtered.length));
}
async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promise<SubsonicSong[]> {
async function pickGoodRatedSongs(
existingIds: Set<string>,
need: number,
mixRatings: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
const out: SubsonicSong[] = [];
const push = (s: SubsonicSong) => {
const r = s.userRating ?? 0;
@@ -125,12 +147,13 @@ async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promi
out.push(s);
};
for (let i = 0; i < 14 && out.length < need; i++) {
for (let i = 0; i < 14 && out.length < need * 8; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
rnd.forEach(push);
}
return sampleRandom(out, need);
const filtered = await filterSongsForLuckyMixRatings(out, mixRatings);
return sampleRandom(filtered, Math.min(need, filtered.length));
}
export async function buildAndPlayLuckyMix(): Promise<void> {
@@ -159,11 +182,13 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
audiomuseByServer: auth.audiomuseNavidromeByServer,
showLuckyMixMenu: auth.showLuckyMixMenu,
});
const mixRatingCfg = getMixMinRatingsConfigFromAuth();
logStep('init', {
activeServerId,
available,
showLuckyMixMenu: auth.showLuckyMixMenu,
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
mixRatingFilter: mixRatingCfg,
});
if (!available) {
logStep('abort_unavailable');
@@ -198,20 +223,19 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
};
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
const isBlockedByRating = (song: SubsonicSong) => {
const rating = song.userRating ?? 0;
return rating === 1 || rating === 2;
};
const startImmediatePlayback = (song: SubsonicSong, source: string) => {
if (startedPlayback || !song?.id || isBlockedByRating(song)) return;
const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
if (startedPlayback || !song?.id) return;
const allowed = await filterSongsForLuckyMixRatings([song], mixRatingCfg);
if (!allowed.length) return;
const play = allowed[0];
startedPlayback = true;
queuedIds.add(song.id);
const track = songToTrack(song);
queuedIds.add(play.id);
const track = songToTrack(play);
usePlayerStore.getState().playTrack(track, [track], true);
logStep('start_immediate_playback', {
source,
song: songDebug([song])[0],
song: songDebug([play])[0],
queuedCount: queuedIds.size,
});
@@ -231,17 +255,18 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}
};
const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => {
const appendSongsToQueue = async (songs: SubsonicSong[], reason: string): Promise<number> => {
if (useLuckyMixStore.getState().cancelRequested) return 0;
if (reachedTarget()) return 0;
if (!songs.length) return 0;
const deduped = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id) && !isBlockedByRating(s));
const unique = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id));
const deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
if (!deduped.length) return 0;
const candidates = [...deduped];
if (!startedPlayback && candidates.length > 0) {
const first = candidates.shift();
if (first) startImmediatePlayback(first, reason);
if (first) await startImmediatePlayback(first, reason);
}
if (!candidates.length) return 0;
@@ -276,10 +301,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
for (const artist of pickedArtists) {
bailIfCancelled();
const songs = await pickSongsForArtist(artist, 3);
const songs = await pickSongsForArtist(artist, 3, mixRatingCfg);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
const firstPlayable = songs[0];
if (firstPlayable) await startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
logStep('pick_artist_songs', {
artist,
pickedCount: songs.length,
@@ -294,10 +319,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
});
for (const album of pickedAlbums) {
bailIfCancelled();
const songs = await pickSongsForAlbum(album.id, 3);
const songs = await pickSongsForAlbum(album.id, 3, mixRatingCfg);
allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s));
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`);
const firstPlayable = songs[0];
if (firstPlayable) await startImmediatePlayback(firstPlayable, `album:${album.id}`);
logStep('pick_album_songs', {
albumId: album.id,
pickedCount: songs.length,
@@ -306,13 +331,13 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}
bailIfCancelled();
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3);
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3, mixRatingCfg);
logStep('pick_rated_songs_4plus_only', {
ratedPickedCount: rated.length,
ratedSongs: songDebug(rated),
});
allSeedSongs = uniqueAppend(allSeedSongs, rated);
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s));
let seeds = await filterSongsForLuckyMixRatings(allSeedSongs, mixRatingCfg);
logStep('seed_after_dedup', {
seedCount: seeds.length,
seeds: songDebug(seeds),
@@ -323,10 +348,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
const allowedRnd = rnd.filter(s => !isBlockedByRating(s));
const allowedRnd = await filterSongsForLuckyMixRatings(rnd, mixRatingCfg);
seeds = uniqueAppend(seeds, allowedRnd);
const firstPlayable = allowedRnd[0];
if (firstPlayable) startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
if (firstPlayable) await startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
logStep('seed_fill_batch', {
batch: i + 1,
fetched: rnd.length,
@@ -344,8 +369,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
throw new Error('no-seeds');
}
if (!startedPlayback) {
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s));
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
const firstPlayableSeed = seeds[0];
if (firstPlayableSeed) await startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
}
let similarRaw: SubsonicSong[] = [];
@@ -357,12 +382,12 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
similarRaw = uniqueAppend(similarRaw, oneRaw);
similar = uniqueAppend(similar, oneScoped);
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
await appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
if (reachedTarget()) break;
}
const seedForPool = seeds.filter(() => Math.random() < 0.5);
let pool = uniqueBySongId([...seedForPool, ...similar]);
appendSongsToQueue(seedForPool, 'seed-50pct');
await appendSongsToQueue(seedForPool, 'seed-50pct');
logStep('instant_mix', {
seedUsedForInstantMixCount: seeds.length,
seedIncludedInPoolCount: seedForPool.length,
@@ -376,7 +401,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
pool = uniqueAppend(pool, rnd);
appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
await appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
logStep('pool_fill_batch', {
batch: i + 1,
fetched: rnd.length,
@@ -386,8 +411,9 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}
bailIfCancelled();
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
appendSongsToQueue(finalSongs, 'finalize-randomized');
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
const finalSongs = sampleRandom(poolFiltered, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
await appendSongsToQueue(finalSongs, 'finalize-randomized');
logStep('final_queue_state', {
poolCount: pool.length,
queuedCount: queuedIds.size,
+84 -20
View File
@@ -1,5 +1,6 @@
import {
getRandomSongs,
parseSubsonicEntityStarRating,
prefetchAlbumUserRatings,
prefetchArtistUserRatings,
type SubsonicAlbum,
@@ -39,35 +40,71 @@ function numRating(v: unknown): number | undefined {
return n;
}
type OpenArtistRefLike = { id?: string; userRating?: unknown; rating?: unknown };
function refStarRating(a: OpenArtistRefLike | undefined): number | undefined {
return numRating(a?.userRating ?? a?.rating);
}
function ratingFromArtistRefs(
list: Array<{ id?: string; userRating?: unknown }> | undefined,
list: OpenArtistRefLike[] | undefined,
preferId?: string,
): number | undefined {
if (!list?.length) return undefined;
if (preferId) {
const m = list.find(a => a.id === preferId);
const r = numRating(m?.userRating);
const m = list.find(x => x.id === preferId);
const r = refStarRating(m);
if (r !== undefined) return r;
}
for (const a of list) {
const r = numRating(a.userRating);
const r = refStarRating(a);
if (r !== undefined) return r;
}
return undefined;
}
const CONTRIBUTOR_ROLES_FOR_ARTIST_ID =
/^(artist|album[\s_-]*artist|performer|track[\s_-]*artist|albumartist)$/i;
/**
* Entity id for artist-level mix rating: canonical `artistId`, else OpenSubsonic `artists[].id`,
* else Navidrome `contributors[].artist.id` when list payloads omit the former.
*/
function artistEntityIdForMixRating(song: SubsonicSong): string | undefined {
if (song.artistId) return song.artistId;
const fromArtists = song.artists?.find(a => a.id)?.id;
if (fromArtists) return fromArtists;
const cList = song.contributors;
if (cList?.length) {
const byRole = cList.find(
c => c.artist?.id && CONTRIBUTOR_ROLES_FOR_ARTIST_ID.test((c.role || '').trim()),
);
if (byRole?.artist?.id) return byRole.artist.id;
const anyId = cList.find(c => c.artist?.id);
if (anyId?.artist?.id) return anyId.artist.id;
}
return undefined;
}
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
const d = numRating(song.artistUserRating);
if (d !== undefined) return d;
const fromArtists = ratingFromArtistRefs(song.artists, song.artistId);
const prefer = artistEntityIdForMixRating(song);
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
if (fromArtists !== undefined) return fromArtists;
return ratingFromArtistRefs(song.albumArtists, song.artistId);
return ratingFromArtistRefs(song.albumArtists, prefer);
}
/** Song-level album (parent) rating when the server puts it on the child payload. */
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
return numRating(song.albumUserRating);
const x = song as SubsonicSong & { albumRating?: unknown };
return numRating(song.albumUserRating ?? x.albumRating);
}
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
const x = song as SubsonicSong & { rating?: unknown };
return numRating(song.userRating ?? x.rating);
}
/**
@@ -77,7 +114,7 @@ function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
if (!c.enabled) return true;
if (c.minSong > 0) {
const r = numRating(song.userRating);
const r = songTrackStarRatingForMix(song);
if (r !== undefined && r > 0 && r <= c.minSong) return false;
}
if (c.minAlbum > 0) {
@@ -109,7 +146,9 @@ export function passesMixMinRatingsForAlbum(
): boolean {
if (!c.enabled) return true;
if (c.minAlbum > 0) {
const r = numRating(album.userRating ?? extra?.albumUserRating);
const r =
parseSubsonicEntityStarRating(album as SubsonicAlbum & { rating?: unknown })
?? numRating(extra?.albumUserRating);
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
}
if (c.minArtist > 0) {
@@ -148,8 +187,19 @@ export async function filterAlbumsByMixRatings(
);
}
/** Enrich when needed, then drop songs excluded by Settings → Ratings → filter-by-rating. */
export async function filterSongsForLuckyMixRatings(
songs: SubsonicSong[],
c: MixMinRatingsConfig,
): Promise<SubsonicSong[]> {
if (!c.enabled) return songs;
const enriched = await enrichSongsForMixRatingFilter(songs, c);
return enriched.filter(s => passesMixMinRatings(s, c));
}
/**
* Merge `getArtist` / `getAlbum` ratings into songs before `passesMixMinRatings` when list payloads omit them.
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
*/
export async function enrichSongsForMixRatingFilter(
songs: SubsonicSong[],
@@ -158,7 +208,18 @@ export async function enrichSongsForMixRatingFilter(
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
const artistIds =
c.minArtist > 0
? [...new Set(songs.filter(s => s.artistUserRating === undefined && effectiveArtistRatingForFilter(s) === undefined && s.artistId).map(s => s.artistId!))]
? [
...new Set(
songs
.filter(
s =>
s.artistUserRating === undefined
&& effectiveArtistRatingForFilter(s) === undefined
&& artistEntityIdForMixRating(s),
)
.map(s => artistEntityIdForMixRating(s)!),
),
]
: [];
const albumIds =
c.minAlbum > 0
@@ -169,15 +230,18 @@ export async function enrichSongsForMixRatingFilter(
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
]);
if (!byArtist.size && !byAlbum.size) return songs;
return songs.map(s => ({
...s,
...(s.artistUserRating === undefined &&
s.artistId &&
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }),
...(s.albumUserRating === undefined &&
s.albumId &&
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }),
}));
return songs.map(s => {
const aid = artistEntityIdForMixRating(s);
const artistPatch =
s.artistUserRating === undefined && aid && byArtist.has(aid)
? { artistUserRating: byArtist.get(aid)! }
: {};
const albumPatch =
s.albumUserRating === undefined && s.albumId && byAlbum.has(s.albumId)
? { albumUserRating: byAlbum.get(s.albumId)! }
: {};
return { ...s, ...artistPatch, ...albumPatch };
});
}
/**