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
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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é',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -340,6 +340,10 @@ export const ruTranslation = {
|
||||
favoriteRemove: 'Убрать из избранного',
|
||||
play: 'Играть',
|
||||
trackGenre: 'Жанр',
|
||||
mixSettingsHeader: 'Настройки микса',
|
||||
exclusionsHeader: 'Исключения',
|
||||
filterPanelInexactSizeNote: 'Размер микса — это цель. При больших запросах сервер может вернуть меньше уникальных треков, если его случайный пул иссякает.',
|
||||
mixSize: 'Размер списка',
|
||||
excludeAudiobooks: 'Исключать аудиокниги и радиоспектакли',
|
||||
excludeAudiobooksDesc:
|
||||
'По ключевым словам в жанре, названии, альбоме и исполнителе — например Hörbuch, Audiobook, Spoken Word и т. п.',
|
||||
|
||||
@@ -321,6 +321,10 @@ export const zhTranslation = {
|
||||
favoriteRemove: '从收藏中移除',
|
||||
play: '播放',
|
||||
trackGenre: '流派',
|
||||
mixSettingsHeader: '混音设置',
|
||||
exclusionsHeader: '排除项',
|
||||
filterPanelInexactSizeNote: '混音大小是一个目标 — 当服务器的随机池耗尽时,较大的请求可能会返回较少的唯一曲目。',
|
||||
mixSize: '列表大小',
|
||||
excludeAudiobooks: '排除有声读物和广播剧',
|
||||
excludeAudiobooksDesc: '根据流派、标题、专辑和艺术家匹配关键词 — 例如 Hörbuch、Audiobook、Spoken Word 等',
|
||||
genreBlocked: '关键词已屏蔽',
|
||||
|
||||
+49
-12
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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