diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a7f0c3..e914b2ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -141,6 +141,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Queue — on-demand track loading for very large queues + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#859](https://github.com/Psychotoxical/psysonic/pull/859)** + +* Continued groundwork for multi-thousand-track queues: track details are resolved on demand through a shared cache rather than all being held at once. No change to how the queue looks or behaves. + + + ## Fixed ### In-page browse — virtual scroll and cover-art priority diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 62b9214a..6ec9d16b 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -54,6 +54,7 @@ import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { usePlayerStore } from '../store/playerStore'; import '../store/previewPlayerVolumeSync'; +import '../store/queueResolverBridge'; import { useThemeStore } from '../store/themeStore'; import { useFontStore } from '../store/fontStore'; import { useEqStore } from '../store/eqStore'; diff --git a/src/hooks/useQueueTracks.test.ts b/src/hooks/useQueueTracks.test.ts new file mode 100644 index 00000000..f4e09702 --- /dev/null +++ b/src/hooks/useQueueTracks.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { usePlayerStore } from '@/store/playerStore'; +import type { Track } from '@/store/playerStoreTypes'; +import { getCachedTrack, _resetQueueResolverForTest } from '@/utils/library/queueTrackResolver'; +import { useQueueTrackAt, useCurrentTrack, useQueueItems } from './useQueueTracks'; +// Importing the bridge registers the queue→resolver seed subscriber. +import '@/store/queueResolverBridge'; + +const track = (id: string, over: Partial = {}): Track => + ({ id, title: id, artist: '', album: 'A', albumId: 'A', duration: 1, ...over }); + +describe('useQueueTracks selectors', () => { + beforeEach(() => { + _resetQueueResolverForTest(); + usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null }); + }); + + it('useQueueTrackAt returns the track at the index, or null', () => { + usePlayerStore.setState({ queue: [track('t1'), track('t2')] }); + expect(renderHook(() => useQueueTrackAt(1)).result.current?.id).toBe('t2'); + expect(renderHook(() => useQueueTrackAt(9)).result.current).toBeNull(); + }); + + it('useCurrentTrack returns the current track', () => { + usePlayerStore.setState({ currentTrack: track('cur') }); + expect(renderHook(() => useCurrentTrack()).result.current?.id).toBe('cur'); + }); + + it('useQueueItems derives thin refs (serverId + flags) from the queue', () => { + usePlayerStore.setState({ + queueServerId: 's1', + queue: [track('t1'), track('t2', { radioAdded: true })], + }); + const { result } = renderHook(() => useQueueItems()); + expect(result.current).toEqual([ + { serverId: 's1', trackId: 't1' }, + { serverId: 's1', trackId: 't2', radioAdded: true }, + ]); + }); +}); + +describe('queueResolverBridge', () => { + beforeEach(() => { + _resetQueueResolverForTest(); + usePlayerStore.setState({ queue: [], queueIndex: 0, queueServerId: 's1', currentTrack: null }); + }); + + it('seeds the resolver cache with tracks around the current index on queue change', () => { + usePlayerStore.setState({ queue: [track('t1'), track('t2')], queueIndex: 0, queueServerId: 's1' }); + expect(getCachedTrack({ serverId: 's1', trackId: 't1' })?.id).toBe('t1'); + expect(getCachedTrack({ serverId: 's1', trackId: 't2' })?.id).toBe('t2'); + }); + + it('does not seed when there is no playback server', () => { + usePlayerStore.setState({ queue: [track('t1')], queueIndex: 0, queueServerId: null }); + expect(getCachedTrack({ serverId: '', trackId: 't1' })).toBeUndefined(); + }); +}); diff --git a/src/hooks/useQueueTracks.ts b/src/hooks/useQueueTracks.ts new file mode 100644 index 00000000..5b2bc5f9 --- /dev/null +++ b/src/hooks/useQueueTracks.ts @@ -0,0 +1,28 @@ +import { useMemo } from 'react'; +import { usePlayerStore } from '../store/playerStore'; +import type { QueueItemRef, Track } from '../store/playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; + +/** + * Stable queue selectors (queue thin-state). Consumers migrate onto these in + * phase 3. Today they read the canonical `queue: Track[]`; once it's dropped + * (phase 4) the implementations move to the resolver (`queueTrackResolver`) + * without changing these signatures. + */ + +/** The track at a queue index, or null. */ +export function useQueueTrackAt(idx: number): Track | null { + return usePlayerStore(s => s.queue[idx] ?? null); +} + +/** The currently playing track, or null. */ +export function useCurrentTrack(): Track | null { + return usePlayerStore(s => s.currentTrack); +} + +/** The whole queue as thin refs (derived; memoized on queue/server identity). */ +export function useQueueItems(): QueueItemRef[] { + const queue = usePlayerStore(s => s.queue); + const serverId = usePlayerStore(s => s.queueServerId); + return useMemo(() => toQueueItemRefs(serverId ?? '', queue), [serverId, queue]); +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 823bc8b8..a0858fdc 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -1,7 +1,8 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { emitPlaybackProgress } from './playbackProgress'; -import type { PlayerState, QueueItemRef } from './playerStoreTypes'; +import type { PlayerState } from './playerStoreTypes'; +import { toQueueItemRefs } from '../utils/library/queueItemRef'; import { readInitialQueueVisibility } from './queueVisibilityStorage'; import { createLastfmActions } from './lastfmActions'; import { createMiscActions } from './miscActions'; @@ -99,13 +100,7 @@ export const usePlayerStore = create()( // 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; - }), + queueItems: toQueueItemRefs(state.queueServerId ?? '', state.queue), queueItemsIndex: qi, isQueueVisible: state.isQueueVisible, // currentTime is intentionally NOT persisted here. diff --git a/src/store/queueResolverBridge.ts b/src/store/queueResolverBridge.ts new file mode 100644 index 00000000..dfb2387a --- /dev/null +++ b/src/store/queueResolverBridge.ts @@ -0,0 +1,21 @@ +/** + * Side-effect wiring (queue thin-state, phase 2b): seed the queue track + * resolver cache with the tracks around the current index whenever the queue + * changes. The store stays `queue: Track[]`-canonical for now — this only fills + * the resolver cache (additive; no mutation or persist change), so the queue + * selectors resolve without a fetch once consumers move onto them (phase 3) and + * after `queue: Track[]` is dropped (phase 4). + */ +import { usePlayerStore } from './playerStore'; +import { seedQueueResolver } from '../utils/library/queueTrackResolver'; + +const SEED_BACK = 50; +const SEED_AHEAD = 200; + +usePlayerStore.subscribe((state, prev) => { + if (state.queue === prev.queue && state.queueServerId === prev.queueServerId) return; + const serverId = state.queueServerId ?? ''; + if (!serverId || state.queue.length === 0) return; + const start = Math.max(0, state.queueIndex - SEED_BACK); + seedQueueResolver(serverId, state.queue.slice(start, state.queueIndex + SEED_AHEAD + 1)); +}); diff --git a/src/utils/library/queueItemRef.ts b/src/utils/library/queueItemRef.ts new file mode 100644 index 00000000..f7d86f1a --- /dev/null +++ b/src/utils/library/queueItemRef.ts @@ -0,0 +1,18 @@ +import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; + +/** + * 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. + */ +export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[] { + return queue.map(t => { + const ref: QueueItemRef = { serverId, trackId: t.id }; + if (t.autoAdded) ref.autoAdded = true; + if (t.radioAdded) ref.radioAdded = true; + if (t.playNextAdded) ref.playNextAdded = true; + return ref; + }); +} diff --git a/src/utils/library/queueTrackResolver.test.ts b/src/utils/library/queueTrackResolver.test.ts new file mode 100644 index 00000000..bfd37d4d --- /dev/null +++ b/src/utils/library/queueTrackResolver.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { onInvoke } from '@/test/mocks/tauri'; +import { useLibraryIndexStore } from '@/store/libraryIndexStore'; +import { usePlayerStore } from '@/store/playerStore'; +import type { TrackRefDto } from '@/api/library'; +import type { QueueItemRef } from '@/store/playerStoreTypes'; +import * as subsonic from '@/api/subsonicLibrary'; +import { + resolveBatch, + resolveVisibleRange, + getCachedTrack, + placeholderTrack, + applyQueueOverrides, + seedQueueResolver, + invalidateQueueResolver, + subscribeQueueResolver, + _resetQueueResolverForTest, +} from './queueTrackResolver'; + +const ready = () => + onInvoke('library_get_status', () => ({ + serverId: 's1', libraryScope: '', syncPhase: 'ready', + capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0, + })); + +const notReady = () => + onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' })); + +const echoBatch = () => + onInvoke('library_get_tracks_batch', (args) => + (args as { refs: TrackRefDto[] }).refs.map(r => ({ + serverId: r.serverId, id: r.trackId, title: `T-${r.trackId}`, + album: 'A', durationSec: 1, syncedAt: 0, rawJson: {}, + })), + ); + +const ref = (trackId: string, extra: Partial = {}): QueueItemRef => ({ serverId: 's1', trackId, ...extra }); + +describe('queueTrackResolver', () => { + beforeEach(() => { + _resetQueueResolverForTest(); + useLibraryIndexStore.getState().setIndexEnabled('s1', true); + usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} }); + vi.restoreAllMocks(); + }); + + it('getCachedTrack returns undefined on a miss (no fetch)', () => { + expect(getCachedTrack(ref('x'))).toBeUndefined(); + }); + + it('resolveBatch fills the cache from the index; getCachedTrack reads it', async () => { + ready(); + echoBatch(); + await resolveBatch([ref('t1'), ref('t2')]); + expect(getCachedTrack(ref('t1'))?.title).toBe('T-t1'); + expect(getCachedTrack(ref('t2'))?.title).toBe('T-t2'); + }); + + it('carries queue-only flags from the ref onto the resolved track', async () => { + ready(); + echoBatch(); + await resolveBatch([ref('t1', { radioAdded: true }), ref('t2', { autoAdded: true, playNextAdded: true })]); + expect(getCachedTrack(ref('t1'))?.radioAdded).toBe(true); + expect(getCachedTrack(ref('t2'))?.autoAdded).toBe(true); + expect(getCachedTrack(ref('t2'))?.playNextAdded).toBe(true); + }); + + it('falls back to network getSong when the index is not ready', async () => { + notReady(); + const spy = vi.spyOn(subsonic, 'getSong').mockResolvedValue({ + id: 't9', title: 'Net Song', album: 'A', duration: 1, + } as never); + await resolveBatch([ref('t9')]); + expect(spy).toHaveBeenCalledWith('t9'); + expect(getCachedTrack(ref('t9'))?.title).toBe('Net Song'); + }); + + it('notifies subscribers when a fetch lands', async () => { + ready(); + echoBatch(); + const cb = vi.fn(); + const unsub = subscribeQueueResolver(cb); + await resolveBatch([ref('t1')]); + expect(cb).toHaveBeenCalled(); + unsub(); + }); + + it('does not re-fetch already-cached refs', async () => { + ready(); + const batch = vi.fn((args: { refs: TrackRefDto[] }) => + args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} }))); + onInvoke('library_get_tracks_batch', batch as never); + await resolveBatch([ref('t1')]); + await resolveBatch([ref('t1')]); // cached → no second batch call + expect(batch).toHaveBeenCalledTimes(1); + }); + + it('seedQueueResolver caches known tracks without a fetch', () => { + seedQueueResolver('s1', [{ id: 't1', title: 'Seeded', artist: '', album: 'A', albumId: 'A', duration: 1 }]); + expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded'); + }); + + it('invalidateQueueResolver drops the cached entry', async () => { + ready(); + echoBatch(); + await resolveBatch([ref('t1')]); + expect(getCachedTrack(ref('t1'))).toBeDefined(); + invalidateQueueResolver('t1'); + expect(getCachedTrack(ref('t1'))).toBeUndefined(); + }); + + it('applyQueueOverrides merges session star/rating overrides', () => { + usePlayerStore.setState({ starredOverrides: { t1: true }, userRatingOverrides: { t1: 4 } }); + const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1 }); + expect(!!merged.starred).toBe(true); + expect(merged.userRating).toBe(4); + }); + + it('applyQueueOverrides clears starred when the override is false', () => { + usePlayerStore.setState({ starredOverrides: { t1: false }, userRatingOverrides: {} }); + const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1, starred: '2020' }); + expect(merged.starred).toBeUndefined(); + }); + + it('placeholderTrack preserves identity + queue flags', () => { + const p = placeholderTrack(ref('t1', { radioAdded: true })); + expect(p.id).toBe('t1'); + expect(p.radioAdded).toBe(true); + }); + + it('resolveVisibleRange prefetches the window around the visible rows', async () => { + ready(); + const batch = vi.fn((args: { refs: TrackRefDto[] }) => + args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} }))); + onInvoke('library_get_tracks_batch', batch as never); + const refs = Array.from({ length: 400 }, (_, i) => ref(`t${i}`)); + resolveVisibleRange(refs, 100, 120); + await vi.waitFor(() => expect(getCachedTrack(ref('t120'))).toBeDefined()); + // back window: t50 in range (100 - 50); t49 out of range + expect(getCachedTrack(ref('t50'))).toBeDefined(); + expect(getCachedTrack(ref('t49'))).toBeUndefined(); + }); +}); diff --git a/src/utils/library/queueTrackResolver.ts b/src/utils/library/queueTrackResolver.ts new file mode 100644 index 00000000..5e449643 --- /dev/null +++ b/src/utils/library/queueTrackResolver.ts @@ -0,0 +1,197 @@ +import { libraryGetTracksBatch, type TrackRefDto } from '../../api/library'; +import { getSong } from '../../api/subsonicLibrary'; +import { usePlayerStore } from '../../store/playerStore'; +import type { QueueItemRef, Track } from '../../store/playerStoreTypes'; +import { songToTrack } from '../playback/songToTrack'; +import { trackToSong } from './advancedSearchLocal'; +import { libraryIsReady } from './libraryReady'; + +/** + * Queue track resolver (thin-state phase 2). Resolves `QueueItemRef`s to full + * `Track`s on demand — index batch (`library_get_tracks_batch`, ≤100/call) → + * network `getSong` fallback (P8) — into a bounded LRU cache. The cache holds + * raw tracks; session star/rating overrides (F4) are merged on read via + * {@link applyQueueOverrides}. Selectors read synchronously from the cache and + * subscribe to {@link subscribeQueueResolver} to re-render as fetches land. + * + * Phase 2a: standalone module + tests, not yet wired into the store/UI. + */ + +const CACHE_CAP = 500; +/** `library_get_tracks_batch` cap (spec §8.6). */ +const BATCH = 100; +/** Prefetch window around the visible range (spec §resolver-contract). */ +const PREFETCH_BACK = 50; +const PREFETCH_AHEAD = 200; + +const refKey = (r: { serverId: string; trackId: string }) => `${r.serverId}:${r.trackId}`; + +// LRU cache: refKey → raw Track (no session overrides). +const cache = new Map(); +const inFlight = new Set(); +const listeners = new Set<() => void>(); + +function notify(): void { + for (const l of listeners) l(); +} + +/** 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); + while (cache.size > CACHE_CAP) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} + +function carryFlags(track: Track, ref: QueueItemRef | undefined): Track { + if (ref?.autoAdded) track.autoAdded = true; + if (ref?.radioAdded) track.radioAdded = true; + if (ref?.playNextAdded) track.playNextAdded = true; + return track; +} + +/** Synchronous cache read (no fetch); undefined on miss. */ +export function getCachedTrack(ref: QueueItemRef): Track | undefined { + return cacheTouch(refKey(ref)); +} + +/** Lightweight placeholder shown until a ref resolves. */ +export function placeholderTrack(ref: QueueItemRef): Track { + return { + id: ref.trackId, + title: '…', + artist: '', + album: '', + albumId: '', + duration: 0, + autoAdded: ref.autoAdded, + radioAdded: ref.radioAdded, + playNextAdded: ref.playNextAdded, + }; +} + +/** Merge session star/rating overrides (F4) onto a resolved track. */ +export function applyQueueOverrides(track: Track): Track { + const s = usePlayerStore.getState(); + const hasStar = track.id in s.starredOverrides; + const hasRating = track.id in s.userRatingOverrides; + if (!hasStar && !hasRating) return track; + const next = { ...track }; + if (hasStar) { + next.starred = s.starredOverrides[track.id] ? (track.starred ?? new Date().toISOString()) : undefined; + } + if (hasRating) next.userRating = s.userRatingOverrides[track.id]; + return next; +} + +/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch. */ +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); + notify(); +} + +/** + * Resolve a batch of refs into the cache: index batch (per server, when ready) + * then network fallback for whatever the index lacks. Skips refs already cached + * or in flight. Notifies once if anything changed. + */ +export async function resolveBatch(refs: QueueItemRef[]): Promise { + const missing = refs.filter(r => { + const k = refKey(r); + return !cache.has(k) && !inFlight.has(k); + }); + if (missing.length === 0) return; + for (const r of missing) inFlight.add(refKey(r)); + + let changed = false; + try { + const byServer = new Map(); + for (const r of missing) { + const arr = byServer.get(r.serverId) ?? []; + arr.push(r); + byServer.set(r.serverId, arr); + } + + for (const [serverId, serverRefs] of byServer) { + if (!serverId) continue; + const stillMissing = new Set(serverRefs.map(r => r.trackId)); + const refByTrack = new Map(serverRefs.map(r => [r.trackId, r])); + + if (await libraryIsReady(serverId)) { + for (let i = 0; i < serverRefs.length; i += BATCH) { + const chunk: TrackRefDto[] = serverRefs + .slice(i, i + BATCH) + .map(r => ({ serverId, trackId: r.trackId })); + try { + const dtos = await libraryGetTracksBatch(chunk); + for (const d of dtos) { + const track = carryFlags(songToTrack(trackToSong(d)), refByTrack.get(d.id)); + cacheSet(refKey({ serverId, trackId: d.id }), track); + stillMissing.delete(d.id); + changed = true; + } + } catch { /* fall through to network */ } + } + } + + // Network fallback (P8) for refs the index couldn't serve. + for (const trackId of stillMissing) { + try { + const song = await getSong(trackId); + if (song) { + const track = carryFlags(songToTrack(song), refByTrack.get(trackId)); + cacheSet(refKey({ serverId, trackId }), track); + changed = true; + } + } catch { /* leave as placeholder */ } + } + } + } finally { + for (const r of missing) inFlight.delete(refKey(r)); + if (changed) notify(); + } +} + +/** Resolve the visible range plus the prefetch window around it. */ +export function resolveVisibleRange(refs: QueueItemRef[], fromIdx: number, toIdx: number): void { + const start = Math.max(0, fromIdx - PREFETCH_BACK); + const end = Math.min(refs.length, toIdx + PREFETCH_AHEAD + 1); + if (end > start) void resolveBatch(refs.slice(start, end)); +} + +/** Drop cached entries for a track id (e.g. after a star/rating sync succeeds, + * so the next read re-fetches the server truth). */ +export function invalidateQueueResolver(trackId: string): void { + let changed = false; + for (const key of [...cache.keys()]) { + if (key.endsWith(`:${trackId}`)) { + cache.delete(key); + changed = true; + } + } + if (changed) notify(); +} + +/** Test-only: clear cache + in-flight set. */ +export function _resetQueueResolverForTest(): void { + cache.clear(); + inFlight.clear(); +}