diff --git a/CHANGELOG.md b/CHANGELOG.md index fb1b39f5..63ed4da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)** diff --git a/src/api/subsonicRatings.test.ts b/src/api/subsonicRatings.test.ts new file mode 100644 index 00000000..ece9b2ee --- /dev/null +++ b/src/api/subsonicRatings.test.ts @@ -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); + }); +}); diff --git a/src/api/subsonicRatings.ts b/src/api/subsonicRatings.ts index cc1bc18d..042d4abe 100644 --- a/src/api/subsonicRatings.ts +++ b/src/api/subsonicRatings.ts @@ -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(); +const ratingCache = new Map(); -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 */ } diff --git a/src/api/subsonicStarRating.ts b/src/api/subsonicStarRating.ts index 0645ee73..fa769afc 100644 --- a/src/api/subsonicStarRating.ts +++ b/src/api/subsonicStarRating.ts @@ -42,6 +42,7 @@ export async function setRating(id: string, rating: number): Promise { // 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(() => {}); } /** diff --git a/src/hooks/tauriBridge/useCliBridge.ts b/src/hooks/tauriBridge/useCliBridge.ts index 03aad1e2..e0ff2ef6 100644 --- a/src/hooks/tauriBridge/useCliBridge.ts +++ b/src/hooks/tauriBridge/useCliBridge.ts @@ -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); diff --git a/src/utils/componentHelpers/contextMenuActions.ts b/src/utils/componentHelpers/contextMenuActions.ts index c70147ae..678e8536 100644 --- a/src/utils/componentHelpers/contextMenuActions.ts +++ b/src/utils/componentHelpers/contextMenuActions.ts @@ -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; diff --git a/src/utils/componentHelpers/randomMixHelpers.test.ts b/src/utils/componentHelpers/randomMixHelpers.test.ts new file mode 100644 index 00000000..08ba5b7f --- /dev/null +++ b/src/utils/componentHelpers/randomMixHelpers.test.ts @@ -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'); + }); +}); diff --git a/src/utils/componentHelpers/randomMixHelpers.ts b/src/utils/componentHelpers/randomMixHelpers.ts index 3111a207..019ceb6d 100644 --- a/src/utils/componentHelpers/randomMixHelpers.ts +++ b/src/utils/componentHelpers/randomMixHelpers.ts @@ -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; }); } diff --git a/src/utils/mix/luckyMix.ts b/src/utils/mix/luckyMix.ts index 2279111c..bd41876a 100644 --- a/src/utils/mix/luckyMix.ts +++ b/src/utils/mix/luckyMix.ts @@ -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 { let unsubPlayer: (() => void) | null = null; let startedPlayback = false; try { - const queuedIds = new Set(); 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 { 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 { 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 { } 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 { 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 { 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 { 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', { diff --git a/src/utils/mix/mixRatingFilter.test.ts b/src/utils/mix/mixRatingFilter.test.ts new file mode 100644 index 00000000..c3e11c87 --- /dev/null +++ b/src/utils/mix/mixRatingFilter.test.ts @@ -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 & Pick): 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']); + }); +}); diff --git a/src/utils/mix/mixRatingFilter.ts b/src/utils/mix/mixRatingFilter.ts index 53dbeafa..54637b52 100644 --- a/src/utils/mix/mixRatingFilter.ts +++ b/src/utils/mix/mixRatingFilter.ts @@ -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( + artists: T[], + c: MixMinRatingsConfig, +): Promise { + 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()), @@ -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 }; }); }