mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)
111 of 122 top-level src/utils/ files move into 16 topic folders (audio, cache, cover, share, server, playback, playlist, deviceSync, waveform, mix, format, export, changelog, ui, perf, componentHelpers). True singletons with no cluster stay at the utils/ root. Pure file-move: a path-aware codemod rewrote 539 relative-import specifiers across 275 files; no logic touched. The hot-path coverage gate list (.github/frontend-hot-path-files.txt) is updated to the new paths for the 11 gated utils files — a mechanical consequence of the move, not a CI change. tsc is green.
This commit is contained in:
committed by
GitHub
parent
2409a1fec8
commit
7a7a9f5e6b
@@ -0,0 +1,358 @@
|
||||
import { getSimilarSongs } from '../../api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import type { Track } from '../../store/playerStoreTypes';
|
||||
import { songToTrack } from '../playback/songToTrack';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import i18n from '../../i18n';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
||||
import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
||||
import { showToast } from '../ui/toast';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from './mixRatingFilter';
|
||||
import {
|
||||
MIX_TARGET_SIZE,
|
||||
SEED_TARGET_SIZE,
|
||||
sampleRandom,
|
||||
uniqueBySongId,
|
||||
uniqueAppend,
|
||||
deriveTopArtistsFromFrequentAlbums,
|
||||
fetchFrequentAlbumsPool,
|
||||
pickSongsForArtist,
|
||||
pickSongsForAlbum,
|
||||
pickGoodRatedSongs,
|
||||
} from './luckyMixHelpers';
|
||||
|
||||
/**
|
||||
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
||||
* flips to true. The `catch` handler swallows it silently (no toast, no
|
||||
* queue restore, no error state) — the user already moved on.
|
||||
*/
|
||||
class LuckyMixCancelled extends Error {
|
||||
constructor() {
|
||||
super('lucky-mix-cancelled');
|
||||
this.name = 'LuckyMixCancelled';
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildAndPlayLuckyMix(): Promise<void> {
|
||||
const lucky = useLuckyMixStore.getState();
|
||||
if (lucky.isRolling) return;
|
||||
const auth = useAuthStore.getState();
|
||||
const debugEnabled = auth.loggingMode === 'debug';
|
||||
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
||||
const logStep = (step: string, details?: unknown) => {
|
||||
if (!debugEnabled) return;
|
||||
const payload = { step, details };
|
||||
debugSteps.push(payload);
|
||||
console.debug('[psysonic][lucky-mix]', payload);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
};
|
||||
const songDebug = (songs: SubsonicSong[]) =>
|
||||
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
||||
const albumDebug = (albums: SubsonicAlbum[]) =>
|
||||
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
|
||||
const activeServerId = auth.activeServerId;
|
||||
const available = isLuckyMixAvailable({
|
||||
activeServerId,
|
||||
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');
|
||||
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the current queue *before* we prune — so if the build fails
|
||||
// before we ever play a track, we can put it back the way it was instead
|
||||
// of leaving the user with an empty player.
|
||||
const playerStateBefore = usePlayerStore.getState();
|
||||
const queueSnapshot: { queue: Track[]; queueIndex: number } = {
|
||||
queue: [...playerStateBefore.queue],
|
||||
queueIndex: playerStateBefore.queueIndex,
|
||||
};
|
||||
|
||||
// Drop the old "upcoming" tail immediately so the queue UI does not show stale
|
||||
// next tracks while the mix is still building (first playTrack may be delayed).
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||
|
||||
lucky.start();
|
||||
// Per-run handles. Live outside the try so `finally`/`catch` can read
|
||||
// `startedPlayback` (drives the queue-restore decision) and clean up the
|
||||
// player-store subscription unconditionally.
|
||||
let unsubPlayer: (() => void) | null = null;
|
||||
let startedPlayback = false;
|
||||
try {
|
||||
const queuedIds = new Set<string>();
|
||||
let allSeedSongs: SubsonicSong[] = [];
|
||||
|
||||
const bailIfCancelled = () => {
|
||||
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
||||
};
|
||||
const reachedTarget = () => queuedIds.size >= MIX_TARGET_SIZE;
|
||||
|
||||
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(play.id);
|
||||
const track = songToTrack(play);
|
||||
usePlayerStore.getState().playTrack(track, [track], true);
|
||||
logStep('start_immediate_playback', {
|
||||
source,
|
||||
song: songDebug([play])[0],
|
||||
queuedCount: queuedIds.size,
|
||||
});
|
||||
|
||||
// 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.
|
||||
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;
|
||||
useLuckyMixStore.getState().cancel();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const appendSongsToQueue = async (songs: SubsonicSong[], reason: string): Promise<number> => {
|
||||
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 deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
|
||||
if (!deduped.length) return 0;
|
||||
|
||||
const candidates = [...deduped];
|
||||
if (!startedPlayback && candidates.length > 0) {
|
||||
const first = candidates.shift();
|
||||
if (first) await startImmediatePlayback(first, reason);
|
||||
}
|
||||
|
||||
if (!candidates.length) return 0;
|
||||
const remaining = Math.max(0, MIX_TARGET_SIZE - queuedIds.size);
|
||||
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));
|
||||
logStep('append_queue_batch', {
|
||||
reason,
|
||||
added: toAdd.length,
|
||||
queuedCount: queuedIds.size,
|
||||
songs: songDebug(toAdd),
|
||||
});
|
||||
return toAdd.length;
|
||||
};
|
||||
|
||||
const frequentAlbums = await fetchFrequentAlbumsPool();
|
||||
bailIfCancelled();
|
||||
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
||||
logStep('fetch_frequent_albums', {
|
||||
fetched: frequentAlbums.length,
|
||||
withPlays: albumsWithPlays.length,
|
||||
});
|
||||
const topArtists = deriveTopArtistsFromFrequentAlbums(albumsWithPlays);
|
||||
const pickedArtists = sampleRandom(topArtists, 2);
|
||||
logStep('pick_top_artists', {
|
||||
topArtistsCount: topArtists.length,
|
||||
pickedArtists,
|
||||
});
|
||||
|
||||
for (const artist of pickedArtists) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForArtist(artist, 3, mixRatingCfg);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
||||
logStep('pick_artist_songs', {
|
||||
artist,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
||||
logStep('pick_top_albums', {
|
||||
poolCount: albumsWithPlays.length,
|
||||
pickedAlbums: albumDebug(pickedAlbums),
|
||||
});
|
||||
for (const album of pickedAlbums) {
|
||||
bailIfCancelled();
|
||||
const songs = await pickSongsForAlbum(album.id, 3, mixRatingCfg);
|
||||
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
||||
const firstPlayable = songs[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
||||
logStep('pick_album_songs', {
|
||||
albumId: album.id,
|
||||
pickedCount: songs.length,
|
||||
songs: songDebug(songs),
|
||||
});
|
||||
}
|
||||
|
||||
bailIfCancelled();
|
||||
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 = await filterSongsForLuckyMixRatings(allSeedSongs, mixRatingCfg);
|
||||
logStep('seed_after_dedup', {
|
||||
seedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
|
||||
if (seeds.length < SEED_TARGET_SIZE) {
|
||||
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
|
||||
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
|
||||
bailIfCancelled();
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
|
||||
const allowedRnd = await filterSongsForLuckyMixRatings(rnd, mixRatingCfg);
|
||||
seeds = uniqueAppend(seeds, allowedRnd);
|
||||
const firstPlayable = allowedRnd[0];
|
||||
if (firstPlayable) await startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
||||
logStep('seed_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
seedCount: seeds.length,
|
||||
});
|
||||
}
|
||||
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
||||
logStep('seed_fill_end', {
|
||||
finalSeedCount: seeds.length,
|
||||
seeds: songDebug(seeds),
|
||||
});
|
||||
}
|
||||
|
||||
if (seeds.length === 0) {
|
||||
throw new Error('no-seeds');
|
||||
}
|
||||
if (!startedPlayback) {
|
||||
const firstPlayableSeed = seeds[0];
|
||||
if (firstPlayableSeed) await startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
||||
}
|
||||
|
||||
let similarRaw: SubsonicSong[] = [];
|
||||
let similar: SubsonicSong[] = [];
|
||||
for (let i = 0; i < seeds.length; i++) {
|
||||
bailIfCancelled();
|
||||
const seed = seeds[i];
|
||||
const oneRaw = await getSimilarSongs(seed.id, 60).catch(() => [] as SubsonicSong[]);
|
||||
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
|
||||
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
||||
similar = uniqueAppend(similar, oneScoped);
|
||||
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]);
|
||||
await appendSongsToQueue(seedForPool, 'seed-50pct');
|
||||
logStep('instant_mix', {
|
||||
seedUsedForInstantMixCount: seeds.length,
|
||||
seedIncludedInPoolCount: seedForPool.length,
|
||||
seedIncludedInPool: songDebug(seedForPool),
|
||||
similarRawCount: similarRaw.length,
|
||||
similarScopedCount: similar.length,
|
||||
initialPoolCount: pool.length,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 10 && pool.length < MIX_TARGET_SIZE; i++) {
|
||||
bailIfCancelled();
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
pool = uniqueAppend(pool, rnd);
|
||||
await appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
|
||||
logStep('pool_fill_batch', {
|
||||
batch: i + 1,
|
||||
fetched: rnd.length,
|
||||
poolCount: pool.length,
|
||||
});
|
||||
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');
|
||||
logStep('final_queue_state', {
|
||||
poolCount: pool.length,
|
||||
queuedCount: queuedIds.size,
|
||||
queuedTarget: MIX_TARGET_SIZE,
|
||||
});
|
||||
if (queuedIds.size === 0) {
|
||||
throw new Error('empty-mix');
|
||||
}
|
||||
showToast(i18n.t('luckyMix.done', { count: queuedIds.size }), 3500, 'success');
|
||||
logStep('done', { queueCount: queuedIds.size });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
// Cancellation is a user-initiated path, not an error. Silent teardown.
|
||||
if (err instanceof LuckyMixCancelled) {
|
||||
logStep('cancelled');
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
console.error('[psysonic] lucky mix failed:', err);
|
||||
logStep('failed', { error: String(err) });
|
||||
if (debugEnabled) {
|
||||
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'lucky-mix',
|
||||
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
// If we failed before ever calling playTrack, the queue-prune we did up
|
||||
// front left the user with nothing. Restore the snapshot so they land
|
||||
// back where they were pre-click instead of in an empty player.
|
||||
// If playback did start, leave it alone — their current track plus
|
||||
// whatever we managed to enqueue is more useful than the old queue.
|
||||
if (!startedPlayback) {
|
||||
usePlayerStore.setState({
|
||||
queue: queueSnapshot.queue,
|
||||
queueIndex: queueSnapshot.queueIndex,
|
||||
});
|
||||
logStep('queue_restored_after_failure', {
|
||||
restoredCount: queueSnapshot.queue.length,
|
||||
});
|
||||
}
|
||||
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
||||
} finally {
|
||||
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
||||
useLuckyMixStore.getState().stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { getTopSongs } from '../../api/subsonicArtists';
|
||||
import { filterSongsToActiveLibrary, getAlbum, getAlbumList, getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
type MixMinRatingsConfig,
|
||||
} from './mixRatingFilter';
|
||||
|
||||
export interface TopArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
export const MOST_PLAYED_PAGE_SIZE = 100;
|
||||
export const MOST_PLAYED_MAX_ALBUMS = 500;
|
||||
export const MIX_TARGET_SIZE = 50;
|
||||
export const SEED_TARGET_SIZE = 15;
|
||||
|
||||
export function sampleRandom<T>(items: T[], count: number): T[] {
|
||||
if (count <= 0 || items.length === 0) return [];
|
||||
const arr = [...items];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr.slice(0, Math.min(count, arr.length));
|
||||
}
|
||||
|
||||
export function uniqueBySongId(items: SubsonicSong[]): SubsonicSong[] {
|
||||
const out: SubsonicSong[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const s of items) {
|
||||
if (!s?.id || seen.has(s.id)) continue;
|
||||
seen.add(s.id);
|
||||
out.push(s);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function uniqueAppend(base: SubsonicSong[], incoming: SubsonicSong[]): SubsonicSong[] {
|
||||
return uniqueBySongId([...base, ...incoming]);
|
||||
}
|
||||
|
||||
export function deriveTopArtistsFromFrequentAlbums(albums: SubsonicAlbum[]): TopArtist[] {
|
||||
const map = new Map<string, TopArtist>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (!a.artistId || !a.artist || plays <= 0) continue;
|
||||
const prev = map.get(a.artistId);
|
||||
if (prev) {
|
||||
prev.totalPlays += plays;
|
||||
continue;
|
||||
}
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, totalPlays: plays });
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
export async function fetchFrequentAlbumsPool(): Promise<SubsonicAlbum[]> {
|
||||
const out: SubsonicAlbum[] = [];
|
||||
let offset = 0;
|
||||
while (out.length < MOST_PLAYED_MAX_ALBUMS) {
|
||||
const page = await getAlbumList('frequent', MOST_PLAYED_PAGE_SIZE, offset);
|
||||
if (!page.length) break;
|
||||
out.push(...page);
|
||||
if (page.length < MOST_PLAYED_PAGE_SIZE) break;
|
||||
offset += MOST_PLAYED_PAGE_SIZE;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function pickSongsForArtist(
|
||||
artist: TopArtist,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const primary = uniqueBySongId(await filterSongsToActiveLibrary(await getTopSongs(artist.name)));
|
||||
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]);
|
||||
}
|
||||
const filtered = await filterSongsForLuckyMixRatings(pool, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
|
||||
export async function pickSongsForAlbum(
|
||||
albumId: string,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const full = await getAlbum(albumId).catch(() => null);
|
||||
if (!full?.songs?.length) return [];
|
||||
const scopedSongs = await filterSongsToActiveLibrary(full.songs);
|
||||
const unique = uniqueBySongId(scopedSongs);
|
||||
const filtered = await filterSongsForLuckyMixRatings(unique, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
|
||||
export async function pickGoodRatedSongs(
|
||||
existingIds: Set<string>,
|
||||
need: number,
|
||||
mixRatings: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const out: SubsonicSong[] = [];
|
||||
const push = (s: SubsonicSong) => {
|
||||
const r = s.userRating ?? 0;
|
||||
if (r < 4) return;
|
||||
if (existingIds.has(s.id)) return;
|
||||
if (out.some(x => x.id === s.id)) return;
|
||||
out.push(s);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 14 && out.length < need * 8; i++) {
|
||||
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
||||
rnd.forEach(push);
|
||||
}
|
||||
|
||||
const filtered = await filterSongsForLuckyMixRatings(out, mixRatings);
|
||||
return sampleRandom(filtered, Math.min(need, filtered.length));
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '../../api/subsonicRatings';
|
||||
import { getRandomSongs } from '../../api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||
/** Subsonic spec caps `getRandomSongs` at 500 per call. */
|
||||
const RANDOM_MIX_BATCH_HARD_CAP = 500;
|
||||
const RANDOM_MIX_BATCH_FLOOR = 50;
|
||||
/** Per-call batch size depends on whether a filter is active.
|
||||
* - No filter: ask for the full target in one call (server ORDER BY random LIMIT N is one query).
|
||||
* - With filter: stick to small 50-track batches — large batches waste bandwidth when only a small
|
||||
* fraction of candidates pass the filter, and they make every loop iteration slow on the server. */
|
||||
function batchSizeFor(targetSize: number, filterActive: boolean): number {
|
||||
if (filterActive) return RANDOM_MIX_BATCH_FLOOR;
|
||||
return Math.min(RANDOM_MIX_BATCH_HARD_CAP, Math.max(RANDOM_MIX_BATCH_FLOOR, targetSize));
|
||||
}
|
||||
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme).
|
||||
* Filtered mode is generous because a selective filter (e.g. only ≥3★ in a library where 5% of tracks are
|
||||
* rated) needs many candidates to find a target's worth of passes. Unfiltered mode just tops up the
|
||||
* fast-path's first call. */
|
||||
function maxBatchesFor(targetSize: number, batchSize: number, filterActive: boolean): number {
|
||||
if (filterActive) {
|
||||
return Math.max(40, Math.ceil((targetSize * 8) / batchSize));
|
||||
}
|
||||
return Math.max(8, Math.ceil((targetSize * 4) / batchSize));
|
||||
}
|
||||
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set).
|
||||
* Scales with target size: at 50 a 6-batch floor is fine; at 150 we tolerate ~25 empty-novel batches
|
||||
* before giving up so libraries with weakly-shuffled random endpoints can still fill the larger requests.
|
||||
* Without this, "All Songs" mixes at 150 stalled around 120–145 while Genre mixes (smaller candidate pool,
|
||||
* lower repeat rate per batch) could reach 150 cleanly. */
|
||||
function dupStreakBudget(targetSize: number): number {
|
||||
return Math.max(8, Math.ceil(targetSize / 6));
|
||||
}
|
||||
|
||||
export interface MixMinRatingsConfig {
|
||||
enabled: boolean;
|
||||
minSong: number;
|
||||
minAlbum: number;
|
||||
minArtist: number;
|
||||
}
|
||||
|
||||
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
|
||||
const s = useAuthStore.getState();
|
||||
return {
|
||||
enabled: s.mixMinRatingFilterEnabled,
|
||||
minSong: s.mixMinRatingSong,
|
||||
minAlbum: s.mixMinRatingAlbum,
|
||||
minArtist: s.mixMinRatingArtist,
|
||||
};
|
||||
}
|
||||
|
||||
function numRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return 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: OpenArtistRefLike[] | undefined,
|
||||
preferId?: string,
|
||||
): number | undefined {
|
||||
if (!list?.length) return undefined;
|
||||
if (preferId) {
|
||||
const m = list.find(x => x.id === preferId);
|
||||
const r = refStarRating(m);
|
||||
if (r !== undefined) return r;
|
||||
}
|
||||
for (const a of list) {
|
||||
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 prefer = artistEntityIdForMixRating(song);
|
||||
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
|
||||
if (fromArtists !== undefined) return fromArtists;
|
||||
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 {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
|
||||
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
|
||||
*/
|
||||
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minSong > 0) {
|
||||
const r = songTrackStarRatingForMix(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minSong) return false;
|
||||
}
|
||||
if (c.minAlbum > 0) {
|
||||
const r = effectiveAlbumRatingOnSong(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||
}
|
||||
if (c.minArtist > 0) {
|
||||
const r = effectiveArtistRatingForFilter(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MixAlbumFilterExtra {
|
||||
/** From `getArtist` when list payloads omit artist rating. */
|
||||
artistUserRating?: number;
|
||||
/** From `getAlbum` when list payloads omit album `userRating`. */
|
||||
albumUserRating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album lists: album `userRating` when present; optional extra from entity fetches.
|
||||
* Song axis is not on this payload. `0` / missing = unrated, keep.
|
||||
*/
|
||||
export function passesMixMinRatingsForAlbum(
|
||||
album: SubsonicAlbum,
|
||||
c: MixMinRatingsConfig,
|
||||
extra?: MixAlbumFilterExtra,
|
||||
): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minAlbum > 0) {
|
||||
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) {
|
||||
const r = numRating(extra?.artistUserRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
|
||||
*/
|
||||
export async function filterAlbumsByMixRatings(
|
||||
albums: SubsonicAlbum[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!c.enabled) return albums;
|
||||
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
|
||||
const needArtist = c.minArtist > 0;
|
||||
const needAlbum = c.minAlbum > 0;
|
||||
let byArtist = new Map<string, number>();
|
||||
let byAlbum = new Map<string, number>();
|
||||
if (needArtist) {
|
||||
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
|
||||
byArtist = await prefetchArtistUserRatings(ids);
|
||||
}
|
||||
if (needAlbum) {
|
||||
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
|
||||
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
|
||||
}
|
||||
return albums.filter(a =>
|
||||
passesMixMinRatingsForAlbum(a, c, {
|
||||
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
|
||||
albumUserRating: byAlbum.get(a.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 when list payloads omit them,
|
||||
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
|
||||
*/
|
||||
export async function enrichSongsForMixRatingFilter(
|
||||
songs: SubsonicSong[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
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)!),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
const albumIds =
|
||||
c.minAlbum > 0
|
||||
? [...new Set(songs.filter(s => s.albumUserRating === undefined && s.albumId).map(s => s.albumId!))]
|
||||
: [];
|
||||
const [byArtist, byAlbum] = await Promise.all([
|
||||
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||||
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
|
||||
]);
|
||||
if (!byArtist.size && !byAlbum.size) return songs;
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads random songs in batches until `targetSize` (or `RANDOM_MIX_TARGET_SIZE`) songs
|
||||
* pass `passesMixMinRatings` (after enrich), or batch/duplicate limits are hit.
|
||||
*
|
||||
* When NO filter is active (neither `enabled` nor `onlyRatedMinStars`), a single
|
||||
* batch fast-path is used and capped to a single batch — for `targetSize` greater
|
||||
* than `RANDOM_MIX_BATCH_SIZE` (50) the loop path is taken so we can issue multiple
|
||||
* `getRandomSongs` calls.
|
||||
*/
|
||||
export async function fetchRandomMixSongsUntilFull(
|
||||
c: MixMinRatingsConfig,
|
||||
opts?: { genre?: string; timeout?: number; targetSize?: number },
|
||||
): Promise<SubsonicSong[]> {
|
||||
const timeout = opts?.timeout ?? 15000;
|
||||
const genre = opts?.genre;
|
||||
const targetSize = opts?.targetSize ?? RANDOM_MIX_TARGET_SIZE;
|
||||
const filterActive = c.enabled;
|
||||
const batchSize = batchSizeFor(targetSize, filterActive);
|
||||
|
||||
// Fast-path: no filter — one call asking for the full target, slice, done. The server-side
|
||||
// `ORDER BY random() LIMIT N` returns N distinct rows, so a single round-trip usually fills
|
||||
// the request without dup-streak gymnastics.
|
||||
if (!filterActive) {
|
||||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||||
if (raw.length >= targetSize) return raw.slice(0, targetSize);
|
||||
// Library smaller than target, or random endpoint returned fewer — fall through to the
|
||||
// batched loop below so we can top up via additional calls (deduped by id).
|
||||
}
|
||||
|
||||
const maxBatches = maxBatchesFor(targetSize, batchSize, filterActive);
|
||||
const maxDupStreak = dupStreakBudget(targetSize);
|
||||
|
||||
const out: SubsonicSong[] = [];
|
||||
const outIds = new Set<string>();
|
||||
const seenFromApi = new Set<string>();
|
||||
let dupStreak = 0;
|
||||
|
||||
for (let b = 0; b < maxBatches && out.length < targetSize; b++) {
|
||||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||||
if (!raw.length) break;
|
||||
|
||||
const novel = raw.filter(s => !seenFromApi.has(s.id));
|
||||
for (const s of raw) seenFromApi.add(s.id);
|
||||
|
||||
if (!novel.length) {
|
||||
if (++dupStreak >= maxDupStreak) break;
|
||||
continue;
|
||||
}
|
||||
dupStreak = 0;
|
||||
|
||||
const enriched = filterActive
|
||||
? await enrichSongsForMixRatingFilter(novel, c)
|
||||
: novel;
|
||||
for (const s of enriched) {
|
||||
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
|
||||
outIds.add(s.id);
|
||||
out.push(s);
|
||||
if (out.length >= targetSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user