diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcbd520..73a7f0c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts index 7dedc932..4146e446 100644 --- a/src/store/playerStore.persistence.test.ts +++ b/src/store/playerStore.persistence.test.ts @@ -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(); + }); }); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index fa247c8d..823bc8b8 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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()( 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 diff --git a/src/store/playerStoreTypes.ts b/src/store/playerStoreTypes.ts index 4ab0805b..606512a8 100644 --- a/src/store/playerStoreTypes.ts +++ b/src/store/playerStoreTypes.ts @@ -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; diff --git a/src/utils/library/queueRestore.test.ts b/src/utils/library/queueRestore.test.ts index 438eb507..5d305681 100644 --- a/src/utils/library/queueRestore.test.ts +++ b/src/utils/library/queueRestore.test.ts @@ -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); + }); }); diff --git a/src/utils/library/queueRestore.ts b/src/utils/library/queueRestore.ts index a18af6d7..326aa277 100644 --- a/src/utils/library/queueRestore.ts +++ b/src/utils/library/queueRestore.ts @@ -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 { 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 { 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 { 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