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.
126 lines
4.6 KiB
JavaScript
126 lines
4.6 KiB
JavaScript
// 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;
|
|
}
|