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; id?: string;
name?: string; name?: string;
userRating?: number; userRating?: number;
/** Navidrome / alternate OpenSubsonic payloads (same meaning as `userRating`). */
rating?: number;
} }
export interface SubsonicSong { export interface SubsonicSong {
@@ -561,6 +563,17 @@ function parseEntityUserRating(v: unknown): number | undefined {
return n; 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. */ /** Parallel `getArtist` calls to fill mix/album filters when list endpoints omit ratings. */
export async function prefetchArtistUserRatings( export async function prefetchArtistUserRatings(
ids: string[], ids: string[],
@@ -571,7 +584,7 @@ export async function prefetchArtistUserRatings(
if (!unique.length) return out; if (!unique.length) return out;
const uncached: string[] = []; const uncached: string[] = [];
for (const id of unique) { 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); } if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id); else uncached.push(id);
} }
@@ -584,8 +597,8 @@ export async function prefetchArtistUserRatings(
const id = uncached[i]; const id = uncached[i];
try { try {
const { artist } = await getArtist(id); const { artist } = await getArtist(id);
const r = parseEntityUserRating(artist.userRating); const r = parseSubsonicEntityStarRating(artist);
setCachedRating(`artist:${id}`, r); setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r); if (r !== undefined) out.set(id, r);
} catch { } catch {
/* ignore */ /* ignore */
@@ -607,7 +620,7 @@ export async function prefetchAlbumUserRatings(
if (!unique.length) return out; if (!unique.length) return out;
const uncached: string[] = []; const uncached: string[] = [];
for (const id of unique) { 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); } if (cached !== null) { if (cached !== undefined) out.set(id, cached); }
else uncached.push(id); else uncached.push(id);
} }
@@ -620,8 +633,8 @@ export async function prefetchAlbumUserRatings(
const id = uncached[i]; const id = uncached[i];
try { try {
const { album } = await getAlbum(id); const { album } = await getAlbum(id);
const r = parseEntityUserRating(album.userRating); const r = parseSubsonicEntityStarRating(album);
setCachedRating(`album:${id}`, r); setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
if (r !== undefined) out.set(id, r); if (r !== undefined) out.set(id, r);
} catch { } catch {
/* ignore */ /* ignore */
+70 -44
View File
@@ -15,6 +15,11 @@ import { songToTrack, usePlayerStore, type Track } from '../store/playerStore';
import { useLuckyMixStore } from '../store/luckyMixStore'; import { useLuckyMixStore } from '../store/luckyMixStore';
import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; import { isLuckyMixAvailable } from '../hooks/useLuckyMixAvailable';
import { showToast } from './toast'; import { showToast } from './toast';
import {
filterSongsForLuckyMixRatings,
getMixMinRatingsConfigFromAuth,
type MixMinRatingsConfig,
} from './mixRatingFilter';
/** /**
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested` * Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
@@ -92,30 +97,47 @@ async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
return out; 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))); const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
if (primary.length >= need) return sampleRandom(primary, need); let pool = primary;
if (primary.length < need) {
const extra: SubsonicSong[] = []; const extra: SubsonicSong[] = [];
for (let i = 0; i < 8 && primary.length + extra.length < need; i++) { for (let i = 0; i < 8 && primary.length + extra.length < need * 4; i++) {
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120)); const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
for (const s of rnd) { for (const s of rnd) {
if (s.artistId === artist.id || s.artist === artist.name) { if (s.artistId === artist.id || s.artist === artist.name) {
extra.push(s); 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); const full = await getAlbum(albumId).catch(() => null);
if (!full?.songs?.length) return []; if (!full?.songs?.length) return [];
const scopedSongs = await filterSongsToActiveLibrary(full.songs); 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 out: SubsonicSong[] = [];
const push = (s: SubsonicSong) => { const push = (s: SubsonicSong) => {
const r = s.userRating ?? 0; const r = s.userRating ?? 0;
@@ -125,12 +147,13 @@ async function pickGoodRatedSongs(existingIds: Set<string>, need: number): Promi
out.push(s); 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)); const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
rnd.forEach(push); 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> { export async function buildAndPlayLuckyMix(): Promise<void> {
@@ -159,11 +182,13 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
audiomuseByServer: auth.audiomuseNavidromeByServer, audiomuseByServer: auth.audiomuseNavidromeByServer,
showLuckyMixMenu: auth.showLuckyMixMenu, showLuckyMixMenu: auth.showLuckyMixMenu,
}); });
const mixRatingCfg = getMixMinRatingsConfigFromAuth();
logStep('init', { logStep('init', {
activeServerId, activeServerId,
available, available,
showLuckyMixMenu: auth.showLuckyMixMenu, showLuckyMixMenu: auth.showLuckyMixMenu,
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all', libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
mixRatingFilter: mixRatingCfg,
}); });
if (!available) { if (!available) {
logStep('abort_unavailable'); logStep('abort_unavailable');
@@ -198,20 +223,19 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled(); if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
}; };
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE; 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) => { const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
if (startedPlayback || !song?.id || isBlockedByRating(song)) return; if (startedPlayback || !song?.id) return;
const allowed = await filterSongsForLuckyMixRatings([song], mixRatingCfg);
if (!allowed.length) return;
const play = allowed[0];
startedPlayback = true; startedPlayback = true;
queuedIds.add(song.id); queuedIds.add(play.id);
const track = songToTrack(song); const track = songToTrack(play);
usePlayerStore.getState().playTrack(track, [track], true); usePlayerStore.getState().playTrack(track, [track], true);
logStep('start_immediate_playback', { logStep('start_immediate_playback', {
source, source,
song: songDebug([song])[0], song: songDebug([play])[0],
queuedCount: queuedIds.size, 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 (useLuckyMixStore.getState().cancelRequested) return 0;
if (reachedTarget()) return 0; if (reachedTarget()) return 0;
if (!songs.length) 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; if (!deduped.length) return 0;
const candidates = [...deduped]; const candidates = [...deduped];
if (!startedPlayback && candidates.length > 0) { if (!startedPlayback && candidates.length > 0) {
const first = candidates.shift(); const first = candidates.shift();
if (first) startImmediatePlayback(first, reason); if (first) await startImmediatePlayback(first, reason);
} }
if (!candidates.length) return 0; if (!candidates.length) return 0;
@@ -276,10 +301,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
for (const artist of pickedArtists) { for (const artist of pickedArtists) {
bailIfCancelled(); bailIfCancelled();
const songs = await pickSongsForArtist(artist, 3); const songs = await pickSongsForArtist(artist, 3, mixRatingCfg);
allSeedSongs = uniqueAppend(allSeedSongs, songs); allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s)); const firstPlayable = songs[0];
if (firstPlayable) startImmediatePlayback(firstPlayable, `artist:${artist.name}`); if (firstPlayable) await startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
logStep('pick_artist_songs', { logStep('pick_artist_songs', {
artist, artist,
pickedCount: songs.length, pickedCount: songs.length,
@@ -294,10 +319,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}); });
for (const album of pickedAlbums) { for (const album of pickedAlbums) {
bailIfCancelled(); bailIfCancelled();
const songs = await pickSongsForAlbum(album.id, 3); const songs = await pickSongsForAlbum(album.id, 3, mixRatingCfg);
allSeedSongs = uniqueAppend(allSeedSongs, songs); allSeedSongs = uniqueAppend(allSeedSongs, songs);
const firstPlayable = songs.find(s => !isBlockedByRating(s)); const firstPlayable = songs[0];
if (firstPlayable) startImmediatePlayback(firstPlayable, `album:${album.id}`); if (firstPlayable) await startImmediatePlayback(firstPlayable, `album:${album.id}`);
logStep('pick_album_songs', { logStep('pick_album_songs', {
albumId: album.id, albumId: album.id,
pickedCount: songs.length, pickedCount: songs.length,
@@ -306,13 +331,13 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
} }
bailIfCancelled(); 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', { logStep('pick_rated_songs_4plus_only', {
ratedPickedCount: rated.length, ratedPickedCount: rated.length,
ratedSongs: songDebug(rated), ratedSongs: songDebug(rated),
}); });
allSeedSongs = uniqueAppend(allSeedSongs, rated); allSeedSongs = uniqueAppend(allSeedSongs, rated);
let seeds = allSeedSongs.filter(s => !isBlockedByRating(s)); let seeds = await filterSongsForLuckyMixRatings(allSeedSongs, mixRatingCfg);
logStep('seed_after_dedup', { logStep('seed_after_dedup', {
seedCount: seeds.length, seedCount: seeds.length,
seeds: songDebug(seeds), 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++) { for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
bailIfCancelled(); bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80)); const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
const allowedRnd = rnd.filter(s => !isBlockedByRating(s)); const allowedRnd = await filterSongsForLuckyMixRatings(rnd, mixRatingCfg);
seeds = uniqueAppend(seeds, allowedRnd); seeds = uniqueAppend(seeds, allowedRnd);
const firstPlayable = allowedRnd[0]; 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', { logStep('seed_fill_batch', {
batch: i + 1, batch: i + 1,
fetched: rnd.length, fetched: rnd.length,
@@ -344,8 +369,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
throw new Error('no-seeds'); throw new Error('no-seeds');
} }
if (!startedPlayback) { if (!startedPlayback) {
const firstPlayableSeed = seeds.find(s => !isBlockedByRating(s)); const firstPlayableSeed = seeds[0];
if (firstPlayableSeed) startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first'); if (firstPlayableSeed) await startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
} }
let similarRaw: SubsonicSong[] = []; let similarRaw: SubsonicSong[] = [];
@@ -357,12 +382,12 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const oneScoped = await filterSongsToActiveLibrary(oneRaw); const oneScoped = await filterSongsToActiveLibrary(oneRaw);
similarRaw = uniqueAppend(similarRaw, oneRaw); similarRaw = uniqueAppend(similarRaw, oneRaw);
similar = uniqueAppend(similar, oneScoped); similar = uniqueAppend(similar, oneScoped);
appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`); await appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
if (reachedTarget()) break; if (reachedTarget()) break;
} }
const seedForPool = seeds.filter(() => Math.random() < 0.5); const seedForPool = seeds.filter(() => Math.random() < 0.5);
let pool = uniqueBySongId([...seedForPool, ...similar]); let pool = uniqueBySongId([...seedForPool, ...similar]);
appendSongsToQueue(seedForPool, 'seed-50pct'); await appendSongsToQueue(seedForPool, 'seed-50pct');
logStep('instant_mix', { logStep('instant_mix', {
seedUsedForInstantMixCount: seeds.length, seedUsedForInstantMixCount: seeds.length,
seedIncludedInPoolCount: seedForPool.length, seedIncludedInPoolCount: seedForPool.length,
@@ -376,7 +401,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
bailIfCancelled(); bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120)); const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
pool = uniqueAppend(pool, rnd); pool = uniqueAppend(pool, rnd);
appendSongsToQueue(rnd, `pool-fill-${i + 1}`); await appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
logStep('pool_fill_batch', { logStep('pool_fill_batch', {
batch: i + 1, batch: i + 1,
fetched: rnd.length, fetched: rnd.length,
@@ -386,8 +411,9 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
} }
bailIfCancelled(); bailIfCancelled();
const finalSongs = sampleRandom(pool, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id)); const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
appendSongsToQueue(finalSongs, 'finalize-randomized'); const finalSongs = sampleRandom(poolFiltered, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
await appendSongsToQueue(finalSongs, 'finalize-randomized');
logStep('final_queue_state', { logStep('final_queue_state', {
poolCount: pool.length, poolCount: pool.length,
queuedCount: queuedIds.size, queuedCount: queuedIds.size,
+84 -20
View File
@@ -1,5 +1,6 @@
import { import {
getRandomSongs, getRandomSongs,
parseSubsonicEntityStarRating,
prefetchAlbumUserRatings, prefetchAlbumUserRatings,
prefetchArtistUserRatings, prefetchArtistUserRatings,
type SubsonicAlbum, type SubsonicAlbum,
@@ -39,35 +40,71 @@ function numRating(v: unknown): number | undefined {
return n; 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( function ratingFromArtistRefs(
list: Array<{ id?: string; userRating?: unknown }> | undefined, list: OpenArtistRefLike[] | undefined,
preferId?: string, preferId?: string,
): number | undefined { ): number | undefined {
if (!list?.length) return undefined; if (!list?.length) return undefined;
if (preferId) { if (preferId) {
const m = list.find(a => a.id === preferId); const m = list.find(x => x.id === preferId);
const r = numRating(m?.userRating); const r = refStarRating(m);
if (r !== undefined) return r; if (r !== undefined) return r;
} }
for (const a of list) { for (const a of list) {
const r = numRating(a.userRating); const r = refStarRating(a);
if (r !== undefined) return r; if (r !== undefined) return r;
} }
return undefined; 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. */ /** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined { function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
const d = numRating(song.artistUserRating); const d = numRating(song.artistUserRating);
if (d !== undefined) return d; 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; 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. */ /** Song-level album (parent) rating when the server puts it on the child payload. */
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined { 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 { export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
if (!c.enabled) return true; if (!c.enabled) return true;
if (c.minSong > 0) { if (c.minSong > 0) {
const r = numRating(song.userRating); const r = songTrackStarRatingForMix(song);
if (r !== undefined && r > 0 && r <= c.minSong) return false; if (r !== undefined && r > 0 && r <= c.minSong) return false;
} }
if (c.minAlbum > 0) { if (c.minAlbum > 0) {
@@ -109,7 +146,9 @@ export function passesMixMinRatingsForAlbum(
): boolean { ): boolean {
if (!c.enabled) return true; if (!c.enabled) return true;
if (c.minAlbum > 0) { 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 (r !== undefined && r > 0 && r <= c.minAlbum) return false;
} }
if (c.minArtist > 0) { 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( export async function enrichSongsForMixRatingFilter(
songs: SubsonicSong[], songs: SubsonicSong[],
@@ -158,7 +208,18 @@ export async function enrichSongsForMixRatingFilter(
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs; if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
const artistIds = const artistIds =
c.minArtist > 0 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 = const albumIds =
c.minAlbum > 0 c.minAlbum > 0
@@ -169,15 +230,18 @@ export async function enrichSongsForMixRatingFilter(
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()), albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
]); ]);
if (!byArtist.size && !byAlbum.size) return songs; if (!byArtist.size && !byAlbum.size) return songs;
return songs.map(s => ({ return songs.map(s => {
...s, const aid = artistEntityIdForMixRating(s);
...(s.artistUserRating === undefined && const artistPatch =
s.artistId && s.artistUserRating === undefined && aid && byArtist.has(aid)
byArtist.has(s.artistId) && { artistUserRating: byArtist.get(s.artistId)! }), ? { artistUserRating: byArtist.get(aid)! }
...(s.albumUserRating === undefined && : {};
s.albumId && const albumPatch =
byAlbum.has(s.albumId) && { albumUserRating: byAlbum.get(s.albumId)! }), s.albumUserRating === undefined && s.albumId && byAlbum.has(s.albumId)
})); ? { albumUserRating: byAlbum.get(s.albumId)! }
: {};
return { ...s, ...artistPatch, ...albumPatch };
});
} }
/** /**