mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
refactor(queue): track resolver + selectors (thin-state phase 2) (#859)
* refactor(queue): add queue track resolver (thin-state phase 2a) Standalone resolver: QueueItemRef → Track via index batch (library_get_tracks_batch, ≤100/call) → network getSong fallback (P8), into a bounded LRU cache. Holds raw tracks; session star/rating overrides (F4) merged on read via applyQueueOverrides. Sync getCachedTrack + subscribeQueueResolver for selectors; resolveVisibleRange prefetches a [-50, +200] window; carries queue-only flags from refs; invalidate drops entries after a sync succeeds. Not wired into the store/UI yet — phase 2b does that. * refactor(queue): extract toQueueItemRefs helper Pure helper deriving thin QueueItemRefs from a Track[] queue (per-item serverId, queue-only flags), shared by the persist partialize and the upcoming phase-2b resolver bridge. No behaviour change. * refactor(queue): resolver bridge + queue selectors (thin-state phase 2b) Seed the resolver cache from the canonical queue (queueResolverBridge, a windowed [-50, +200] seed around the current index) and add the stable queue selectors (useQueueTrackAt / useCurrentTrack / useQueueItems). Additive: the store stays queue: Track[]-canonical; consumers migrate onto the selectors in phase 3, and the selector impls move to the resolver once queue: Track[] is dropped in phase 4. No mutation or persist change — the persisted queueItems keeps its single restore role (no dual-role clash). * docs(changelog): on-demand queue track loading groundwork (#859)
This commit is contained in:
committed by
GitHub
parent
d15e270499
commit
090a31bc82
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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<PlayerState>()(
|
||||
// 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.
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, Track>();
|
||||
const inFlight = new Set<string>();
|
||||
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<void> {
|
||||
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<string, QueueItemRef[]>();
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user