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
+11
View File
@@ -166,6 +166,17 @@ Every song card across the app whose rating is greater than zero now shows a sma
Backed by an opt-in 60 s in-memory cache for `ndListSongs` (used only by the new rail; paginated browsing is unaffected). The cache is cleared automatically when you rate a track, switch server, or click the rail's reroll button.
### Random Mix — Playlist Size Selector and Filter Panel Cleanup
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#445](https://github.com/Psychotoxical/psysonic/pull/445), prompted by Foxhunter-de in discussion [#442](https://github.com/Psychotoxical/psysonic/discussions/442)**
Random Mix can now build longer mixes. A new playlist-size picker (50 / 75 / 100 / 125 / 150) sits at the top of the filter panel. Clicking a size button immediately reloads the current mix at the new count — Genre Mix and All Songs both honored, no extra Remix click needed. The choice is persisted across restarts.
The filter panel layout was tightened up at the same time: two sub-headings (**MIX SETTINGS** and **EXCLUSIONS**) with a divider between them, and a slightly larger panel-level header so the visual hierarchy reads cleanly. A small italic note below MIX SETTINGS explains that large mix sizes may return fewer unique tracks if the server's random pool runs short.
Under the hood, `fetchRandomMixSongsUntilFull` now scales batch size, max-batch ceiling and dup-streak budget with the requested target — so a 150-track mix can finish in a single round-trip on most libraries instead of stalling out at ~120.
## Fixed
- **Settings → Audio no longer blanks the app on macOS** *(Issue [#382](https://github.com/Psychotoxical/psysonic/issues/382), PR [#384](https://github.com/Psychotoxical/psysonic/pull/384), by [@Psychotoxical](https://github.com/Psychotoxical))*: Fixed a macOS-only crash where opening Settings → Audio could turn the whole app into a blank window. The Equalizer canvas now waits until it has valid layout dimensions before drawing, and redraws automatically once the section is visible.
+4
View File
@@ -323,6 +323,10 @@ export const deTranslation = {
favoriteRemove: 'Aus Favoriten entfernen',
play: 'Abspielen',
trackGenre: 'Genre',
mixSettingsHeader: 'Mix-Einstellungen',
exclusionsHeader: 'Ausschlüsse',
filterPanelInexactSizeNote: 'Die Mix-Größe ist ein Ziel — bei großen Anfragen kann der Server weniger eindeutige Tracks liefern, wenn sein Zufallspool erschöpft ist.',
mixSize: 'Playlistgröße',
excludeAudiobooks: 'Hörbücher & Hörspiele ausschließen',
excludeAudiobooksDesc: 'Prüft Keywords gegen Genre, Titel, Album und Künstler — z. B. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword gesperrt',
+4
View File
@@ -325,6 +325,10 @@ export const enTranslation = {
favoriteRemove: 'Remove from Favorites',
play: 'Play',
trackGenre: 'Genre',
mixSettingsHeader: 'Mix Settings',
exclusionsHeader: 'Exclusions',
filterPanelInexactSizeNote: 'Mix size is a target — large requests may return fewer unique tracks if the server\'s random pool runs short.',
mixSize: 'Playlist size',
excludeAudiobooks: 'Exclude audiobooks & radio plays',
excludeAudiobooksDesc: 'Matches keywords against genre, title, album, and artist — e.g. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Keyword blocked',
+4
View File
@@ -324,6 +324,10 @@ export const esTranslation = {
favoriteRemove: 'Quitar de Favoritos',
play: 'Reproducir',
trackGenre: 'Género',
mixSettingsHeader: 'Ajustes del mix',
exclusionsHeader: 'Exclusiones',
filterPanelInexactSizeNote: 'El tamaño del mix es un objetivo — las solicitudes grandes pueden devolver menos pistas únicas si el grupo aleatorio del servidor se agota.',
mixSize: 'Tamaño de lista',
excludeAudiobooks: 'Excluir audiolibros y radioteatros',
excludeAudiobooksDesc: 'Busca palabras clave en género, título, álbum y artista — ej. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Palabra clave bloqueada',
+4
View File
@@ -323,6 +323,10 @@ export const frTranslation = {
favoriteRemove: 'Retirer des favoris',
play: 'Lire',
trackGenre: 'Genre',
mixSettingsHeader: 'Réglages du mix',
exclusionsHeader: 'Exclusions',
filterPanelInexactSizeNote: 'La taille du mix est une cible — les grandes requêtes peuvent renvoyer moins de morceaux uniques si le pool aléatoire du serveur s\'épuise.',
mixSize: 'Taille de liste',
excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques',
excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre, l\'album et l\'artiste — ex. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Mot-clé bloqué',
+4
View File
@@ -323,6 +323,10 @@ export const nbTranslation = {
favoriteRemove: 'Fjern fra favoritter',
play: 'Spill',
trackGenre: 'Sjanger',
mixSettingsHeader: 'Miks-innstillinger',
exclusionsHeader: 'Ekskluderinger',
filterPanelInexactSizeNote: 'Miks-størrelsen er et mål — store forespørsler kan returnere færre unike spor hvis serverens tilfeldige pool tar slutt.',
mixSize: 'Listestørrelse',
excludeAudiobooks: 'Ekskluder lydbøker og hørespill',
excludeAudiobooksDesc: 'Samsvarer nøkkelord mot sjanger, tittel, album og artist - f.eks. Hørespill, Lydbok, Spoken Word, …',
genreBlocked: 'Nøkkelord blokkert',
+4
View File
@@ -322,6 +322,10 @@ export const nlTranslation = {
favoriteRemove: 'Uit favorieten verwijderen',
play: 'Afspelen',
trackGenre: 'Genre',
mixSettingsHeader: 'Mixinstellingen',
exclusionsHeader: 'Uitsluitingen',
filterPanelInexactSizeNote: 'De mixgrootte is een doel — grote verzoeken kunnen minder unieke nummers opleveren als de willekeurige pool van de server opraakt.',
mixSize: 'Lijstgrootte',
excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten',
excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel, album en artiest — bijv. Hörbuch, Audiobook, Spoken Word, …',
genreBlocked: 'Trefwoord geblokkeerd',
+4
View File
@@ -340,6 +340,10 @@ export const ruTranslation = {
favoriteRemove: 'Убрать из избранного',
play: 'Играть',
trackGenre: 'Жанр',
mixSettingsHeader: 'Настройки микса',
exclusionsHeader: 'Исключения',
filterPanelInexactSizeNote: 'Размер микса — это цель. При больших запросах сервер может вернуть меньше уникальных треков, если его случайный пул иссякает.',
mixSize: 'Размер списка',
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
excludeAudiobooksDesc:
'По ключевым словам в жанре, названии, альбоме и исполнителе — например Hörbuch, Audiobook, Spoken Word и т. п.',
+4
View File
@@ -321,6 +321,10 @@ export const zhTranslation = {
favoriteRemove: '从收藏中移除',
play: '播放',
trackGenre: '流派',
mixSettingsHeader: '混音设置',
exclusionsHeader: '排除项',
filterPanelInexactSizeNote: '混音大小是一个目标 — 当服务器的随机池耗尽时,较大的请求可能会返回较少的唯一曲目。',
mixSize: '列表大小',
excludeAudiobooks: '排除有声读物和广播剧',
excludeAudiobooksDesc: '根据流派、标题、专辑和艺术家匹配关键词 — 例如 Hörbuch、Audiobook、Spoken Word 等',
genreBlocked: '关键词已屏蔽',
+49 -12
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { getGenres, SubsonicSong, SubsonicGenre, star, unstar } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePreviewStore } from '../store/previewStore';
import { useAuthStore } from '../store/authStore';
import { useAuthStore, RANDOM_MIX_SIZE_OPTIONS } from '../store/authStore';
import { Play, RefreshCw, ChevronDown, ChevronRight, ChevronUp, Heart, Square } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext';
@@ -56,6 +56,8 @@ export default function RandomMix() {
mixMinRatingSong,
mixMinRatingAlbum,
mixMinRatingArtist,
randomMixSize,
setRandomMixSize,
} = useAuthStore();
const mixRatingCfg = useMemo(
@@ -88,10 +90,10 @@ export default function RandomMix() {
const [genreMixLoading, setGenreMixLoading] = useState(false);
const [genreMixComplete, setGenreMixComplete] = useState(false);
const fetchSongs = () => {
const fetchSongs = (overrideSize?: number) => {
setLoading(true);
setSongs([]);
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth())
fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), { targetSize: overrideSize ?? randomMixSize })
.then(list => {
setSongs(list);
const st = new Set<string>();
@@ -163,7 +165,7 @@ export default function RandomMix() {
}
};
const loadGenreMix = async (genre: string) => {
const loadGenreMix = async (genre: string, overrideSize?: number) => {
setGenreMixLoading(true);
setGenreMixComplete(false);
setGenreMixSongs([]);
@@ -171,6 +173,7 @@ export default function RandomMix() {
const list = await fetchRandomMixSongsUntilFull(getMixMinRatingsConfigFromAuth(), {
genre,
timeout: 45000,
targetSize: overrideSize ?? randomMixSize,
});
setGenreMixSongs(list);
} catch {}
@@ -195,7 +198,7 @@ export default function RandomMix() {
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
className="btn btn-surface"
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : fetchSongs}
onClick={selectedGenre ? () => loadGenreMix(selectedGenre) : () => fetchSongs()}
disabled={selectedGenre ? genreMixLoading : loading}
data-tooltip={selectedGenre
? t('randomMix.remixTooltipGenre', { genre: selectedGenre })
@@ -215,7 +218,7 @@ export default function RandomMix() {
disabled={isDisabled}
>
{isGenreLoading ? (
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, 50)} / 50</>
<><div className="spinner" style={{ width: 14, height: 14, borderWidth: 2 }} /> {Math.min(genreMixSongs.length, randomMixSize)} / {randomMixSize}</>
) : (
<><Play size={18} fill="currentColor" /> {t('randomMix.playAll')}</>
)}
@@ -241,24 +244,58 @@ export default function RandomMix() {
{isMobile ? (
<button
className="btn btn-ghost"
style={{ width: '100%', justifyContent: 'space-between', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', padding: '0' }}
style={{ width: '100%', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', padding: '0' }}
onClick={() => setFiltersExpanded(v => !v)}
>
{t('randomMix.filterPanelTitle')}
{filtersExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
)}
{(!isMobile || filtersExpanded) && (
<div style={{ marginTop: isMobile ? '0.75rem' : 0 }}>
<p style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem', lineHeight: 1.5 }}>
{/* ── Mix Settings ── */}
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.4rem' }}>
{t('randomMix.mixSettingsHeader')}
</div>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 0, marginBottom: '0.6rem', lineHeight: 1.45, fontStyle: 'italic' }}>
{t('randomMix.filterPanelInexactSizeNote')}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.25rem' }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('randomMix.mixSize')}</span>
{RANDOM_MIX_SIZE_OPTIONS.map(n => (
<button
key={n}
className={`btn ${randomMixSize === n ? 'btn-primary' : 'btn-surface'}`}
style={{ fontSize: 12, padding: '3px 10px' }}
onClick={() => {
if (n === randomMixSize) return;
setRandomMixSize(n);
if (selectedGenre) loadGenreMix(selectedGenre, n);
else fetchSongs(n);
}}
>{n}</button>
))}
</div>
{/* ── divider ── */}
<div style={{ borderTop: '1px solid var(--border)', margin: '0.85rem 0' }} />
{/* ── Exclusions ── */}
<div style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.5rem' }}>
{t('randomMix.exclusionsHeader')}
</div>
<p style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: '0.6rem', lineHeight: 1.45 }}>
{t('randomMix.filterPanelDesc')}
</p>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', cursor: 'pointer', fontSize: 13, marginBottom: '0.6rem' }}>
<input
type="checkbox"
checked={excludeAudiobooks}
@@ -340,14 +377,14 @@ export default function RandomMix() {
{isMobile ? (
<button
className="btn btn-ghost"
style={{ width: '100%', justifyContent: 'space-between', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', padding: '0' }}
style={{ width: '100%', justifyContent: 'space-between', fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', padding: '0' }}
onClick={() => setGenreMixExpanded(v => !v)}
>
{t('randomMix.genreMixTitle')}
{genreMixExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
) : (
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
<div style={{ fontSize: 14, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-secondary)', marginBottom: '0.85rem' }}>
{t('randomMix.genreMixTitle')}
</div>
)}
+1
View File
@@ -351,6 +351,7 @@ const CONTRIBUTORS = [
'Audio: frame-align gapless-off track-separation silence (fixes mono-channel playback after natural track end) (PR #439)',
'Settings: 3-state animation mode (Full / Reduced / Static) — replaces boolean reduce-animations toggle (PR #441)',
'Tracks: Highly Rated rail and per-card star display, with cache layer for ndListSongs (PR #443)',
'Random Mix: playlist-size picker (50/75/100/125/150) and filter-panel layout cleanup (PR #445)',
],
},
] as const;
+21
View File
@@ -220,6 +220,8 @@ interface AuthState {
mixMinRatingAlbum: number;
/** 0 = ignore; artist rating from payload / nested OpenSubsonic fields or `getArtist`. */
mixMinRatingArtist: number;
/** Random Mix target list size (50, 75, 100, 125, or 150). */
randomMixSize: number;
/** Show "Lucky Mix" as a regular sidebar/menu item. */
showLuckyMixMenu: boolean;
@@ -346,6 +348,7 @@ interface AuthState {
setMixMinRatingSong: (v: number) => void;
setMixMinRatingAlbum: (v: number) => void;
setMixMinRatingArtist: (v: number) => void;
setRandomMixSize: (v: number) => void;
setShowLuckyMixMenu: (v: boolean) => void;
setMusicFolders: (folders: Array<{ id: string; name: string }>) => void;
setMusicLibraryFilter: (folderId: 'all' | string) => void;
@@ -373,6 +376,21 @@ function clampMixFilterMinStars(v: number): number {
return Math.max(0, Math.min(MIX_MIN_RATING_FILTER_MAX_STARS, Math.round(v)));
}
export const RANDOM_MIX_SIZE_OPTIONS: readonly number[] = [50, 75, 100, 125, 150];
function clampRandomMixSize(v: number): number {
if (!Number.isFinite(v)) return 50;
// Snap to the nearest allowed option so a tampered persisted value can't break the picker.
let nearest = RANDOM_MIX_SIZE_OPTIONS[0];
let bestDelta = Math.abs(v - nearest);
for (const opt of RANDOM_MIX_SIZE_OPTIONS) {
const d = Math.abs(v - opt);
if (d < bestDelta) { nearest = opt; bestDelta = d; }
}
return nearest;
}
function clampSkipStarThreshold(v: number): number {
if (!Number.isFinite(v)) return 3;
return Math.max(1, Math.min(99, Math.round(v)));
@@ -468,6 +486,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: 0,
mixMinRatingAlbum: 0,
mixMinRatingArtist: 0,
randomMixSize: 50,
showLuckyMixMenu: true,
randomNavMode: 'hub',
musicFolders: [],
@@ -662,6 +681,7 @@ export const useAuthStore = create<AuthState>()(
setMixMinRatingSong: (v) => set({ mixMinRatingSong: clampMixFilterMinStars(v) }),
setMixMinRatingAlbum: (v) => set({ mixMinRatingAlbum: clampMixFilterMinStars(v) }),
setMixMinRatingArtist: (v) => set({ mixMinRatingArtist: clampMixFilterMinStars(v) }),
setRandomMixSize: (v) => set({ randomMixSize: clampRandomMixSize(v) }),
setShowLuckyMixMenu: (v) => set({ showLuckyMixMenu: v }),
setRandomNavMode: (v) => set({ randomNavMode: v }),
@@ -848,6 +868,7 @@ export const useAuthStore = create<AuthState>()(
mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number),
mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number),
mixMinRatingArtist: clampMixFilterMinStars(state.mixMinRatingArtist as number),
randomMixSize: clampRandomMixSize(state.randomMixSize as number),
skipStarManualSkipCountsByKey: sanitizeSkipStarCounts(
(state as { skipStarManualSkipCountsByKey?: unknown }).skipStarManualSkipCountsByKey,
),
+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;
}
}