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:
KeelrLeek committed 2026-07-22 18:29:03 -05:00
1 parent 6dcc62a2fd
commit 8ede6a8adf
14 files changed
+1722 -2

No files matched your search

+3 -2
View File
@@ -2,11 +2,12 @@
"name": "soulstream", "name": "soulstream",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "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": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"tauri": "tauri" "tauri": "tauri",
"test": "node --test tests/preview/*.test.mjs"
}, },
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
+125
View File
@@ -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;
}
+150
View File
@@ -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))
);
});
}
+116
View File
@@ -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;
}
+130
View File
@@ -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,
});
}
+64
View File
@@ -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;
}
}
+63
View File
@@ -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;
},
};
}
+277
View File
@@ -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;
}
}
+82
View File
@@ -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),
};
}
+169
View File
@@ -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);
});
+124
View File
@@ -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"));
});
+122
View File
@@ -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");
});
+212
View File
@@ -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);
});
+85
View File
@@ -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" }));
});