mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(player): E.1 — extract queue-identity helpers to utils/ (#563)
Four pure helpers move out of playerStore: normalizeAnalysisTrackId, sameQueueTrackId, queuesStructuralEqual, shallowCloneQueueTracks. Adds focused unit tests for the stream:-prefix normalization and the no-op detection that prevents unnecessary queue rewrites. Behaviour preserved verbatim. playerStore 3618 → 3598 LOC.
This commit is contained in:
committed by
GitHub
parent
f0971d5108
commit
dcf3dd98e0
@@ -24,6 +24,12 @@ import { resolveReplayGainDb } from '../utils/resolveReplayGainDb';
|
|||||||
import { shuffleArray } from '../utils/shuffleArray';
|
import { shuffleArray } from '../utils/shuffleArray';
|
||||||
import { songToTrack } from '../utils/songToTrack';
|
import { songToTrack } from '../utils/songToTrack';
|
||||||
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
import { buildInfiniteQueueCandidates } from '../utils/buildInfiniteQueueCandidates';
|
||||||
|
import {
|
||||||
|
normalizeAnalysisTrackId,
|
||||||
|
queuesStructuralEqual,
|
||||||
|
sameQueueTrackId,
|
||||||
|
shallowCloneQueueTracks,
|
||||||
|
} from '../utils/queueIdentity';
|
||||||
import { getWindowKind } from '../app/windowKind';
|
import { getWindowKind } from '../app/windowKind';
|
||||||
|
|
||||||
// Re-export for backward compatibility with the ~30 call sites that still
|
// Re-export for backward compatibility with the ~30 call sites that still
|
||||||
@@ -370,10 +376,6 @@ export function consumePendingQueueListScrollTop(): number | undefined {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shallowCloneQueueTracks(queue: Track[]): Track[] {
|
|
||||||
return queue.map(t => ({ ...t }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
|
function queueUndoSnapshotFromState(s: PlayerState): QueueUndoSnapshot {
|
||||||
const scrollTop = readQueueListScrollTopForUndo();
|
const scrollTop = readQueueListScrollTopForUndo();
|
||||||
return {
|
return {
|
||||||
@@ -476,28 +478,6 @@ function emitNormalizationDebug(step: string, details?: Record<string, unknown>)
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
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. */
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
|
function normalizationAlmostEqual(a: number | null, b: number | null, eps = 0.12): boolean {
|
||||||
if (a == null && b == null) return true;
|
if (a == null && b == null) return true;
|
||||||
if (a == null || b == null) return false;
|
if (a == null || b == null) return false;
|
||||||
|
|||||||
@@ -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 { describe, expect, it } from 'vitest';
|
||||||
|
import type { Track } from '../store/playerStore';
|
||||||
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Track } from '../store/playerStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user