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
-205
View File
@@ -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> = {}): 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]);
});
});
+10 -124
View File
@@ -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<T>(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<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);
}
interface PlayerState {
currentTrack: Track | null;
waveformBins: number[] | null;
@@ -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> = {}): 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([]);
});
});
+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);
}
+75
View File
@@ -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> = {}): 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();
});
});
+32
View File
@@ -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;
}
+51
View File
@@ -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]);
});
});
+11
View File
@@ -0,0 +1,11 @@
/**
* Fisher-Yates shuffle. Returns a new array; the input is not mutated.
*/
export function shuffleArray<T>(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;
}
+96
View File
@@ -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();
});
});
+28
View File
@@ -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,
};
}