mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
6afbdf9c60
commit
d3a8160b37
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user