refactor(queue): thin-state refs as canonical, full Track via resolver (#872)

* refactor(queue): wire queue UI to the track resolver (thin-state phase 3)

cucadmuh's phase-3 steps:
- Selectors (useQueueTracks) read resolver-first: getCachedTrack → queue: Track[]
  fallback (until phase 4), F4 star/rating overrides merged on read.
- QueueList rows source their track from the resolver (queue fallback); rows show
  title/artist/duration only, so no override merge there.
- pendingStarSync star/rating success → invalidateQueueResolver so the cache
  reflects the synced value.
- queueResolverBridge re-seeds on queueIndex change too — the prefetch window
  travels with the playing track.

Additive: queue: Track[] stays canonical and behaviour is unchanged (rows
resolve to the same data). Phase 4 drops queue: Track[] and the fallbacks.

* docs(changelog): queue panel reads through track cache (#860)

* fix(queue): stop a render loop that froze the UI on long queues

A long virtualized queue + a track change could lock the WebView for ~2 min:
- useVirtualizer was handed a fresh `initialRect` object literal every render, so
  it kept re-initializing in a loop. Hoisted it to a stable module constant.
- getCachedTrack did an LRU bump (Map delete+set) during render — a render-time
  side effect. Made it a pure read; recency is set at write time in cacheSet.

* perf(mobile): virtualize the mobile player queue drawer

The mobile now-playing queue drawer rendered the full queue with .map; a
multi-thousand-track queue meant thousands of DOM nodes. Virtualize it with
@tanstack/react-virtual (uniform rows, stable initialRect) so the DOM stays at
O(visible rows), matching the desktop QueuePanel. Active track is centred on open
via scrollToIndex.

* perf(mini): virtualize the mini-player queue list

The mini-player queue rendered the full MiniSyncPayload queue with .map.
Virtualize it against the OverlayScrollArea viewport (stable initialRect) so the
mini window's DOM stays at O(visible rows). Drag-reorder is preserved: rows keep
data-mq-idx alongside the virtualizer's measureElement.

* refactor(queue): add resolveQueueTrack/getQueueTracksView helper (thin-state phase 4)

Render-safe ref→Track view for the phase-4 consumer migration off queue: Track[].
Resolver cache → caller fallback (legacy queue[idx] during dual-write) →
placeholder; ref queue-only flags carried, F4 overrides merged. Pure synchronous
read, no cache mutation (the freeze landmine), so it is safe in render.

* refactor(queue): keep queueItems as the canonical in-memory mirror (thin-state phase 4)

Step 1b: dual-write the thin queueItems ref list at every queue write site
(the 11 mutations, next/radio top-up, playTrack, undo/redo restore, instant-mix,
radio, server-queue init, lucky-mix rollback, and hydrate) so it tracks
queue: Track[] in memory, not only at persist time. Identity-preserving maps
(star/rating overrides) keep the same refs and are intentionally left untouched.

Resolves the restore double-role flagged for 1b: queueItemsIndex is now the
restore-pending sentinel that gates hydrateQueueFromIndex, while queueItems
stays canonical -- rebuilt from the whole queue after a full hydrate instead of
cleared. Normal mutations never set the sentinel, so it only fires on a fresh
cold-start restore, not on later server switches.

No behaviour change; queue: Track[] stays the source consumers read until
phase 3. tsc + full vitest suite (1119 tests) green.

* refactor(queue): mobile queue drawer reads through the track resolver (thin-state phase 4)

Step 2: the mobile now-playing queue drawer resolves each row's track from the
resolver cache (→ queue: Track[] fallback until phase 4), matching the desktop
QueueList wired in the phase-3 commit. Subscribes to the resolver version so
rows re-render as the cache fills. Structure (count, order, keys, the playTrack
arg) still comes from queue: Track[] until it is dropped in the final step.

The mobile drawer was the last queue display surface still reading track
metadata straight off the fat queue. tsc + full vitest suite green.

* refactor(queue): ref-native queue mutations + dual-write bridge (thin-state phase 4)

Step 3a: the 11 queueMutationActions now splice/filter/reorder QueueItemRef[]
(matching by trackId + the ref's queue-only flags) instead of Track[].
`bridgeQueueFromItems` rebuilds the dual-written queue: Track[] from the new
refs by id — purely structural (no resolver/override merge), so behaviour is
byte-identical and playerStore.queue.test.ts stays unchanged green. The working
ref list comes from `itemsOf(state)` (derived from queue: Track[] for now); the
final step swaps that one line to state.queueItems once the fat queue is gone.

enqueue / enqueueAt / enqueueRadio seed the resolver cache with incoming tracks
(seed-before-splice) so they resolve without a network round-trip after the fat
queue is dropped. Adds a DEV-only id-parity guardrail (queue vs queueItems);
dev-runtime only, silent in vitest and prod.

tsc + full vitest suite (1119) green; contract test unchanged.

* refactor(queue): ref-native radio/infinite top-ups (thin-state phase 4)

Step 3b: nextAction's proactive infinite-queue and radio top-ups build the new
queue as QueueItemRef[] and bridge back to queue: Track[] (same as the queue
mutations), and seed the resolver cache with the freshly fetched tracks so they
resolve without a network round-trip after the fat queue is dropped. The radio
top-up keeps its HISTORY_KEEP front-trim, now expressed on refs.

The exhausted-queue refill paths hand their new queue to playTrack, which keeps
its fat-queue handling until the final step (its no-arg case needs the resolver-
derived queue that lands with the queue: Track[] removal). tsc + full vitest
(1119) green; contract test unchanged.

* refactor(queue): undo snapshots store thin refs, not Track[] (thin-state phase 4)

Step 4: QueueUndoSnapshot.queue: Track[] becomes queueItems: QueueItemRef[],
killing the undo "hidden multiplier" — 32 snapshots of a 50k queue now cost
refs, not 32×50k full tracks. applyQueueHistorySnapshot rebuilds the display
queue from the refs via resolveQueueTrack: resolver cache → the live queue by id
(covers tracks the edit didn't remove) → placeholder. currentTrack stays a full
track in the snapshot and is restored to the engine unchanged.

The snapshot refs derive from queue: Track[] for now (so the undo/redo contract
cases, which seed only `queue`, stay green); the final step swaps that to
[...s.queueItems]. tsc + full vitest suite (1119) green.

* perf(mini): cap the mini-player queue snapshot to ±100 around the current track (thin-state phase 4)

Step 5: the mini bridge no longer serializes the full queue over IPC on every
push — a 50k Artist-Radio queue would otherwise re-encode in full on every track
advance. snapshot() sends a window of 100 tracks before/after the playing song;
queueIndex is made slice-relative. The mini component stays unchanged (slice-
relative); jump/reorder/remove control events are translated back to absolute
queue indices via the window offset captured on the last push.

tsc + full vitest suite (1119) green. Mini bridge has no unit tests — needs a
quick mini-player smoke (queue shows ±100, jump/reorder/remove land correctly).

* refactor(queue): make queueItems a required PlayerState field (thin-state phase 4)

Foundation for the final consumer migration off queue: Track[]: queueItems has
been written at every queue write site since phase 1b, so promoting it from
optional to required is a no-op at runtime (tsc confirms zero new errors) and
lets the upcoming reader migrations read state.queueItems without `?? []` noise.

* refactor(queue): migrate structural queue readers off queue: Track[] (thin-state phase 4)

First reader batch toward dropping queue: Track[]: the queue-length selectors
(usePlaybackServerId, usePlaybackCoverArt, useQueuePanelDrag, useMiniQueueDrag)
now read state.queueItems.length, and FullscreenPlayer's next-track cover prefetch
resolves through useQueueTrackAt instead of indexing the fat queue. All behaviour-
identical during dual-write (queueItems is in lockstep with queue). tsc + full
vitest suite (1119) green.

Note: getPlaybackServerId() (playbackServer.ts) deliberately stays on queue for
now — it is called from many partially-mocked test stores, so it migrates with
the final field removal where the seedQueue helper covers those tests.

* refactor(queue): QueuePanel save/share/playlist read queueItems (thin-state phase 4)

The id/length reads (save to playlist, share link, create playlist, empty-queue
guards, next-tracks divider) now read state.queueItems instead of the fat queue.
Behaviour-identical during dual-write; queue: Track[] stays for the rendered
QueueList + auto-scroll until the field is dropped. tsc + full suite (1119) green.

* refactor(queue): drop queue: Track[] — thin queueItems is the only queue (thin-state phase 4)

The store no longer holds the fat queue. `queueItems: QueueItemRef[]` is the sole
canonical queue; full `Track`s resolve on demand via the resolver (index batch →
getSong fallback, bounded LRU cache); only `currentTrack` stays a full Track. At
50k tracks the store holds ~hundreds of resolved tracks + the refs, not 50k Track
objects.

- **Persist:** partialize is refs-only (no windowed slice / PERSIST_QUEUE_HALF).
  A `merge` migrates every historical blob shape → `queueItems` (existing
  `queueItems` → legacy `queueRefs` → pre-ref windowed `queue: Track[]`) and drops
  the obsolete `queue` key, so saved queues survive the upgrade.
- **Restore (decision B):** `hydrateQueueFromIndex` eager-resolves the whole
  ref list into the cache on cold start (index → getSong, so an index-off queue
  still plays), clears the restore sentinel.
- **Resolver bridge:** keeps `[idx-50, idx+200]` warm via `resolveVisibleRange`.
- **Mutations / actions / playback:** operate on refs; the playing track is
  `currentTrack`, the next/neighbour tracks resolve from the cache. Navigation
  (next/previous/row-jump) keeps `queueItems` and only moves the index — no full
  resolve or queue rebuild per track change.
- **Persist tests** cover the three old-blob migrations; `seedQueue` test helper
  replaces the `setState({ queue })` seeds.

tsc + full vitest suite (1115) green. Behaviour-preserving by the test contract;
the gapless track change + cold-start restore + mini cap still want a live smoke
before merge.

* fix(queue): star/rating keeps the queue row resolved instead of blanking to "…" (thin-state)

Rating/starring a queue song flashed the row's title to the "…" placeholder
until the next track change. Root cause: on sync success pendingStarSync called
invalidateQueueResolver, which DROPPED the cached track — and with queue: Track[]
gone there's no fat fallback, so the row resolved to a placeholder until the
resolver bridge re-fetched the window.

Fix: add patchCachedTrack(trackId, patch) and use it on star/rating success to
update the cached entry in place (title kept, synced starred/userRating applied)
instead of dropping it. No placeholder flash, no re-fetch.

tsc + full vitest suite (1115) green.

* fix(player): quota-safe persist so a full localStorage can't kill playback

A very large queue (~50k refs) overflows the localStorage quota; the persist
write then threw QuotaExceededError from inside set(), which aborted playTrack
before audio_play — no audio output at all. Back the player persist with a
quota-safe storage wrapper so a failed write degrades to a no-op instead of
throwing. Restoring the full ref list at that ceiling (vs a windowed cap) is
left as a follow-up.

* polish(player): throttle the quota-skip persist warning to once per key

The quota-safe persist logs a skip on every failed write; on a huge queue that
floods the dev console once per mutation. Warn once per key per quota-exceeded
streak, re-armed when a write to that key next succeeds.

* fix(queue): port new cover-pipeline readers to thin-state

Main's cover pipeline (#870) reads s.queue.length and seeds the player
store with queue: [track] in its tests. Under thin-state, queue: Track[]
no longer exists — the canonical queue is queueItems: QueueItemRef[].
These four files were brought across in the merge but still spoke the
old shape; this commit aligns them with the thin-state contract.

- src/cover/usePlaybackCoverArt: queueLength = queueItems.length
- src/cover/usePlaybackCoverArt.test: seed via toQueueItemRefs
- src/api/coverCache.test: same
- src/hooks/useNowPlayingPrewarm.test: same (two test cases)

* fix(queue): canonicalize thin-state server identity for mixed-server queues

`QueueItemRef.serverId` and `PlayerState.queueServerId` are now written as
the URL-derived index key on every writer path, matching the library index
direction. Mixed-server queues with duplicate `trackId` across servers stay
unambiguous because the resolver cache, persistence, and playback bindings
all share one key shape.

- new `canonicalQueueServerKey()` helper (idempotent UUID-or-key normalizer)
- `toQueueItemRefs`, `bindQueueServerForPlayback`, `seedQueueResolver`, and
  `hydrateQueueFromIndex` emit canonical keys
- `getCachedTrack` falls back to the canonical lookup so refs persisted in
  the legacy UUID shape still resolve through the migration window
- persist `merge` rewrites `queueServerId` and every ref `serverId` on
  rehydrate, so the live store never holds mixed shapes
- `removeServer` compares against the resolved id so a profile delete still
  clears the matching queue binding
- the two `playbackServer.test.ts` asserts that hard-coded the UUID shape
  are updated to the canonical key (existing reader-tolerance is unchanged)

* fix(queue-undo): bind snapshot prepend to snapshot-canonical server identity

When `applyQueueHistorySnapshot` has to prepend the still-playing track
(the snapshot's queue does not contain it), the new ref must follow the
snapshot's playback server, not the live `queueServerId`. A server switch
racing the undo would otherwise stamp the prepended ref with the new
server, mis-resolving the playing track on the very next render.

- `QueueUndoSnapshot` now carries `queueServerId` (captured by
  `queueUndoSnapshotFromState`); older in-memory entries fall back through
  the snapshot's own refs and finally the live store value
- the prepend in `applyQueueHistorySnapshot` plus the post-restore
  `seedQueueResolver` both source the server identity from this snapshot
  context, run through `canonicalQueueServerKey` so cache bucket and ref
  shape stay in lockstep

* test(queue): regression cluster for mixed-server queues with duplicate trackId

Covers the four invariants the thin-state review called out:

- resolver correctness: same `trackId` on two servers maps to two distinct
  cache entries via canonical keys, and legacy UUID-shaped refs still read
  the same entries through the compat lookup path
- restore/hydrate: persist `merge` forward-migrates UUID-form blobs in
  three shapes (canonical `queueItems`, legacy `queueRefs`, mixed-server
  `queueItems`) to canonical keys
- undo snapshot application: prepended ref follows the snapshot's playback
  server even when the live queue has been rebound to a different one,
  with fallback to snapshot refs and live state for legacy entries
- queue sync id emission: `flushPlayQueuePosition` -> `savePlayQueue`
  passes plain track ids and the playback server out of band, no per-ref
  `serverId` ever leaks into the request body

Also asserts the write helpers (`toQueueItemRefs`,
`bindQueueServerForPlayback`) emit canonical keys directly.

* perf(queue-header): coalesce resolver burst updates and aggregate in one pass

`QueueHeader` recomputed total and remaining queue durations on every
resolver cache version bump via two separate full-queue reduces. A mass
resolve burst (queue restore, prefetch window slide) bumps the version
dozens of times in one frame, and very long queues turned that into
visible main-thread stutter.

- one pass: a single for-loop produces both total and future-tracks
  duration; a 50k-track queue costs one walk per recompute, not two
- `useDeferredValue(version)` coalesces the burst into a single
  low-priority commit so the cache version is only sampled once per
  React frame instead of once per cache write

* fix(queue): use stable artist seed for radio top-up

The proactive radio top-up in `runNext` seeded `getSimilarSongs2` and
`getTopSongs` from `resolveQueueTrack(nextRef)` metadata. When the next
ref is still cold in the resolver cache, the placeholder track has empty
artist fields, and the top-up would fire `getSimilarSongs2('')` -- silently
returning nothing and leaving the queue dry just before the radio rail
would have refilled.

- prefer the just-played `currentTrack` (always fully resolved in the
  player store) and the stored radio seed artist id
- fall back to the next-track metadata only when those are missing
- skip the top-up entirely when no stable seed is available, instead of
  emitting a non-deterministic empty request

* docs(changelog): queue mixed-server routing and quota-safe persist (#872)
This commit is contained in:
Frank Stellmacher
2026-05-27 00:10:34 +02:00
committed by GitHub
parent a8cfff0b62
commit 45b9229ceb
88 changed files with 1965 additions and 944 deletions
+2 -1
View File
@@ -66,5 +66,6 @@ export function restartPlaybackForRateChange(): void {
setAtMs: Date.now(),
});
}
player.playTrack(track, player.queue, true);
// No-arg queue: keep the canonical refs, restart the current track in place.
player.playTrack(track, undefined, true);
}
@@ -6,6 +6,7 @@ import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import { resolveQueueTrack } from '../library/queueTrackView';
import { useZipDownloadStore } from '../../store/zipDownloadStore';
import { useDownloadModalStore } from '../../store/downloadModalStore';
import type { EntityShareKind } from '../share/shareLink';
@@ -90,8 +91,13 @@ export async function startRadio(
.filter(t => t.id !== topTracks[0].id),
);
if (similarTracks.length === 0) return;
const { queue, queueIndex } = usePlayerStore.getState();
const pendingRadio = queue.slice(queueIndex + 1).filter(t => t.radioAdded);
const { queueItems, queueIndex } = usePlayerStore.getState();
// Thin-state: resolve the upcoming radio refs (cache-warm window) back to
// Tracks so they merge with the new similars in enqueueRadio.
const pendingRadio = queueItems
.slice(queueIndex + 1)
.filter(r => r.radioAdded)
.map(r => resolveQueueTrack(r));
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
});
} catch (e) {
@@ -1,6 +1,10 @@
import { usePlayerStore } from '../../store/playerStore';
import { resolveQueueTrack } from '../library/queueTrackView';
import type { MiniSyncPayload, MiniTrackInfo } from '../miniPlayerBridge';
/** Half-width of the mini initial-snapshot queue window (matches the bridge). */
const MINI_SNAPSHOT_HALF = 100;
export const COLLAPSED_SIZE = { w: 340, h: 260 };
export const EXPANDED_SIZE = { w: 340, h: 500 };
// Minimum window dimensions per state. When the queue is open the floor must
@@ -54,10 +58,17 @@ export function toMini(t: any): MiniTrackInfo {
export function initialSnapshot(): MiniSyncPayload {
try {
const s = usePlayerStore.getState();
// Thin-state: resolve a window around the index (resolver cache →
// placeholder), remapping queueIndex like the live bridge snapshot.
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_SNAPSHOT_HALF);
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_SNAPSHOT_HALF + 1)
.map(r => resolveQueueTrack(r));
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
queue: windowed.map(toMini),
queueIndex: idx - start,
queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying,
volume: s.volume ?? 1,
+10 -5
View File
@@ -1,15 +1,20 @@
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
/**
* Derive thin `QueueItemRef`s from a `Track[]` queue (thin-state). Per-item
* `serverId` is the single playback server in v1; queue-only flags are carried
* through, others omitted to keep the persisted/derived list small. Pure — no
* store import, so both `playerStore` (persist) and the resolver bridge can use
* it without a circular dependency.
* `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[] {
const canonicalId = canonicalQueueServerKey(serverId);
return queue.map(t => {
const ref: QueueItemRef = { serverId, trackId: t.id };
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;
+51 -71
View File
@@ -4,6 +4,10 @@ import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePlayerStore } from '@/store/playerStore';
import type { TrackRefDto } from '@/api/library';
import type { Track } from '@/store/playerStoreTypes';
import {
getCachedTrack,
_resetQueueResolverForTest,
} from './queueTrackResolver';
import { hydrateQueueFromIndex } from './queueRestore';
const ready = () =>
@@ -34,72 +38,39 @@ const track = (id: string): Track => ({ id, title: id, artist: '', album: 'A', a
function seedStore(over: Partial<ReturnType<typeof usePlayerStore.getState>> = {}) {
usePlayerStore.setState({
queue: [track('w1')],
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 persisted refs', async () => {
seedStore({ queueRefs: undefined });
it('does nothing without a restore-pending sentinel', async () => {
seedStore({ queueItems: [{ serverId: 's1', trackId: 'w1' }], queueItemsIndex: undefined });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
// No resolve dispatched (no sentinel) → nothing cached.
expect(getCachedTrack({ serverId: 's1', trackId: 'w1' })).toBeUndefined();
});
it('keeps the windowed fallback when the index is not ready', async () => {
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
seedStore({ queueRefs: ['t1', 't2', 't3'], queueRefsIndex: 1 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']);
expect(usePlayerStore.getState().queueRefs).toEqual(['t1', 't2', 't3']); // not cleared
});
it('restores the full queue and re-locates the current track when ready', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1,
currentTrack: track('t2'),
});
await hydrateQueueFromIndex();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
expect(s.queueIndex).toBe(1); // re-located to current track t2
expect(s.queueRefs).toBeUndefined(); // cleared after success
});
it('batches refs in chunks of 100', async () => {
ready();
echoBatch();
const refs = Array.from({ length: 150 }, (_, i) => `t${i}`);
seedStore({ queueRefs: refs, queueRefsIndex: 0 });
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue).toHaveLength(150);
});
it('keeps the fallback when the current track is not in the hydrated list', async () => {
ready();
echoBatch();
seedStore({
queueRefs: ['t1', 't2'],
currentTrack: track('gone'),
});
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // unchanged
});
it('hydrates from queueItems (preferred) and clears the ref lists', async () => {
it('resolves the whole queueItems ref list into the resolver cache and clears the sentinel', async () => {
ready();
echoBatch();
seedStore({
@@ -113,17 +84,31 @@ describe('hydrateQueueFromIndex', () => {
});
await hydrateQueueFromIndex();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
expect(s.queueIndex).toBe(1); // re-located to current track t2
expect(s.queueItems).toBeUndefined();
expect(s.queueRefs).toBeUndefined();
// 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('upgrades a legacy queueRefs-only store (no queueItems) via queueServerId', async () => {
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: undefined, // pre-Phase-1 persist shape
queueItems: [], // pre-thin-state in-memory shape
queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1,
queueServerId: 's1',
@@ -131,28 +116,23 @@ describe('hydrateQueueFromIndex', () => {
});
await hydrateQueueFromIndex();
const s = usePlayerStore.getState();
expect(s.queue.map(t => t.id)).toEqual(['t1', 't2', 't3']);
expect(s.queueIndex).toBe(1);
expect(s.queueRefs).toBeUndefined(); // both ref lists cleared after success
expect(s.queueItems).toBeUndefined();
// 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('carries queue-only flags from queueItems onto hydrated tracks', async () => {
ready();
echoBatch();
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' },
{ serverId: 's1', trackId: 't2', radioAdded: true },
{ serverId: 's1', trackId: 't3', autoAdded: true, playNextAdded: true },
],
queueItems: [{ serverId: 's1', trackId: 't1' }],
queueItemsIndex: 0,
});
await hydrateQueueFromIndex();
const q = usePlayerStore.getState().queue;
expect(q.find(t => t.id === 't1')?.radioAdded).toBeUndefined();
expect(q.find(t => t.id === 't2')?.radioAdded).toBe(true);
expect(q.find(t => t.id === 't3')?.autoAdded).toBe(true);
expect(q.find(t => t.id === 't3')?.playNextAdded).toBe(true);
// 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']);
});
});
+43 -72
View File
@@ -1,89 +1,60 @@
import { libraryGetTracksBatch, type LibraryTrackDto, type TrackRefDto } from '../../api/library';
import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { trackToSong } from './advancedSearchLocal';
import { libraryIsReady } from './libraryReady';
/** `library_get_tracks_batch` cap (spec §8.6 — max 100 refs/call). */
const BATCH = 100;
import type { QueueItemRef } from '../../store/playerStoreTypes';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { resolveBatch } from './queueTrackResolver';
/**
* Full-queue restore. The player store rehydrates a *windowed* `queue` plus a
* full thin-ref list. When the library index is ready for the queue's server,
* hydrate the entire queue from the index (`library_get_tracks_batch`, ≤100
* refs/call) and swap it in, re-locating the current track so the playback
* position stays correct even if some refs were dropped (unknown to the index).
* 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).
*
* Phase 1: prefers `queueItems` (per-item serverId + queue-only flags) and
* carries those flags onto the hydrated tracks; falls back to the legacy
* `queueRefs` string list for stores persisted before Phase 1.
*
* Best-effort: missing refs / index not ready / any failure leave the windowed
* `queue` untouched — no regression when the index is off (the P6 default).
* Clears the ref lists once a full hydrate succeeds so it runs at most once.
* 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();
const items = player.queueItems;
let refs: TrackRefDto[] | null = null;
if (items?.length) {
refs = items.map(it => ({ serverId: it.serverId, trackId: it.trackId }));
} else if (player.queueRefs?.length) {
const sid = player.queueServerId ?? useAuthStore.getState().activeServerId;
if (sid) refs = player.queueRefs.map(trackId => ({ serverId: sid, trackId }));
// 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 }));
}
if (!refs || refs.length === 0) return;
const clearRefs = () =>
usePlayerStore.setState({
queueItems: undefined, queueItemsIndex: undefined,
queueRefs: undefined, queueRefsIndex: undefined,
});
// 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,
});
// v1 is single-server; gate readiness on the queue's server.
const serverId = refs[0].serverId || player.queueServerId || useAuthStore.getState().activeServerId;
if (!serverId) {
clearRefs();
return;
}
// Keep the windowed fallback (and the refs, for a later ready startup) when
// the index can't serve the queue yet.
if (!(await libraryIsReady(serverId))) return;
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 {
const dtos: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += BATCH) {
dtos.push(...(await libraryGetTracksBatch(refs.slice(i, i + BATCH))));
}
if (dtos.length === 0) return; // index has none of them → keep fallback
// The index doesn't store queue-only flags (radio/auto/play-next dividers),
// so carry them from the refs onto the hydrated tracks.
const flags = new Map(items?.map(it => [it.trackId, it]));
const hydrated: Track[] = dtos.map(d => {
const t = songToTrack(trackToSong(d));
const f = flags.get(t.id);
if (f?.autoAdded) t.autoAdded = true;
if (f?.radioAdded) t.radioAdded = true;
if (f?.playNextAdded) t.playNextAdded = true;
return t;
});
// Re-locate the current track so queueIndex stays aligned with playback.
const cur = usePlayerStore.getState().currentTrack;
const idx = cur ? hydrated.findIndex(t => t.id === cur.id) : -1;
if (cur && idx < 0) return; // can't align playback → keep windowed fallback
usePlayerStore.setState({
queue: hydrated,
queueIndex: idx >= 0 ? idx : 0,
queueItems: undefined, queueItemsIndex: undefined,
queueRefs: undefined, queueRefsIndex: undefined,
});
await resolveBatch(refs);
} catch {
// best-effort; the windowed fallback stays in place
/* best-effort */
}
}
+43 -14
View File
@@ -3,6 +3,7 @@ import { getSong } from '../../api/subsonicLibrary';
import { usePlayerStore } from '../../store/playerStore';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { canonicalQueueServerKey } from '../server/serverIndexKey';
import { trackToSong } from './advancedSearchLocal';
import { libraryIsReady } from './libraryReady';
@@ -30,26 +31,24 @@ const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r.
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 cacheTouch(key: string): Track | undefined {
const t = cache.get(key);
if (t !== undefined) {
cache.delete(key);
cache.set(key, t); // move to most-recent
}
return t;
}
function cacheSet(key: string, track: Track): void {
if (cache.has(key)) cache.delete(key);
cache.set(key, track);
@@ -69,7 +68,20 @@ function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
/** Synchronous cache read (no fetch); undefined on miss. */
export function getCachedTrack(ref: QueueItemRef): Track | undefined {
return cacheTouch(refKey(ref));
// 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. */
@@ -101,10 +113,13 @@ export function applyQueueOverrides(track: Track): Track {
return next;
}
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch. */
/** 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;
for (const t of tracks) cacheSet(refKey({ serverId, trackId: t.id }), t);
const canonicalId = canonicalQueueServerKey(serverId);
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
notify();
}
@@ -177,8 +192,7 @@ export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx
if (end > start) void resolveBatch(refs.slice(start, end));
}
/** Drop cached entries for a track id (e.g. after a star/rating sync succeeds,
* so the next read re-fetches the server truth). */
/** 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()]) {
@@ -190,6 +204,21 @@ export function invalidateQueueResolver(trackId: string): void {
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();
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { usePlayerStore } from '@/store/playerStore';
import type { QueueItemRef, Track } from '@/store/playerStoreTypes';
import { seedQueueResolver, _resetQueueResolverForTest } from '@/utils/library/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']);
});
});
+66
View File
@@ -0,0 +1,66 @@
import type { QueueItemRef, Track } from '../../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]));
}
+37 -15
View File
@@ -2,6 +2,7 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { listen, emitTo } from '@tauri-apps/api/event';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { resolveQueueTrack } from './library/queueTrackView';
import type { SubsonicOpenArtistRef } from '../api/subsonicTypes';
export const MINI_WINDOW_LABEL = 'mini';
@@ -56,13 +57,27 @@ function toMini(t: any): MiniTrackInfo {
};
}
/** Cap the queue pushed to the mini at ±100 tracks around the playing song — a
* 50k Artist-Radio queue must not serialize in full over IPC on every push. The
* mini stays slice-relative (no component change); control events (jump/reorder/
* remove) are translated back to absolute indices via {@link miniWindowStart}. */
const MINI_QUEUE_HALF = 100;
let miniWindowStart = 0;
function snapshot(): MiniSyncPayload {
const s = usePlayerStore.getState();
const a = useAuthStore.getState();
const idx = s.queueIndex ?? 0;
const start = Math.max(0, idx - MINI_QUEUE_HALF);
// Thin-state: resolve the windowed slice (resolver cache → placeholder).
const windowed = (s.queueItems ?? [])
.slice(start, idx + MINI_QUEUE_HALF + 1)
.map(r => resolveQueueTrack(r));
miniWindowStart = start;
return {
track: s.currentTrack ? toMini(s.currentTrack) : null,
queue: (s.queue ?? []).map(toMini),
queueIndex: s.queueIndex ?? 0,
queue: windowed.map(toMini),
queueIndex: idx - start, // local position within the windowed slice
queueServerId: s.queueServerId ?? null,
isPlaying: s.isPlaying,
volume: s.volume,
@@ -112,7 +127,7 @@ export function initMiniPlayerBridgeOnMain(): () => void {
|| state.isPlaying !== prev.isPlaying
|| state.currentTrack?.starred !== prev.currentTrack?.starred
|| state.queueIndex !== prev.queueIndex
|| state.queue !== prev.queue
|| state.queueItems !== prev.queueItems
|| state.queueServerId !== prev.queueServerId
|| state.volume !== prev.volume) {
push();
@@ -153,30 +168,37 @@ export function initMiniPlayerBridgeOnMain(): () => void {
}
});
// Jump to a specific queue index.
// Jump to a specific queue index. The mini sends a slice-relative index; add
// the window offset from the last push to land on the absolute queue position.
const jumpUnlisten = listen<{ index: number }>('mini:jump', (e) => {
const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1;
if (idx < 0 || idx >= store.queue.length) return;
const track = store.queue[idx];
if (track) store.playTrack(track, store.queue, true);
const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queueItems.length) return;
const ref = store.queueItems[idx];
if (ref) {
// Resolve the target ref; pass undefined so playTrack keeps the canonical
// queue and just jumps to this slot.
store.playTrack(resolveQueueTrack(ref), undefined, true, false, idx);
}
});
// PsyDnD reorder forwarded from the mini queue.
// PsyDnD reorder forwarded from the mini queue (slice-relative → absolute).
const reorderUnlisten = listen<{ from: number; to: number }>('mini:reorder', (e) => {
const store = usePlayerStore.getState();
const { from, to } = e.payload ?? { from: -1, to: -1 };
if (from < 0 || from >= store.queue.length) return;
if (to < 0 || to > store.queue.length) return;
const raw = e.payload ?? { from: -1, to: -1 };
const from = raw.from + miniWindowStart;
const to = raw.to + miniWindowStart;
if (from < 0 || from >= store.queueItems.length) return;
if (to < 0 || to > store.queueItems.length) return;
if (from === to) return;
store.reorderQueue(from, to);
});
// Remove a track at index (context menu → "Remove from queue").
// Remove a track at index (context menu → "Remove from queue"; slice-relative).
const removeUnlisten = listen<{ index: number }>('mini:remove', (e) => {
const store = usePlayerStore.getState();
const idx = e.payload?.index ?? -1;
if (idx < 0 || idx >= store.queue.length) return;
const idx = (e.payload?.index ?? -1) + miniWindowStart;
if (idx < 0 || idx >= store.queueItems.length) return;
store.removeTrack(idx);
});
+10 -9
View File
@@ -1,7 +1,7 @@
import { getSimilarSongs } from '../../api/subsonicArtists';
import { filterSongsToActiveLibrary, getRandomSongs } from '../../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicSong } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import type { QueueItemRef } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { invoke } from '@tauri-apps/api/core';
import i18n from '../../i18n';
@@ -93,14 +93,15 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
// Snapshot the current queue *before* we prune — so if the build fails
// before we ever play a track, we can put it back the way it was instead
// of leaving the user with an empty player.
// of leaving the user with an empty player. Thin-state: snapshot the refs and
// the resolved tracks (to re-seed the resolver on restore).
const playerStateBefore = usePlayerStore.getState();
const queueSnapshot: {
queue: Track[];
queueItems: QueueItemRef[];
queueIndex: number;
queueServerId: string | null;
} = {
queue: [...playerStateBefore.queue],
queueItems: [...playerStateBefore.queueItems],
queueIndex: playerStateBefore.queueIndex,
queueServerId: playerStateBefore.queueServerId,
};
@@ -125,8 +126,8 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
try {
let allSeedSongs: SubsonicSong[] = [];
const mixQueueSize = () => usePlayerStore.getState().queue.length;
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queue.map(t => t.id));
const mixQueueSize = () => usePlayerStore.getState().queueItems.length;
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId));
const bailIfCancelled = () => {
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
@@ -156,7 +157,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const nextId = state.currentTrack?.id ?? null;
if (nextId === prevId) return;
if (!nextId) return;
if (state.queue.some(t => t.id === nextId)) return;
if (state.queueItems.some(r => r.trackId === nextId)) return;
useLuckyMixStore.getState().cancel();
});
}
@@ -389,12 +390,12 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
// whatever we managed to enqueue is more useful than the old queue.
if (!startedPlayback) {
usePlayerStore.setState({
queue: queueSnapshot.queue,
queueItems: queueSnapshot.queueItems,
queueIndex: queueSnapshot.queueIndex,
queueServerId: queueSnapshot.queueServerId,
});
logStep('queue_restored_after_failure', {
restoredCount: queueSnapshot.queue.length,
restoredCount: queueSnapshot.queueItems.length,
});
}
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
+2 -2
View File
@@ -45,14 +45,14 @@ export async function playSongNow(song: SubsonicSong, queue?: SubsonicSong[]): P
export async function enqueueAndPlay(song: SubsonicSong): Promise<void> {
const track = songToTrack(song);
const store = usePlayerStore.getState();
const { isPlaying, volume, queue } = store;
const { isPlaying, volume, queueItems } = store;
if (isPlaying) {
await fadeOut(store.setVolume, volume, 700);
usePlayerStore.setState({ volume });
}
if (!queue.some(t => t.id === track.id)) {
if (!queueItems.some(r => r.trackId === track.id)) {
usePlayerStore.getState().enqueue([track]);
}
// playTrack with no queue arg uses the current state.queue, finds the track by id,
+18 -11
View File
@@ -30,7 +30,7 @@ describe('playbackServer', () => {
isLoggedIn: true,
});
usePlayerStore.setState({
queue: [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }],
queueItems: [{ serverId: 'a', trackId: 't1' }],
queueServerId: 'a',
queueIndex: 0,
});
@@ -43,21 +43,22 @@ describe('playbackServer', () => {
it('getPlaybackServerId falls back to active when queue is empty', () => {
clearQueueServerForPlayback();
usePlayerStore.setState({ queue: [] });
usePlayerStore.setState({ queueItems: [] });
useAuthStore.setState({ activeServerId: 'b' });
expect(getPlaybackServerId()).toBe('b');
});
it('bindQueueServerForPlayback pins active server', () => {
it('bindQueueServerForPlayback pins active server as canonical index key', () => {
useAuthStore.setState({ activeServerId: 'b' });
bindQueueServerForPlayback();
expect(usePlayerStore.getState().queueServerId).toBe('b');
// B1: writers emit the canonical (URL-derived) server key, not the UUID.
expect(usePlayerStore.getState().queueServerId).toBe('b.test');
});
it('playbackServerDiffersFromActive when queue server != active', () => {
useAuthStore.setState({ activeServerId: 'b' });
expect(playbackServerDiffersFromActive()).toBe(true);
usePlayerStore.setState({ queue: [] });
usePlayerStore.setState({ queueItems: [] });
expect(playbackServerDiffersFromActive()).toBe(false);
});
@@ -66,16 +67,20 @@ describe('playbackServer', () => {
useAuthStore.setState({ activeServerId: 'b' });
prepareActiveServerForNewMix();
const s = usePlayerStore.getState();
expect(s.queue).toEqual([]);
expect(s.queueItems).toEqual([]);
expect(s.currentTrack).toBeNull();
expect(s.queueServerId).toBe('b');
// Canonical index key on re-pin (B1).
expect(s.queueServerId).toBe('b.test');
expect(playbackServerDiffersFromActive()).toBe(false);
});
it('prepareActiveServerForNewMix is a no-op when queue already matches active', () => {
useAuthStore.setState({ activeServerId: 'a' });
prepareActiveServerForNewMix();
expect(usePlayerStore.getState().queue).toHaveLength(1);
expect(usePlayerStore.getState().queueItems).toHaveLength(1);
// Pre-existing queueServerId='a' (UUID) is tolerated by the reader helpers
// even while writers emit canonical index keys — this is the migration
// window the resolver compat path covers (B1).
expect(usePlayerStore.getState().queueServerId).toBe('a');
});
@@ -108,12 +113,14 @@ describe('playbackServer', () => {
});
it('shouldBindQueueServerForPlay detects queue replacement', () => {
const prev = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }];
// Thin-state: prevQueue is the canonical refs; newQueue / explicit arg are Tracks.
const prevRefs = [{ serverId: 'a', trackId: 't1' }];
const sameTrack = [{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 }];
const next = [
{ id: 't1', title: 'T', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
{ id: 't2', title: 'T2', artist: 'A', album: 'Al', albumId: 'al1', duration: 100 },
];
expect(shouldBindQueueServerForPlay(prev, next, next)).toBe(true);
expect(shouldBindQueueServerForPlay(prev, prev, undefined)).toBe(false);
expect(shouldBindQueueServerForPlay(prevRefs, next, next)).toBe(true);
expect(shouldBindQueueServerForPlay(prevRefs, sameTrack, undefined)).toBe(false);
});
});
+23 -13
View File
@@ -6,22 +6,26 @@ import { useAuthStore } from '../../store/authStore';
import { usePlayerStore } from '../../store/playerStore';
import { switchActiveServer } from '../server/switchActiveServer';
import { sameQueueTrackId } from './queueIdentity';
import type { Track } from '../../store/playerStoreTypes';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { resolveIndexKey, serverIndexKeyFromUrl } from '../server/serverIndexKey';
import {
resolveIndexKey,
serverIndexKeyForProfile,
serverIndexKeyFromUrl,
} from '../server/serverIndexKey';
/** Server that owns the current queue / stream URLs (may differ from the browsed server). */
export function getPlaybackServerId(): string {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) {
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveServerIdForIndexKey(queueServerId);
}
return useAuthStore.getState().activeServerId ?? '';
}
export function getPlaybackIndexKey(): string {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) > 0 && queueServerId) {
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) > 0 && queueServerId) {
return resolveIndexKey(queueServerId);
}
const activeId = useAuthStore.getState().activeServerId ?? '';
@@ -43,7 +47,13 @@ export function getPlaybackCacheServerKey(): string {
export function bindQueueServerForPlayback(): void {
const sid = useAuthStore.getState().activeServerId;
if (!sid) return;
usePlayerStore.setState({ queueServerId: sid });
const server = useAuthStore.getState().servers.find(s => s.id === sid);
// Canonical index key on writes so mixed-server queues stay unambiguous —
// every ref/queue-level server identifier follows the same shape that the
// library index already uses. Falls back to the raw id when the server
// profile cannot be resolved (e.g. tests with a stubbed auth store).
const canonical = server ? serverIndexKeyForProfile(server) || sid : sid;
usePlayerStore.setState({ queueServerId: canonical });
}
export function clearQueueServerForPlayback(): void {
@@ -51,8 +61,8 @@ export function clearQueueServerForPlayback(): void {
}
export function playbackServerDiffersFromActive(): boolean {
const { queueServerId, queue } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0 || !queueServerId) return false;
const { queueServerId, queueItems } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) === 0 || !queueServerId) return false;
const activeSid = useAuthStore.getState().activeServerId;
const resolvedQueue = resolveServerIdForIndexKey(queueServerId);
return !!activeSid && resolvedQueue !== activeSid;
@@ -65,8 +75,8 @@ export function playbackServerDiffersFromActive(): boolean {
export function shouldHandoffQueueToActiveServer(): boolean {
const activeSid = useAuthStore.getState().activeServerId;
if (!activeSid) return false;
const { queue, queueServerId } = usePlayerStore.getState();
if ((queue?.length ?? 0) === 0) return false;
const { queueItems, queueServerId } = usePlayerStore.getState();
if ((queueItems?.length ?? 0) === 0) return false;
if (!queueServerId) return true;
return resolveServerIdForIndexKey(queueServerId) !== activeSid;
}
@@ -101,7 +111,7 @@ export function playbackCoverArtForId(coverId: string, displayCssPx: number): {
}
export function shouldBindQueueServerForPlay(
prevQueue: Track[],
prevQueue: QueueItemRef[],
newQueue: Track[],
explicitQueueArg: Track[] | undefined,
): boolean {
@@ -109,5 +119,5 @@ export function shouldBindQueueServerForPlay(
if (prevQueue.length === 0) return true;
if (explicitQueueArg === undefined) return false;
if (explicitQueueArg.length !== prevQueue.length) return true;
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.id, t.id));
return !explicitQueueArg.every((t, i) => sameQueueTrackId(prevQueue[i]?.trackId, t.id));
}
+22
View File
@@ -17,3 +17,25 @@ export function resolveIndexKey(serverIdOrKey: string): string {
if (!server) return serverIdOrKey;
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
/**
* Canonical key for queue-thin-state writers: returns the URL-derived index key
* for any known server (whether the caller passed the UUID or the index key),
* and leaves unknown / already-canonical values untouched. Idempotent.
*
* Use this on every write path that lands in `QueueItemRef.serverId` or
* `PlayerState.queueServerId`. Reading sides may still receive legacy UUID
* values from persisted blobs; `serverLookup` helpers accept both shapes.
*/
export function canonicalQueueServerKey(serverIdOrKey: string): string {
if (!serverIdOrKey) return serverIdOrKey;
// Defensive: tests sometimes stub `useAuthStore` without seeding `servers`.
// Treat a missing list as "unknown server" rather than crashing the read.
const servers = useAuthStore.getState().servers;
if (!servers) return serverIdOrKey;
const server = servers.find(s => s.id === serverIdOrKey);
if (server) {
return serverIndexKeyFromUrl(server.url) || serverIdOrKey;
}
return serverIdOrKey;
}