Phase 1: preview contracts and pure session core, 61 tests
src/lib/preview/ — framework-free ESM modules, per design §12–14: - contracts.js: versioned manifest/item/source-ref factories; signed URLs structurally excluded from manifests; Apple identity first-class. - lease.js: discriminated MediaLease union; computeSafeForMs derives lifetimes provider-clock-first with fallback/contradiction handling (margins are ADRs backed by Phase 0 soak evidence). - redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact host allowlist gate applied to initial URLs and every redirect. - manifest.js: artist (rank order, exact-id dedupe), album (disc/track sort, context inheritance) and fixture builders — pure, no fetching. - session.js: reducer with session/generation guards on every async action; keyed active item with derived playable indices/cursor; auto-advance under fresh generations; close is terminal. - resolver.js: PreviewResolver contract + scripted fixture resolver with classified failures (stale_lease/not_found/network/…). - transport.js: transport seam + deterministic fake (manual ticks). - focus.js: revision gateway (all main-player entry points; focus- internal tag) and the coordinator — acquisition only via a generation-bound silence ack, restore only with revisions unchanged. tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing, covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the exit gate: stale lease/play events are inert, no restoration after any simulated main command, and no component renders anywhere.
This commit is contained in:
1 parent
6dcc62a2fd
commit
8ede6a8adf
14 files changed
+1722
-2
No files matched your search
+3
-2
@@ -2,11 +2,12 @@
|
||||
"name": "soulstream",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "SoulStream — music player, downloader and streamer for the Soulseek network",
|
||||
"description": "SoulStream \u2014 music player, downloader and streamer for the Soulseek network",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"test": "node --test tests/preview/*.test.mjs"
|
||||
},
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Versioned data contracts for the preview feature — design §12, schema v1.
|
||||
//
|
||||
// Plain frozen objects, framework-free, so the whole session core is
|
||||
// unit-testable under node:test with no renderer. The reducer and manifest
|
||||
// builders depend on THIS file's shapes, never on raw CatalogTrack rows.
|
||||
|
||||
export const MANIFEST_SCHEMA_VERSION = 1;
|
||||
|
||||
export const AVAILABILITY = Object.freeze({
|
||||
UNRESOLVED: "unresolved",
|
||||
AVAILABLE: "available",
|
||||
NOT_PROVIDED: "not_provided",
|
||||
UNRELEASED: "unreleased",
|
||||
REGION_UNAVAILABLE: "region_unavailable",
|
||||
TEMP_FAILED: "temporarily_failed",
|
||||
});
|
||||
|
||||
const CONTEXT_KINDS = new Set(["artist", "album", "playlist", "search", "collection"]);
|
||||
const PROVIDERS = new Set(["deezer", "apple", "local"]);
|
||||
const CONFIDENCE = new Set(["native", "isrc", "strong_metadata"]);
|
||||
|
||||
/**
|
||||
* Stable provider identity for one source of a preview. Signed URLs are NOT
|
||||
* identity (Phase 0: every fetch mints a different URL); the provider track
|
||||
* id is. Apple rows keep their REAL provider id here — the backend's
|
||||
* synthetic-id flattening is a Phase 2 extension, and this contract is the
|
||||
* reason it can't silently regress.
|
||||
*/
|
||||
export function makeSourceRef({ provider, providerTrackId, isrc, matchConfidence = "native" }) {
|
||||
if (!PROVIDERS.has(provider)) throw new Error(`unknown provider ${provider}`);
|
||||
if (!CONFIDENCE.has(matchConfidence)) throw new Error(`unknown confidence ${matchConfidence}`);
|
||||
const id = String(providerTrackId ?? "");
|
||||
if (!id) throw new Error("providerTrackId required");
|
||||
return Object.freeze({ provider, providerTrackId: id, isrc, matchConfidence });
|
||||
}
|
||||
|
||||
export function makeContext({ kind, contextKey, title, subtitle, artwork, sourceTrackCount }) {
|
||||
if (!CONTEXT_KINDS.has(kind)) throw new Error(`unknown context kind ${kind}`);
|
||||
if (!contextKey) throw new Error("contextKey required");
|
||||
return Object.freeze({
|
||||
kind,
|
||||
contextKey: String(contextKey),
|
||||
title: title ?? "",
|
||||
subtitle,
|
||||
artwork,
|
||||
sourceTrackCount: sourceTrackCount | 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One occurrence in a manifest. `sessionKey` is unique per occurrence (so
|
||||
* playlist duplicates stay distinct); `recordingKey` ties repeated
|
||||
* occurrences of the same recording together for shared action state.
|
||||
*/
|
||||
export function makeItem({
|
||||
sessionKey,
|
||||
sourceOrdinal,
|
||||
discNumber,
|
||||
trackNumber,
|
||||
recordingKey,
|
||||
title,
|
||||
artists,
|
||||
album,
|
||||
albumId,
|
||||
artwork,
|
||||
fullDurationMs,
|
||||
explicit,
|
||||
sources,
|
||||
initialAvailability,
|
||||
actions,
|
||||
}) {
|
||||
if (!sessionKey) throw new Error("sessionKey required");
|
||||
if (!Number.isInteger(sourceOrdinal) || sourceOrdinal < 0) {
|
||||
throw new Error("sourceOrdinal must be a non-negative integer");
|
||||
}
|
||||
const avail = initialAvailability ?? AVAILABILITY.UNRESOLVED;
|
||||
if (!Object.values(AVAILABILITY).includes(avail)) {
|
||||
throw new Error(`unknown availability ${avail}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
sessionKey: String(sessionKey),
|
||||
sourceOrdinal,
|
||||
discNumber,
|
||||
trackNumber,
|
||||
recordingKey,
|
||||
title: title ?? "",
|
||||
artists: Object.freeze(Array.isArray(artists) ? [...artists] : []),
|
||||
album,
|
||||
albumId: albumId != null ? String(albumId) : undefined,
|
||||
artwork,
|
||||
fullDurationMs,
|
||||
explicit,
|
||||
sources: Object.freeze((sources ?? []).map((s) => (Object.isFrozen(s) ? s : makeSourceRef(s)))),
|
||||
initialAvailability: avail,
|
||||
actions: Object.freeze(actions ?? { playFull: true, save: true, addToPlaylist: true, openAlbum: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Immutable, versioned manifest. `createdAtMs` is supplied by the caller so
|
||||
* everything here stays pure and replayable in tests. */
|
||||
export function makeManifest({ context, items, createdAtMs }) {
|
||||
return Object.freeze({
|
||||
schemaVersion: MANIFEST_SCHEMA_VERSION,
|
||||
context,
|
||||
items: Object.freeze([...items]),
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sanity check used by tests and by the session boundary in dev builds. */
|
||||
export function isValidManifest(m) {
|
||||
if (!m || m.schemaVersion !== MANIFEST_SCHEMA_VERSION) return false;
|
||||
if (!m.context || !CONTEXT_KINDS.has(m.context.kind)) return false;
|
||||
if (!Array.isArray(m.items)) return false;
|
||||
const keys = new Set();
|
||||
for (const it of m.items) {
|
||||
if (!it.sessionKey || keys.has(it.sessionKey)) return false; // dupes forbidden
|
||||
keys.add(it.sessionKey);
|
||||
// No signed URL may ride in a manifest — identity is provider ids.
|
||||
for (const s of it.sources ?? []) {
|
||||
if (typeof s.url === "string") return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Audio-focus coordinator and main-transport revision gateway — design §10,
|
||||
// pure logic over injected transports so every rule is testable without
|
||||
// audio hardware. The native primitive underneath (Player::pause_acked with
|
||||
// the desired-paused latch) was proven in Phase 0.4.
|
||||
//
|
||||
// Invariants enforced here, verbatim from the design:
|
||||
// - Focus acquisition can NEVER complete from renderer status alone: it
|
||||
// requires a silence acknowledgement bound to the captured playback
|
||||
// generation. A stale/wrong-generation ack leaves preview parked.
|
||||
// - Main resumes on release only if preview caused the pause AND every
|
||||
// captured revision (user-command, queue, playback) is unchanged.
|
||||
// - A boolean resumeMainOnClose does not exist, by construction.
|
||||
// - Coordinator-issued commands are tagged focus-internal so they do not
|
||||
// invalidate their own token.
|
||||
|
||||
/**
|
||||
* Revision gateway: the single object every main-player entry point calls
|
||||
* through. Each user-visible command family bumps its revision; the
|
||||
* coordinator's own pause/resume pass `{ focusInternal: true }` and bump
|
||||
* nothing — that is what keeps the token valid across the pause the
|
||||
* coordinator itself requested.
|
||||
*/
|
||||
export function createRevisionGateway() {
|
||||
const rev = { userCommand: 0, queue: 0, playback: 0 };
|
||||
const bump = (k, opts) => {
|
||||
if (opts?.focusInternal) return;
|
||||
rev[k] += 1;
|
||||
};
|
||||
return {
|
||||
// every ordinary entry point maps to exactly one of these:
|
||||
userPlay: (o) => (bump("userCommand", o), bump("playback", o)),
|
||||
userPauseResume: (o) => bump("userCommand", o),
|
||||
userStop: (o) => (bump("userCommand", o), bump("playback", o)),
|
||||
userSeekOrScratch: (o) => bump("userCommand", o),
|
||||
queueJumpOrNextPrev: (o) => (bump("userCommand", o), bump("queue", o), bump("playback", o)),
|
||||
queueMutation: (o) => bump("queue", o),
|
||||
autoAdvance: (o) => (bump("queue", o), bump("playback", o)),
|
||||
nativeSourceChange: (o) => bump("playback", o),
|
||||
snapshot: () => ({ ...rev }),
|
||||
changedSince: (snap) =>
|
||||
snap.userCommand !== rev.userCommand || snap.queue !== rev.queue || snap.playback !== rev.playback,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {() => {status: string, trackKey: string|null, positionMs: number,
|
||||
* audible: boolean, playbackGeneration: number}} deps.mainStatus
|
||||
* Renderer-side view of the main player. Used for the SNAPSHOT only —
|
||||
* never as proof of silence.
|
||||
* @param {(generation: number) => Promise<{generation: number, applied: boolean}>}
|
||||
* deps.requestSilence
|
||||
* Asks the native transport for an acknowledged pause (pause_acked).
|
||||
* The returned generation is the one the ack is bound to.
|
||||
* @param {(snapshot: object, opts: {focusInternal: true}) => void} deps.resumeMain
|
||||
* Resumes main playback at the snapshot; tagged focus-internal.
|
||||
* @param {ReturnType<createRevisionGateway>} deps.gateway
|
||||
* @param {number} [deps.ackTimeoutMs]
|
||||
*/
|
||||
export function createFocusCoordinator({ mainStatus, requestSilence, resumeMain, gateway, ackTimeoutMs = 2_000 }) {
|
||||
let owner = "none"; // none | main | preview
|
||||
let token = null;
|
||||
|
||||
async function acquirePreview() {
|
||||
const status = mainStatus();
|
||||
const revisions = gateway.snapshot();
|
||||
|
||||
// Main idle or already paused by the user: nothing to silence, and
|
||||
// nothing to restore later — preview simply takes focus.
|
||||
if (!status.audible) {
|
||||
owner = "preview";
|
||||
token = Object.freeze({
|
||||
id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${status.playbackGeneration}`,
|
||||
pausedByPreview: false,
|
||||
snapshot: null,
|
||||
revisions,
|
||||
});
|
||||
return { granted: true, token };
|
||||
}
|
||||
|
||||
// Main is audible: capture the snapshot, then demand the
|
||||
// generation-bound acknowledgement. No ack, no focus — a renderer
|
||||
// status flag or a timeout NEVER substitutes for silence.
|
||||
const snapshot = Object.freeze({
|
||||
trackKey: status.trackKey,
|
||||
positionMs: status.positionMs,
|
||||
playbackGeneration: status.playbackGeneration,
|
||||
});
|
||||
let ack = null;
|
||||
try {
|
||||
ack = await withTimeout(requestSilence(status.playbackGeneration), ackTimeoutMs);
|
||||
} catch {
|
||||
ack = null;
|
||||
}
|
||||
const ackValid =
|
||||
!!ack && ack.applied === true && ack.generation === snapshot.playbackGeneration;
|
||||
if (!ackValid || gateway.changedSince(revisions)) {
|
||||
// Stale generation, timeout, or the user acted mid-negotiation:
|
||||
// preview stays parked; the caller shows the transient wait state.
|
||||
return { granted: false, token: null };
|
||||
}
|
||||
owner = "preview";
|
||||
token = Object.freeze({
|
||||
id: `focus-${revisions.userCommand}-${revisions.queue}-${revisions.playback}-${snapshot.playbackGeneration}`,
|
||||
pausedByPreview: true,
|
||||
snapshot,
|
||||
revisions,
|
||||
});
|
||||
return { granted: true, token };
|
||||
}
|
||||
|
||||
/** Release focus. Main resumes ONLY when preview caused the pause and no
|
||||
* revision moved since the snapshot — otherwise audio stays idle. */
|
||||
function release(t) {
|
||||
if (!t || t !== token) return { restored: false, reason: "stale_token" };
|
||||
owner = "none";
|
||||
token = null;
|
||||
if (!t.pausedByPreview) return { restored: false, reason: "main_was_not_audible" };
|
||||
if (gateway.changedSince(t.revisions)) return { restored: false, reason: "revisions_changed" };
|
||||
resumeMain(t.snapshot, { focusInternal: true });
|
||||
owner = "main";
|
||||
return { restored: true };
|
||||
}
|
||||
|
||||
/** Play-full-track handoff: ownership moves to ordinary playback and the
|
||||
* old snapshot is intentionally discarded — never restored. */
|
||||
function handoffToMain() {
|
||||
owner = "main";
|
||||
token = null;
|
||||
return { restored: false, reason: "handoff" };
|
||||
}
|
||||
|
||||
return {
|
||||
acquirePreview,
|
||||
release,
|
||||
handoffToMain,
|
||||
owner: () => owner,
|
||||
currentToken: () => token,
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout(promise, ms) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error("ack timeout")), ms);
|
||||
promise.then(
|
||||
(v) => (clearTimeout(t), resolve(v)),
|
||||
(e) => (clearTimeout(t), reject(e))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Ephemeral media leases — the discriminated union from design §12.
|
||||
//
|
||||
// A lease is the resolver's answer to "how do I play this item RIGHT NOW":
|
||||
// either a transport-private URL (webview) or an opaque native handle, plus
|
||||
// a conservative `safeForMs` after which the transport must re-resolve.
|
||||
// Leases are never persisted, never used as cache identity, and never logged
|
||||
// in full (see redact.js).
|
||||
|
||||
/**
|
||||
* Safety margin subtracted from every derived lifetime. ADR (Phase 0): leases
|
||||
* hard-403 after their signed exp with NO grace observed (206 at +14 min
|
||||
* against a +14.97 exp, 403 by +17.5). 20 s covers request latency, decoder
|
||||
* spin-up and one settle-window retry without meaningfully shortening a
|
||||
* 13-minute lease.
|
||||
*/
|
||||
export const LEASE_SAFETY_MARGIN_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Fallback lifetime when provider time data is missing, malformed or
|
||||
* contradictory. Deliberately short: Phase 0 never observed a lease younger
|
||||
* than ~13 min, so 60 s is far inside any real lifetime while still letting
|
||||
* a clip play out (clips are 30 s) before a forced refresh.
|
||||
*/
|
||||
export const LEASE_FALLBACK_SAFE_MS = 60_000;
|
||||
|
||||
/** Longest lifetime we will ever trust, however healthy the inputs look —
|
||||
* a parse artifact claiming hours must not disable refresh. */
|
||||
export const LEASE_MAX_SAFE_MS = 30 * 60_000;
|
||||
|
||||
/**
|
||||
* Derive `safeForMs` from provider time data, per design §12:
|
||||
* conservative, provider-clock based, never trusting any single input.
|
||||
*
|
||||
* @param {object} t
|
||||
* @param {number|null} t.expEpochMs parsed signed expiry (provider clock)
|
||||
* @param {number|null} t.dateHeaderMs provider `Date` header (provider clock)
|
||||
* @param {number} t.localNowMs local wall clock at receipt
|
||||
* @returns {number} milliseconds the lease may be used, >= 0
|
||||
*/
|
||||
export function computeSafeForMs({ expEpochMs, dateHeaderMs, localNowMs }) {
|
||||
const bad =
|
||||
!Number.isFinite(expEpochMs) ||
|
||||
!Number.isFinite(localNowMs) ||
|
||||
expEpochMs <= 0;
|
||||
if (bad) return LEASE_FALLBACK_SAFE_MS;
|
||||
|
||||
// Remaining lifetime on the provider's own clock when both ends of it
|
||||
// are known — immune to local clock skew entirely.
|
||||
let remaining = null;
|
||||
if (Number.isFinite(dateHeaderMs) && dateHeaderMs > 0) {
|
||||
remaining = expEpochMs - dateHeaderMs;
|
||||
// Contradictory provider data (exp at/before Date, or a lifetime far
|
||||
// beyond anything the provider actually issues) → fallback.
|
||||
if (remaining <= 0 || remaining > 2 * LEASE_MAX_SAFE_MS) {
|
||||
return LEASE_FALLBACK_SAFE_MS;
|
||||
}
|
||||
}
|
||||
|
||||
// Local-clock view. If the local clock is skewed AHEAD of the provider,
|
||||
// this is smaller than `remaining` and correctly wins (we refresh early).
|
||||
// If skewed BEHIND, `remaining` wins and the local view can't extend it.
|
||||
const localView = expEpochMs - localNowMs;
|
||||
if (localView <= 0 && remaining === null) return LEASE_FALLBACK_SAFE_MS;
|
||||
|
||||
const usable =
|
||||
remaining === null ? localView : Math.min(remaining, localView > 0 ? localView : remaining);
|
||||
const safe = Math.min(usable, LEASE_MAX_SAFE_MS) - LEASE_SAFETY_MARGIN_MS;
|
||||
return Math.max(0, safe);
|
||||
}
|
||||
|
||||
/** Build a native-transport lease (the ratified transport). */
|
||||
export function makeNativeLease({ source, opaqueLeaseId, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
|
||||
if (typeof opaqueLeaseId !== "string" || !opaqueLeaseId) {
|
||||
throw new Error("native lease requires opaqueLeaseId");
|
||||
}
|
||||
return Object.freeze({
|
||||
transport: "native",
|
||||
source,
|
||||
opaqueLeaseId,
|
||||
safeForMs,
|
||||
mimeType,
|
||||
clipDurationMs,
|
||||
byteLength,
|
||||
supportsRanges,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build a webview-transport lease (documented fallback transport). */
|
||||
export function makeWebviewLease({ source, url, safeForMs, mimeType, clipDurationMs, byteLength, supportsRanges }) {
|
||||
if (typeof url !== "string" || !url) {
|
||||
throw new Error("webview lease requires url");
|
||||
}
|
||||
return Object.freeze({
|
||||
transport: "webview",
|
||||
source,
|
||||
url,
|
||||
safeForMs,
|
||||
mimeType,
|
||||
clipDurationMs,
|
||||
byteLength,
|
||||
supportsRanges,
|
||||
});
|
||||
}
|
||||
|
||||
/** Union validity: exactly one locator, matching the transport tag. */
|
||||
export function isValidLease(lease) {
|
||||
if (!lease || typeof lease !== "object") return false;
|
||||
if (!Number.isFinite(lease.safeForMs) || lease.safeForMs < 0) return false;
|
||||
if (lease.transport === "native") {
|
||||
return typeof lease.opaqueLeaseId === "string" && lease.opaqueLeaseId !== "" && lease.url === undefined;
|
||||
}
|
||||
if (lease.transport === "webview") {
|
||||
return typeof lease.url === "string" && lease.url !== "" && lease.opaqueLeaseId === undefined;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Manifest builders — design §13. Inputs are the shapes the app ALREADY
|
||||
// holds (CatalogTrack rows from the artist page / album page); output is the
|
||||
// immutable PreviewManifest. No fetching happens here, ever: builders are
|
||||
// pure so the artist adapter can never become a discography crawler.
|
||||
|
||||
import { AVAILABILITY, makeContext, makeItem, makeManifest, makeSourceRef } from "./contracts.js";
|
||||
|
||||
/** Availability a catalog row can declare up front. The `preview` URL itself
|
||||
* is treated as one-bit evidence ("a preview exists") and then DISCARDED —
|
||||
* it expires in minutes and identity is the provider track id. */
|
||||
function availabilityFromCatalogTrack(t) {
|
||||
if (t.unreleased) return AVAILABILITY.UNRELEASED;
|
||||
if (typeof t.preview === "string" && t.preview.length > 0) return AVAILABILITY.AVAILABLE;
|
||||
return AVAILABILITY.NOT_PROVIDED;
|
||||
}
|
||||
|
||||
function itemFromCatalogTrack(t, ordinal, inherit = {}) {
|
||||
return makeItem({
|
||||
sessionKey: `s${ordinal}-dz${t.id}`,
|
||||
sourceOrdinal: ordinal,
|
||||
discNumber: t.disk_number ?? undefined,
|
||||
trackNumber: t.track_no ?? undefined,
|
||||
recordingKey: `dz:${t.id}`,
|
||||
title: t.title ?? "",
|
||||
artists: [t.artist || inherit.artist || ""].filter(Boolean),
|
||||
album: t.album || inherit.album,
|
||||
albumId: t.album_id ?? inherit.albumId,
|
||||
artwork: t.cover ?? inherit.artwork,
|
||||
fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined,
|
||||
sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })],
|
||||
initialAvailability: availabilityFromCatalogTrack(t),
|
||||
actions: { playFull: !t.unreleased, save: !t.unreleased, addToPlaylist: !t.unreleased, openAlbum: t.album_id != null || inherit.albumId != null },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Artist adapter: the COMPLETE ranked top-track payload (never the five
|
||||
* rendered rows), rank order preserved, collapsing only exact repeated
|
||||
* provider ids — repetition adds no ordering meaning in a top list.
|
||||
*/
|
||||
export function buildArtistManifest({ artist, popularTracks, createdAtMs }) {
|
||||
const seen = new Set();
|
||||
const items = [];
|
||||
for (const t of popularTracks ?? []) {
|
||||
const key = `dz:${t.id}`;
|
||||
if (seen.has(key)) continue; // exact provider-id dupe only
|
||||
seen.add(key);
|
||||
items.push(itemFromCatalogTrack(t, items.length, { artist: artist?.name, artwork: artist?.picture }));
|
||||
}
|
||||
return makeManifest({
|
||||
context: makeContext({
|
||||
kind: "artist",
|
||||
contextKey: `artist:${artist?.id ?? "unknown"}`,
|
||||
title: artist?.name ?? "",
|
||||
subtitle: "Top tracks",
|
||||
artwork: artist?.picture,
|
||||
sourceTrackCount: (popularTracks ?? []).length,
|
||||
}),
|
||||
items,
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog album adapter: disc/track order (then stable response order),
|
||||
* context metadata inherited by rows whose track endpoint omitted nested
|
||||
* album data, unavailable tracks preserved in source counts.
|
||||
*/
|
||||
export function buildAlbumManifest({ album, tracks, createdAtMs }) {
|
||||
const rows = (tracks ?? [])
|
||||
.map((t, i) => ({ t, i }))
|
||||
.sort((a, b) =>
|
||||
(a.t.disk_number ?? 1) - (b.t.disk_number ?? 1) ||
|
||||
(a.t.track_no ?? 0) - (b.t.track_no ?? 0) ||
|
||||
a.i - b.i
|
||||
);
|
||||
const inherit = {
|
||||
artist: album?.artist,
|
||||
album: album?.title,
|
||||
albumId: album?.id,
|
||||
artwork: album?.cover,
|
||||
};
|
||||
return makeManifest({
|
||||
context: makeContext({
|
||||
kind: "album",
|
||||
contextKey: `album:${album?.id ?? "unknown"}`,
|
||||
title: album?.title ?? "",
|
||||
subtitle: album?.artist,
|
||||
artwork: album?.cover,
|
||||
sourceTrackCount: (tracks ?? []).length,
|
||||
}),
|
||||
items: rows.map(({ t }, ordinal) => itemFromCatalogTrack(t, ordinal, inherit)),
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic fixture adapter for tests and future surfaces (playlists keep
|
||||
* duplicates: occurrences get distinct sessionKeys, shared recordingKey).
|
||||
*/
|
||||
export function buildFixtureManifest({ kind = "collection", contextKey = "fixture", title = "Fixture", rows, createdAtMs }) {
|
||||
const items = (rows ?? []).map((r, ordinal) =>
|
||||
makeItem({
|
||||
sessionKey: `s${ordinal}-${r.provider ?? "deezer"}${r.providerTrackId}`,
|
||||
sourceOrdinal: ordinal,
|
||||
recordingKey: `${r.provider ?? "deezer"}:${r.providerTrackId}`,
|
||||
title: r.title ?? "",
|
||||
artists: r.artists ?? [],
|
||||
album: r.album,
|
||||
albumId: r.albumId,
|
||||
artwork: r.artwork,
|
||||
fullDurationMs: r.fullDurationMs,
|
||||
explicit: r.explicit,
|
||||
sources: [
|
||||
makeSourceRef({
|
||||
provider: r.provider ?? "deezer",
|
||||
providerTrackId: r.providerTrackId,
|
||||
isrc: r.isrc,
|
||||
matchConfidence: r.matchConfidence ?? "native",
|
||||
}),
|
||||
],
|
||||
initialAvailability: r.initialAvailability ?? AVAILABILITY.AVAILABLE,
|
||||
})
|
||||
);
|
||||
return makeManifest({
|
||||
context: makeContext({ kind, contextKey, title, sourceTrackCount: items.length }),
|
||||
items,
|
||||
createdAtMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// URL redaction and provider host policy for the preview feature.
|
||||
//
|
||||
// Signed preview URLs are ephemeral secrets: they carry an hmac and expire in
|
||||
// minutes (Phase 0: 13–15 min, then 403). They must never appear in logs,
|
||||
// session persistence, or diagnostics. Everything that wants to talk about a
|
||||
// URL goes through redactUrl() and gets host + a stable hash + the parsed
|
||||
// expiry — enough to correlate, nothing to replay.
|
||||
|
||||
/** Providers the preview resolver may fetch media from. HTTPS only; exact
|
||||
* host match — no subdomain wildcards, because the CDN host is a single
|
||||
* stable name (Phase 0: 71/71 samples on cdnt-preview.dzcdn.net). */
|
||||
export const MEDIA_HOST_ALLOWLIST = Object.freeze([
|
||||
"cdnt-preview.dzcdn.net",
|
||||
// Apple preview hosts land here in Phase 2 when Apple identity is
|
||||
// plumbed through the backend; adding a host is a reviewed change.
|
||||
]);
|
||||
|
||||
/** Parse the signed expiry out of a preview URL. Deezer uses an Akamai
|
||||
* `hdnea=exp=<epoch-seconds>~…` token; a bare `exp=` query also matches.
|
||||
* Returns epoch MILLISECONDS or null — never throws. */
|
||||
export function parseSignedExpiryMs(url) {
|
||||
if (typeof url !== "string") return null;
|
||||
const m = /(?:^|[?&~])(?:hdnea=)?exp=(\d{9,11})(?:[~&]|$)/.exec(url);
|
||||
if (!m) return null;
|
||||
return Number(m[1]) * 1000;
|
||||
}
|
||||
|
||||
/** FNV-1a — tiny, stable, dependency-free. Not cryptographic and does not
|
||||
* need to be: the hash only correlates log lines about the same URL. */
|
||||
function fnv1a(str) {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/** The only representation of a media URL allowed outside the transport:
|
||||
* `{ host, hash, expMs }`. */
|
||||
export function redactUrl(url) {
|
||||
if (typeof url !== "string" || url.length === 0) {
|
||||
return { host: null, hash: null, expMs: null };
|
||||
}
|
||||
let host = null;
|
||||
try {
|
||||
host = new URL(url).host;
|
||||
} catch {
|
||||
/* malformed: host stays null, hash still correlates */
|
||||
}
|
||||
return { host, hash: fnv1a(url), expMs: parseSignedExpiryMs(url) };
|
||||
}
|
||||
|
||||
/** True only for an https URL whose host is on the media allowlist. Used for
|
||||
* the initial URL and re-checked for every redirect target — a redirect off
|
||||
* the allowlist is a hard failure, not a follow. */
|
||||
export function isAllowedMediaUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === "https:" && MEDIA_HOST_ALLOWLIST.includes(u.host);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// PreviewResolver contract (design §14) plus the fixture implementation the
|
||||
// modal is built against in Phase 2 before the production resolver exists.
|
||||
//
|
||||
// Contract: resolve(sourceRef, {generation, bypassCache}) → Promise of a
|
||||
// discriminated result:
|
||||
// { ok: true, lease: MediaLease, generation }
|
||||
// { ok: false, failure: <class>, generation, retryable }
|
||||
//
|
||||
// Failure classes mirror what Phase 0 actually observed:
|
||||
// "stale_lease" — 403 from the CDN (signed exp passed). Retryable once
|
||||
// with bypassCache: the API mints a fresh URL per fetch.
|
||||
// "not_provided" — provider answered but has no preview (empty field).
|
||||
// "not_found" — HTTP-200 DataException body (Deezer's error shape).
|
||||
// "network" — timeout / transport error. Retryable with backoff.
|
||||
// "invalid_media" — response wasn't audio/mpeg (e.g. the Akamai HTML
|
||||
// error page) or failed the decoder sniff.
|
||||
// The resolver NEVER loops providers and never retries without a cap —
|
||||
// callers get one bypassCache retry per user intent, then a failure state.
|
||||
|
||||
import { isValidLease, makeNativeLease } from "./lease.js";
|
||||
|
||||
/**
|
||||
* Fixture resolver for tests and Phase 2 UI bring-up. `plan` maps
|
||||
* providerTrackId → either a lease-ish object or {failure, retryable} or a
|
||||
* function(callCount, opts) returning one of those, so tests can script
|
||||
* "fail once, then succeed" or artificial latency deterministically.
|
||||
*/
|
||||
export function createFixtureResolver(plan, { defaultSafeForMs = 300_000 } = {}) {
|
||||
const calls = new Map(); // providerTrackId -> count
|
||||
|
||||
return {
|
||||
async resolve(sourceRef, { generation, bypassCache = false } = {}) {
|
||||
const id = sourceRef.providerTrackId;
|
||||
const n = (calls.get(id) ?? 0) + 1;
|
||||
calls.set(id, n);
|
||||
|
||||
let spec = plan[id];
|
||||
if (typeof spec === "function") spec = spec(n, { bypassCache });
|
||||
if (spec && typeof spec.then === "function") spec = await spec;
|
||||
|
||||
if (!spec) {
|
||||
return { ok: false, failure: "not_found", generation, retryable: false };
|
||||
}
|
||||
if (spec.failure) {
|
||||
return { ok: false, failure: spec.failure, generation, retryable: !!spec.retryable };
|
||||
}
|
||||
const lease = makeNativeLease({
|
||||
source: sourceRef,
|
||||
opaqueLeaseId: spec.opaqueLeaseId ?? `fixture-${id}-${n}`,
|
||||
safeForMs: spec.safeForMs ?? defaultSafeForMs,
|
||||
mimeType: "audio/mpeg",
|
||||
clipDurationMs: spec.clipDurationMs ?? 30_000,
|
||||
byteLength: spec.byteLength ?? 480_000,
|
||||
supportsRanges: true,
|
||||
});
|
||||
if (!isValidLease(lease)) throw new Error("fixture produced invalid lease");
|
||||
return { ok: true, lease, generation };
|
||||
},
|
||||
callCount(id) {
|
||||
return calls.get(id) ?? 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Pure preview-session reducer — design §12 runtime state, §9 navigation
|
||||
// invariants. No timers, no I/O, no Date.now(): every action carries what it
|
||||
// needs, so the whole machine replays deterministically under node:test.
|
||||
//
|
||||
// THE one rule everything else hangs off: any action produced by async work
|
||||
// (resolver, transport) carries {sessionId, generation}, and the reducer
|
||||
// drops it unless BOTH match the current state. A late lease from generation
|
||||
// 3 can never start audio under the cover generation 4 is showing.
|
||||
|
||||
import { AVAILABILITY } from "./contracts.js";
|
||||
|
||||
export const PHASE = Object.freeze({
|
||||
RESOLVING_MANIFEST: "resolving_manifest",
|
||||
READY: "ready",
|
||||
RESOLVING_MEDIA: "resolving_media",
|
||||
BUFFERING: "buffering",
|
||||
PLAYING: "playing",
|
||||
PAUSED: "paused",
|
||||
ENDED: "ended",
|
||||
ITEM_FAILED: "item_failed",
|
||||
EMPTY: "empty",
|
||||
CLOSING: "closing",
|
||||
});
|
||||
|
||||
export function createInitialState(sessionId) {
|
||||
if (!sessionId) throw new Error("sessionId required");
|
||||
return Object.freeze({
|
||||
sessionId,
|
||||
phase: PHASE.RESOLVING_MANIFEST,
|
||||
manifest: null,
|
||||
itemRuntimeByKey: Object.freeze({}),
|
||||
activeItemKey: null,
|
||||
activeManifestIndex: null,
|
||||
requestGeneration: 0,
|
||||
desiredPlaying: false,
|
||||
muted: false,
|
||||
positionMs: 0,
|
||||
clipDurationMs: undefined,
|
||||
focusToken: null,
|
||||
errorClass: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ derivations --
|
||||
|
||||
/** Effective availability: runtime overrides the manifest's initial value. */
|
||||
export function effectiveAvailability(state, item) {
|
||||
return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
||||
}
|
||||
|
||||
/** Manifest indices whose items are currently playable, in manifest order.
|
||||
* Derived every time — never stored, so it can't drift (design §9). */
|
||||
export function playableManifestIndices(state) {
|
||||
if (!state.manifest) return [];
|
||||
const out = [];
|
||||
state.manifest.items.forEach((item, i) => {
|
||||
if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Position of the active item inside the playable list, or null when the
|
||||
* active item is not currently eligible (e.g. it failed and is being shown
|
||||
* in its error state). The active item is identified by sessionKey + index —
|
||||
* never by this cursor (design §9: availability changes recompute the cursor
|
||||
* AROUND the stable item; they never move the item). */
|
||||
export function playableCursor(state) {
|
||||
if (state.activeManifestIndex == null) return null;
|
||||
const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex);
|
||||
return idx === -1 ? null : idx;
|
||||
}
|
||||
|
||||
function itemAt(state, manifestIndex) {
|
||||
return state.manifest?.items[manifestIndex] ?? null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- reducer ----
|
||||
|
||||
function guard(state, action, { needGeneration }) {
|
||||
if (action.sessionId !== state.sessionId) return false;
|
||||
if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands
|
||||
if (needGeneration && action.generation !== state.requestGeneration) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function withRuntime(state, sessionKey, patch) {
|
||||
const prev = state.itemRuntimeByKey[sessionKey] ?? {
|
||||
availability: undefined,
|
||||
mediaPhase: "idle",
|
||||
errorClass: undefined,
|
||||
};
|
||||
return Object.freeze({
|
||||
...state.itemRuntimeByKey,
|
||||
[sessionKey]: Object.freeze({ ...prev, ...patch }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Activate the item at `manifestIndex`, bumping the generation — the
|
||||
* common core of first-pick, step, click and auto-advance. */
|
||||
function activate(state, manifestIndex, { keepDesired }) {
|
||||
const item = itemAt(state, manifestIndex);
|
||||
if (!item) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
phase: PHASE.RESOLVING_MEDIA,
|
||||
activeItemKey: item.sessionKey,
|
||||
activeManifestIndex: manifestIndex,
|
||||
requestGeneration: state.requestGeneration + 1,
|
||||
desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying,
|
||||
positionMs: 0,
|
||||
clipDurationMs: undefined,
|
||||
errorClass: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function reduce(state, action) {
|
||||
switch (action.type) {
|
||||
// ---- session lifecycle ------------------------------------------
|
||||
case "MANIFEST_READY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const next = Object.freeze({ ...state, manifest: action.manifest });
|
||||
const playable = playableManifestIndices(next);
|
||||
if (playable.length === 0) {
|
||||
return Object.freeze({ ...next, phase: PHASE.EMPTY });
|
||||
}
|
||||
// Opening takes audio (decision 2026-07-22): the first playable
|
||||
// item becomes active with listen intent already set; the focus
|
||||
// coordinator upstream decides when sound may actually start.
|
||||
return activate(
|
||||
Object.freeze({ ...next, desiredPlaying: true }),
|
||||
action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex)
|
||||
? action.startAtManifestIndex
|
||||
: playable[0],
|
||||
{ keepDesired: true }
|
||||
);
|
||||
}
|
||||
case "CLOSE": {
|
||||
if (action.sessionId !== state.sessionId) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null });
|
||||
}
|
||||
|
||||
// ---- navigation (user intent, current-generation by definition) --
|
||||
case "STEP": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const playable = playableManifestIndices(state);
|
||||
if (playable.length === 0) return state;
|
||||
const cur = playableCursor(state);
|
||||
let target;
|
||||
if (cur != null) {
|
||||
target = cur + action.dir;
|
||||
} else {
|
||||
// Active item isn't eligible (failed): move to the next
|
||||
// eligible MANIFEST ordinal in the step direction (design §9).
|
||||
const from = state.activeManifestIndex ?? -1;
|
||||
target =
|
||||
action.dir > 0
|
||||
? playable.findIndex((i) => i > from)
|
||||
: (() => {
|
||||
let last = -1;
|
||||
playable.forEach((i, k) => {
|
||||
if (i < from) last = k;
|
||||
});
|
||||
return last;
|
||||
})();
|
||||
}
|
||||
if (target < 0 || target >= playable.length) return state; // no wrap
|
||||
return activate(state, playable[target], { keepDesired: true });
|
||||
}
|
||||
case "ACTIVATE_KEY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1;
|
||||
if (idx === -1) return state;
|
||||
const item = state.manifest.items[idx];
|
||||
if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state;
|
||||
if (idx === state.activeManifestIndex) return state;
|
||||
return activate(state, idx, { keepDesired: true });
|
||||
}
|
||||
case "REPLAY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
if (state.activeManifestIndex == null) return state;
|
||||
return Object.freeze({
|
||||
...activate(state, state.activeManifestIndex, { keepDesired: true }),
|
||||
desiredPlaying: true,
|
||||
});
|
||||
}
|
||||
case "SET_DESIRED_PLAYING": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, desiredPlaying: !!action.value });
|
||||
}
|
||||
case "SET_MUTED": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, muted: !!action.value });
|
||||
}
|
||||
case "FOCUS_SET": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
return Object.freeze({ ...state, focusToken: action.token ?? null });
|
||||
}
|
||||
|
||||
// ---- async completions (generation-gated) ------------------------
|
||||
case "RESOLVE_STARTED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
if (action.sessionKey !== state.activeItemKey) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }),
|
||||
});
|
||||
}
|
||||
case "LEASE_READY": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
if (action.sessionKey !== state.activeItemKey) return state;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY,
|
||||
clipDurationMs: action.clipDurationMs ?? state.clipDurationMs,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }),
|
||||
});
|
||||
}
|
||||
case "MEDIA_PLAYING": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.PLAYING });
|
||||
}
|
||||
case "MEDIA_PAUSED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, phase: PHASE.PAUSED });
|
||||
}
|
||||
case "MEDIA_POSITION": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
return Object.freeze({ ...state, positionMs: action.positionMs });
|
||||
}
|
||||
case "MEDIA_ENDED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
// Auto-advance (decision 2026-07-22): roll into the next playable
|
||||
// item under the SAME generation rules — activate() bumps, so any
|
||||
// straggler event from the finished clip is stale by construction.
|
||||
const playable = playableManifestIndices(state);
|
||||
const cur = playableCursor(state);
|
||||
if (cur != null && cur + 1 < playable.length) {
|
||||
return activate(state, playable[cur + 1], { keepDesired: true });
|
||||
}
|
||||
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
|
||||
}
|
||||
case "ITEM_FAILED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
const runtime = withRuntime(state, action.sessionKey, {
|
||||
availability: action.availability ?? AVAILABILITY.TEMP_FAILED,
|
||||
mediaPhase: "failed",
|
||||
errorClass: action.errorClass,
|
||||
});
|
||||
const isActive = action.sessionKey === state.activeItemKey;
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: runtime,
|
||||
// A failed ACTIVE item stays visible in its error state
|
||||
// (design §9) — no auto-skip, the user retries or steps.
|
||||
phase: isActive ? PHASE.ITEM_FAILED : state.phase,
|
||||
errorClass: isActive ? action.errorClass : state.errorClass,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- runtime availability (metadata, NOT generation-gated) -------
|
||||
case "AVAILABILITY_CHANGED": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
// Only itemRuntimeByKey changes: a newly available EARLIER item
|
||||
// must never move the current card (design §9) — and it can't,
|
||||
// because the active item is keyed, not cursor-positioned.
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: withRuntime(state, action.sessionKey, {
|
||||
availability: action.availability,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Preview transport interface — the seam between the pure session core and
|
||||
// whatever actually makes sound. Phase 0 ratified the NATIVE transport; this
|
||||
// file pins the contract and provides the deterministic fake that Phase 2
|
||||
// builds the modal against before the native backend lands.
|
||||
//
|
||||
// Contract (all methods take {sessionId, generation} and the transport
|
||||
// stamps them onto every event it emits — the reducer's guard does the rest):
|
||||
//
|
||||
// load(lease, ctx) accept a MediaLease; emit LEASE-side effects only
|
||||
// play(ctx) request audibility (focus must already be held)
|
||||
// pause(ctx) park; position is retained
|
||||
// stop(ctx) discard media; a later play needs a fresh load
|
||||
// dispose() teardown at session close — must be idempotent
|
||||
//
|
||||
// Events are dispatched as reducer actions: MEDIA_PLAYING, MEDIA_PAUSED,
|
||||
// MEDIA_POSITION, MEDIA_ENDED, ITEM_FAILED. A transport NEVER dispatches
|
||||
// with a generation other than the one the command carried — stale
|
||||
// suppression is the reducer's job, honest stamping is the transport's.
|
||||
|
||||
import { isValidLease } from "./lease.js";
|
||||
|
||||
/**
|
||||
* Deterministic fake: no timers — the test (or storybook harness) calls
|
||||
* `tick(ms)` to advance playback. Emits exactly what the native transport
|
||||
* will emit, so swapping it out changes no session code.
|
||||
*/
|
||||
export function createFixtureTransport(dispatch) {
|
||||
let current = null; // { lease, sessionId, generation, sessionKey, positionMs, playing }
|
||||
let disposed = false;
|
||||
|
||||
const emit = (type, extra = {}) => {
|
||||
if (!current) return;
|
||||
dispatch({
|
||||
type,
|
||||
sessionId: current.sessionId,
|
||||
generation: current.generation,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
load(lease, { sessionId, generation, sessionKey }) {
|
||||
if (disposed) throw new Error("transport disposed");
|
||||
if (!isValidLease(lease)) {
|
||||
current = { sessionId, generation, sessionKey, positionMs: 0, playing: false };
|
||||
emit("ITEM_FAILED", { sessionKey, errorClass: "invalid_media" });
|
||||
current = null;
|
||||
return;
|
||||
}
|
||||
current = { lease, sessionId, generation, sessionKey, positionMs: 0, playing: false };
|
||||
},
|
||||
play() {
|
||||
if (!current) return;
|
||||
current.playing = true;
|
||||
emit("MEDIA_PLAYING");
|
||||
},
|
||||
pause() {
|
||||
if (!current) return;
|
||||
current.playing = false;
|
||||
emit("MEDIA_PAUSED");
|
||||
},
|
||||
stop() {
|
||||
current = null;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
current = null;
|
||||
},
|
||||
/** Advance fake playback; emits POSITION then ENDED at the clip end. */
|
||||
tick(ms) {
|
||||
if (!current || !current.playing) return;
|
||||
const clip = current.lease?.clipDurationMs ?? 30_000;
|
||||
current.positionMs = Math.min(current.positionMs + ms, clip);
|
||||
emit("MEDIA_POSITION", { positionMs: current.positionMs });
|
||||
if (current.positionMs >= clip) {
|
||||
current.playing = false;
|
||||
emit("MEDIA_ENDED");
|
||||
}
|
||||
},
|
||||
_debugState: () => (current ? { ...current } : null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createFocusCoordinator, createRevisionGateway } from "../../src/lib/preview/focus.js";
|
||||
import { createFixtureResolver } from "../../src/lib/preview/resolver.js";
|
||||
|
||||
function harness({ audible = true, ackGeneration, ackApplied = true, ackDelayMs = 0, ackHangs = false } = {}) {
|
||||
const gateway = createRevisionGateway();
|
||||
const resumed = [];
|
||||
const status = {
|
||||
status: audible ? "playing" : "idle",
|
||||
trackKey: "track-A",
|
||||
positionMs: 102_000,
|
||||
audible,
|
||||
playbackGeneration: 7,
|
||||
};
|
||||
const coord = createFocusCoordinator({
|
||||
mainStatus: () => ({ ...status }),
|
||||
requestSilence: (gen) =>
|
||||
ackHangs
|
||||
? new Promise(() => {})
|
||||
: new Promise((res) =>
|
||||
setTimeout(() => res({ generation: ackGeneration ?? gen, applied: ackApplied }), ackDelayMs)
|
||||
),
|
||||
resumeMain: (snapshot, opts) => resumed.push({ snapshot, opts }),
|
||||
gateway,
|
||||
ackTimeoutMs: 50,
|
||||
});
|
||||
return { coord, gateway, resumed, status };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ acquisition --
|
||||
|
||||
test("main idle: focus granted without any silence negotiation, nothing to restore", async () => {
|
||||
const { coord, resumed } = harness({ audible: false });
|
||||
const { granted, token } = await coord.acquirePreview();
|
||||
assert.ok(granted);
|
||||
assert.equal(token.pausedByPreview, false);
|
||||
const rel = coord.release(token);
|
||||
assert.equal(rel.restored, false);
|
||||
assert.equal(rel.reason, "main_was_not_audible");
|
||||
assert.equal(resumed.length, 0);
|
||||
});
|
||||
|
||||
test("main audible: granted only through a generation-bound acknowledgement", async () => {
|
||||
const { coord } = harness({ audible: true });
|
||||
const { granted, token } = await coord.acquirePreview();
|
||||
assert.ok(granted);
|
||||
assert.equal(token.pausedByPreview, true);
|
||||
assert.equal(token.snapshot.trackKey, "track-A");
|
||||
assert.equal(token.snapshot.positionMs, 102_000);
|
||||
});
|
||||
|
||||
test("stale/wrong-generation ack leaves preview parked", async () => {
|
||||
const { coord } = harness({ audible: true, ackGeneration: 3 }); // main is at 7
|
||||
const { granted, token } = await coord.acquirePreview();
|
||||
assert.equal(granted, false);
|
||||
assert.equal(token, null);
|
||||
});
|
||||
|
||||
test("ack that never arrives (timeout) leaves preview parked — renderer status is not proof", async () => {
|
||||
const { coord } = harness({ audible: true, ackHangs: true });
|
||||
const { granted } = await coord.acquirePreview();
|
||||
assert.equal(granted, false);
|
||||
});
|
||||
|
||||
test("ack applied=false is not silence", async () => {
|
||||
const { coord } = harness({ audible: true, ackApplied: false });
|
||||
const { granted } = await coord.acquirePreview();
|
||||
assert.equal(granted, false);
|
||||
});
|
||||
|
||||
test("user command DURING negotiation invalidates the grant", async () => {
|
||||
const { coord, gateway } = harness({ audible: true, ackDelayMs: 20 });
|
||||
const p = coord.acquirePreview();
|
||||
gateway.userSeekOrScratch(); // user scrubbed while we negotiated
|
||||
const { granted } = await p;
|
||||
assert.equal(granted, false);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------- release ----
|
||||
|
||||
test("clean release restores main at the snapshot, tagged focus-internal", async () => {
|
||||
const { coord, gateway, resumed } = harness({ audible: true });
|
||||
const { token } = await coord.acquirePreview();
|
||||
const rel = coord.release(token);
|
||||
assert.ok(rel.restored);
|
||||
assert.equal(resumed.length, 1);
|
||||
assert.equal(resumed[0].snapshot.trackKey, "track-A");
|
||||
assert.equal(resumed[0].snapshot.positionMs, 102_000);
|
||||
assert.equal(resumed[0].opts.focusInternal, true);
|
||||
// and the focus-internal resume did not invalidate anything
|
||||
assert.equal(gateway.changedSince(token.revisions), false);
|
||||
});
|
||||
|
||||
for (const [name, bump] of [
|
||||
["user pause/resume", (g) => g.userPauseResume()],
|
||||
["user starts another track", (g) => g.userPlay()],
|
||||
["queue jump / next / previous", (g) => g.queueJumpOrNextPrev()],
|
||||
["queue mutation", (g) => g.queueMutation()],
|
||||
["seek/scratch", (g) => g.userSeekOrScratch()],
|
||||
["stop", (g) => g.userStop()],
|
||||
["auto-advance", (g) => g.autoAdvance()],
|
||||
["native source change", (g) => g.nativeSourceChange()],
|
||||
]) {
|
||||
test(`no restoration after ${name}`, async () => {
|
||||
const { coord, gateway, resumed } = harness({ audible: true });
|
||||
const { token } = await coord.acquirePreview();
|
||||
bump(gateway);
|
||||
const rel = coord.release(token);
|
||||
assert.equal(rel.restored, false);
|
||||
assert.equal(rel.reason, "revisions_changed");
|
||||
assert.equal(resumed.length, 0);
|
||||
});
|
||||
}
|
||||
|
||||
test("focus-internal commands do not invalidate their own token", async () => {
|
||||
const { coord, gateway } = harness({ audible: true });
|
||||
const { token } = await coord.acquirePreview();
|
||||
gateway.userPauseResume({ focusInternal: true }); // the coordinator's own pause
|
||||
gateway.userPlay({ focusInternal: true });
|
||||
assert.equal(gateway.changedSince(token.revisions), false);
|
||||
assert.ok(coord.release(token).restored);
|
||||
});
|
||||
|
||||
test("play-full handoff never restores the old track", async () => {
|
||||
const { coord, resumed } = harness({ audible: true });
|
||||
await coord.acquirePreview();
|
||||
const h = coord.handoffToMain();
|
||||
assert.equal(h.restored, false);
|
||||
assert.equal(h.reason, "handoff");
|
||||
assert.equal(resumed.length, 0);
|
||||
assert.equal(coord.owner(), "main");
|
||||
});
|
||||
|
||||
test("a stale token cannot release anything", async () => {
|
||||
const { coord } = harness({ audible: true });
|
||||
const { token } = await coord.acquirePreview();
|
||||
coord.release(token);
|
||||
const again = coord.release(token);
|
||||
assert.equal(again.restored, false);
|
||||
assert.equal(again.reason, "stale_token");
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- resolver ----
|
||||
|
||||
test("fixture resolver: scripted fail-once-then-succeed with bypassCache", async () => {
|
||||
const src = { provider: "deezer", providerTrackId: "42", matchConfidence: "native" };
|
||||
const resolver = createFixtureResolver({
|
||||
42: (n) => (n === 1 ? { failure: "stale_lease", retryable: true } : { opaqueLeaseId: "op-fresh" }),
|
||||
});
|
||||
const first = await resolver.resolve(src, { generation: 5 });
|
||||
assert.equal(first.ok, false);
|
||||
assert.equal(first.failure, "stale_lease");
|
||||
assert.ok(first.retryable);
|
||||
const second = await resolver.resolve(src, { generation: 5, bypassCache: true });
|
||||
assert.ok(second.ok);
|
||||
assert.equal(second.lease.transport, "native");
|
||||
assert.equal(second.lease.opaqueLeaseId, "op-fresh");
|
||||
assert.equal(resolver.callCount("42"), 2);
|
||||
});
|
||||
|
||||
test("fixture resolver: unknown id classifies as not_found, not an exception", async () => {
|
||||
const resolver = createFixtureResolver({});
|
||||
const r = await resolver.resolve({ provider: "deezer", providerTrackId: "999", matchConfidence: "native" }, { generation: 1 });
|
||||
assert.equal(r.ok, false);
|
||||
assert.equal(r.failure, "not_found");
|
||||
assert.equal(r.retryable, false);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
computeSafeForMs,
|
||||
makeNativeLease,
|
||||
makeWebviewLease,
|
||||
isValidLease,
|
||||
LEASE_FALLBACK_SAFE_MS,
|
||||
LEASE_SAFETY_MARGIN_MS,
|
||||
LEASE_MAX_SAFE_MS,
|
||||
} from "../../src/lib/preview/lease.js";
|
||||
import { redactUrl, parseSignedExpiryMs, isAllowedMediaUrl } from "../../src/lib/preview/redact.js";
|
||||
|
||||
const SRC = Object.freeze({ provider: "deezer", providerTrackId: "1", matchConfidence: "native" });
|
||||
const NOW = 1_784_760_000_000;
|
||||
const MIN = 60_000;
|
||||
|
||||
// ---------------------------------------------------------- safeForMs -----
|
||||
|
||||
test("valid exp + Date: provider-clock lifetime minus margin", () => {
|
||||
const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: NOW, localNowMs: NOW });
|
||||
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
|
||||
});
|
||||
|
||||
test("local clock AHEAD of provider: the shorter local view wins", () => {
|
||||
const safe = computeSafeForMs({
|
||||
expEpochMs: NOW + 14 * MIN,
|
||||
dateHeaderMs: NOW,
|
||||
localNowMs: NOW + 10 * MIN, // local thinks only 4 min remain
|
||||
});
|
||||
assert.equal(safe, 4 * MIN - LEASE_SAFETY_MARGIN_MS);
|
||||
});
|
||||
|
||||
test("local clock BEHIND provider: local view cannot extend the lease", () => {
|
||||
const safe = computeSafeForMs({
|
||||
expEpochMs: NOW + 14 * MIN,
|
||||
dateHeaderMs: NOW,
|
||||
localNowMs: NOW - 60 * MIN, // local view claims 74 min
|
||||
});
|
||||
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
|
||||
});
|
||||
|
||||
test("missing exp → short capped fallback", () => {
|
||||
assert.equal(computeSafeForMs({ expEpochMs: null, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS);
|
||||
});
|
||||
|
||||
test("malformed exp → fallback", () => {
|
||||
assert.equal(computeSafeForMs({ expEpochMs: NaN, dateHeaderMs: NOW, localNowMs: NOW }), LEASE_FALLBACK_SAFE_MS);
|
||||
});
|
||||
|
||||
test("contradictory: exp before Date → fallback", () => {
|
||||
assert.equal(
|
||||
computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: NOW, localNowMs: NOW }),
|
||||
LEASE_FALLBACK_SAFE_MS
|
||||
);
|
||||
});
|
||||
|
||||
test("contradictory: absurd multi-hour lifetime → fallback", () => {
|
||||
assert.equal(
|
||||
computeSafeForMs({ expEpochMs: NOW + 6 * 60 * MIN, dateHeaderMs: NOW, localNowMs: NOW }),
|
||||
LEASE_FALLBACK_SAFE_MS
|
||||
);
|
||||
});
|
||||
|
||||
test("missing Date but sane local view → local view minus margin, capped", () => {
|
||||
const safe = computeSafeForMs({ expEpochMs: NOW + 14 * MIN, dateHeaderMs: null, localNowMs: NOW });
|
||||
assert.equal(safe, 14 * MIN - LEASE_SAFETY_MARGIN_MS);
|
||||
const capped = computeSafeForMs({ expEpochMs: NOW + 90 * MIN, dateHeaderMs: null, localNowMs: NOW });
|
||||
assert.equal(capped, LEASE_MAX_SAFE_MS - LEASE_SAFETY_MARGIN_MS);
|
||||
});
|
||||
|
||||
test("already expired with no Date → fallback (refresh immediately-ish, never negative)", () => {
|
||||
assert.equal(
|
||||
computeSafeForMs({ expEpochMs: NOW - MIN, dateHeaderMs: null, localNowMs: NOW }),
|
||||
LEASE_FALLBACK_SAFE_MS
|
||||
);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ union validity ----
|
||||
|
||||
test("discriminated union: native carries exactly an opaque id", () => {
|
||||
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 1000 });
|
||||
assert.ok(isValidLease(lease));
|
||||
assert.equal(lease.url, undefined);
|
||||
assert.ok(!isValidLease({ ...lease, url: "https://x" }));
|
||||
});
|
||||
|
||||
test("discriminated union: webview carries exactly a url", () => {
|
||||
const lease = makeWebviewLease({ source: SRC, url: "https://cdnt-preview.dzcdn.net/a.mp3", safeForMs: 1000 });
|
||||
assert.ok(isValidLease(lease));
|
||||
assert.equal(lease.opaqueLeaseId, undefined);
|
||||
assert.ok(!isValidLease({ ...lease, opaqueLeaseId: "x" }));
|
||||
});
|
||||
|
||||
test("constructors refuse the wrong locator", () => {
|
||||
assert.throws(() => makeNativeLease({ source: SRC, safeForMs: 1 }));
|
||||
assert.throws(() => makeWebviewLease({ source: SRC, safeForMs: 1 }));
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ redaction ---
|
||||
|
||||
test("redactUrl yields host + hash + exp, never the url", () => {
|
||||
const url = "https://cdnt-preview.dzcdn.net/api/x.mp3?hdnea=exp=1784761537~acl=/x*~hmac=deadbeef";
|
||||
const r = redactUrl(url);
|
||||
assert.equal(r.host, "cdnt-preview.dzcdn.net");
|
||||
assert.equal(r.expMs, 1784761537000);
|
||||
assert.match(r.hash, /^[0-9a-f]{8}$/);
|
||||
assert.ok(!JSON.stringify(r).includes("hmac"));
|
||||
});
|
||||
|
||||
test("parseSignedExpiryMs handles bare exp params and garbage", () => {
|
||||
assert.equal(parseSignedExpiryMs("https://h/x?exp=1784761537"), 1784761537000);
|
||||
assert.equal(parseSignedExpiryMs("https://h/x"), null);
|
||||
assert.equal(parseSignedExpiryMs(null), null);
|
||||
});
|
||||
|
||||
test("host policy: https + exact allowlist, redirects re-checked by the same gate", () => {
|
||||
assert.ok(isAllowedMediaUrl("https://cdnt-preview.dzcdn.net/a.mp3"));
|
||||
assert.ok(!isAllowedMediaUrl("http://cdnt-preview.dzcdn.net/a.mp3")); // scheme
|
||||
assert.ok(!isAllowedMediaUrl("https://evil.dzcdn.net/a.mp3")); // host
|
||||
assert.ok(!isAllowedMediaUrl("https://cdnt-preview.dzcdn.net.evil.com/a.mp3")); // suffix trick
|
||||
assert.ok(!isAllowedMediaUrl("not a url"));
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AVAILABILITY, isValidManifest } from "../../src/lib/preview/contracts.js";
|
||||
import { buildArtistManifest, buildAlbumManifest, buildFixtureManifest } from "../../src/lib/preview/manifest.js";
|
||||
|
||||
const T0 = 1_700_000_000_000;
|
||||
|
||||
const catalogTrack = (id, over = {}) => ({
|
||||
id,
|
||||
title: `Track ${id}`,
|
||||
artist: "Röyksopp",
|
||||
album: "Melody A.M.",
|
||||
album_id: 9,
|
||||
cover: `cover-${id}`,
|
||||
duration_secs: 219,
|
||||
preview: `https://cdnt-preview.dzcdn.net/x-${id}.mp3?hdnea=exp=1784761537~hmac=aa`,
|
||||
track_no: 1,
|
||||
disk_number: 1,
|
||||
unreleased: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
test("artist manifest: rank order kept, exact provider-id dupes collapse", () => {
|
||||
const m = buildArtistManifest({
|
||||
artist: { id: 480, name: "Röyksopp", picture: "pic" },
|
||||
popularTracks: [catalogTrack(1), catalogTrack(2), catalogTrack(1), catalogTrack(3)],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
assert.equal(m.items.length, 3); // dupe of id 1 collapsed
|
||||
assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["1", "2", "3"]);
|
||||
assert.deepEqual(m.items.map((i) => i.sourceOrdinal), [0, 1, 2]);
|
||||
assert.equal(m.context.kind, "artist");
|
||||
assert.equal(m.context.sourceTrackCount, 4); // source count is truthful
|
||||
assert.ok(isValidManifest(m));
|
||||
});
|
||||
|
||||
test("artist manifest: same title, different provider ids do NOT collapse", () => {
|
||||
const m = buildArtistManifest({
|
||||
artist: { id: 480, name: "Röyksopp" },
|
||||
popularTracks: [catalogTrack(1, { title: "Same" }), catalogTrack(2, { title: "Same" })],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
assert.equal(m.items.length, 2);
|
||||
});
|
||||
|
||||
test("availability from catalog rows: preview/empty/unreleased", () => {
|
||||
const m = buildArtistManifest({
|
||||
artist: { id: 1, name: "A" },
|
||||
popularTracks: [
|
||||
catalogTrack(1),
|
||||
catalogTrack(2, { preview: "" }),
|
||||
catalogTrack(3, { preview: undefined }),
|
||||
catalogTrack(4, { unreleased: true }),
|
||||
],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
assert.deepEqual(
|
||||
m.items.map((i) => i.initialAvailability),
|
||||
[AVAILABILITY.AVAILABLE, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.NOT_PROVIDED, AVAILABILITY.UNRELEASED]
|
||||
);
|
||||
// unreleased rows lose their mutating actions
|
||||
assert.equal(m.items[3].actions.playFull, false);
|
||||
assert.equal(m.items[3].actions.save, false);
|
||||
});
|
||||
|
||||
test("no signed URL survives into the manifest", () => {
|
||||
const m = buildArtistManifest({
|
||||
artist: { id: 1, name: "A" },
|
||||
popularTracks: [catalogTrack(1)],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
const json = JSON.stringify(m);
|
||||
assert.ok(!json.includes("hdnea"), "signed URL leaked into manifest");
|
||||
assert.ok(!json.includes("cdnt-preview"), "media host leaked into manifest");
|
||||
});
|
||||
|
||||
test("album manifest: disc/track sort with stable fallback + context inheritance", () => {
|
||||
const m = buildAlbumManifest({
|
||||
album: { id: 77, title: "The Wall", artist: "Pink Floyd", cover: "wall.jpg" },
|
||||
tracks: [
|
||||
catalogTrack(21, { disk_number: 2, track_no: 1, album: "", album_id: null, cover: null }),
|
||||
catalogTrack(11, { disk_number: 1, track_no: 1 }),
|
||||
catalogTrack(12, { disk_number: 1, track_no: 2 }),
|
||||
catalogTrack(13, { disk_number: null, track_no: null }), // sorts by response order within disc 1
|
||||
],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
assert.deepEqual(m.items.map((i) => i.sources[0].providerTrackId), ["13", "11", "12", "21"]);
|
||||
const disc2 = m.items[3];
|
||||
assert.equal(disc2.album, "The Wall"); // inherited
|
||||
assert.equal(disc2.albumId, "77");
|
||||
assert.equal(disc2.artwork, "wall.jpg");
|
||||
assert.equal(m.context.sourceTrackCount, 4);
|
||||
});
|
||||
|
||||
test("fixture manifest: playlist duplicates keep distinct sessionKeys, shared recordingKey", () => {
|
||||
const m = buildFixtureManifest({
|
||||
kind: "playlist",
|
||||
rows: [
|
||||
{ providerTrackId: "9", title: "Song" },
|
||||
{ providerTrackId: "8", title: "Other" },
|
||||
{ providerTrackId: "9", title: "Song" }, // duplicate occurrence
|
||||
],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
assert.equal(m.items.length, 3);
|
||||
assert.notEqual(m.items[0].sessionKey, m.items[2].sessionKey);
|
||||
assert.equal(m.items[0].recordingKey, m.items[2].recordingKey);
|
||||
assert.ok(isValidManifest(m));
|
||||
});
|
||||
|
||||
test("fixture manifest: apple identity is a first-class source ref", () => {
|
||||
const m = buildFixtureManifest({
|
||||
rows: [{ provider: "apple", providerTrackId: "ap-123", isrc: "USX9P1234567", matchConfidence: "isrc", title: "X" }],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
const src = m.items[0].sources[0];
|
||||
assert.equal(src.provider, "apple");
|
||||
assert.equal(src.providerTrackId, "ap-123");
|
||||
assert.equal(src.matchConfidence, "isrc");
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AVAILABILITY } from "../../src/lib/preview/contracts.js";
|
||||
import { buildFixtureManifest } from "../../src/lib/preview/manifest.js";
|
||||
import {
|
||||
createInitialState,
|
||||
reduce,
|
||||
PHASE,
|
||||
playableManifestIndices,
|
||||
playableCursor,
|
||||
} from "../../src/lib/preview/session.js";
|
||||
|
||||
const T0 = 1_700_000_000_000;
|
||||
const SID = "sess-1";
|
||||
|
||||
/** rows: a=available, x=not provided — e.g. "aaxa" */
|
||||
function manifestOf(pattern) {
|
||||
return buildFixtureManifest({
|
||||
rows: [...pattern].map((c, i) => ({
|
||||
providerTrackId: String(i + 1),
|
||||
title: `T${i + 1}`,
|
||||
initialAvailability: c === "a" ? AVAILABILITY.AVAILABLE : AVAILABILITY.NOT_PROVIDED,
|
||||
})),
|
||||
createdAtMs: T0,
|
||||
});
|
||||
}
|
||||
|
||||
function opened(pattern) {
|
||||
const s0 = createInitialState(SID);
|
||||
return reduce(s0, { type: "MANIFEST_READY", sessionId: SID, manifest: manifestOf(pattern) });
|
||||
}
|
||||
|
||||
const key = (state, i) => state.manifest.items[i].sessionKey;
|
||||
|
||||
// ------------------------------------------------------------ lifecycle ---
|
||||
|
||||
test("manifest ready: first playable becomes active with listen intent, generation bumps", () => {
|
||||
const s = opened("xaa");
|
||||
assert.equal(s.phase, PHASE.RESOLVING_MEDIA);
|
||||
assert.equal(s.activeManifestIndex, 1); // index 0 is not playable
|
||||
assert.equal(s.requestGeneration, 1);
|
||||
assert.equal(s.desiredPlaying, true); // take-audio-on-open decision
|
||||
});
|
||||
|
||||
test("empty manifest → empty phase, no active item", () => {
|
||||
const s = opened("xx");
|
||||
assert.equal(s.phase, PHASE.EMPTY);
|
||||
assert.equal(s.activeItemKey, null);
|
||||
});
|
||||
|
||||
test("close is terminal: nothing lands afterwards", () => {
|
||||
let s = opened("aa");
|
||||
s = reduce(s, { type: "CLOSE", sessionId: SID });
|
||||
assert.equal(s.phase, PHASE.CLOSING);
|
||||
const after = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: s.requestGeneration });
|
||||
assert.equal(after.phase, PHASE.CLOSING);
|
||||
const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(stepped.activeManifestIndex, s.activeManifestIndex);
|
||||
});
|
||||
|
||||
test("session replacement: actions from the old session id are ignored", () => {
|
||||
const old = opened("aa");
|
||||
const fresh = reduce(createInitialState("sess-2"), {
|
||||
type: "MANIFEST_READY",
|
||||
sessionId: "sess-2",
|
||||
manifest: manifestOf("aa"),
|
||||
});
|
||||
const poked = reduce(fresh, { type: "STEP", sessionId: SID, dir: 1 }); // old session id
|
||||
assert.equal(poked, fresh);
|
||||
assert.notEqual(old.sessionId, fresh.sessionId);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------ navigation --
|
||||
|
||||
test("step skips unavailable ordinals and never wraps", () => {
|
||||
let s = opened("axa"); // playable indices [0, 2]
|
||||
assert.equal(s.activeManifestIndex, 0);
|
||||
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(s.activeManifestIndex, 2); // skipped 1
|
||||
const end = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(end.activeManifestIndex, 2); // no wrap
|
||||
const back = reduce(s, { type: "STEP", sessionId: SID, dir: -1 });
|
||||
assert.equal(back.activeManifestIndex, 0);
|
||||
const start = reduce(back, { type: "STEP", sessionId: SID, dir: -1 });
|
||||
assert.equal(start.activeManifestIndex, 0); // no wrap at the front either
|
||||
});
|
||||
|
||||
test("every activation bumps the generation", () => {
|
||||
let s = opened("aaa");
|
||||
const g1 = s.requestGeneration;
|
||||
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(s.requestGeneration, g1 + 1);
|
||||
s = reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) });
|
||||
assert.equal(s.requestGeneration, g1 + 2);
|
||||
});
|
||||
|
||||
test("activating an unavailable or already-active card is refused", () => {
|
||||
const s = opened("ax");
|
||||
assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 1) }), s);
|
||||
assert.equal(reduce(s, { type: "ACTIVATE_KEY", sessionId: SID, sessionKey: key(s, 0) }), s);
|
||||
});
|
||||
|
||||
// -------------------------------------------------- ordinal/cursor maths --
|
||||
|
||||
test("playable indices and cursor reflect manifest order and availability", () => {
|
||||
const s = opened("axaxa");
|
||||
assert.deepEqual(playableManifestIndices(s), [0, 2, 4]);
|
||||
assert.equal(playableCursor(s), 0);
|
||||
const at4 = reduce(reduce(s, { type: "STEP", sessionId: SID, dir: 1 }), { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(at4.activeManifestIndex, 4);
|
||||
assert.equal(playableCursor(at4), 2);
|
||||
});
|
||||
|
||||
test("a newly available EARLIER item recomputes the cursor but never moves the active card", () => {
|
||||
let s = opened("xaa");
|
||||
assert.equal(s.activeManifestIndex, 1);
|
||||
assert.equal(playableCursor(s), 0);
|
||||
s = reduce(s, {
|
||||
type: "AVAILABILITY_CHANGED",
|
||||
sessionId: SID,
|
||||
sessionKey: key(s, 0),
|
||||
availability: AVAILABILITY.AVAILABLE,
|
||||
});
|
||||
assert.equal(s.activeManifestIndex, 1); // card did not move
|
||||
assert.equal(s.activeItemKey, key(s, 1));
|
||||
assert.equal(playableCursor(s), 1); // cursor recomputed around it
|
||||
assert.deepEqual(playableManifestIndices(s), [0, 1, 2]);
|
||||
});
|
||||
|
||||
test("a failed ACTIVE item stays visible in its error state; stepping leaves it", () => {
|
||||
let s = opened("aaa");
|
||||
const activeKey = s.activeItemKey;
|
||||
s = reduce(s, {
|
||||
type: "ITEM_FAILED",
|
||||
sessionId: SID,
|
||||
generation: s.requestGeneration,
|
||||
sessionKey: activeKey,
|
||||
errorClass: "stale_lease",
|
||||
});
|
||||
assert.equal(s.phase, PHASE.ITEM_FAILED);
|
||||
assert.equal(s.activeItemKey, activeKey); // still the visible card
|
||||
assert.equal(playableCursor(s), null); // not currently eligible
|
||||
// stepping forward finds the next eligible MANIFEST ordinal
|
||||
const stepped = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
assert.equal(stepped.activeManifestIndex, 1);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------ stale rejection ---
|
||||
|
||||
test("a lease from an older generation cannot touch the newer item", () => {
|
||||
let s = opened("aaa");
|
||||
const staleGen = s.requestGeneration;
|
||||
const staleKey = s.activeItemKey;
|
||||
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 }); // gen bumped
|
||||
const poked = reduce(s, {
|
||||
type: "LEASE_READY",
|
||||
sessionId: SID,
|
||||
generation: staleGen,
|
||||
sessionKey: staleKey,
|
||||
clipDurationMs: 30_000,
|
||||
});
|
||||
assert.equal(poked, s); // dropped entirely
|
||||
});
|
||||
|
||||
test("a stale MEDIA_PLAYING cannot flip the phase", () => {
|
||||
let s = opened("aa");
|
||||
const staleGen = s.requestGeneration;
|
||||
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
const poked = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: staleGen });
|
||||
assert.equal(poked.phase, s.phase);
|
||||
});
|
||||
|
||||
test("current-generation media flow: lease → buffering → playing → position", () => {
|
||||
let s = opened("aa");
|
||||
const g = s.requestGeneration;
|
||||
s = reduce(s, { type: "LEASE_READY", sessionId: SID, generation: g, sessionKey: s.activeItemKey, clipDurationMs: 30_000 });
|
||||
assert.equal(s.phase, PHASE.BUFFERING); // desiredPlaying was true
|
||||
assert.equal(s.clipDurationMs, 30_000);
|
||||
s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g });
|
||||
assert.equal(s.phase, PHASE.PLAYING);
|
||||
s = reduce(s, { type: "MEDIA_POSITION", sessionId: SID, generation: g, positionMs: 12_000 });
|
||||
assert.equal(s.positionMs, 12_000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------- auto-advance --
|
||||
|
||||
test("clip end auto-advances to the next playable item under a fresh generation", () => {
|
||||
let s = opened("axa");
|
||||
const g = s.requestGeneration;
|
||||
s = reduce(s, { type: "MEDIA_PLAYING", sessionId: SID, generation: g });
|
||||
s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g });
|
||||
assert.equal(s.activeManifestIndex, 2); // skipped the unavailable ordinal
|
||||
assert.equal(s.requestGeneration, g + 1); // stale events from the old clip die
|
||||
assert.equal(s.desiredPlaying, true); // keeps playing
|
||||
assert.equal(s.phase, PHASE.RESOLVING_MEDIA);
|
||||
});
|
||||
|
||||
test("clip end on the LAST playable item stops there (Replay state)", () => {
|
||||
let s = opened("aa");
|
||||
s = reduce(s, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
const g = s.requestGeneration;
|
||||
s = reduce(s, { type: "MEDIA_ENDED", sessionId: SID, generation: g });
|
||||
assert.equal(s.phase, PHASE.ENDED);
|
||||
assert.equal(s.desiredPlaying, false);
|
||||
assert.equal(s.activeManifestIndex, 1); // stays on the last card
|
||||
// Replay restarts the same item with a new generation
|
||||
const r = reduce(s, { type: "REPLAY", sessionId: SID });
|
||||
assert.equal(r.activeManifestIndex, 1);
|
||||
assert.equal(r.requestGeneration, g + 1);
|
||||
assert.equal(r.desiredPlaying, true);
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AVAILABILITY } from "../../src/lib/preview/contracts.js";
|
||||
import { buildFixtureManifest } from "../../src/lib/preview/manifest.js";
|
||||
import { createInitialState, reduce, PHASE } from "../../src/lib/preview/session.js";
|
||||
import { createFixtureTransport } from "../../src/lib/preview/transport.js";
|
||||
import { makeNativeLease } from "../../src/lib/preview/lease.js";
|
||||
|
||||
const SID = "sess-t";
|
||||
const T0 = 1_700_000_000_000;
|
||||
const SRC = { provider: "deezer", providerTrackId: "1", matchConfidence: "native" };
|
||||
|
||||
function openSession() {
|
||||
const manifest = buildFixtureManifest({
|
||||
rows: [
|
||||
{ providerTrackId: "1", title: "One", initialAvailability: AVAILABILITY.AVAILABLE },
|
||||
{ providerTrackId: "2", title: "Two", initialAvailability: AVAILABILITY.AVAILABLE },
|
||||
],
|
||||
createdAtMs: T0,
|
||||
});
|
||||
return reduce(createInitialState(SID), { type: "MANIFEST_READY", sessionId: SID, manifest });
|
||||
}
|
||||
|
||||
test("end-to-end against the fake transport: load → play → ticks → auto-advance", () => {
|
||||
let state = openSession();
|
||||
const transport = createFixtureTransport((action) => {
|
||||
state = reduce(state, action);
|
||||
});
|
||||
|
||||
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000, clipDurationMs: 30_000 });
|
||||
state = reduce(state, {
|
||||
type: "LEASE_READY", sessionId: SID, generation: state.requestGeneration,
|
||||
sessionKey: state.activeItemKey, clipDurationMs: 30_000,
|
||||
});
|
||||
transport.load(lease, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey });
|
||||
transport.play();
|
||||
assert.equal(state.phase, PHASE.PLAYING);
|
||||
|
||||
transport.tick(12_000);
|
||||
assert.equal(state.positionMs, 12_000);
|
||||
|
||||
transport.tick(18_000); // reaches clip end → MEDIA_ENDED → auto-advance
|
||||
assert.equal(state.activeManifestIndex, 1);
|
||||
assert.equal(state.phase, PHASE.RESOLVING_MEDIA);
|
||||
assert.equal(state.desiredPlaying, true);
|
||||
});
|
||||
|
||||
test("events after a step carry the old generation and are inert", () => {
|
||||
let state = openSession();
|
||||
const transport = createFixtureTransport((action) => {
|
||||
state = reduce(state, action);
|
||||
});
|
||||
const lease = makeNativeLease({ source: SRC, opaqueLeaseId: "op-1", safeForMs: 300_000 });
|
||||
const genAtLoad = state.requestGeneration;
|
||||
transport.load(lease, { sessionId: SID, generation: genAtLoad, sessionKey: state.activeItemKey });
|
||||
transport.play();
|
||||
assert.equal(state.phase, PHASE.PLAYING);
|
||||
|
||||
// user steps: the reducer bumps the generation…
|
||||
state = reduce(state, { type: "STEP", sessionId: SID, dir: 1 });
|
||||
const after = state;
|
||||
// …but the un-stopped transport keeps ticking with the OLD generation
|
||||
transport.tick(5_000);
|
||||
assert.equal(state.positionMs, after.positionMs, "stale position must not land");
|
||||
assert.equal(state.phase, PHASE.RESOLVING_MEDIA);
|
||||
});
|
||||
|
||||
test("invalid lease surfaces ITEM_FAILED(invalid_media) instead of playing", () => {
|
||||
let state = openSession();
|
||||
const transport = createFixtureTransport((action) => {
|
||||
state = reduce(state, action);
|
||||
});
|
||||
const bogus = { transport: "native", safeForMs: 100 }; // no opaqueLeaseId
|
||||
transport.load(bogus, { sessionId: SID, generation: state.requestGeneration, sessionKey: state.activeItemKey });
|
||||
assert.equal(state.phase, PHASE.ITEM_FAILED);
|
||||
assert.equal(state.errorClass, "invalid_media");
|
||||
});
|
||||
|
||||
test("dispose is terminal and idempotent", () => {
|
||||
const transport = createFixtureTransport(() => {});
|
||||
transport.dispose();
|
||||
transport.dispose();
|
||||
assert.throws(() => transport.load({}, { sessionId: SID, generation: 1, sessionKey: "k" }));
|
||||
});
|
||||
Reference in new issue
Block a user