mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
+19
-6
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user