refactor(player): M0 — extract pure helpers from playerStore.ts (#554)

Moves four self-contained helpers into src/utils/, each with co-located
characterization tests. playerStore re-exports them for the ~30 existing
call sites; Phase E will migrate those imports.

  - shuffleArray              (Fisher-Yates, generic)
  - resolveReplayGainDb       (track/album/auto mode resolution)
  - songToTrack               (Subsonic -> Track shape)
  - buildInfiniteQueueCandidates  (Instant-Mix top-up source)

playerStore.ts: 3732 -> 3618 LOC (-114).
This commit is contained in:
Frank Stellmacher
2026-05-12 01:24:04 +02:00
committed by GitHub
parent 6afbdf9c60
commit d3a8160b37
11 changed files with 616 additions and 329 deletions
+61
View File
@@ -0,0 +1,61 @@
import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic';
import {
enrichSongsForMixRatingFilter,
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from './mixRatingFilter';
import { shuffleArray } from './shuffleArray';
import { songToTrack } from './songToTrack';
import type { Track } from '../store/playerStore';
/**
* Infinite queue source strategy (Instant Mix-like):
* 1) Prefer artist-driven candidates (Top + Similar) around the current track.
* 2) Fallback to random songs when artist-driven fetches are empty.
*/
export async function buildInfiniteQueueCandidates(
seedTrack: Track | null,
existingIds: Set<string>,
count = 5,
): Promise<Track[]> {
const RANDOM_TOPUP_BATCH_SIZE = Math.max(10, count * 2);
const RANDOM_TOPUP_MAX_BATCHES = 8;
const artistId = seedTrack?.artistId?.trim() || null;
const artistName = seedTrack?.artist?.trim() || null;
const [similar, top] = await Promise.all([
artistId ? getSimilarSongs2(artistId).catch(() => []) : Promise.resolve([]),
artistName ? getTopSongs(artistName).catch(() => []) : Promise.resolve([]),
]);
const seedId = seedTrack?.id ?? null;
const mixCfg = getMixMinRatingsConfigFromAuth();
const mixedSources = [...top, ...similar];
const filteredMixedSongs = mixCfg.enabled
? (await enrichSongsForMixRatingFilter(mixedSources, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg))
: mixedSources;
const out: Track[] = shuffleArray(
filteredMixedSongs
.map(songToTrack)
.filter(t => t.id !== seedId && !existingIds.has(t.id)),
)
.slice(0, count)
.map(t => ({ ...t, autoAdded: true as const }));
const seenIds = new Set<string>([...existingIds, ...out.map(t => t.id)]);
for (let b = 0; out.length < count && b < RANDOM_TOPUP_MAX_BATCHES; b++) {
const random = await getRandomSongs(RANDOM_TOPUP_BATCH_SIZE, seedTrack?.genre).catch(() => []);
if (!random.length) break;
const filteredRandomSongs = mixCfg.enabled
? (await enrichSongsForMixRatingFilter(random, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg))
: random;
for (const track of shuffleArray(filteredRandomSongs.map(songToTrack))) {
if (track.id === seedId || seenIds.has(track.id)) continue;
out.push({ ...track, autoAdded: true as const });
seenIds.add(track.id);
if (out.length >= count) break;
}
}
return out.slice(0, count);
}