refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)

111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:27:44 +02:00
committed by GitHub
parent 2409a1fec8
commit 7a7a9f5e6b
324 changed files with 551 additions and 551 deletions
@@ -0,0 +1,250 @@
/**
* 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 { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
import { getRandomSongs } from '../../api/subsonicLibrary';
import type { Track } from '../../store/playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../api/subsonicArtists', () => ({
getSimilarSongs2: vi.fn(),
getTopSongs: vi.fn(),
}));
vi.mock('../../api/subsonicLibrary', () => ({
getRandomSongs: vi.fn(),
}));
vi.mock('../mix/mixRatingFilter', () => ({
getMixMinRatingsConfigFromAuth: vi.fn(),
enrichSongsForMixRatingFilter: vi.fn(),
passesMixMinRatings: vi.fn(),
}));
import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates';
import {
enrichSongsForMixRatingFilter,
getMixMinRatingsConfigFromAuth,
} from '../mix/mixRatingFilter';
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([]);
});
});
@@ -0,0 +1,61 @@
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
import { getRandomSongs } from '../../api/subsonicLibrary';
import type { Track } from '../../store/playerStoreTypes';
import {
enrichSongsForMixRatingFilter,
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '../mix/mixRatingFilter';
import { shuffleArray } from './shuffleArray';
import { songToTrack } from './songToTrack';
/**
* 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);
}
@@ -0,0 +1,18 @@
import { getArtist } from '../../api/subsonicArtists';
import { getAlbum } from '../../api/subsonicLibrary';
import { getPlaylist } from '../../api/subsonicPlaylists';
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { DeviceSyncSource } from '../../store/deviceSyncStore';
export async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
const { albums } = await getArtist(source.id);
// Parallel album fetches — Navidrome handles getAlbum requests in flight
// without serialising. Sequential awaits here multiplied a 50-album artist
// sync into 50 round-trips (~7 s blocking) before any device write started.
const results = await Promise.all(
albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [] as SubsonicSong[])),
);
return results.flat();
}
+55
View File
@@ -0,0 +1,55 @@
import { getAlbum } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore';
import { songToTrack } from './songToTrack';
import { useOrbitStore } from '../../store/orbitStore';
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
export async function playAlbum(albumId: string): Promise<void> {
const albumData = await getAlbum(albumId);
const albumGenre = albumData.album.genre;
const tracks = albumData.songs.map(s => {
const track = songToTrack(s);
if (!track.genre && albumGenre) track.genre = albumGenre;
return track;
});
if (!tracks.length) return;
// In Orbit sessions, playAlbum is effectively an append operation (the
// playerStore bulk-gate also routes replaces into enqueue). Skip the
// fadeOut entirely — the current track keeps playing, the album goes
// onto the end of the queue after the user confirms the bulk dialog.
const orbitRole = useOrbitStore.getState().role;
if (orbitRole === 'host' || orbitRole === 'guest') {
usePlayerStore.getState().enqueue(tracks);
return;
}
const store = usePlayerStore.getState();
const { isPlaying, volume } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
// Restore volume only in the Zustand store — do NOT call audio_set_volume here,
// otherwise the old track glitches back to full volume before playTrack stops it.
// playTrack reads state.volume and passes it to audio_play, so the new track
// starts at the correct volume without the Rust engine ever hearing a restore.
usePlayerStore.setState({ volume });
}
usePlayerStore.getState().playTrack(tracks[0], tracks);
}
+27
View File
@@ -0,0 +1,27 @@
import { getArtist } from '../../api/subsonicArtists';
import { getAlbum } from '../../api/subsonicLibrary';
import { songToTrack } from './songToTrack';
import { shuffleArray } from './shuffleArray';
import { usePlayerStore } from '../../store/playerStore';
/**
* All tracks from the artists albums, shuffled — same idea as Artist page “shuffle play”.
*/
export async function playArtistShuffled(artistId: string): Promise<void> {
const { albums } = await getArtist(artistId);
if (albums.length === 0) {
throw new Error('play_artist_no_tracks');
}
const results = await Promise.all(albums.map(a => getAlbum(a.id)));
const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
const tracks = sorted.flatMap(r =>
[...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0)).map(songToTrack),
);
if (tracks.length === 0) {
throw new Error('play_artist_no_tracks');
}
const shuffled = shuffleArray(tracks);
usePlayerStore.getState().playTrack(shuffled[0], shuffled);
}
+35
View File
@@ -0,0 +1,35 @@
import { getAlbum, getSong } from '../../api/subsonicLibrary';
import { songToTrack } from './songToTrack';
import { playAlbum } from './playAlbum';
import { playArtistShuffled } from './playArtistShuffled';
import { usePlayerStore } from '../../store/playerStore';
/**
* `getSong` → `getAlbum` → `getArtist`: one opaque Subsonic id may refer to a track,
* album, or artist depending on the server.
*/
export async function playByOpaqueId(id: string): Promise<void> {
const trimmed = id.trim();
if (!trimmed) return;
const song = await getSong(trimmed);
if (song) {
usePlayerStore.getState().playTrack(songToTrack(song));
return;
}
try {
const { songs } = await getAlbum(trimmed);
if (songs.length > 0) {
await playAlbum(trimmed);
return;
}
} catch {
/* not an album */
}
try {
await playArtistShuffled(trimmed);
} catch {
throw new Error('play_by_id_not_found');
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from './songToTrack';
import { usePlayerStore } from '../../store/playerStore';
function fadeOut(setVolume: (v: number) => void, from: number, durationMs: number): Promise<void> {
return new Promise(resolve => {
const steps = 16;
const stepMs = durationMs / steps;
let step = 0;
const id = setInterval(() => {
step++;
setVolume(Math.max(0, from * (1 - step / steps)));
if (step >= steps) {
clearInterval(id);
resolve();
}
}, stepMs);
});
}
/**
* Play a single song. When `queue` is provided, surrounds the chosen song with that queue
* so Next/Prev work — pass the rail / pool the click came from. Mirrors playAlbum's fade-out.
*/
export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): Promise<void> {
const track = songToTrack(song);
const tracks = queue && queue.length > 0
? queue.map(songToTrack)
: [track];
const store = usePlayerStore.getState();
const { isPlaying, volume } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
usePlayerStore.getState().playTrack(track, tracks);
}
/**
* Append the song to the existing queue (if not already there) and immediately jump to it.
* Existing queue stays intact — different from playSongNow which replaces the queue.
*/
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
const track = songToTrack(song);
const store = usePlayerStore.getState();
const { isPlaying, volume, queue } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
if (!queue.some(t => t.id === track.id)) {
usePlayerStore.getState().enqueue([track]);
}
// playTrack with no queue arg uses the current state.queue, finds the track by id,
// and sets queueIndex accordingly.
usePlayerStore.getState().playTrack(track);
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Pure helpers extracted from playerStore. The interesting behaviour is the
* `stream:` prefix normalization (Rust events sometimes wrap track ids when
* routing through the HTTP source) and the no-op detection in
* `queuesStructuralEqual` that prevents unnecessary store rewrites.
*/
import type { Track } from '../../store/playerStoreTypes';
import { describe, expect, it } from 'vitest';
import {
normalizeAnalysisTrackId,
queuesStructuralEqual,
sameQueueTrackId,
shallowCloneQueueTracks,
} from './queueIdentity';
function track(id: string, overrides: Partial<Track> = {}): Track {
return {
id,
title: `Title ${id}`,
artist: 'Artist',
album: 'Album',
albumId: 'A',
duration: 180,
...overrides,
};
}
describe('normalizeAnalysisTrackId', () => {
it('strips the stream: prefix', () => {
expect(normalizeAnalysisTrackId('stream:abc123')).toBe('abc123');
});
it('returns bare ids unchanged', () => {
expect(normalizeAnalysisTrackId('abc123')).toBe('abc123');
});
it('returns null for null / undefined / empty', () => {
expect(normalizeAnalysisTrackId(null)).toBeNull();
expect(normalizeAnalysisTrackId(undefined)).toBeNull();
expect(normalizeAnalysisTrackId('')).toBeNull();
});
});
describe('sameQueueTrackId', () => {
it('matches bare ids', () => {
expect(sameQueueTrackId('a', 'a')).toBe(true);
expect(sameQueueTrackId('a', 'b')).toBe(false);
});
it('matches across stream: prefix mismatch', () => {
expect(sameQueueTrackId('stream:a', 'a')).toBe(true);
expect(sameQueueTrackId('a', 'stream:a')).toBe(true);
expect(sameQueueTrackId('stream:a', 'stream:a')).toBe(true);
});
it('returns false when either side is null', () => {
expect(sameQueueTrackId(null, 'a')).toBe(false);
expect(sameQueueTrackId('a', null)).toBe(false);
expect(sameQueueTrackId(null, null)).toBe(false);
});
});
describe('queuesStructuralEqual', () => {
it('returns true for same ids in same order', () => {
expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('b')])).toBe(true);
});
it('returns true when one side wraps ids with stream:', () => {
expect(queuesStructuralEqual(
[track('a'), track('b')],
[track('stream:a'), track('stream:b')],
)).toBe(true);
});
it('returns false for different lengths', () => {
expect(queuesStructuralEqual([track('a')], [track('a'), track('b')])).toBe(false);
});
it('returns false for any id mismatch', () => {
expect(queuesStructuralEqual([track('a'), track('b')], [track('a'), track('c')])).toBe(false);
});
it('treats empty queues as equal', () => {
expect(queuesStructuralEqual([], [])).toBe(true);
});
});
describe('shallowCloneQueueTracks', () => {
it('returns a new array with new objects (callers can mutate freely)', () => {
const original = [track('a'), track('b')];
const cloned = shallowCloneQueueTracks(original);
expect(cloned).not.toBe(original);
expect(cloned[0]).not.toBe(original[0]);
expect(cloned[1]).not.toBe(original[1]);
expect(cloned).toEqual(original);
});
it('preserves all fields', () => {
const original = [track('a', { coverArt: 'cover', userRating: 5, autoAdded: true })];
const cloned = shallowCloneQueueTracks(original);
expect(cloned[0]).toEqual(original[0]);
});
it('handles empty queues', () => {
expect(shallowCloneQueueTracks([])).toEqual([]);
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { Track } from '../../store/playerStoreTypes';
/**
* Strip the `stream:` prefix that some Rust events attach to track ids when
* they're routed through the HTTP source. Both forms identify the same track,
* so equality and structural-diff checks need to normalize first.
*/
export function normalizeAnalysisTrackId(trackId?: string | null): string | null {
if (!trackId) return null;
if (trackId.startsWith('stream:')) return trackId.slice('stream:'.length);
return trackId;
}
/** Compare track ids across `stream:` / bare Subsonic forms. */
export function sameQueueTrackId(a: string | undefined | null, b: string | undefined | null): boolean {
if (a == null || b == null) return false;
const na = normalizeAnalysisTrackId(a) ?? a;
const nb = normalizeAnalysisTrackId(b) ?? b;
return na === nb;
}
/**
* Same-length + same-ids check. Used to skip no-op queue rewrites that would
* otherwise reset selection / scroll / drag-source state in subscribers.
*/
export function queuesStructuralEqual(a: Track[], b: Track[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (!sameQueueTrackId(a[i]?.id, b[i]?.id)) return false;
}
return true;
}
/** One-level clone so callers can mutate per-track fields without aliasing state. */
export function shallowCloneQueueTracks(queue: Track[]): Track[] {
return queue.map(t => ({ ...t }));
}
@@ -0,0 +1,111 @@
/**
* `resolvePlaybackUrl` precedence + `streamUrlTrackId` parser tests (Phase F3).
*
* Precedence pinned by the function: offline → hot cache → HTTP stream.
* Refactors that reorder this break playback for users with offline /
* hot-cache entries.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useOfflineStore } from '@/store/offlineStore';
import { useHotCacheStore } from '@/store/hotCacheStore';
import {
getPlaybackSourceKind,
resolvePlaybackUrl,
streamUrlTrackId,
} from './resolvePlaybackUrl';
import { useAuthStore } from '@/store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
beforeEach(() => {
resetAuthStore();
// Reset the offline + hot-cache store getLocalUrl mocks before each test.
vi.spyOn(useOfflineStore.getState(), 'getLocalUrl').mockReturnValue(null);
vi.spyOn(useHotCacheStore.getState(), 'getLocalUrl').mockReturnValue(null);
// Set up an active server so buildStreamUrl works.
const id = useAuthStore.getState().addServer({
name: 'Test', url: 'https://music.example.com', username: 'alice', password: 'pw',
});
useAuthStore.getState().setActiveServer(id);
});
describe('resolvePlaybackUrl — precedence', () => {
it('returns the offline URL when present (1st priority)', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/track-1.flac');
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://offline/track-1.flac');
});
it('falls through to the hot-cache URL when offline is absent (2nd priority)', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue(null);
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/track-1.flac');
expect(resolvePlaybackUrl('track-1', 'srv-1')).toBe('psysonic-local://hot/track-1.flac');
});
it('falls through to the HTTP stream URL when neither local source is present', () => {
const url = resolvePlaybackUrl('track-1', 'srv-1');
expect(url).toMatch(/^https:\/\/music\.example\.com\/rest\/stream\.view\?/);
expect(url).toContain('id=track-1');
});
it('forwards trackId + serverId to both stores so per-server entries scope correctly', () => {
resolvePlaybackUrl('track-7', 'srv-3');
expect(useOfflineStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
expect(useHotCacheStore.getState().getLocalUrl).toHaveBeenCalledWith('track-7', 'srv-3');
});
});
describe('getPlaybackSourceKind', () => {
it('returns "offline" when the offline store has the track', () => {
vi.mocked(useOfflineStore.getState().getLocalUrl).mockReturnValue('psysonic-local://offline/t1.flac');
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('offline');
});
it('returns "hot" when only the hot-cache has the track', () => {
vi.mocked(useHotCacheStore.getState().getLocalUrl).mockReturnValue('psysonic-local://hot/t1.flac');
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('hot');
});
it('returns "stream" when neither has the track and no engine preload hint matches', () => {
expect(getPlaybackSourceKind('t1', 'srv-1')).toBe('stream');
});
it('returns "hot" when the engine reported a preload for this trackId (RAM-loaded)', () => {
expect(getPlaybackSourceKind('t1', 'srv-1', 't1')).toBe('hot');
});
it('returns "stream" when the engine preload hint is for a different track', () => {
expect(getPlaybackSourceKind('t1', 'srv-1', 'other-track')).toBe('stream');
});
});
describe('streamUrlTrackId', () => {
it('extracts the id query param from a stream.view URL', () => {
const url = 'https://music.example.com/rest/stream.view?id=track-1&u=alice&t=hash';
expect(streamUrlTrackId(url)).toBe('track-1');
});
it('returns null for URLs that are not stream.view', () => {
expect(streamUrlTrackId('https://music.example.com/rest/getCoverArt.view?id=cover')).toBeNull();
});
it('returns null when the URL has no query string', () => {
expect(streamUrlTrackId('https://music.example.com/rest/stream.view')).toBeNull();
});
it('returns null when stream.view URL lacks an id param', () => {
expect(streamUrlTrackId('https://music.example.com/rest/stream.view?u=alice')).toBeNull();
});
it('decodes URL-encoded id values', () => {
expect(streamUrlTrackId('https://x/rest/stream.view?id=AC%2FDC%20Back')).toBe('AC/DC Back');
});
it('falls back to manual query parsing when URL constructor would throw', () => {
// Relative path — `new URL(...)` requires a base, so the function's
// manual fallback parses the query directly.
const url = '/rest/stream.view?id=relative-track&u=u';
expect(streamUrlTrackId(url)).toBe('relative-track');
});
});
+63
View File
@@ -0,0 +1,63 @@
import { buildStreamUrl } from '../../api/subsonicStreamUrl';
import { useOfflineStore } from '../../store/offlineStore';
import { useHotCacheStore } from '../../store/hotCacheStore';
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
/**
* Subsonic `buildStreamUrl()` rotates `t`/`s` on every call; Rust matches by `id` (see `playback_identity`).
*/
export function streamUrlTrackId(url: string): string | null {
if (!url.includes('stream.view')) return null;
try {
const fromUrl = new URL(url).searchParams.get('id');
if (fromUrl) return fromUrl;
} catch {
// Fallback for non-standard/relative URLs: parse query manually.
}
const q = url.split('?')[1];
if (!q) return null;
for (const part of q.split('&')) {
const [k, v = ''] = part.split('=');
if (k === 'id') {
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
}
return null;
}
/**
* @param enginePreloadedTrackId — song id for which `audio_preload` finished into the engine RAM slot
* (parsed from `audio:preload-ready` payload URL).
*/
export function getPlaybackSourceKind(
trackId: string,
serverId: string,
enginePreloadedTrackId: string | null = null,
): PlaybackSourceKind {
if (useOfflineStore.getState().getLocalUrl(trackId, serverId)) return 'offline';
if (useHotCacheStore.getState().getLocalUrl(trackId, serverId)) return 'hot';
const resolved = resolvePlaybackUrl(trackId, serverId);
if (
!resolved.startsWith('psysonic-local://')
&& enginePreloadedTrackId
&& trackId === enginePreloadedTrackId
) {
return 'hot';
}
return 'stream';
}
/** Offline library → hot playback cache → HTTP stream. */
export function resolvePlaybackUrl(trackId: string, serverId: string): string {
const offline = useOfflineStore.getState().getLocalUrl(trackId, serverId);
if (offline) return offline;
const hot = useHotCacheStore.getState().getLocalUrl(trackId, serverId);
if (hot) return hot;
return buildStreamUrl(trackId);
}
+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 type { Track } from '../../store/playerStoreTypes';
import { describe, expect, it } from 'vitest';
import { songToTrack } from './songToTrack';
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();
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
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,
};
}