From d3a8160b37681973cadbd35b936c8519aa3f21f3 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 01:24:04 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20M0=20=E2=80=94=20extract=20?= =?UTF-8?q?pure=20helpers=20from=20playerStore.ts=20(#554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .github/frontend-hot-path-files.txt | 6 + src/store/playerStore.trackShape.test.ts | 205 --------------- src/store/playerStore.ts | 134 +--------- .../buildInfiniteQueueCandidates.test.ts | 246 ++++++++++++++++++ src/utils/buildInfiniteQueueCandidates.ts | 61 +++++ src/utils/resolveReplayGainDb.test.ts | 75 ++++++ src/utils/resolveReplayGainDb.ts | 32 +++ src/utils/shuffleArray.test.ts | 51 ++++ src/utils/shuffleArray.ts | 11 + src/utils/songToTrack.test.ts | 96 +++++++ src/utils/songToTrack.ts | 28 ++ 11 files changed, 616 insertions(+), 329 deletions(-) delete mode 100644 src/store/playerStore.trackShape.test.ts create mode 100644 src/utils/buildInfiniteQueueCandidates.test.ts create mode 100644 src/utils/buildInfiniteQueueCandidates.ts create mode 100644 src/utils/resolveReplayGainDb.test.ts create mode 100644 src/utils/resolveReplayGainDb.ts create mode 100644 src/utils/shuffleArray.test.ts create mode 100644 src/utils/shuffleArray.ts create mode 100644 src/utils/songToTrack.test.ts create mode 100644 src/utils/songToTrack.ts diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt index fef58350..620b436d 100644 --- a/.github/frontend-hot-path-files.txt +++ b/.github/frontend-hot-path-files.txt @@ -32,5 +32,11 @@ src/utils/dynamicColors.ts src/utils/resolvePlaybackUrl.ts src/utils/copyEntityShareLink.ts +# ── M0: pure helpers extracted from playerStore.ts (2026-05-12) ────── +src/utils/shuffleArray.ts +src/utils/resolveReplayGainDb.ts +src/utils/songToTrack.ts +src/utils/buildInfiniteQueueCandidates.ts + # ── stores (added as their tests grew past the floor) ──────────────── src/store/previewStore.ts diff --git a/src/store/playerStore.trackShape.test.ts b/src/store/playerStore.trackShape.test.ts deleted file mode 100644 index ba6f205c..00000000 --- a/src/store/playerStore.trackShape.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Pure-helper characterization for `playerStore` mapping + utility functions. - * - * Scope: `songToTrack`, `resolveReplayGainDb`, `shuffleArray`. No store - * mutation — these are exported pure functions used by enqueue paths and the - * server-queue restore. - * - * Pinned as part of Phase F1 / PR 2a (pre-refactor testing plan, 2026-05-11). - */ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { resolveReplayGainDb, shuffleArray, songToTrack, type Track } from './playerStore'; -import { makeSubsonicSong } from '@/test/helpers/factories'; - -describe('songToTrack', () => { - it('maps required Subsonic fields verbatim', () => { - const song = makeSubsonicSong({ - id: 's1', - title: 'Hello', - artist: 'World', - album: 'Sample', - albumId: 'a1', - duration: 240, - }); - const t = songToTrack(song); - expect(t.id).toBe('s1'); - expect(t.title).toBe('Hello'); - expect(t.artist).toBe('World'); - expect(t.album).toBe('Sample'); - expect(t.albumId).toBe('a1'); - expect(t.duration).toBe(240); - }); - - it('copies optional fields when present', () => { - const song = makeSubsonicSong({ - id: 's2', - artistId: 'ar-1', - track: 5, - year: 2024, - bitRate: 320, - suffix: 'flac', - userRating: 4, - starred: '2026-05-01T00:00:00Z', - genre: 'Rock', - samplingRate: 48000, - bitDepth: 24, - size: 9_000_000, - coverArt: 's2', - }); - const t = songToTrack(song); - expect(t.artistId).toBe('ar-1'); - expect(t.track).toBe(5); - expect(t.year).toBe(2024); - expect(t.bitRate).toBe(320); - expect(t.suffix).toBe('flac'); - expect(t.userRating).toBe(4); - expect(t.starred).toBe('2026-05-01T00:00:00Z'); - expect(t.genre).toBe('Rock'); - expect(t.samplingRate).toBe(48000); - expect(t.bitDepth).toBe(24); - expect(t.size).toBe(9_000_000); - expect(t.coverArt).toBe('s2'); - }); - - it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => { - const song = makeSubsonicSong({ - replayGain: { - trackGain: -6.5, - albumGain: -7.1, - trackPeak: 0.98, - albumPeak: 0.99, - }, - }); - const t = songToTrack(song); - expect(t.replayGainTrackDb).toBe(-6.5); - expect(t.replayGainAlbumDb).toBe(-7.1); - expect(t.replayGainPeak).toBe(0.98); - // albumPeak is intentionally not surfaced — only trackPeak. - expect((t as Track & { replayGainAlbumPeak?: number }).replayGainAlbumPeak).toBeUndefined(); - }); - - it('leaves replayGain fields undefined when the song has no replayGain block', () => { - const song = makeSubsonicSong({ replayGain: undefined }); - const t = songToTrack(song); - expect(t.replayGainTrackDb).toBeUndefined(); - expect(t.replayGainAlbumDb).toBeUndefined(); - expect(t.replayGainPeak).toBeUndefined(); - }); - - it('does not invent fields that the Subsonic song lacks', () => { - const song = makeSubsonicSong({}); - const t = songToTrack(song); - // Internal queue-routing flags are added by enqueue paths, not by mapping. - expect(t.autoAdded).toBeUndefined(); - expect(t.radioAdded).toBeUndefined(); - expect(t.playNextAdded).toBeUndefined(); - }); -}); - -describe('resolveReplayGainDb', () => { - const t = (overrides: Partial = {}): Track => ({ - id: 'x', - title: 'x', - artist: 'x', - album: 'a', - albumId: 'a-1', - duration: 100, - ...overrides, - }); - - it('returns null when ReplayGain is disabled', () => { - const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); - expect(resolveReplayGainDb(track, null, null, false, 'track')).toBeNull(); - expect(resolveReplayGainDb(track, null, null, false, 'album')).toBeNull(); - expect(resolveReplayGainDb(track, null, null, false, 'auto')).toBeNull(); - }); - - it('mode=track uses the track gain', () => { - const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); - expect(resolveReplayGainDb(track, null, null, true, 'track')).toBe(-6); - }); - - it('mode=album uses the album gain when present', () => { - const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); - expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-7); - }); - - it('mode=album falls back to track gain when album is missing', () => { - const track = t({ replayGainTrackDb: -6 }); - expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-6); - }); - - it('mode=auto picks album gain when the prev neighbour shares the albumId', () => { - const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); - const prev = t({ albumId: 'shared' }); - expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-8); - }); - - it('mode=auto picks album gain when the next neighbour shares the albumId', () => { - const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); - const next = t({ albumId: 'shared' }); - expect(resolveReplayGainDb(track, null, next, true, 'auto')).toBe(-8); - }); - - it('mode=auto picks track gain when neither neighbour shares the albumId', () => { - const track = t({ albumId: 'a-1', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); - const other = t({ albumId: 'a-2' }); - expect(resolveReplayGainDb(track, other, other, true, 'auto')).toBe(-6); - }); - - it('mode=auto treats a missing albumId as no-album-match (returns track gain)', () => { - const track = t({ albumId: '', replayGainTrackDb: -6, replayGainAlbumDb: -8 } as Track); - const prev = t({ albumId: '' } as Track); - expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-6); - }); - - it('returns null when both gains are missing', () => { - const track = t({}); - expect(resolveReplayGainDb(track, null, null, true, 'track')).toBeNull(); - expect(resolveReplayGainDb(track, null, null, true, 'album')).toBeNull(); - expect(resolveReplayGainDb(track, null, null, true, 'auto')).toBeNull(); - }); -}); - -describe('shuffleArray', () => { - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('does not mutate the input array', () => { - const input = [1, 2, 3, 4, 5]; - const snapshot = [...input]; - shuffleArray(input); - expect(input).toEqual(snapshot); - }); - - it('preserves the multiset of elements (same length, same members)', () => { - const input = ['a', 'b', 'c', 'd', 'e']; - const out = shuffleArray(input); - expect(out).toHaveLength(input.length); - expect([...out].sort()).toEqual([...input].sort()); - }); - - it('returns a copy (not the same reference)', () => { - const input = [1, 2, 3]; - expect(shuffleArray(input)).not.toBe(input); - }); - - it('returns an empty array when called with an empty array', () => { - expect(shuffleArray([])).toEqual([]); - }); - - it('returns the single-element input unchanged', () => { - expect(shuffleArray(['only'])).toEqual(['only']); - }); - - it('produces a deterministic order under a mocked RNG (Math.random=0 picks j=0 each iteration)', () => { - vi.spyOn(Math, 'random').mockReturnValue(0); - // With Math.random()=0, j=floor(0 * (i+1))=0 for every i. The Fisher-Yates - // step swaps arr[i] with arr[0]. Walk it through for [1,2,3,4]: - // i=3: swap(3,0) → [4,2,3,1] - // i=2: swap(2,0) → [3,2,4,1] - // i=1: swap(1,0) → [2,3,4,1] - expect(shuffleArray([1, 2, 3, 4])).toEqual([2, 3, 4, 1]); - }); -}); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index dc835696..00b1d594 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; import i18n from '../i18n'; -import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic'; +import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, getSong, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic'; import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; @@ -18,13 +18,17 @@ import { useOrbitStore } from './orbitStore'; import { estimateLivePosition } from '../api/orbit'; import { loudnessGainPlaceholderUntilCacheDb } from '../utils/loudnessPlaceholder'; import { effectiveLoudnessPreAnalysisAttenuationDb } from '../utils/loudnessPreAnalysisSlider'; -import { - enrichSongsForMixRatingFilter, - getMixMinRatingsConfigFromAuth, - passesMixMinRatings, -} from '../utils/mixRatingFilter'; import { getPerfProbeFlags } from '../utils/perfFlags'; import { bumpPerfCounter } from '../utils/perfTelemetry'; +import { resolveReplayGainDb } from '../utils/resolveReplayGainDb'; +import { shuffleArray } from '../utils/shuffleArray'; +import { songToTrack } from '../utils/songToTrack'; +import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates'; + +// Re-export for backward compatibility with the ~30 call sites that still +// import these helpers from playerStore. Phase E (store splits) will migrate +// the imports to '../utils/*' directly and drop these re-exports. +export { resolveReplayGainDb, shuffleArray, songToTrack }; const QUEUE_VISIBILITY_STORAGE_KEY = 'psysonic_queue_visible'; @@ -80,124 +84,6 @@ export interface Track { playNextAdded?: boolean; } -export function songToTrack(song: SubsonicSong): Track { - return { - id: song.id, - title: song.title, - artist: song.artist, - album: song.album, - albumId: song.albumId, - artistId: song.artistId, - duration: song.duration, - coverArt: song.coverArt, - track: song.track, - year: song.year, - bitRate: song.bitRate, - suffix: song.suffix, - userRating: song.userRating, - replayGainTrackDb: song.replayGain?.trackGain, - replayGainAlbumDb: song.replayGain?.albumGain, - replayGainPeak: song.replayGain?.trackPeak, - starred: song.starred, - genre: song.genre, - samplingRate: song.samplingRate, - bitDepth: song.bitDepth, - size: song.size, - }; -} - -/** - * Resolve the ReplayGain dB value for a track based on the configured mode. - * In 'auto' mode, picks album-gain when an adjacent queue neighbour shares the - * same albumId (i.e. the track is being played as part of an album), otherwise - * track-gain. Falls back to track-gain when album-gain is missing. - */ -export function resolveReplayGainDb( - track: Track, - prevTrack: Track | null | undefined, - nextTrack: Track | null | undefined, - enabled: boolean, - mode: 'track' | 'album' | 'auto', -): number | null { - if (!enabled) return null; - let useAlbum: boolean; - if (mode === 'album') { - useAlbum = true; - } else if (mode === 'track') { - useAlbum = false; - } else { - const albumId = track.albumId; - useAlbum = !!albumId && ( - prevTrack?.albumId === albumId || nextTrack?.albumId === albumId - ); - } - const value = useAlbum - ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) - : track.replayGainTrackDb; - return value ?? null; -} - -export function shuffleArray(items: T[]): T[] { - const arr = [...items]; - for (let i = arr.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [arr[i], arr[j]] = [arr[j], arr[i]]; - } - return arr; -} - -/** - * 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. - */ -async function buildInfiniteQueueCandidates( - seedTrack: Track | null, - existingIds: Set, - count = 5, -): Promise { - 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([...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); -} - interface PlayerState { currentTrack: Track | null; waveformBins: number[] | null; diff --git a/src/utils/buildInfiniteQueueCandidates.test.ts b/src/utils/buildInfiniteQueueCandidates.test.ts new file mode 100644 index 00000000..f648e527 --- /dev/null +++ b/src/utils/buildInfiniteQueueCandidates.test.ts @@ -0,0 +1,246 @@ +/** + * Characterization for `buildInfiniteQueueCandidates` (Instant-Mix-style + * top-up source for the infinite queue). + * + * Originally lived in `playerStore.ts`; extracted in M0 of the frontend + * refactor (2026-05-12). This test pins the artist-first / random-fallback + * order, the dedup contract against existingIds, and the autoAdded flag. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../api/subsonic', () => ({ + getSimilarSongs2: vi.fn(), + getTopSongs: vi.fn(), + getRandomSongs: vi.fn(), +})); + +vi.mock('./mixRatingFilter', () => ({ + getMixMinRatingsConfigFromAuth: vi.fn(), + enrichSongsForMixRatingFilter: vi.fn(), + passesMixMinRatings: vi.fn(), +})); + +import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates'; +import { getRandomSongs, getSimilarSongs2, getTopSongs } from '../api/subsonic'; +import { + enrichSongsForMixRatingFilter, + getMixMinRatingsConfigFromAuth, +} from './mixRatingFilter'; +import type { Track } from '../store/playerStore'; +import { makeSubsonicSong } from '@/test/helpers/factories'; + +const seed = (overrides: Partial = {}): Track => ({ + id: 'seed', + title: 'Seed', + artist: 'Artist A', + album: 'Album A', + albumId: 'al-A', + artistId: 'ar-A', + duration: 180, + genre: 'Rock', + ...overrides, +}); + +beforeEach(() => { + vi.clearAllMocks(); + // Default mocks — individual tests override as needed. The random-topup loop + // calls getRandomSongs unconditionally when artist sources don't fill `count`, + // so a default empty resolution avoids "Cannot read properties of undefined". + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + // Default: filter disabled — the function then short-circuits the enrich path. + vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({ + enabled: false, + minSong: 0, + minAlbum: 0, + minArtist: 0, + }); + // Deterministic shuffle: Math.random()=0 collapses Fisher-Yates to a known order. + vi.spyOn(Math, 'random').mockReturnValue(0); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('buildInfiniteQueueCandidates', () => { + it('asks for similar + top in parallel when seedTrack has artistId + artist', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]); + vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]); + + await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A'); + expect(getTopSongs).toHaveBeenCalledWith('Artist A'); + }); + + it('skips similar when artistId is missing', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]); + + await buildInfiniteQueueCandidates(seed({ artistId: undefined }), new Set(), 5); + + expect(getSimilarSongs2).not.toHaveBeenCalled(); + expect(getTopSongs).toHaveBeenCalledWith('Artist A'); + }); + + it('skips top when artist name is missing', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]); + vi.mocked(getTopSongs).mockResolvedValue([]); + + await buildInfiniteQueueCandidates(seed({ artist: '' }), new Set(), 5); + + expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A'); + expect(getTopSongs).not.toHaveBeenCalled(); + }); + + it('skips both when seedTrack is null', async () => { + vi.mocked(getRandomSongs).mockResolvedValue([]); + + await buildInfiniteQueueCandidates(null, new Set(), 5); + + expect(getSimilarSongs2).not.toHaveBeenCalled(); + expect(getTopSongs).not.toHaveBeenCalled(); + }); + + it('marks every returned candidate with autoAdded=true', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([ + makeSubsonicSong({ id: 'sim-1' }), + makeSubsonicSong({ id: 'sim-2' }), + ]); + vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(out.length).toBeGreaterThan(0); + for (const t of out) expect(t.autoAdded).toBe(true); + }); + + it('excludes the seedTrack id and existingIds from the result', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([ + makeSubsonicSong({ id: 'seed' }), // self → excluded + makeSubsonicSong({ id: 'already-in-queue' }), // in existingIds → excluded + makeSubsonicSong({ id: 'fresh-1' }), + ]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(['already-in-queue']), 5); + + const ids = out.map(t => t.id); + expect(ids).toContain('fresh-1'); + expect(ids).not.toContain('seed'); + expect(ids).not.toContain('already-in-queue'); + }); + + it('falls back to getRandomSongs when artist sources are empty', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([ + makeSubsonicSong({ id: 'rnd-1' }), + makeSubsonicSong({ id: 'rnd-2' }), + makeSubsonicSong({ id: 'rnd-3' }), + ]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3); + + expect(getRandomSongs).toHaveBeenCalled(); + expect(out.map(t => t.id).sort()).toEqual(['rnd-1', 'rnd-2', 'rnd-3']); + }); + + it('passes the seed track genre to getRandomSongs', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'rnd-1' })]); + + await buildInfiniteQueueCandidates(seed({ genre: 'Jazz' }), new Set(), 1); + + expect(getRandomSongs).toHaveBeenCalledWith(expect.any(Number), 'Jazz'); + }); + + it('stops after up to 8 random batches when supply is exhausted', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + // Each batch returns one same song that's already counted → no progress. + vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'dup' })]); + + await buildInfiniteQueueCandidates(seed(), new Set(['dup']), 5); + + // Cap is 8 batches. + expect(vi.mocked(getRandomSongs).mock.calls.length).toBeLessThanOrEqual(8); + }); + + it('breaks the random loop early when getRandomSongs returns an empty batch', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + // First batch is empty → loop breaks immediately, no second call. + expect(vi.mocked(getRandomSongs).mock.calls.length).toBe(1); + }); + + it('survives a rejected getSimilarSongs2 call (catches and treats as empty)', async () => { + vi.mocked(getSimilarSongs2).mockRejectedValue(new Error('boom')); + vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(out.map(t => t.id)).toContain('top-1'); + }); + + it('survives a rejected getTopSongs call (catches and treats as empty)', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]); + vi.mocked(getTopSongs).mockRejectedValue(new Error('boom')); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(out.map(t => t.id)).toContain('sim-1'); + }); + + it('returns at most `count` items even when sources oversupply', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue( + Array.from({ length: 10 }, (_, i) => makeSubsonicSong({ id: `sim-${i}` })), + ); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3); + + expect(out).toHaveLength(3); + }); + + it('runs the rating-filter enrichment pipeline when filter is enabled', async () => { + vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({ + enabled: true, + minSong: 3, + minAlbum: 0, + minArtist: 0, + }); + vi.mocked(enrichSongsForMixRatingFilter).mockResolvedValue([ + makeSubsonicSong({ id: 'sim-1' }), + ]); + vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(enrichSongsForMixRatingFilter).toHaveBeenCalled(); + }); + + it('returns an empty array when nothing usable is found', async () => { + vi.mocked(getSimilarSongs2).mockResolvedValue([]); + vi.mocked(getTopSongs).mockResolvedValue([]); + vi.mocked(getRandomSongs).mockResolvedValue([]); + + const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5); + + expect(out).toEqual([]); + }); +}); diff --git a/src/utils/buildInfiniteQueueCandidates.ts b/src/utils/buildInfiniteQueueCandidates.ts new file mode 100644 index 00000000..d8624f54 --- /dev/null +++ b/src/utils/buildInfiniteQueueCandidates.ts @@ -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, + count = 5, +): Promise { + 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([...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); +} diff --git a/src/utils/resolveReplayGainDb.test.ts b/src/utils/resolveReplayGainDb.test.ts new file mode 100644 index 00000000..f7937dea --- /dev/null +++ b/src/utils/resolveReplayGainDb.test.ts @@ -0,0 +1,75 @@ +/** + * Pure-helper characterization for `resolveReplayGainDb`. + * + * Picks track vs album gain based on mode + adjacent queue neighbours. + * Originally lived in `playerStore.ts`; extracted in M0 of the frontend + * refactor (2026-05-12). + */ +import { describe, expect, it } from 'vitest'; +import { resolveReplayGainDb } from './resolveReplayGainDb'; +import type { Track } from '../store/playerStore'; + +describe('resolveReplayGainDb', () => { + const t = (overrides: Partial = {}): Track => ({ + id: 'x', + title: 'x', + artist: 'x', + album: 'a', + albumId: 'a-1', + duration: 100, + ...overrides, + }); + + it('returns null when ReplayGain is disabled', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, false, 'track')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, false, 'album')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, false, 'auto')).toBeNull(); + }); + + it('mode=track uses the track gain', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, true, 'track')).toBe(-6); + }); + + it('mode=album uses the album gain when present', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-7); + }); + + it('mode=album falls back to track gain when album is missing', () => { + const track = t({ replayGainTrackDb: -6 }); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-6); + }); + + it('mode=auto picks album gain when the prev neighbour shares the albumId', () => { + const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const prev = t({ albumId: 'shared' }); + expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-8); + }); + + it('mode=auto picks album gain when the next neighbour shares the albumId', () => { + const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const next = t({ albumId: 'shared' }); + expect(resolveReplayGainDb(track, null, next, true, 'auto')).toBe(-8); + }); + + it('mode=auto picks track gain when neither neighbour shares the albumId', () => { + const track = t({ albumId: 'a-1', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const other = t({ albumId: 'a-2' }); + expect(resolveReplayGainDb(track, other, other, true, 'auto')).toBe(-6); + }); + + it('mode=auto treats a missing albumId as no-album-match (returns track gain)', () => { + const track = t({ albumId: '', replayGainTrackDb: -6, replayGainAlbumDb: -8 } as Track); + const prev = t({ albumId: '' } as Track); + expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-6); + }); + + it('returns null when both gains are missing', () => { + const track = t({}); + expect(resolveReplayGainDb(track, null, null, true, 'track')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, true, 'auto')).toBeNull(); + }); +}); diff --git a/src/utils/resolveReplayGainDb.ts b/src/utils/resolveReplayGainDb.ts new file mode 100644 index 00000000..ac9ecb96 --- /dev/null +++ b/src/utils/resolveReplayGainDb.ts @@ -0,0 +1,32 @@ +import type { Track } from '../store/playerStore'; + +/** + * Resolve the ReplayGain dB value for a track based on the configured mode. + * In 'auto' mode, picks album-gain when an adjacent queue neighbour shares the + * same albumId (i.e. the track is being played as part of an album), otherwise + * track-gain. Falls back to track-gain when album-gain is missing. + */ +export function resolveReplayGainDb( + track: Track, + prevTrack: Track | null | undefined, + nextTrack: Track | null | undefined, + enabled: boolean, + mode: 'track' | 'album' | 'auto', +): number | null { + if (!enabled) return null; + let useAlbum: boolean; + if (mode === 'album') { + useAlbum = true; + } else if (mode === 'track') { + useAlbum = false; + } else { + const albumId = track.albumId; + useAlbum = !!albumId && ( + prevTrack?.albumId === albumId || nextTrack?.albumId === albumId + ); + } + const value = useAlbum + ? (track.replayGainAlbumDb ?? track.replayGainTrackDb) + : track.replayGainTrackDb; + return value ?? null; +} diff --git a/src/utils/shuffleArray.test.ts b/src/utils/shuffleArray.test.ts new file mode 100644 index 00000000..a189e9bb --- /dev/null +++ b/src/utils/shuffleArray.test.ts @@ -0,0 +1,51 @@ +/** + * Pure-helper characterization for `shuffleArray` (Fisher-Yates). + * + * Originally lived in `playerStore.ts`; extracted in M0 of the frontend + * refactor (2026-05-12). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { shuffleArray } from './shuffleArray'; + +describe('shuffleArray', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not mutate the input array', () => { + const input = [1, 2, 3, 4, 5]; + const snapshot = [...input]; + shuffleArray(input); + expect(input).toEqual(snapshot); + }); + + it('preserves the multiset of elements (same length, same members)', () => { + const input = ['a', 'b', 'c', 'd', 'e']; + const out = shuffleArray(input); + expect(out).toHaveLength(input.length); + expect([...out].sort()).toEqual([...input].sort()); + }); + + it('returns a copy (not the same reference)', () => { + const input = [1, 2, 3]; + expect(shuffleArray(input)).not.toBe(input); + }); + + it('returns an empty array when called with an empty array', () => { + expect(shuffleArray([])).toEqual([]); + }); + + it('returns the single-element input unchanged', () => { + expect(shuffleArray(['only'])).toEqual(['only']); + }); + + it('produces a deterministic order under a mocked RNG (Math.random=0 picks j=0 each iteration)', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + // With Math.random()=0, j=floor(0 * (i+1))=0 for every i. The Fisher-Yates + // step swaps arr[i] with arr[0]. Walk it through for [1,2,3,4]: + // i=3: swap(3,0) → [4,2,3,1] + // i=2: swap(2,0) → [3,2,4,1] + // i=1: swap(1,0) → [2,3,4,1] + expect(shuffleArray([1, 2, 3, 4])).toEqual([2, 3, 4, 1]); + }); +}); diff --git a/src/utils/shuffleArray.ts b/src/utils/shuffleArray.ts new file mode 100644 index 00000000..9e0b6471 --- /dev/null +++ b/src/utils/shuffleArray.ts @@ -0,0 +1,11 @@ +/** + * Fisher-Yates shuffle. Returns a new array; the input is not mutated. + */ +export function shuffleArray(items: T[]): T[] { + const arr = [...items]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} diff --git a/src/utils/songToTrack.test.ts b/src/utils/songToTrack.test.ts new file mode 100644 index 00000000..8632cd29 --- /dev/null +++ b/src/utils/songToTrack.test.ts @@ -0,0 +1,96 @@ +/** + * Pure-helper characterization for `songToTrack`. + * + * Maps a Subsonic song record into the internal `Track` shape used by the + * playback queue. Originally lived in `playerStore.ts`; extracted in M0 of + * the frontend refactor (2026-05-12). + */ +import { describe, expect, it } from 'vitest'; +import { songToTrack } from './songToTrack'; +import type { Track } from '../store/playerStore'; +import { makeSubsonicSong } from '@/test/helpers/factories'; + +describe('songToTrack', () => { + it('maps required Subsonic fields verbatim', () => { + const song = makeSubsonicSong({ + id: 's1', + title: 'Hello', + artist: 'World', + album: 'Sample', + albumId: 'a1', + duration: 240, + }); + const t = songToTrack(song); + expect(t.id).toBe('s1'); + expect(t.title).toBe('Hello'); + expect(t.artist).toBe('World'); + expect(t.album).toBe('Sample'); + expect(t.albumId).toBe('a1'); + expect(t.duration).toBe(240); + }); + + it('copies optional fields when present', () => { + const song = makeSubsonicSong({ + id: 's2', + artistId: 'ar-1', + track: 5, + year: 2024, + bitRate: 320, + suffix: 'flac', + userRating: 4, + starred: '2026-05-01T00:00:00Z', + genre: 'Rock', + samplingRate: 48000, + bitDepth: 24, + size: 9_000_000, + coverArt: 's2', + }); + const t = songToTrack(song); + expect(t.artistId).toBe('ar-1'); + expect(t.track).toBe(5); + expect(t.year).toBe(2024); + expect(t.bitRate).toBe(320); + expect(t.suffix).toBe('flac'); + expect(t.userRating).toBe(4); + expect(t.starred).toBe('2026-05-01T00:00:00Z'); + expect(t.genre).toBe('Rock'); + expect(t.samplingRate).toBe(48000); + expect(t.bitDepth).toBe(24); + expect(t.size).toBe(9_000_000); + expect(t.coverArt).toBe('s2'); + }); + + it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => { + const song = makeSubsonicSong({ + replayGain: { + trackGain: -6.5, + albumGain: -7.1, + trackPeak: 0.98, + albumPeak: 0.99, + }, + }); + const t = songToTrack(song); + expect(t.replayGainTrackDb).toBe(-6.5); + expect(t.replayGainAlbumDb).toBe(-7.1); + expect(t.replayGainPeak).toBe(0.98); + // albumPeak is intentionally not surfaced — only trackPeak. + expect((t as Track & { replayGainAlbumPeak?: number }).replayGainAlbumPeak).toBeUndefined(); + }); + + it('leaves replayGain fields undefined when the song has no replayGain block', () => { + const song = makeSubsonicSong({ replayGain: undefined }); + const t = songToTrack(song); + expect(t.replayGainTrackDb).toBeUndefined(); + expect(t.replayGainAlbumDb).toBeUndefined(); + expect(t.replayGainPeak).toBeUndefined(); + }); + + it('does not invent fields that the Subsonic song lacks', () => { + const song = makeSubsonicSong({}); + const t = songToTrack(song); + // Internal queue-routing flags are added by enqueue paths, not by mapping. + expect(t.autoAdded).toBeUndefined(); + expect(t.radioAdded).toBeUndefined(); + expect(t.playNextAdded).toBeUndefined(); + }); +}); diff --git a/src/utils/songToTrack.ts b/src/utils/songToTrack.ts new file mode 100644 index 00000000..4ad7cd2d --- /dev/null +++ b/src/utils/songToTrack.ts @@ -0,0 +1,28 @@ +import type { SubsonicSong } from '../api/subsonic'; +import type { Track } from '../store/playerStore'; + +export function songToTrack(song: SubsonicSong): Track { + return { + id: song.id, + title: song.title, + artist: song.artist, + album: song.album, + albumId: song.albumId, + artistId: song.artistId, + duration: song.duration, + coverArt: song.coverArt, + track: song.track, + year: song.year, + bitRate: song.bitRate, + suffix: song.suffix, + userRating: song.userRating, + replayGainTrackDb: song.replayGain?.trackGain, + replayGainAlbumDb: song.replayGain?.albumGain, + replayGainPeak: song.replayGain?.trackPeak, + starred: song.starred, + genre: song.genre, + samplingRate: song.samplingRate, + bitDepth: song.bitDepth, + size: song.size, + }; +}