mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(playback): move thin-state queue resolver out of utils/library into the feature
The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef, queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the playback engine's thin-state queue subsystem, not shared library infra. It sat in utils/library only because it queries the local index. Co-locate into features/playback/store so those edges become intra-feature; its remaining deps (advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are plain core/infra (feature -> infra, no inversion). Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0, targeted suites green.
This commit is contained in:
@@ -3,11 +3,11 @@ import { useTranslation } from 'react-i18next';
|
||||
import { X } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { listen, emitTo } from '@tauri-apps/api/event';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setTransitionMode, type TransitionMode } from '@/features/playback/utils/playback/playbackTransition';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import type { SubsonicOpenArtistRef } from '@/lib/api/subsonicTypes';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import type { MiniSyncPayload, MiniTrackInfo } from '@/features/miniPlayer/utils/miniPlayerBridge';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { makeTrack } from '@/test/helpers/factories';
|
||||
import { resetAllStores } from '@/test/helpers/storeReset';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
|
||||
vi.mock('@/api/coverCache', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('@/api/coverCache')>();
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { clearPreloadingIds } from '@/features/playback/store/gaplessPreloadState';
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import type { PlayerState, QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
import { sameQueueTrackId } from '@/features/playback/utils/playback/queueIdentity';
|
||||
import { queueUndoRestoreAudioEngine } from '@/features/playback/store/queueUndoAudioRestore';
|
||||
|
||||
@@ -2,8 +2,8 @@ import { getPlayQueueForServer, type PlayQueueResult } from '@/lib/api/subsonicP
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { bindQueueServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
playbackReportStart,
|
||||
playbackReportStopped,
|
||||
} from '@/features/playback/store/playbackReportSession';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getMusicNetworkRuntimeOrNull } from '@/music-network';
|
||||
import { setDeferHotCachePrefetch } from '@/utils/cache/hotCacheGate';
|
||||
|
||||
@@ -16,13 +16,13 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { bindQueueServerForPlayback } from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
_resetQueueResolverForTest,
|
||||
getCachedTrack,
|
||||
seedQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { applyQueueHistorySnapshot } from '@/features/playback/store/applyQueueHistorySnapshot';
|
||||
import {
|
||||
pushQueueUndoSnapshot,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { computeWaveformSilence, planCrossfadeTransition } from '@/utils/wavefor
|
||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import {
|
||||
hasPlannedCrossfade,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { reseedLoudnessForTrackId } from '@/features/playback/store/loudnessRese
|
||||
import { getPlaybackProgressSnapshot } from '@/features/playback/store/playbackProgress';
|
||||
import { shouldRebindPlaybackToHotCache } from '@/features/playback/store/playbackUrlRouting';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
|
||||
import {
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from '@/features/playback/store/infiniteQueueState';
|
||||
import { isInOrbitSession } from '@/store/orbitRuntime';
|
||||
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import {
|
||||
addRadioSessionSeen,
|
||||
getCurrentRadioArtistId,
|
||||
|
||||
@@ -29,7 +29,7 @@ vi.mock('@/features/playback/store/infiniteQueueState', () => ({
|
||||
}));
|
||||
vi.mock('@/features/playback/store/engineState', () => ({ setIsAudioPaused: vi.fn() }));
|
||||
vi.mock('@/features/playback/store/skipStarRating', () => ({ applySkipStarOnManualNext: vi.fn() }));
|
||||
vi.mock('@/utils/library/queueTrackView', () => ({
|
||||
vi.mock('@/features/playback/store/queueTrackView', () => ({
|
||||
resolveQueueTrack: (ref: { trackId: string }) => ({
|
||||
id: ref.trackId,
|
||||
artistId: 'a1',
|
||||
@@ -41,8 +41,8 @@ vi.mock('@/features/playback/utils/playback/buildInfiniteQueueCandidates', () =>
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/playback/songToTrack', () => ({ songToTrack: (s: unknown) => s }));
|
||||
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({ ensureQueueServerPinned: () => null }));
|
||||
vi.mock('@/utils/library/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
|
||||
vi.mock('@/utils/library/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
|
||||
vi.mock('@/features/playback/store/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
|
||||
vi.mock('@/features/playback/store/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
|
||||
|
||||
import { runNext } from '@/features/playback/store/nextAction';
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@/utils/library/queueTrackView', () => ({
|
||||
vi.mock('@/features/playback/store/queueTrackView', () => ({
|
||||
getQueueTracksView: vi.fn((refs: { trackId: string }[]) =>
|
||||
refs.map(r => ({
|
||||
id: r.trackId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { getQueueTracksView } from '@/utils/library/queueTrackView';
|
||||
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
|
||||
import { scheduleHotCachePrefetchForTrack } from '@/hotCachePrefetch';
|
||||
import { getPlaybackCacheServerKey } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
getCachedTrack,
|
||||
seedQueueResolver,
|
||||
_resetQueueResolverForTest,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { patchCachedTrack } from '@/utils/library/queueTrackResolver';
|
||||
import { patchCachedTrack } from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
/**
|
||||
* F4 — pending-sync for **song** star + rating (spec §6.5 / R7-18).
|
||||
|
||||
@@ -58,9 +58,9 @@ import {
|
||||
recordEnginePlayUrl,
|
||||
} from '@/features/playback/store/playbackUrlRouting';
|
||||
import type { PlayerState, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { getQueueTracksView, resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
|
||||
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { readInitialPlayerPrefs, persistPlayerPrefs } from '@/features/playback/
|
||||
import { createHydrationGatedStorage, createSafeJSONStorage } from '@/lib/util/safeStorage';
|
||||
import { emitPlaybackProgress } from '@/features/playback/store/playbackProgress';
|
||||
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
import { readInitialQueueVisibility } from '@/features/playback/store/queueVisibilityStorage';
|
||||
import { createNetworkLoveActions } from '@/features/playback/store/networkLoveActions';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { toQueueItemRefs } from './queueItemRef';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
|
||||
describe('toQueueItemRefs', () => {
|
||||
it('uses per-track serverId when present', () => {
|
||||
const queue: Track[] = [
|
||||
{ id: 't1', title: 'A', artist: '', album: '', albumId: '', duration: 1, serverId: 'srv-a' },
|
||||
{ id: 't2', title: 'B', artist: '', album: '', albumId: '', duration: 1, serverId: 'srv-b' },
|
||||
];
|
||||
const refs = toQueueItemRefs('fallback', queue);
|
||||
expect(refs[0].serverId).not.toBe(refs[1].serverId);
|
||||
expect(refs[0].trackId).toBe('t1');
|
||||
expect(refs[1].trackId).toBe('t2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { stampTrackServerId } from '@/features/playback/utils/playback/trackServerScope';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
|
||||
/**
|
||||
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
|
||||
* `serverId` is the canonical server index key — every writer normalizes here
|
||||
* so refs are unambiguous across mixed-server queues (same `trackId` on two
|
||||
* servers must collide on nothing, since the resolver uses `serverId:trackId`).
|
||||
* Queue-only flags are carried through, others omitted to keep the persisted /
|
||||
* derived list small. Pure — no store import beyond the canonicalizer, so both
|
||||
* `playerStore` (persist) and the resolver bridge can use it without a
|
||||
* circular dependency.
|
||||
*/
|
||||
export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] {
|
||||
return queue.map(t => {
|
||||
const scoped = stampTrackServerId(t, serverId);
|
||||
const canonicalId = canonicalQueueServerKey(scoped.serverId ?? serverId);
|
||||
const ref: QueueItemRef = { serverId: canonicalId, trackId: t.id };
|
||||
if (t.autoAdded) ref.autoAdded = true;
|
||||
if (t.radioAdded) ref.radioAdded = true;
|
||||
if (t.playNextAdded) ref.playNextAdded = true;
|
||||
return ref;
|
||||
});
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { useAuthStore } from '@/store/authStore';
|
||||
import { setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
import { prefetchLoudnessForEnqueuedTracks } from '@/features/playback/store/loudnessPrefetch';
|
||||
import type { PlayerState, QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { seedQueueResolver } from '@/utils/library/queueTrackResolver';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { syncUserQueueMutationToServer } from '@/features/playback/store/queueSync';
|
||||
import {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* track here, off the render path.
|
||||
*/
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resolveVisibleRange } from '@/utils/library/queueTrackResolver';
|
||||
import { resolveVisibleRange } from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
usePlayerStore.subscribe((state, prev) => {
|
||||
// Re-seed when the queue refs or the current index change — the prefetch
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { TrackRefDto } from '@/lib/api/library';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import {
|
||||
getCachedTrack,
|
||||
_resetQueueResolverForTest,
|
||||
} from './queueTrackResolver';
|
||||
import { hydrateQueueFromIndex } from './queueRestore';
|
||||
|
||||
const ready = () =>
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1',
|
||||
libraryScope: '',
|
||||
syncPhase: 'ready',
|
||||
capabilityFlags: 0,
|
||||
libraryTier: 'unknown',
|
||||
syncedAt: 0,
|
||||
}));
|
||||
|
||||
/** Echo each requested ref back as a minimal LibraryTrackDto (order preserved). */
|
||||
const echoBatch = () =>
|
||||
onInvoke('library_get_tracks_batch', (args) =>
|
||||
(args as { refs: TrackRefDto[] }).refs.map(r => ({
|
||||
serverId: r.serverId,
|
||||
id: r.trackId,
|
||||
title: `T-${r.trackId}`,
|
||||
album: 'A',
|
||||
durationSec: 1,
|
||||
syncedAt: 0,
|
||||
rawJson: {},
|
||||
})),
|
||||
);
|
||||
|
||||
const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1 });
|
||||
|
||||
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
|
||||
usePlayerStore.setState({
|
||||
queueServerId: 's1',
|
||||
queueIndex: 0,
|
||||
currentTrack: null,
|
||||
queueItems: [],
|
||||
queueItemsIndex: undefined,
|
||||
queueRefs: undefined,
|
||||
queueRefsIndex: undefined,
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin-state `hydrateQueueFromIndex`: the store is refs-canonical, so cold
|
||||
* restore eagerly resolves the whole `queueItems` ref list into the resolver
|
||||
* cache (index batch → getSong fallback) and clears the restore-pending
|
||||
* sentinel. It no longer swaps a fat `Track[]` into the store — `queueItems`
|
||||
* stays the source of truth.
|
||||
*/
|
||||
describe('hydrateQueueFromIndex', () => {
|
||||
beforeEach(() => {
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
_resetQueueResolverForTest();
|
||||
seedStore();
|
||||
});
|
||||
|
||||
it('does nothing without a restore-pending sentinel', async () => {
|
||||
seedStore({ queueItems: [{ serverId: 's1', trackId: 'w1' }], queueItemsIndex: undefined });
|
||||
await hydrateQueueFromIndex();
|
||||
// No resolve dispatched (no sentinel) → nothing cached.
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 'w1' })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves the whole queueItems ref list into the resolver cache and clears the sentinel', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
seedStore({
|
||||
queueItems: [
|
||||
{ serverId: 's1', trackId: 't1' },
|
||||
{ serverId: 's1', trackId: 't2' },
|
||||
{ serverId: 's1', trackId: 't3' },
|
||||
],
|
||||
queueItemsIndex: 1,
|
||||
currentTrack: track('t2'),
|
||||
});
|
||||
await hydrateQueueFromIndex();
|
||||
const s = usePlayerStore.getState();
|
||||
// queueItems stays canonical (no fat-array swap).
|
||||
expect(s.queueItems.map(r => r.trackId)).toEqual(['t1', 't2', 't3']);
|
||||
// Restore-pending sentinel cleared so it runs at most once.
|
||||
expect(s.queueItemsIndex).toBeUndefined();
|
||||
// Every ref was resolved into the cache.
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
|
||||
});
|
||||
|
||||
it('batches refs in chunks of 100', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
const items = Array.from({ length: 150 }, (_, i) => ({ serverId: 's1', trackId: `t${i}` }));
|
||||
seedStore({ queueItems: items, queueItemsIndex: 0 });
|
||||
await hydrateQueueFromIndex();
|
||||
// All 150 resolved into the cache (the resolver chunks ≤100/call internally).
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't0' })?.id).toBe('t0');
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't149' })?.id).toBe('t149');
|
||||
});
|
||||
|
||||
it('upgrades a legacy queueRefs-only blob via queueServerId, then clears it', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
seedStore({
|
||||
queueItems: [], // pre-thin-state in-memory shape
|
||||
queueRefs: ['t1', 't2', 't3'],
|
||||
queueRefsIndex: 1,
|
||||
queueServerId: 's1',
|
||||
currentTrack: track('t2'),
|
||||
});
|
||||
await hydrateQueueFromIndex();
|
||||
const s = usePlayerStore.getState();
|
||||
// Legacy refs resolved into the cache and the legacy fields cleared.
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1');
|
||||
expect(getCachedTrack({ serverId: 's1', trackId: 't3' })?.id).toBe('t3');
|
||||
expect(s.queueItemsIndex).toBeUndefined();
|
||||
expect(s.queueRefs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the sentinel even when the index is not ready (best-effort getSong fallback runs)', async () => {
|
||||
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
||||
onInvoke('library_get_tracks_batch', () => []);
|
||||
seedStore({
|
||||
queueItems: [{ serverId: 's1', trackId: 't1' }],
|
||||
queueItemsIndex: 0,
|
||||
});
|
||||
await hydrateQueueFromIndex();
|
||||
// Sentinel cleared up front so the eager resolve runs at most once; refs stay.
|
||||
expect(usePlayerStore.getState().queueItemsIndex).toBeUndefined();
|
||||
expect(usePlayerStore.getState().queueItems.map(r => r.trackId)).toEqual(['t1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
import { resolveBatch } from './queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Full-queue restore (thin-state, decision B). The player store rehydrates the
|
||||
* whole thin `queueItems` ref list from localStorage on startup; this eagerly
|
||||
* resolves every ref into the resolver cache so the queue UI / playback paths
|
||||
* have real `Track` metadata. `resolveBatch` does the index batch
|
||||
* (`library_get_tracks_batch`, ≤100 refs/call) → `getSong` network fallback (P8)
|
||||
* internally, so the queue is never empty even with the index off (the P6
|
||||
* default — every ref still resolves via getSong).
|
||||
*
|
||||
* Clears the restore-pending sentinel (`queueItemsIndex`) once the eager resolve
|
||||
* is dispatched so it runs at most once; `queueItems` stays canonical. Legacy
|
||||
* pre-thin-state blobs that only carried `queueRefs` were normalised into
|
||||
* `queueItems` by the store's persist `merge`, so this reads `queueItems` only.
|
||||
*/
|
||||
export async function hydrateQueueFromIndex(): Promise<void> {
|
||||
const player = usePlayerStore.getState();
|
||||
|
||||
// Restore-pending sentinel: `partialize` writes `queueItemsIndex` alongside
|
||||
// the full `queueItems` on every persist, so a fresh rehydrate carries it
|
||||
// back. Normal in-memory mutations keep `queueItems` canonical but never set
|
||||
// the index, so its presence — not a non-empty `queueItems` — marks "this
|
||||
// restored queue still needs an eager resolve". Without it (steady state /
|
||||
// later server switch) there is nothing to do.
|
||||
const restorePending =
|
||||
player.queueItemsIndex !== undefined || (player.queueRefs?.length ?? 0) > 0;
|
||||
if (!restorePending) return;
|
||||
|
||||
let refs: QueueItemRef[] = player.queueItems ?? [];
|
||||
if (refs.length === 0 && player.queueRefs?.length) {
|
||||
const rawSid = player.queueServerId ?? useAuthStore.getState().activeServerId ?? '';
|
||||
const sid = canonicalQueueServerKey(rawSid);
|
||||
refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId }));
|
||||
}
|
||||
|
||||
// Clear the restore-pending sentinel + any legacy refs; `queueItems` stays the
|
||||
// canonical mirror. Done up front so a later resolve never re-triggers.
|
||||
usePlayerStore.setState({
|
||||
queueItemsIndex: undefined,
|
||||
queueRefs: undefined,
|
||||
queueRefsIndex: undefined,
|
||||
});
|
||||
|
||||
if (refs.length === 0) return;
|
||||
|
||||
// Eager resolve of the whole queue into the resolver cache (best-effort —
|
||||
// index batch when ready, else getSong window fallback so the queue plays
|
||||
// even with the index off). Failures leave refs as placeholders until a row
|
||||
// scrolls into view and the resolver bridge fetches them.
|
||||
try {
|
||||
await resolveBatch(refs);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { onInvoke } from '@/test/mocks/tauri';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { TrackRefDto } from '@/lib/api/library';
|
||||
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
|
||||
import * as subsonic from '@/lib/api/subsonicLibrary';
|
||||
import {
|
||||
resolveBatch,
|
||||
resolveVisibleRange,
|
||||
getCachedTrack,
|
||||
placeholderTrack,
|
||||
applyQueueOverrides,
|
||||
seedQueueResolver,
|
||||
invalidateQueueResolver,
|
||||
subscribeQueueResolver,
|
||||
_resetQueueResolverForTest,
|
||||
} from './queueTrackResolver';
|
||||
|
||||
const ready = () =>
|
||||
onInvoke('library_get_status', () => ({
|
||||
serverId: 's1', libraryScope: '', syncPhase: 'ready',
|
||||
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
|
||||
}));
|
||||
|
||||
const notReady = () =>
|
||||
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
||||
|
||||
const echoBatch = () =>
|
||||
onInvoke('library_get_tracks_batch', (args) =>
|
||||
(args as { refs: TrackRefDto[] }).refs.map(r => ({
|
||||
serverId: r.serverId, id: r.trackId, title: `T-${r.trackId}`,
|
||||
album: 'A', durationSec: 1, syncedAt: 0, rawJson: {},
|
||||
})),
|
||||
);
|
||||
|
||||
const ref = (trackId: string, extra: Partial<QueueItemRef> = {}): QueueItemRef => ({ serverId: 's1', trackId, ...extra });
|
||||
|
||||
describe('queueTrackResolver', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
useLibraryIndexStore.setState({ masterEnabled: true });
|
||||
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('getCachedTrack returns undefined on a miss (no fetch)', () => {
|
||||
expect(getCachedTrack(ref('x'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolveBatch fills the cache from the index; getCachedTrack reads it', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
await resolveBatch([ref('t1'), ref('t2')]);
|
||||
expect(getCachedTrack(ref('t1'))?.title).toBe('T-t1');
|
||||
expect(getCachedTrack(ref('t2'))?.title).toBe('T-t2');
|
||||
});
|
||||
|
||||
it('carries queue-only flags from the ref onto the resolved track', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
await resolveBatch([ref('t1', { radioAdded: true }), ref('t2', { autoAdded: true, playNextAdded: true })]);
|
||||
expect(getCachedTrack(ref('t1'))?.radioAdded).toBe(true);
|
||||
expect(getCachedTrack(ref('t2'))?.autoAdded).toBe(true);
|
||||
expect(getCachedTrack(ref('t2'))?.playNextAdded).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to network getSongForServer when the index is not ready', async () => {
|
||||
notReady();
|
||||
const spy = vi.spyOn(subsonic, 'getSongForServer').mockResolvedValue({
|
||||
id: 't9', title: 'Net Song', album: 'A', duration: 1,
|
||||
} as never);
|
||||
await resolveBatch([ref('t9')]);
|
||||
expect(spy).toHaveBeenCalledWith('s1', 't9');
|
||||
expect(getCachedTrack(ref('t9'))?.title).toBe('Net Song');
|
||||
});
|
||||
|
||||
it('notifies subscribers when a fetch lands', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
const cb = vi.fn();
|
||||
const unsub = subscribeQueueResolver(cb);
|
||||
await resolveBatch([ref('t1')]);
|
||||
expect(cb).toHaveBeenCalled();
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('does not re-fetch already-cached refs', async () => {
|
||||
ready();
|
||||
const batch = vi.fn((args: { refs: TrackRefDto[] }) =>
|
||||
args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} })));
|
||||
onInvoke('library_get_tracks_batch', batch as never);
|
||||
await resolveBatch([ref('t1')]);
|
||||
await resolveBatch([ref('t1')]); // cached → no second batch call
|
||||
expect(batch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seedQueueResolver caches known tracks without a fetch', () => {
|
||||
seedQueueResolver('s1', [{ id: 't1', title: 'Seeded', artist: '', album: 'A', albumId: 'A', duration: 1 }]);
|
||||
expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded');
|
||||
});
|
||||
|
||||
it('invalidateQueueResolver drops the cached entry', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
await resolveBatch([ref('t1')]);
|
||||
expect(getCachedTrack(ref('t1'))).toBeDefined();
|
||||
invalidateQueueResolver('t1');
|
||||
expect(getCachedTrack(ref('t1'))).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applyQueueOverrides merges session star/rating overrides', () => {
|
||||
usePlayerStore.setState({ starredOverrides: { t1: true }, userRatingOverrides: { t1: 4 } });
|
||||
const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1 });
|
||||
expect(!!merged.starred).toBe(true);
|
||||
expect(merged.userRating).toBe(4);
|
||||
});
|
||||
|
||||
it('applyQueueOverrides clears starred when the override is false', () => {
|
||||
usePlayerStore.setState({ starredOverrides: { t1: false }, userRatingOverrides: {} });
|
||||
const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1, starred: '2020' });
|
||||
expect(merged.starred).toBeUndefined();
|
||||
});
|
||||
|
||||
it('placeholderTrack preserves identity + queue flags', () => {
|
||||
const p = placeholderTrack(ref('t1', { radioAdded: true }));
|
||||
expect(p.id).toBe('t1');
|
||||
expect(p.radioAdded).toBe(true);
|
||||
});
|
||||
|
||||
it('resolveVisibleRange prefetches the window around the visible rows', async () => {
|
||||
ready();
|
||||
const batch = vi.fn((args: { refs: TrackRefDto[] }) =>
|
||||
args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} })));
|
||||
onInvoke('library_get_tracks_batch', batch as never);
|
||||
const refs = Array.from({ length: 400 }, (_, i) => ref(`t${i}`));
|
||||
resolveVisibleRange(refs, 100, 120);
|
||||
await vi.waitFor(() => expect(getCachedTrack(ref('t120'))).toBeDefined());
|
||||
// back window: t50 in range (100 - 50); t49 out of range
|
||||
expect(getCachedTrack(ref('t50'))).toBeDefined();
|
||||
expect(getCachedTrack(ref('t49'))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { libraryGetTracksBatch, type TrackRefDto } from '@/lib/api/library';
|
||||
import { getSongForServer } from '@/lib/api/subsonicLibrary';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
|
||||
import { trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { libraryIsReady } from '@/utils/library/libraryReady';
|
||||
|
||||
/**
|
||||
* Queue track resolver (thin-state phase 2). Resolves `QueueItemRef`s to full
|
||||
* `Track`s on demand — index batch (`library_get_tracks_batch`, ≤100/call) →
|
||||
* network `getSong` fallback (P8) — into a bounded LRU cache. The cache holds
|
||||
* raw tracks; session star/rating overrides (F4) are merged on read via
|
||||
* {@link applyQueueOverrides}. Selectors read synchronously from the cache and
|
||||
* subscribe to {@link subscribeQueueResolver} to re-render as fetches land.
|
||||
*
|
||||
* Phase 2a: standalone module + tests, not yet wired into the store/UI.
|
||||
*/
|
||||
|
||||
const CACHE_CAP = 500;
|
||||
/** `library_get_tracks_batch` cap (spec §8.6). */
|
||||
const BATCH = 100;
|
||||
/** Prefetch window around the visible range (spec §resolver-contract). */
|
||||
const PREFETCH_BACK = 50;
|
||||
const PREFETCH_AHEAD = 200;
|
||||
|
||||
const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r.trackId}`;
|
||||
|
||||
// LRU cache: refKey → raw Track (no session overrides).
|
||||
const cache = new Map<string, Track>();
|
||||
const inFlight = new Set<string>();
|
||||
const listeners = new Set<() => void>();
|
||||
let cacheVersion = 0;
|
||||
|
||||
function notify(): void {
|
||||
cacheVersion++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
/** Monotonic version, bumped on every cache change (for `useSyncExternalStore`). */
|
||||
export function getQueueResolverVersion(): number {
|
||||
return cacheVersion;
|
||||
}
|
||||
|
||||
/** Subscribe to cache changes (for `useSyncExternalStore` selectors). */
|
||||
export function subscribeQueueResolver(cb: () => void): () => void {
|
||||
listeners.add(cb);
|
||||
return () => { listeners.delete(cb); };
|
||||
}
|
||||
|
||||
function cacheSet(key: string, track: Track): void {
|
||||
if (cache.has(key)) cache.delete(key);
|
||||
cache.set(key, track);
|
||||
while (cache.size > CACHE_CAP) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
|
||||
if (ref?.autoAdded) track.autoAdded = true;
|
||||
if (ref?.radioAdded) track.radioAdded = true;
|
||||
if (ref?.playNextAdded) track.playNextAdded = true;
|
||||
return track;
|
||||
}
|
||||
|
||||
/** Synchronous cache read (no fetch); undefined on miss. */
|
||||
export function getCachedTrack(ref: QueueItemRef): Track | undefined {
|
||||
// Pure read — no LRU bump. Called from component render (QueueList rows), where
|
||||
// a Map mutation (delete+set) is a render side-effect. Recency is set at write
|
||||
// time in cacheSet instead; this cache is effectively insertion-order/FIFO.
|
||||
const direct = cache.get(refKey(ref));
|
||||
if (direct) return direct;
|
||||
// Compat: refs persisted before B1 (queue server identity canonicalization)
|
||||
// may still carry a UUID. Writes are canonical now, so the live cache key
|
||||
// is `${indexKey}:${trackId}`; map UUID → indexKey on read to bridge the
|
||||
// migration window.
|
||||
const canonical = canonicalQueueServerKey(ref.serverId);
|
||||
if (canonical && canonical !== ref.serverId) {
|
||||
return cache.get(refKey({ serverId: canonical, trackId: ref.trackId }));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Lightweight placeholder shown until a ref resolves. */
|
||||
export function placeholderTrack(ref: QueueItemRef): Track {
|
||||
return {
|
||||
id: ref.trackId,
|
||||
title: '…',
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
autoAdded: ref.autoAdded,
|
||||
radioAdded: ref.radioAdded,
|
||||
playNextAdded: ref.playNextAdded,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge session star/rating overrides (F4) onto a resolved track. */
|
||||
export function applyQueueOverrides(track: Track): Track {
|
||||
const s = usePlayerStore.getState();
|
||||
const hasStar = track.id in s.starredOverrides;
|
||||
const hasRating = track.id in s.userRatingOverrides;
|
||||
if (!hasStar && !hasRating) return track;
|
||||
const next = { ...track };
|
||||
if (hasStar) {
|
||||
next.starred = s.starredOverrides[track.id] ? (track.starred ?? new Date().toISOString()) : undefined;
|
||||
}
|
||||
if (hasRating) next.userRating = s.userRatingOverrides[track.id];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch.
|
||||
* Canonicalizes the caller-supplied server id so seed and refs always agree
|
||||
* on a single key shape. */
|
||||
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
||||
if (tracks.length === 0) return;
|
||||
const canonicalId = canonicalQueueServerKey(serverId);
|
||||
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a batch of refs into the cache: index batch (per server, when ready)
|
||||
* then network fallback for whatever the index lacks. Skips refs already cached
|
||||
* or in flight. Notifies once if anything changed.
|
||||
*/
|
||||
export async function resolveBatch(refs: QueueItemRef[]): Promise<void> {
|
||||
const missing = refs.filter(r => {
|
||||
const k = refKey(r);
|
||||
return !cache.has(k) && !inFlight.has(k);
|
||||
});
|
||||
if (missing.length === 0) return;
|
||||
for (const r of missing) inFlight.add(refKey(r));
|
||||
|
||||
let changed = false;
|
||||
try {
|
||||
const byServer = new Map<string, QueueItemRef[]>();
|
||||
for (const r of missing) {
|
||||
const arr = byServer.get(r.serverId) ?? [];
|
||||
arr.push(r);
|
||||
byServer.set(r.serverId, arr);
|
||||
}
|
||||
|
||||
for (const [serverId, serverRefs] of byServer) {
|
||||
if (!serverId) continue;
|
||||
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
||||
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
||||
|
||||
if (await libraryIsReady(serverId)) {
|
||||
for (let i = 0; i < serverRefs.length; i += BATCH) {
|
||||
const chunk: TrackRefDto[] = serverRefs
|
||||
.slice(i, i + BATCH)
|
||||
.map(r => ({ serverId, trackId: r.trackId }));
|
||||
try {
|
||||
const dtos = await libraryGetTracksBatch(chunk);
|
||||
for (const d of dtos) {
|
||||
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
|
||||
const track = carryFlags(
|
||||
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? profileId },
|
||||
refByTrack.get(d.id),
|
||||
);
|
||||
cacheSet(refKey({ serverId, trackId: d.id }), track);
|
||||
stillMissing.delete(d.id);
|
||||
changed = true;
|
||||
}
|
||||
} catch { /* fall through to network */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Network fallback (P8) for refs the index couldn't serve.
|
||||
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
|
||||
for (const trackId of stillMissing) {
|
||||
try {
|
||||
const song = await getSongForServer(profileId, trackId);
|
||||
if (song) {
|
||||
const track = carryFlags(
|
||||
{ ...songToTrack(song), serverId: profileId },
|
||||
refByTrack.get(trackId),
|
||||
);
|
||||
cacheSet(refKey({ serverId, trackId }), track);
|
||||
changed = true;
|
||||
}
|
||||
} catch { /* leave as placeholder */ }
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
for (const r of missing) inFlight.delete(refKey(r));
|
||||
if (changed) notify();
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the visible range plus the prefetch window around it. */
|
||||
export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx: number): void {
|
||||
const start = Math.max(0, fromIdx - PREFETCH_BACK);
|
||||
const end = Math.min(refs.length, toIdx + PREFETCH_AHEAD + 1);
|
||||
if (end > start) void resolveBatch(refs.slice(start, end));
|
||||
}
|
||||
|
||||
/** Drop cached entries for a track id, forcing the next resolve to re-fetch. */
|
||||
export function invalidateQueueResolver(trackId: string): void {
|
||||
let changed = false;
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (key.endsWith(`:${trackId}`)) {
|
||||
cache.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
/** Patch cached entries for a track id in place (e.g. after a star/rating sync
|
||||
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
|
||||
* visible queue row never blanks to a placeholder — the row stays resolved and
|
||||
* just reflects the synced value. No-op for refs not currently cached. */
|
||||
export function patchCachedTrack(trackId: string, patch: Partial<Track>): void {
|
||||
let changed = false;
|
||||
for (const [key, track] of cache) {
|
||||
if (key.endsWith(`:${trackId}`)) {
|
||||
cache.set(key, { ...track, ...patch });
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notify();
|
||||
}
|
||||
|
||||
/** Test-only: clear cache + in-flight set. */
|
||||
export function _resetQueueResolverForTest(): void {
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { seedQueueResolver, _resetQueueResolverForTest } from '@/features/playback/store/queueTrackResolver';
|
||||
import { resolveQueueTrack, getQueueTracksView } from './queueTrackView';
|
||||
|
||||
const track = (id: string, over: Partial<Track> = {}): Track =>
|
||||
({ id, title: id, artist: 'A', album: 'Al', albumId: 'Al', duration: 1, ...over });
|
||||
const ref = (trackId: string, over: Partial<QueueItemRef> = {}): QueueItemRef =>
|
||||
({ serverId: 's1', trackId, ...over });
|
||||
|
||||
describe('queueTrackView', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueueResolverForTest();
|
||||
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
|
||||
});
|
||||
|
||||
it('resolves from the resolver cache when present', () => {
|
||||
seedQueueResolver('s1', [track('t1', { title: 'Cached' })]);
|
||||
expect(resolveQueueTrack(ref('t1')).title).toBe('Cached');
|
||||
});
|
||||
|
||||
it('falls back to the provided Track on cache miss', () => {
|
||||
expect(resolveQueueTrack(ref('t2'), track('t2', { title: 'Fallback' })).title).toBe('Fallback');
|
||||
});
|
||||
|
||||
it('returns a placeholder on miss with no fallback', () => {
|
||||
const r = resolveQueueTrack(ref('t3'));
|
||||
expect(r.id).toBe('t3');
|
||||
expect(r.title).toBe('…');
|
||||
});
|
||||
|
||||
it('carries the ref queue-only flags onto the resolved track', () => {
|
||||
seedQueueResolver('s1', [track('t4')]);
|
||||
const r = resolveQueueTrack(ref('t4', { radioAdded: true }));
|
||||
expect(r.radioAdded).toBe(true);
|
||||
});
|
||||
|
||||
it('merges session star/rating overrides', () => {
|
||||
seedQueueResolver('s1', [track('t5')]);
|
||||
usePlayerStore.setState({ starredOverrides: { t5: true }, userRatingOverrides: { t5: 4 } });
|
||||
const r = resolveQueueTrack(ref('t5'));
|
||||
expect(!!r.starred).toBe(true);
|
||||
expect(r.userRating).toBe(4);
|
||||
});
|
||||
|
||||
it('getQueueTracksView resolves each ref, preferring cache then fallback', () => {
|
||||
seedQueueResolver('s1', [track('a', { title: 'CachedA' })]);
|
||||
const out = getQueueTracksView([ref('a'), ref('b')], [track('a'), track('b', { title: 'FbB' })]);
|
||||
expect(out.map(t => t.title)).toEqual(['CachedA', 'FbB']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { getCachedTrack, placeholderTrack, applyQueueOverrides } from './queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
|
||||
* from the canonical `QueueItemRef[]` after a ref-native mutation. Each ref's
|
||||
* track is sourced from the supplied `pools` (the previous queue + any tracks
|
||||
* just handed to the mutation) by id — **purely structural**: no resolver cache
|
||||
* read and no F4 override merge (display still applies those), so the derived
|
||||
* array is byte-identical to the old fat-array mutation result. The ref is the
|
||||
* source of truth for the queue-only flags. A ref with no pooled track falls
|
||||
* back to a placeholder (does not happen during dual-write, where every ref's
|
||||
* track is in hand). Removed in the final step together with `queue: Track[]`.
|
||||
*/
|
||||
export function bridgeQueueFromItems(items: QueueItemRef[], pools: Track[][]): Track[] {
|
||||
const byId = new Map<string, Track>();
|
||||
for (const pool of pools) {
|
||||
for (const t of pool) if (!byId.has(t.id)) byId.set(t.id, t);
|
||||
}
|
||||
return items.map(ref => {
|
||||
const base = byId.get(ref.trackId);
|
||||
if (!base) return placeholderTrack(ref);
|
||||
if (
|
||||
base.autoAdded === ref.autoAdded &&
|
||||
base.radioAdded === ref.radioAdded &&
|
||||
base.playNextAdded === ref.playNextAdded
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
return { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue thin-state phase 4: turn a `QueueItemRef` into a display `Track` for the
|
||||
* upcoming consumer migration off `queue: Track[]`.
|
||||
*
|
||||
* Resolver-first: cache → caller fallback (the legacy `queue[idx]` Track during
|
||||
* the dual-write transition) → placeholder. Queue-only flags come from the ref
|
||||
* (they are not in the index/cache); session star/rating overrides (F4) are
|
||||
* merged last. Pure synchronous read — **no fetch, no cache mutation** — so it is
|
||||
* safe to call from render (the resolver's `getCachedTrack` is a plain `cache.get`
|
||||
* for exactly this reason; see the freeze fix in queueTrackResolver).
|
||||
*/
|
||||
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
|
||||
const base = getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref);
|
||||
// Carry the ref's queue-only flags onto the resolved track without mutating the
|
||||
// cached object (a render-time mutation is what caused the earlier render loop).
|
||||
const needsFlags =
|
||||
base.autoAdded !== ref.autoAdded ||
|
||||
base.radioAdded !== ref.radioAdded ||
|
||||
base.playNextAdded !== ref.playNextAdded;
|
||||
const flagged = needsFlags
|
||||
? { ...base, autoAdded: ref.autoAdded, radioAdded: ref.radioAdded, playNextAdded: ref.playNextAdded }
|
||||
: base;
|
||||
return applyQueueOverrides(flagged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a whole ref list to display `Track`s (non-React call sites: snapshots,
|
||||
* hot-cache planning, sync). Same per-item rules as {@link resolveQueueTrack};
|
||||
* `fallbacks[i]` is the legacy `queue[i]` during the dual-write transition.
|
||||
*/
|
||||
export function getQueueTracksView(refs: QueueItemRef[], fallbacks?: Track[]): Track[] {
|
||||
return refs.map((ref, i) => resolveQueueTrack(ref, fallbacks?.[i]));
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* to restore list scroll position after an undo/redo commit.
|
||||
*/
|
||||
import type { PlayerState, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/utils/library/queueItemRef';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
QUEUE_UNDO_MAX,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
recordEnginePlayUrl,
|
||||
} from '@/features/playback/store/playbackUrlRouting';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||
import { pushQueueOnPlaybackStart, flushLocalQueueWhenTakingPlayback } from '@/features/playback/store/queueSync';
|
||||
import { markPlaybackActive } from '@/features/playback/store/queuePlaybackIdle';
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock('@/features/playback/store/playerStore', () => ({
|
||||
}));
|
||||
|
||||
import { applySkipStarOnManualNext } from '@/features/playback/store/skipStarRating';
|
||||
import { seedQueueResolver, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
||||
import { seedQueueResolver, _resetQueueResolverForTest } from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
function track(id: string, overrides: Partial<Track> = {}): Track {
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { getCachedTrack } from '@/utils/library/queueTrackResolver';
|
||||
import { getCachedTrack } from '@/features/playback/store/queueTrackResolver';
|
||||
import { queueSongRating } from '@/features/playback/store/pendingStarSync';
|
||||
/**
|
||||
* Skip → 1★ behaviour: every user-initiated `next()` on an unrated track
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { deriveNormalizationSnapshot } from '@/features/playback/store/normalizationSnapshot';
|
||||
import { invokeAudioUpdateReplayGainDeduped } from '@/features/playback/store/normalizationIpcDedupe';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
|
||||
@@ -11,7 +11,7 @@ import { PlaylistArtistCell } from '@/features/playlist/components/PlaylistArtis
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { usePlaylistLayoutStore } from '@/features/playlist/store/playlistLayoutStore';
|
||||
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
|
||||
import { getQueueTracksView } from '@/utils/library/queueTrackView';
|
||||
import { getQueueTracksView } from '@/features/playback/store/queueTrackView';
|
||||
import { codecLabel } from '@/utils/componentHelpers/playlistDetailHelpers';
|
||||
import { formatLastSeen } from '@/utils/componentHelpers/userMgmtHelpers';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
|
||||
@@ -9,11 +9,11 @@ import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import type { DurationMode } from '@/utils/componentHelpers/queuePanelHelpers';
|
||||
import { formatLongDuration } from '@/lib/format/formatDuration';
|
||||
import { formatClockTime } from '@/lib/format/formatClockTime';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
interface Props {
|
||||
queue: QueueItemRef[];
|
||||
|
||||
@@ -8,11 +8,11 @@ import { useLuckyMixStore } from '@/store/luckyMixStore';
|
||||
import type { QueueItemRef, PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import type { QueueDisplayMode } from '@/store/authStoreTypes';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
import { findQueueItemRefIndex } from '@/features/playback/utils/playback/queueIdentity';
|
||||
import type { TimelineDisplayRow } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
import { findTimelineScrollLocalIndex } from '@/utils/queue/buildTimelineDisplayRows';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver';
|
||||
import { getCachedTrack, _resetQueueResolverForTest } from '@/features/playback/store/queueTrackResolver';
|
||||
import { seedQueue } from '@/test/helpers/factories';
|
||||
import { useQueueTrackAt, useCurrentTrack, useQueueItems } from '@/features/queue/hooks/useQueueTracks';
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMemo, useSyncExternalStore } from 'react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import type { QueueItemRef, Track } from '@/features/playback/store/playerStoreTypes';
|
||||
import { resolveQueueTrack } from '@/utils/library/queueTrackView';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getQueueResolverVersion,
|
||||
subscribeQueueResolver,
|
||||
} from '@/utils/library/queueTrackResolver';
|
||||
} from '@/features/playback/store/queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Stable queue selectors (queue thin-state). The store is refs-canonical now:
|
||||
|
||||
Reference in New Issue
Block a user