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:
cucadmuh
2026-05-15 12:45:02 +03:00
committed by GitHub
parent cfcbbd79e4
commit 02fd828049
11 changed files with 323 additions and 58 deletions
+8
View File
@@ -292,6 +292,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
## Fixed
### Mixes — rating filter and Lucky Mix queue fill
**By [@cucadmuh](https://github.com/cucadmuh), PR [#714](https://github.com/Psychotoxical/psysonic/pull/714)**
* **Settings → Ratings → filter by rating** now applies reliably across **Lucky Mix**, **Random Mix**, **Instant Mix** (context menu + CLI), and infinite-queue top-ups: entity ratings are refreshed after **`setRating`**, unrated artists/albums are no longer negative-cached for seven minutes, optimistic UI stars are honored, and artist/album stars from **`getArtist`** / **`getAlbum`** override misleading OpenSubsonic refs on song payloads.
* **Random Mix** applies the rating filter even when audiobook exclusion is off (previously skipped in that path).
* **Lucky Mix** reports the real queue length in the success toast and keeps filling until **`playerStore.queue`** reaches the target (up to 50), instead of counting track ids before **`enqueue`** completed or stopping pool fill when the unfiltered candidate pool was large enough.
### Statistics / playlists — duration totals rounded to the nearest minute again
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#710](https://github.com/Psychotoxical/psysonic/pull/710)**
+54
View File
@@ -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);
});
});
+19 -9
View File
@@ -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 */
}
+1
View File
@@ -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(() => {});
}
/**
+7 -1
View File
@@ -5,6 +5,7 @@ import type { NavigateFunction } from 'react-router-dom';
import { getSimilarSongs } from '../../api/subsonicArtists';
import { getMusicFolders } from '../../api/subsonicLibrary';
import { search as subsonicSearch } from '../../api/subsonicSearch';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '../../utils/mix/mixRatingFilter';
import { shuffleArray } from '../../utils/playback/shuffleArray';
import { songToTrack } from '../../utils/playback/songToTrack';
import { showToast } from '../../utils/ui/toast';
@@ -49,7 +50,12 @@ export function useCliBridge(navigate: NavigateFunction) {
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const base = similar.filter(s => s.id !== song.id).map(s => songToTrack(s));
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
similar.filter(s => s.id !== song.id),
mixCfg,
);
const base = ratedFiltered.map(s => songToTrack(s));
if (mode === 'append') {
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
@@ -1,6 +1,7 @@
import { join } from '@tauri-apps/api/path';
import { invoke } from '@tauri-apps/api/core';
import { getSimilarSongs2, getSimilarSongs, getTopSongs } from '../../api/subsonicArtists';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '../mix/mixRatingFilter';
import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
@@ -107,10 +108,13 @@ export async function startInstantMix(
try {
const similar = await getSimilarSongs(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
similar.filter(s => s.id !== song.id),
mixCfg,
);
const shuffled = shuffleArray(
similar
.filter(s => s.id !== song.id)
.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
ratedFiltered.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { filterRandomMixSongs } from './randomMixHelpers';
function song(id: string): SubsonicSong {
return {
id,
title: 't',
artist: 'A',
album: 'Al',
albumId: 'alb',
duration: 1,
artistUserRating: 1,
};
}
describe('filterRandomMixSongs', () => {
it('applies mix rating filter even when audiobook exclusion is off', () => {
const cfg = { enabled: true, minSong: 0, minAlbum: 0, minArtist: 2 };
const out = filterRandomMixSongs([song('1'), song('2')], {
excludeAudiobooks: false,
customGenreBlacklist: [],
mixRatingCfg: { ...cfg, minArtist: 2 },
});
expect(out).toHaveLength(0);
const kept = filterRandomMixSongs([song('1'), { ...song('2'), artistUserRating: 4 }], {
excludeAudiobooks: false,
customGenreBlacklist: [],
mixRatingCfg: cfg,
});
expect(kept).toHaveLength(1);
expect(kept[0].id).toBe('2');
});
});
@@ -24,6 +24,7 @@ interface FilterArgs {
export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): SubsonicSong[] {
const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args;
return songs.filter(song => {
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
if (!excludeAudiobooks) return true;
const checkText = (text: string) => {
const t = text.toLowerCase();
@@ -35,7 +36,6 @@ export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): S
if (song.title && checkText(song.title)) return false;
if (song.album && checkText(song.album)) return false;
if (song.artist && checkText(song.artist)) return false;
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
return true;
});
}
+46 -24
View File
@@ -12,6 +12,7 @@ import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
import { showToast } from '../ui/toast';
import {
filterSongsForLuckyMixRatings,
filterTopArtistsForMixRatings,
getMixMinRatingsConfigFromAuth,
} from './mixRatingFilter';
import {
@@ -99,13 +100,15 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
let unsubPlayer: (() => void) | null = null;
let startedPlayback = false;
try {
const queuedIds = new Set<string>();
let allSeedSongs: SubsonicSong[] = [];
const mixQueueSize = () => usePlayerStore.getState().queue.length;
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
const bailIfCancelled = () => {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
};
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
const reachedTarget = () => mixQueueSize() >= MIX_TARGET_SIZE;
const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
if (startedPlayback || !song?.id) return;
@@ -113,26 +116,24 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
if (!allowed.length) return;
const play = allowed[0];
startedPlayback = true;
queuedIds.add(play.id);
const track = songToTrack(play);
usePlayerStore.getState().playTrack(track, [track], true);
logStep('start_immediate_playback', {
source,
song: songDebug([play])[0],
queuedCount: queuedIds.size,
queuedCount: mixQueueSize(),
});
// Auto-cancel: once we're playing, watch the player store. If the
// current track switches to something the user picked themselves (not
// in our queuedIds set), treat that as "user moved on" and cancel the
// build so we don't later overwrite their choice with our finalised mix.
// in the mix queue), treat that as "user moved on" and cancel the build.
if (!unsubPlayer) {
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
const prevId = prev.currentTrack?.id ?? null;
const nextId = state.currentTrack?.id ?? null;
if (nextId === prevId) return;
if (!nextId) return;
if (queuedIds.has(nextId)) return;
if (state.queue.some(t => t.id === nextId)) return;
useLuckyMixStore.getState().cancel();
});
}
@@ -142,7 +143,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
if (useLuckyMixStore.getState().cancelRequested) return 0;
if (reachedTarget()) return 0;
if (!songs.length) return 0;
const unique = uniqueBySongId(songs).filter(s => !queuedIds.has(s.id));
const knownIds = mixQueueTrackIds();
const unique = uniqueBySongId(songs).filter(s => !knownIds.has(s.id));
const deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
if (!deduped.length) return 0;
@@ -153,19 +155,20 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}
if (!candidates.length) return 0;
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
const remaining = Math.max(0, MIX_TARGET_SIZE - mixQueueSize());
if (remaining <= 0) return 0;
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
if (!toAdd.length) return 0;
toAdd.forEach(s => queuedIds.add(s.id));
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
const before = mixQueueSize();
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true);
const added = mixQueueSize() - before;
logStep('append_queue_batch', {
reason,
added: toAdd.length,
queuedCount: queuedIds.size,
added,
queuedCount: mixQueueSize(),
songs: songDebug(toAdd),
});
return toAdd.length;
return added;
};
const frequentAlbums = await fetchFrequentAlbumsPool();
@@ -175,7 +178,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
fetched: frequentAlbums.length,
withPlays: albumsWithPlays.length,
});
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
const topArtists = await filterTopArtistsForMixRatings(
deriveTopArtistsFromFrequentAlbums(albumsWithPlays),
mixRatingCfg,
);
const pickedArtists = sampleRandom(topArtists, 2);
logStep('pick_top_artists', {
topArtistsCount: topArtists.length,
@@ -280,7 +286,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
initialPoolCount: pool.length,
});
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
const poolFillMaxBatches = mixRatingCfg.enabled ? 25 : 10;
for (let i = 0; i < poolFillMaxBatches && !reachedTarget(); i++) {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
pool = uniqueAppend(pool, rnd);
@@ -289,24 +296,39 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
batch: i + 1,
fetched: rnd.length,
poolCount: pool.length,
queueCount: mixQueueSize(),
});
if (reachedTarget()) break;
}
bailIfCancelled();
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
const finalSongs = sampleRandom(poolFiltered, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
await appendSongsToQueue(finalSongs, 'finalize-randomized');
if (!reachedTarget()) {
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
const need = MIX_TARGET_SIZE - mixQueueSize();
const finalSongs = sampleRandom(
poolFiltered.filter(s => !mixQueueTrackIds().has(s.id)),
need,
);
await appendSongsToQueue(finalSongs, 'finalize-randomized');
}
for (let i = 0; i < 20 && !reachedTarget(); i++) {
bailIfCancelled();
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
const added = await appendSongsToQueue(rnd, `topup-${i + 1}`);
if (added === 0 && i >= 8) break;
}
const finalQueueCount = mixQueueSize();
logStep('final_queue_state', {
poolCount: pool.length,
queuedCount: queuedIds.size,
queuedCount: finalQueueCount,
queuedTarget: MIX_TARGET_SIZE,
});
if (queuedIds.size === 0) {
if (finalQueueCount === 0) {
throw new Error('empty-mix');
}
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
logStep('done', { queueCount: queuedIds.size });
showToast(i18n.t('luckyMix.done', { count: finalQueueCount }), 3500, 'success');
logStep('done', { queueCount: finalQueueCount });
if (debugEnabled) {
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
void invoke('frontend_debug_log', {
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { usePlayerStore } from '../../store/playerStore';
import { resetPlayerStore } from '../../test/helpers/storeReset';
vi.mock('../../api/subsonicRatings', () => ({
prefetchArtistUserRatings: vi.fn(),
prefetchAlbumUserRatings: vi.fn(),
parseSubsonicEntityStarRating: vi.fn(),
}));
import { prefetchArtistUserRatings, prefetchAlbumUserRatings } from '../../api/subsonicRatings';
import {
enrichSongsForMixRatingFilter,
filterTopArtistsForMixRatings,
passesMixMinRatings,
} from './mixRatingFilter';
const enabledArtist2: { enabled: true; minSong: 0; minAlbum: 0; minArtist: 2 } = {
enabled: true,
minSong: 0,
minAlbum: 0,
minArtist: 2,
};
function song(partial: Partial<SubsonicSong> & Pick<SubsonicSong, 'id'>): SubsonicSong {
return {
title: 't',
artist: 'A',
album: 'Al',
albumId: 'alb-1',
artistId: 'art-1',
duration: 180,
...partial,
};
}
beforeEach(() => {
resetPlayerStore();
vi.mocked(prefetchArtistUserRatings).mockReset();
vi.mocked(prefetchAlbumUserRatings).mockReset();
vi.mocked(prefetchAlbumUserRatings).mockResolvedValue(new Map());
});
describe('passesMixMinRatings — artist axis', () => {
it('excludes when artistUserRating is at or below threshold', () => {
expect(passesMixMinRatings(song({ id: '1', artistUserRating: 1 }), enabledArtist2)).toBe(false);
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 2 }), enabledArtist2)).toBe(false);
expect(passesMixMinRatings(song({ id: '3', artistUserRating: 3 }), enabledArtist2)).toBe(true);
});
it('keeps unrated artists (missing or zero)', () => {
expect(passesMixMinRatings(song({ id: '1' }), enabledArtist2)).toBe(true);
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 0 }), enabledArtist2)).toBe(true);
});
it('uses playerStore userRatingOverrides before API fields', () => {
usePlayerStore.getState().setUserRatingOverride('art-1', 1);
expect(
passesMixMinRatings(song({ id: '1', artistUserRating: 5 }), enabledArtist2),
).toBe(false);
});
it('uses OpenSubsonic artists[] ref when artistUserRating is absent', () => {
const low = song({
id: '1',
artists: [{ id: 'art-1', userRating: 1 }],
});
expect(passesMixMinRatings(low, enabledArtist2)).toBe(false);
});
});
describe('enrichSongsForMixRatingFilter', () => {
it('prefetches entity artist rating even when song carries a misleading artists[] ref', async () => {
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(new Map([['art-1', 1]]));
const input = [
song({
id: '1',
artists: [{ id: 'art-1', userRating: 5 }],
}),
];
const out = await enrichSongsForMixRatingFilter(input, enabledArtist2);
expect(prefetchArtistUserRatings).toHaveBeenCalledWith(['art-1']);
expect(out[0].artistUserRating).toBe(1);
expect(passesMixMinRatings(out[0], enabledArtist2)).toBe(false);
});
});
describe('filterTopArtistsForMixRatings', () => {
it('drops artists rated at or below the threshold', async () => {
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(
new Map([
['a1', 1],
['a2', 3],
]),
);
const out = await filterTopArtistsForMixRatings(
[
{ id: 'a1', name: 'Low' },
{ id: 'a2', name: 'Ok' },
{ id: 'a3', name: 'Unrated' },
],
enabledArtist2,
);
expect(out.map(a => a.id)).toEqual(['a2', 'a3']);
});
});
+34 -20
View File
@@ -2,6 +2,7 @@ import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtist
import { getRandomSongs } from '../../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
export const RANDOM_MIX_TARGET_SIZE = 50;
@@ -59,6 +60,14 @@ function numRating(v: unknown): number | undefined {
return n;
}
/** Optimistic stars from the UI (`setUserRatingOverride`) take precedence over API payloads. */
function mixRatingOverrideForEntity(entityId: string | undefined): number | undefined {
if (!entityId) return undefined;
const o = usePlayerStore.getState().userRatingOverrides[entityId];
if (o === undefined || o <= 0) return undefined;
return o;
}
type OpenArtistRefLike = { id?: string; userRating?: unknown; rating?: unknown };
function refStarRating(a: OpenArtistRefLike | undefined): number | undefined {
@@ -107,9 +116,11 @@ function artistEntityIdForMixRating(song: SubsonicSong): string | undefined {
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
const prefer = artistEntityIdForMixRating(song);
const fromOverride = mixRatingOverrideForEntity(prefer);
if (fromOverride !== undefined) return fromOverride;
const d = numRating(song.artistUserRating);
if (d !== undefined) return d;
const prefer = artistEntityIdForMixRating(song);
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
if (fromArtists !== undefined) return fromArtists;
return ratingFromArtistRefs(song.albumArtists, prefer);
@@ -117,11 +128,15 @@ function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined
/** Song-level album (parent) rating when the server puts it on the child payload. */
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
const fromOverride = mixRatingOverrideForEntity(song.albumId);
if (fromOverride !== undefined) return fromOverride;
const x = song as SubsonicSong & { albumRating?: unknown };
return numRating(song.albumUserRating ?? x.albumRating);
}
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
const fromOverride = mixRatingOverrideForEntity(song.id);
if (fromOverride !== undefined) return fromOverride;
const x = song as SubsonicSong & { rating?: unknown };
return numRating(song.userRating ?? x.rating);
}
@@ -220,6 +235,20 @@ export async function filterSongsForLuckyMixRatings(
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
*/
/** Drop low-rated seed artists before Lucky Mix picks from listening history. */
export async function filterTopArtistsForMixRatings<T extends { id: string }>(
artists: T[],
c: MixMinRatingsConfig,
): Promise<T[]> {
if (!c.enabled || c.minArtist <= 0 || !artists.length) return artists;
const byArtist = await prefetchArtistUserRatings(artists.map(a => a.id));
return artists.filter(a => {
const r = mixRatingOverrideForEntity(a.id) ?? byArtist.get(a.id);
if (r === undefined || r <= 0) return true;
return r > c.minArtist;
});
}
export async function enrichSongsForMixRatingFilter(
songs: SubsonicSong[],
c: MixMinRatingsConfig,
@@ -227,22 +256,11 @@ 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
&& artistEntityIdForMixRating(s),
)
.map(s => artistEntityIdForMixRating(s)!),
),
]
? [...new Set(songs.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
: [];
const albumIds =
c.minAlbum > 0
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
? [...new Set(songs.filter(s => s.albumId).map(s => s.albumId!))]
: [];
const [byArtist, byAlbum] = await Promise.all([
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
@@ -252,13 +270,9 @@ export async function enrichSongsForMixRatingFilter(
return songs.map(s => {
const aid = artistEntityIdForMixRating(s);
const artistPatch =
s.artistUserRating === undefined && aid && byArtist.has(aid)
? { artistUserRating: byArtist.get(aid)! }
: {};
aid && byArtist.has(aid) ? { artistUserRating: byArtist.get(aid)! } : {};
const albumPatch =
s.albumUserRating === undefined && s.albumId && byAlbum.has(s.albumId)
? { albumUserRating: byAlbum.get(s.albumId)! }
: {};
s.albumId && byAlbum.has(s.albumId) ? { albumUserRating: byAlbum.get(s.albumId)! } : {};
return { ...s, ...artistPatch, ...albumPatch };
});
}