refactor(queue): persist thin QueueItemRef list (thin-state phase 1) (#858)

* refactor(queue): persist thin QueueItemRef list (thin-state phase 1)

Introduce QueueItemRef ({ serverId, trackId, autoAdded?, radioAdded?,
playNextAdded? }) and persist the whole queue as a thin queueItems list,
superseding the queueRefs id list. Dual-write: the windowed queue: Track[]
stays as the index-off fallback; the in-memory store is unchanged.

hydrateQueueFromIndex now prefers queueItems (per-item serverId) and carries
the queue-only flags onto the hydrated tracks (the index doesn't store them),
with a queueRefs fallback for stores persisted before this change.

Phase 1 of the queue thin-state plan; no store/consumer changes yet.

* test(queue): cover legacy queueRefs-only upgrade in hydrate

* docs(changelog): queue section dividers kept on index restore (#858)
This commit is contained in:
Frank Stellmacher
2026-05-22 22:43:41 +02:00
committed by GitHub
parent 06213cb5d8
commit d15e270499
6 changed files with 164 additions and 22 deletions
+57
View File
@@ -98,4 +98,61 @@ describe('hydrateQueueFromIndex', () => {
await hydrateQueueFromIndex();
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['w1']); // unchanged
});
it('hydrates from queueItems (preferred) and clears the ref lists', 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();
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();
});
it('upgrades a legacy queueRefs-only store (no queueItems) via queueServerId', async () => {
ready();
echoBatch();
seedStore({
queueItems: undefined, // pre-Phase-1 persist shape
queueRefs: ['t1', 't2', 't3'],
queueRefsIndex: 1,
queueServerId: 's1',
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);
expect(s.queueRefs).toBeUndefined(); // both ref lists cleared after success
expect(s.queueItems).toBeUndefined();
});
it('carries queue-only flags from queueItems onto hydrated tracks', async () => {
ready();
echoBatch();
seedStore({
queueItems: [
{ serverId: 's1', trackId: 't1' },
{ serverId: 's1', trackId: 't2', radioAdded: true },
{ serverId: 's1', trackId: 't3', autoAdded: true, playNextAdded: true },
],
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);
});
});
+43 -15
View File
@@ -10,25 +10,43 @@ import { libraryIsReady } from './libraryReady';
const BATCH = 100;
/**
* F5 — full-queue restore. The player store rehydrates a *windowed* `queue`
* plus a full `queueRefs` id 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. 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).
*
* 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 `queueRefs` once a full hydrate succeeds so it runs at most once.
* Clears the ref lists once a full hydrate succeeds so it runs at most once.
*/
export async function hydrateQueueFromIndex(): Promise<void> {
const player = usePlayerStore.getState();
const refs = player.queueRefs;
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 }));
}
if (!refs || refs.length === 0) return;
const serverId = player.queueServerId ?? useAuthStore.getState().activeServerId;
const clearRefs = () =>
usePlayerStore.setState({
queueItems: undefined, 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) {
usePlayerStore.setState({ queueRefs: undefined, queueRefsIndex: undefined });
clearRefs();
return;
}
// Keep the windowed fallback (and the refs, for a later ready startup) when
@@ -38,12 +56,22 @@ export async function hydrateQueueFromIndex(): Promise<void> {
try {
const dtos: LibraryTrackDto[] = [];
for (let i = 0; i < refs.length; i += BATCH) {
const chunk: TrackRefDto[] = refs.slice(i, i + BATCH).map(trackId => ({ serverId, trackId }));
dtos.push(...(await libraryGetTracksBatch(chunk)));
dtos.push(...(await libraryGetTracksBatch(refs.slice(i, i + BATCH))));
}
if (dtos.length === 0) return; // index has none of them → keep fallback
const hydrated: Track[] = dtos.map(d => songToTrack(trackToSong(d)));
// 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;
@@ -52,8 +80,8 @@ export async function hydrateQueueFromIndex(): Promise<void> {
usePlayerStore.setState({
queue: hydrated,
queueIndex: idx >= 0 ? idx : 0,
queueRefs: undefined,
queueRefsIndex: undefined,
queueItems: undefined, queueItemsIndex: undefined,
queueRefs: undefined, queueRefsIndex: undefined,
});
} catch {
// best-effort; the windowed fallback stays in place