Files
Psychotoxical-psysonic/src/features/playback/store/queueTrackResolver.ts
T
2026-07-20 18:57:22 +03:00

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();
}