mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
import { libraryGetTracksBatch, type TrackRefDto } from '@/lib/api/library';
|
|
import { getSongForServer } from '@/lib/api/subsonicLibrary';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
|
import { songToTrack } from '@/lib/media/songToTrack';
|
|
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
|
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
|
import { trackToSong } from '@/lib/library/advancedSearchLocal';
|
|
import { libraryIsReady } from '@/lib/library/libraryReady';
|
|
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID } from '@/lib/share/navidromePublicSharePlayback';
|
|
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
|
|
|
|
/**
|
|
* 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>();
|
|
let cacheVersion = 0;
|
|
|
|
function notify(): void {
|
|
cacheVersion++;
|
|
for (const l of listeners) l();
|
|
}
|
|
|
|
/** Monotonic version, bumped on every cache change (for `useSyncExternalStore`). */
|
|
export function getQueueResolverVersion(): number {
|
|
return cacheVersion;
|
|
}
|
|
|
|
/** Subscribe to cache changes (for `useSyncExternalStore` selectors). */
|
|
export function subscribeQueueResolver(cb: () => void): () => void {
|
|
listeners.add(cb);
|
|
return () => { listeners.delete(cb); };
|
|
}
|
|
|
|
function cacheSet(key: string, track: Track, opts?: { skipCap?: boolean }): void {
|
|
if (cache.has(key)) cache.delete(key);
|
|
cache.set(key, track);
|
|
if (opts?.skipCap) return;
|
|
while (cache.size > CACHE_CAP) {
|
|
const oldest = cache.keys().next().value;
|
|
if (oldest === undefined) break;
|
|
cache.delete(oldest);
|
|
}
|
|
}
|
|
|
|
function trimCacheExcept(preserve: Set<string>): void {
|
|
for (const key of [...cache.keys()]) {
|
|
if (cache.size <= CACHE_CAP) return;
|
|
if (!preserve.has(key)) cache.delete(key);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// Pure read — no LRU bump. Called from component render (QueueList rows), where
|
|
// a Map mutation (delete+set) is a render side-effect. Recency is set at write
|
|
// time in cacheSet instead; this cache is effectively insertion-order/FIFO.
|
|
const direct = cache.get(refKey(ref));
|
|
if (direct) return direct;
|
|
// Compat: refs persisted before B1 (queue server identity canonicalization)
|
|
// may still carry a UUID. Writes are canonical now, so the live cache key
|
|
// is `${indexKey}:${trackId}`; map UUID → indexKey on read to bridge the
|
|
// migration window.
|
|
const canonical = canonicalQueueServerKey(ref.serverId);
|
|
if (canonical && canonical !== ref.serverId) {
|
|
return cache.get(refKey({ serverId: canonical, trackId: ref.trackId }));
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
/** 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,
|
|
directStreamUrl: ref.directStreamUrl,
|
|
directCoverArtUrl: ref.directCoverArtUrl,
|
|
serverId: ref.serverId,
|
|
};
|
|
}
|
|
|
|
export function mergeDirectShareUrls(track: Track, ref: QueueItemRef): Track {
|
|
if (!ref.directStreamUrl && !ref.directCoverArtUrl) return track;
|
|
if (track.directStreamUrl === ref.directStreamUrl
|
|
&& track.directCoverArtUrl === ref.directCoverArtUrl) {
|
|
return track;
|
|
}
|
|
return {
|
|
...track,
|
|
directStreamUrl: track.directStreamUrl ?? ref.directStreamUrl,
|
|
directCoverArtUrl: track.directCoverArtUrl ?? ref.directCoverArtUrl,
|
|
serverId: track.serverId ?? ref.serverId,
|
|
};
|
|
}
|
|
|
|
/** Merge session star/rating overrides (F4) onto a resolved track. */
|
|
export function applyQueueOverrides(track: Track): Track {
|
|
const s = usePlayerStore.getState();
|
|
const starred = ownedOverrideValue(s.starredOverrides, track);
|
|
const rating = ownedOverrideValue(s.userRatingOverrides, track);
|
|
if (starred === undefined && rating === undefined) return track;
|
|
const next = { ...track };
|
|
if (starred !== undefined) {
|
|
next.starred = starred ? (track.starred ?? new Date().toISOString()) : undefined;
|
|
}
|
|
if (rating !== undefined) next.userRating = rating;
|
|
return next;
|
|
}
|
|
|
|
/** Seed the cache with already-known tracks (e.g. on enqueue) — no fetch.
|
|
* Canonicalizes the caller-supplied server id so seed and refs always agree
|
|
* on a single key shape. */
|
|
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
|
if (tracks.length === 0) return;
|
|
const canonicalId = canonicalQueueServerKey(serverId);
|
|
const preserve = new Set<string>();
|
|
for (const t of tracks) {
|
|
const key = refKey({ serverId: canonicalId, trackId: t.id });
|
|
preserve.add(key);
|
|
cacheSet(key, t, { skipCap: true });
|
|
}
|
|
// Bulk queue replace: keep the whole incoming set (may exceed CACHE_CAP).
|
|
// Single-track touch (queue navigation): no eviction — must not wipe siblings.
|
|
if (tracks.length > 1) trimCacheExcept(preserve);
|
|
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;
|
|
|
|
if (serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) {
|
|
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
|
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
|
|
|
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 ref = refByTrack.get(d.id);
|
|
const track = carryFlags(
|
|
mergeDirectShareUrls(
|
|
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? serverId },
|
|
ref ?? { serverId, trackId: d.id },
|
|
),
|
|
ref,
|
|
);
|
|
cacheSet(refKey({ serverId, trackId: d.id }), track, { skipCap: true });
|
|
stillMissing.delete(d.id);
|
|
changed = true;
|
|
}
|
|
} catch { /* fall through */ }
|
|
}
|
|
}
|
|
|
|
for (const trackId of stillMissing) {
|
|
const ref = refByTrack.get(trackId);
|
|
if (!ref?.directStreamUrl) continue;
|
|
const track = carryFlags(mergeDirectShareUrls(placeholderTrack(ref), ref), ref);
|
|
cacheSet(refKey({ serverId, trackId }), track, { skipCap: true });
|
|
changed = true;
|
|
}
|
|
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 profileId = resolveServerIdForIndexKey(serverId) || serverId;
|
|
const track = carryFlags(
|
|
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? profileId },
|
|
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.
|
|
const profileId = resolveServerIdForIndexKey(serverId) || serverId;
|
|
for (const trackId of stillMissing) {
|
|
try {
|
|
const song = await getSongForServer(profileId, trackId);
|
|
if (song) {
|
|
const track = carryFlags(
|
|
{ ...songToTrack(song), serverId: profileId },
|
|
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 one owner-qualified cached entry, forcing the next resolve to re-fetch. */
|
|
export function invalidateQueueResolver(trackId: string, serverId: string): void {
|
|
const key = refKey({ serverId: canonicalQueueServerKey(serverId), trackId });
|
|
if (cache.delete(key)) notify();
|
|
}
|
|
|
|
/** Patch cached entries for a track id in place (e.g. after a star/rating sync
|
|
* succeeds). Unlike {@link invalidateQueueResolver}, this keeps the entry so a
|
|
* visible queue row never blanks to a placeholder — the row stays resolved and
|
|
* just reflects the synced value. No-op for refs not currently cached. */
|
|
export function patchCachedTrack(
|
|
trackId: string,
|
|
patch: Partial<Track>,
|
|
serverId: string,
|
|
): void {
|
|
const key = refKey({ serverId: canonicalQueueServerKey(serverId), trackId });
|
|
const track = cache.get(key);
|
|
if (!track) return;
|
|
cache.set(key, { ...track, ...patch });
|
|
notify();
|
|
}
|
|
|
|
/** Test-only: clear cache + in-flight set. */
|
|
export function _resetQueueResolverForTest(): void {
|
|
cache.clear();
|
|
inFlight.clear();
|
|
}
|