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:
Frank Stellmacher
2026-05-03 19:57:08 +02:00
committed by GitHub
parent 1799e90e04
commit 3b4d54431b
13 changed files with 173 additions and 29 deletions
+59 -17
View File
@@ -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 120145 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;
}
}