mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
@@ -292,6 +292,14 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa
|
|||||||
|
|
||||||
## Fixed
|
## 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
|
### 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)**
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#710](https://github.com/Psychotoxical/psysonic/pull/710)**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,19 +3,25 @@ import { getAlbum } from './subsonicLibrary';
|
|||||||
|
|
||||||
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
const MIX_RATING_PREFETCH_CONCURRENCY = 8;
|
||||||
const RATING_CACHE_TTL = 7 * 60 * 1000; // 7 minutes
|
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);
|
const entry = ratingCache.get(key);
|
||||||
if (!entry) return null; // cache miss
|
if (!entry) return null; // cache miss
|
||||||
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
if (Date.now() > entry.expiresAt) { ratingCache.delete(key); return null; }
|
||||||
return entry.value;
|
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 });
|
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 {
|
function parseEntityUserRating(v: unknown): number | undefined {
|
||||||
if (v === null || v === undefined) return undefined;
|
if (v === null || v === undefined) return undefined;
|
||||||
const n = typeof v === 'number' ? v : Number(v);
|
const n = typeof v === 'number' ? v : Number(v);
|
||||||
@@ -45,7 +51,7 @@ export async function prefetchArtistUserRatings(
|
|||||||
const uncached: string[] = [];
|
const uncached: string[] = [];
|
||||||
for (const id of unique) {
|
for (const id of unique) {
|
||||||
const cached = getCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
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);
|
else uncached.push(id);
|
||||||
}
|
}
|
||||||
if (!uncached.length) return out;
|
if (!uncached.length) return out;
|
||||||
@@ -58,8 +64,10 @@ export async function prefetchArtistUserRatings(
|
|||||||
try {
|
try {
|
||||||
const { artist } = await getArtist(id);
|
const { artist } = await getArtist(id);
|
||||||
const r = parseSubsonicEntityStarRating(artist);
|
const r = parseSubsonicEntityStarRating(artist);
|
||||||
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
if (r !== undefined && r > 0) {
|
||||||
if (r !== undefined) out.set(id, r);
|
setCachedRating(`artist:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||||
|
out.set(id, r);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -81,7 +89,7 @@ export async function prefetchAlbumUserRatings(
|
|||||||
const uncached: string[] = [];
|
const uncached: string[] = [];
|
||||||
for (const id of unique) {
|
for (const id of unique) {
|
||||||
const cached = getCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`);
|
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);
|
else uncached.push(id);
|
||||||
}
|
}
|
||||||
if (!uncached.length) return out;
|
if (!uncached.length) return out;
|
||||||
@@ -94,8 +102,10 @@ export async function prefetchAlbumUserRatings(
|
|||||||
try {
|
try {
|
||||||
const { album } = await getAlbum(id);
|
const { album } = await getAlbum(id);
|
||||||
const r = parseSubsonicEntityStarRating(album);
|
const r = parseSubsonicEntityStarRating(album);
|
||||||
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
if (r !== undefined && r > 0) {
|
||||||
if (r !== undefined) out.set(id, r);
|
setCachedRating(`album:${ENTITY_RATING_CACHE_KEY_VER}:${id}`, r);
|
||||||
|
out.set(id, r);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function setRating(id: string, rating: number): Promise<void> {
|
|||||||
// subsonic ← navidromeBrowse and avoid pulling Tauri internals into shared
|
// subsonic ← navidromeBrowse and avoid pulling Tauri internals into shared
|
||||||
// type-only consumers.
|
// type-only consumers.
|
||||||
void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
|
void import('./navidromeBrowse').then(m => m.ndInvalidateSongsCache()).catch(() => {});
|
||||||
|
void import('./subsonicRatings').then(m => m.invalidateEntityUserRatingCaches(id)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { NavigateFunction } from 'react-router-dom';
|
|||||||
import { getSimilarSongs } from '../../api/subsonicArtists';
|
import { getSimilarSongs } from '../../api/subsonicArtists';
|
||||||
import { getMusicFolders } from '../../api/subsonicLibrary';
|
import { getMusicFolders } from '../../api/subsonicLibrary';
|
||||||
import { search as subsonicSearch } from '../../api/subsonicSearch';
|
import { search as subsonicSearch } from '../../api/subsonicSearch';
|
||||||
|
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '../../utils/mix/mixRatingFilter';
|
||||||
import { shuffleArray } from '../../utils/playback/shuffleArray';
|
import { shuffleArray } from '../../utils/playback/shuffleArray';
|
||||||
import { songToTrack } from '../../utils/playback/songToTrack';
|
import { songToTrack } from '../../utils/playback/songToTrack';
|
||||||
import { showToast } from '../../utils/ui/toast';
|
import { showToast } from '../../utils/ui/toast';
|
||||||
@@ -49,7 +50,12 @@ export function useCliBridge(navigate: NavigateFunction) {
|
|||||||
try {
|
try {
|
||||||
const similar = await getSimilarSongs(song.id, 50);
|
const similar = await getSimilarSongs(song.id, 50);
|
||||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
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') {
|
if (mode === 'append') {
|
||||||
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
|
const toAdd = shuffleArray(base.map(t => ({ ...t, autoAdded: true as const })));
|
||||||
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
|
if (toAdd.length > 0) usePlayerStore.getState().enqueue(toAdd);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join } from '@tauri-apps/api/path';
|
import { join } from '@tauri-apps/api/path';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { getSimilarSongs2, getSimilarSongs, getTopSongs } from '../../api/subsonicArtists';
|
import { getSimilarSongs2, getSimilarSongs, getTopSongs } from '../../api/subsonicArtists';
|
||||||
|
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '../mix/mixRatingFilter';
|
||||||
import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
|
import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { usePlayerStore } from '../../store/playerStore';
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
@@ -107,10 +108,13 @@ export async function startInstantMix(
|
|||||||
try {
|
try {
|
||||||
const similar = await getSimilarSongs(song.id, 50);
|
const similar = await getSimilarSongs(song.id, 50);
|
||||||
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
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(
|
const shuffled = shuffleArray(
|
||||||
similar
|
ratedFiltered.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
|
||||||
.filter(s => s.id !== song.id)
|
|
||||||
.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
|
|
||||||
);
|
);
|
||||||
if (shuffled.length > 0) {
|
if (shuffled.length > 0) {
|
||||||
const aid = song.artistId?.trim() || undefined;
|
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[] {
|
export function filterRandomMixSongs(songs: SubsonicSong[], args: FilterArgs): SubsonicSong[] {
|
||||||
const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args;
|
const { excludeAudiobooks, customGenreBlacklist, mixRatingCfg } = args;
|
||||||
return songs.filter(song => {
|
return songs.filter(song => {
|
||||||
|
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
||||||
if (!excludeAudiobooks) return true;
|
if (!excludeAudiobooks) return true;
|
||||||
const checkText = (text: string) => {
|
const checkText = (text: string) => {
|
||||||
const t = text.toLowerCase();
|
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.title && checkText(song.title)) return false;
|
||||||
if (song.album && checkText(song.album)) return false;
|
if (song.album && checkText(song.album)) return false;
|
||||||
if (song.artist && checkText(song.artist)) return false;
|
if (song.artist && checkText(song.artist)) return false;
|
||||||
if (!passesMixMinRatings(song, mixRatingCfg)) return false;
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-24
@@ -12,6 +12,7 @@ import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
|||||||
import { showToast } from '../ui/toast';
|
import { showToast } from '../ui/toast';
|
||||||
import {
|
import {
|
||||||
filterSongsForLuckyMixRatings,
|
filterSongsForLuckyMixRatings,
|
||||||
|
filterTopArtistsForMixRatings,
|
||||||
getMixMinRatingsConfigFromAuth,
|
getMixMinRatingsConfigFromAuth,
|
||||||
} from './mixRatingFilter';
|
} from './mixRatingFilter';
|
||||||
import {
|
import {
|
||||||
@@ -99,13 +100,15 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
let unsubPlayer: (() => void) | null = null;
|
let unsubPlayer: (() => void) | null = null;
|
||||||
let startedPlayback = false;
|
let startedPlayback = false;
|
||||||
try {
|
try {
|
||||||
const queuedIds = new Set<string>();
|
|
||||||
let allSeedSongs: SubsonicSong[] = [];
|
let allSeedSongs: SubsonicSong[] = [];
|
||||||
|
|
||||||
|
const mixQueueSize = () => usePlayerStore.getState().queue.length;
|
||||||
|
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
|
||||||
|
|
||||||
const bailIfCancelled = () => {
|
const bailIfCancelled = () => {
|
||||||
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
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) => {
|
const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
|
||||||
if (startedPlayback || !song?.id) return;
|
if (startedPlayback || !song?.id) return;
|
||||||
@@ -113,26 +116,24 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
if (!allowed.length) return;
|
if (!allowed.length) return;
|
||||||
const play = allowed[0];
|
const play = allowed[0];
|
||||||
startedPlayback = true;
|
startedPlayback = true;
|
||||||
queuedIds.add(play.id);
|
|
||||||
const track = songToTrack(play);
|
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([play])[0],
|
song: songDebug([play])[0],
|
||||||
queuedCount: queuedIds.size,
|
queuedCount: mixQueueSize(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-cancel: once we're playing, watch the player store. If the
|
// Auto-cancel: once we're playing, watch the player store. If the
|
||||||
// current track switches to something the user picked themselves (not
|
// current track switches to something the user picked themselves (not
|
||||||
// in our queuedIds set), treat that as "user moved on" and cancel the
|
// in the mix queue), treat that as "user moved on" and cancel the build.
|
||||||
// build so we don't later overwrite their choice with our finalised mix.
|
|
||||||
if (!unsubPlayer) {
|
if (!unsubPlayer) {
|
||||||
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
|
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
|
||||||
const prevId = prev.currentTrack?.id ?? null;
|
const prevId = prev.currentTrack?.id ?? null;
|
||||||
const nextId = state.currentTrack?.id ?? null;
|
const nextId = state.currentTrack?.id ?? null;
|
||||||
if (nextId === prevId) return;
|
if (nextId === prevId) return;
|
||||||
if (!nextId) return;
|
if (!nextId) return;
|
||||||
if (queuedIds.has(nextId)) return;
|
if (state.queue.some(t => t.id === nextId)) return;
|
||||||
useLuckyMixStore.getState().cancel();
|
useLuckyMixStore.getState().cancel();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -142,7 +143,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
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 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);
|
const deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
|
||||||
if (!deduped.length) return 0;
|
if (!deduped.length) return 0;
|
||||||
|
|
||||||
@@ -153,19 +155,20 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!candidates.length) return 0;
|
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;
|
if (remaining <= 0) return 0;
|
||||||
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
||||||
if (!toAdd.length) return 0;
|
if (!toAdd.length) return 0;
|
||||||
toAdd.forEach(s => queuedIds.add(s.id));
|
const before = mixQueueSize();
|
||||||
usePlayerStore.getState().enqueue(toAdd.map(songToTrack));
|
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true);
|
||||||
|
const added = mixQueueSize() - before;
|
||||||
logStep('append_queue_batch', {
|
logStep('append_queue_batch', {
|
||||||
reason,
|
reason,
|
||||||
added: toAdd.length,
|
added,
|
||||||
queuedCount: queuedIds.size,
|
queuedCount: mixQueueSize(),
|
||||||
songs: songDebug(toAdd),
|
songs: songDebug(toAdd),
|
||||||
});
|
});
|
||||||
return toAdd.length;
|
return added;
|
||||||
};
|
};
|
||||||
|
|
||||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||||
@@ -175,7 +178,10 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
fetched: frequentAlbums.length,
|
fetched: frequentAlbums.length,
|
||||||
withPlays: albumsWithPlays.length,
|
withPlays: albumsWithPlays.length,
|
||||||
});
|
});
|
||||||
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
|
const topArtists = await filterTopArtistsForMixRatings(
|
||||||
|
deriveTopArtistsFromFrequentAlbums(albumsWithPlays),
|
||||||
|
mixRatingCfg,
|
||||||
|
);
|
||||||
const pickedArtists = sampleRandom(topArtists, 2);
|
const pickedArtists = sampleRandom(topArtists, 2);
|
||||||
logStep('pick_top_artists', {
|
logStep('pick_top_artists', {
|
||||||
topArtistsCount: topArtists.length,
|
topArtistsCount: topArtists.length,
|
||||||
@@ -280,7 +286,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
initialPoolCount: pool.length,
|
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();
|
bailIfCancelled();
|
||||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||||
pool = uniqueAppend(pool, rnd);
|
pool = uniqueAppend(pool, rnd);
|
||||||
@@ -289,24 +296,39 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
|
|||||||
batch: i + 1,
|
batch: i + 1,
|
||||||
fetched: rnd.length,
|
fetched: rnd.length,
|
||||||
poolCount: pool.length,
|
poolCount: pool.length,
|
||||||
|
queueCount: mixQueueSize(),
|
||||||
});
|
});
|
||||||
if (reachedTarget()) break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bailIfCancelled();
|
bailIfCancelled();
|
||||||
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
|
if (!reachedTarget()) {
|
||||||
const finalSongs = sampleRandom(poolFiltered, MIX_TARGET_SIZE).filter(s => !queuedIds.has(s.id));
|
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
|
||||||
await appendSongsToQueue(finalSongs, 'finalize-randomized');
|
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', {
|
logStep('final_queue_state', {
|
||||||
poolCount: pool.length,
|
poolCount: pool.length,
|
||||||
queuedCount: queuedIds.size,
|
queuedCount: finalQueueCount,
|
||||||
queuedTarget: MIX_TARGET_SIZE,
|
queuedTarget: MIX_TARGET_SIZE,
|
||||||
});
|
});
|
||||||
if (queuedIds.size === 0) {
|
if (finalQueueCount === 0) {
|
||||||
throw new Error('empty-mix');
|
throw new Error('empty-mix');
|
||||||
}
|
}
|
||||||
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
|
showToast(i18n.t('luckyMix.done', { count: finalQueueCount }), 3500, 'success');
|
||||||
logStep('done', { queueCount: queuedIds.size });
|
logStep('done', { queueCount: finalQueueCount });
|
||||||
if (debugEnabled) {
|
if (debugEnabled) {
|
||||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||||
void invoke('frontend_debug_log', {
|
void invoke('frontend_debug_log', {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtist
|
|||||||
import { getRandomSongs } from '../../api/subsonicLibrary';
|
import { getRandomSongs } from '../../api/subsonicLibrary';
|
||||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { usePlayerStore } from '../../store/playerStore';
|
||||||
|
|
||||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||||
@@ -59,6 +60,14 @@ function numRating(v: unknown): number | undefined {
|
|||||||
return n;
|
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 };
|
type OpenArtistRefLike = { id?: string; userRating?: unknown; rating?: unknown };
|
||||||
|
|
||||||
function refStarRating(a: OpenArtistRefLike | undefined): number | undefined {
|
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. */
|
/** 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 prefer = artistEntityIdForMixRating(song);
|
||||||
|
const fromOverride = mixRatingOverrideForEntity(prefer);
|
||||||
|
if (fromOverride !== undefined) return fromOverride;
|
||||||
const d = numRating(song.artistUserRating);
|
const d = numRating(song.artistUserRating);
|
||||||
if (d !== undefined) return d;
|
if (d !== undefined) return d;
|
||||||
const prefer = artistEntityIdForMixRating(song);
|
|
||||||
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
|
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
|
||||||
if (fromArtists !== undefined) return fromArtists;
|
if (fromArtists !== undefined) return fromArtists;
|
||||||
return ratingFromArtistRefs(song.albumArtists, prefer);
|
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. */
|
/** 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 {
|
||||||
|
const fromOverride = mixRatingOverrideForEntity(song.albumId);
|
||||||
|
if (fromOverride !== undefined) return fromOverride;
|
||||||
const x = song as SubsonicSong & { albumRating?: unknown };
|
const x = song as SubsonicSong & { albumRating?: unknown };
|
||||||
return numRating(song.albumUserRating ?? x.albumRating);
|
return numRating(song.albumUserRating ?? x.albumRating);
|
||||||
}
|
}
|
||||||
|
|
||||||
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
|
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
|
||||||
|
const fromOverride = mixRatingOverrideForEntity(song.id);
|
||||||
|
if (fromOverride !== undefined) return fromOverride;
|
||||||
const x = song as SubsonicSong & { rating?: unknown };
|
const x = song as SubsonicSong & { rating?: unknown };
|
||||||
return numRating(song.userRating ?? x.rating);
|
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,
|
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
|
||||||
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
|
* 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(
|
export async function enrichSongsForMixRatingFilter(
|
||||||
songs: SubsonicSong[],
|
songs: SubsonicSong[],
|
||||||
c: MixMinRatingsConfig,
|
c: MixMinRatingsConfig,
|
||||||
@@ -227,22 +256,11 @@ 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.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
|
||||||
...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
|
||||||
? [...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([
|
const [byArtist, byAlbum] = await Promise.all([
|
||||||
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||||||
@@ -252,13 +270,9 @@ export async function enrichSongsForMixRatingFilter(
|
|||||||
return songs.map(s => {
|
return songs.map(s => {
|
||||||
const aid = artistEntityIdForMixRating(s);
|
const aid = artistEntityIdForMixRating(s);
|
||||||
const artistPatch =
|
const artistPatch =
|
||||||
s.artistUserRating === undefined && aid && byArtist.has(aid)
|
aid && byArtist.has(aid) ? { artistUserRating: byArtist.get(aid)! } : {};
|
||||||
? { artistUserRating: byArtist.get(aid)! }
|
|
||||||
: {};
|
|
||||||
const albumPatch =
|
const albumPatch =
|
||||||
s.albumUserRating === undefined && s.albumId && byAlbum.has(s.albumId)
|
s.albumId && byAlbum.has(s.albumId) ? { albumUserRating: byAlbum.get(s.albumId)! } : {};
|
||||||
? { albumUserRating: byAlbum.get(s.albumId)! }
|
|
||||||
: {};
|
|
||||||
return { ...s, ...artistPatch, ...albumPatch };
|
return { ...s, ...artistPatch, ...albumPatch };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user