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",
|
"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": {
|
||||||
|
|||||||
@@ -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