// 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; }