diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 43a6366d..81cef56c 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 */ diff --git a/src/utils/luckyMix.ts b/src/utils/luckyMix.ts index b1acb89b..5ffa9da2 100644 --- a/src/utils/luckyMix.ts +++ b/src/utils/luckyMix.ts @@ -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 { return out; } -async function pickSongsForArtist(artist: TopArtist, need: number): Promise { +async function pickSongsForArtist( + artist: TopArtist, + need: number, + mixRatings: MixMinRatingsConfig, +): Promise { 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 { +async function pickSongsForAlbum( + albumId: string, + need: number, + mixRatings: MixMinRatingsConfig, +): Promise { 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, need: number): Promise { +async function pickGoodRatedSongs( + existingIds: Set, + need: number, + mixRatings: MixMinRatingsConfig, +): Promise { const out: SubsonicSong[] = []; const push = (s: SubsonicSong) => { const r = s.userRating ?? 0; @@ -125,12 +147,13 @@ async function pickGoodRatedSongs(existingIds: Set, 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 { @@ -159,11 +182,13 @@ export async function buildAndPlayLuckyMix(): Promise { 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 { 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 { } }; - const appendSongsToQueue = (songs: SubsonicSong[], reason: string): number => { + const appendSongsToQueue = async (songs: SubsonicSong[], reason: string): Promise => { 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 { 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 { }); 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 { } 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 { 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 { 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 { 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 { 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 { } 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, diff --git a/src/utils/mixRatingFilter.ts b/src/utils/mixRatingFilter.ts index dcf9f061..a59de085 100644 --- a/src/utils/mixRatingFilter.ts +++ b/src/utils/mixRatingFilter.ts @@ -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 { + 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()), ]); 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 }; + }); } /**