mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
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:
committed by
GitHub
parent
06213cb5d8
commit
d15e270499
@@ -133,6 +133,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
|
||||
|
||||
### Queue — section dividers kept when restoring from the local index
|
||||
|
||||
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#858](https://github.com/Psychotoxical/psysonic/pull/858)**
|
||||
|
||||
* When the queue is rebuilt from the local library index on startup, the **Radio** and **Auto-added** section dividers are now preserved. Groundwork toward keeping very large queues fast and light.
|
||||
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
### In-page browse — virtual scroll and cover-art priority
|
||||
|
||||
@@ -285,4 +285,25 @@ describe('partialize: localStorage queue window', () => {
|
||||
expect((partial.queue as unknown[]).length).toBe(0);
|
||||
expect(partial.queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('persists the whole queue as thin queueItems (refs + serverId + flags), not just the window', () => {
|
||||
const tracks = makeTracks(600);
|
||||
tracks[3].radioAdded = true;
|
||||
tracks[4].autoAdded = true;
|
||||
usePlayerStore.setState({ queue: tracks, queueIndex: 300, queueServerId: 's1' });
|
||||
|
||||
const partial = getPartialize()(usePlayerStore.getState());
|
||||
const items = partial.queueItems as {
|
||||
serverId: string; trackId: string; radioAdded?: boolean; autoAdded?: boolean;
|
||||
}[];
|
||||
|
||||
// windowed `queue` is capped, but queueItems carries the WHOLE queue
|
||||
expect((partial.queue as unknown[]).length).toBe(501);
|
||||
expect(items.length).toBe(600);
|
||||
expect(partial.queueItemsIndex).toBe(300);
|
||||
expect(items[0].serverId).toBe('s1');
|
||||
expect(items[3].radioAdded).toBe(true);
|
||||
expect(items[4].autoAdded).toBe(true);
|
||||
expect(items[0].radioAdded).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||
import { emitPlaybackProgress } from './playbackProgress';
|
||||
import type { PlayerState } from './playerStoreTypes';
|
||||
import type { PlayerState, QueueItemRef } from './playerStoreTypes';
|
||||
import { readInitialQueueVisibility } from './queueVisibilityStorage';
|
||||
import { createLastfmActions } from './lastfmActions';
|
||||
import { createMiscActions } from './miscActions';
|
||||
@@ -94,12 +94,19 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queue: windowedQueue,
|
||||
queueServerId: state.queueServerId,
|
||||
queueIndex: qi - start, // remap into the windowed slice
|
||||
// F5: full ordered ref list (ids are tiny) so the *whole* queue can be
|
||||
// rehydrated from the local index on startup. The windowed `queue`
|
||||
// above stays as the no-index fallback (queue never empty when the
|
||||
// index is off — the P6 default).
|
||||
queueRefs: state.queue.map(t => t.id),
|
||||
queueRefsIndex: qi,
|
||||
// Phase 1: full ordered thin-ref list (tiny) so the *whole* queue can
|
||||
// be rehydrated from the local index on startup. Dual-write — the
|
||||
// windowed `queue` above stays as the no-index fallback (queue never
|
||||
// empty when the index is off, the P6 default). Per-item serverId is
|
||||
// the playback server (single-server v1); supersedes `queueRefs`.
|
||||
queueItems: state.queue.map((t): QueueItemRef => {
|
||||
const ref: QueueItemRef = { serverId: state.queueServerId ?? '', trackId: t.id };
|
||||
if (t.autoAdded) ref.autoAdded = true;
|
||||
if (t.radioAdded) ref.radioAdded = true;
|
||||
if (t.playNextAdded) ref.playNextAdded = true;
|
||||
return ref;
|
||||
}),
|
||||
queueItemsIndex: qi,
|
||||
isQueueVisible: state.isQueueVisible,
|
||||
// currentTime is intentionally NOT persisted here.
|
||||
// handleAudioProgress fires every 100ms and each setState with a
|
||||
|
||||
@@ -34,6 +34,21 @@ export interface Track {
|
||||
playNextAdded?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin canonical queue item (queue thin-state plan, §5.10). Identity plus the
|
||||
* queue-only flags; library metadata (title/artist/cover/…) is resolved from
|
||||
* the local index or network on demand from Phase 2 on. `serverId` is per-item
|
||||
* (day-1 schema for mixed-server queues); v1 fills it with the single playback
|
||||
* server.
|
||||
*/
|
||||
export interface QueueItemRef {
|
||||
serverId: string;
|
||||
trackId: string;
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
playNextAdded?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
currentTrack: Track | null;
|
||||
waveformBins: number[] | null;
|
||||
@@ -64,6 +79,12 @@ export interface PlayerState {
|
||||
* are then cleared. Absent / index-off → the windowed `queue` is used as-is. */
|
||||
queueRefs?: string[];
|
||||
queueRefsIndex?: number;
|
||||
/** Phase 1 (transient): thin canonical ref list persisted alongside the
|
||||
* windowed `queue`, superseding `queueRefs`. Carries per-item `serverId` +
|
||||
* queue-only flags. Rehydrated like `queueRefs` and cleared after a full
|
||||
* hydrate from the index. The in-memory queue stays `Track[]` until Phase 2. */
|
||||
queueItems?: QueueItemRef[];
|
||||
queueItemsIndex?: number;
|
||||
isPlaying: boolean;
|
||||
/** HTTP stream still buffering (network / demux probe) — show loading on cover art. */
|
||||
isPlaybackBuffering: boolean;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user