mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
feat(random-mix): playlist size selector + filter panel layout cleanup (#445)
* feat(random-mix): playlist size selector + filter panel layout cleanup Adds a 5-button playlist-size picker (50/75/100/125/150) at the top of the Random Mix filter panel, persisted via authStore. Clicking a size immediately reruns the current mix (genre-scoped or All Songs) at the new size — no second click on Remix needed. Filter panel layout cleaned up: - Two sub-sections "MIX SETTINGS" and "EXCLUSIONS" with a divider between them so the panel reads cleanly with the new size row. - Larger panel-level headers (FILTERS / GENRE MIX) so the hierarchy panel-title > sub-section is visually unambiguous. - Italic muted note under MIX SETTINGS calling out that large mix sizes may return fewer unique tracks if the server's random pool runs short — sets honest expectations instead of users wondering why a 150 request returned ~126. fetchRandomMixSongsUntilFull now scales batch size, max-batch ceiling and dup-streak budget with target size; when no Settings-level mix filter is active, the first call asks for the full target so a 150 mix can finish in a single round-trip on most libraries. The loop falls through to top up with deduped follow-up calls if the server returns fewer than requested. * docs(changelog): add #445 Random Mix playlist size selector entry * chore(credits): add #445 to Psychotoxical contributions
This commit is contained in:
committed by
GitHub
parent
1799e90e04
commit
3b4d54431b
@@ -8,13 +8,37 @@ import {
|
||||
} from '../api/subsonic';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
|
||||
/** Target list size for Random Mix after rating filter. */
|
||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||
const RANDOM_MIX_BATCH_SIZE = 50;
|
||||
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme). */
|
||||
const RANDOM_MIX_MAX_BATCHES = 40;
|
||||
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set). */
|
||||
const RANDOM_MIX_MAX_DUP_STREAK = 6;
|
||||
/** 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;
|
||||
@@ -245,45 +269,63 @@ export async function enrichSongsForMixRatingFilter(
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads random songs in batches until `RANDOM_MIX_TARGET_SIZE` pass `passesMixMinRatings` (after enrich),
|
||||
* or limits are hit. When the mix rating filter is off, a single batch is used.
|
||||
* 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 },
|
||||
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);
|
||||
|
||||
if (!c.enabled) {
|
||||
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||
return raw.slice(0, RANDOM_MIX_TARGET_SIZE);
|
||||
// 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 < RANDOM_MIX_MAX_BATCHES && out.length < RANDOM_MIX_TARGET_SIZE; b++) {
|
||||
const raw = await getRandomSongs(RANDOM_MIX_BATCH_SIZE, genre, timeout);
|
||||
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 >= RANDOM_MIX_MAX_DUP_STREAK) break;
|
||||
if (++dupStreak >= maxDupStreak) break;
|
||||
continue;
|
||||
}
|
||||
dupStreak = 0;
|
||||
|
||||
const enriched = await enrichSongsForMixRatingFilter(novel, c);
|
||||
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 >= RANDOM_MIX_TARGET_SIZE) break;
|
||||
if (out.length >= targetSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user