openvibers/openvibe.media Commits

Readiness levels (WS-G task 5): every media object answers metadata, bytes_verified and playable, read from the object row and its copies' recorded checks (server/objects/readiness.js). bytes_verified needs a copy that is present with a verified_at (the local file found, a B2/R2 HEAD with the right size, a v2 upload hashed on receipt; sha256 against content_hash where the verification job checks it) and no checksum contradicting content_hash; a B2 copy nobody has checked yet does not count. playable adds a finished vod or clip (lifecycle ready: not recording, not still cutting, not failed or deleted); the size invariant does not gate it, as in code it only refuses oversized v2 uploads. v2 object answers and the v1 VOD/clip answers carry readiness (webhook payloads do not; existing fields unchanged). The /v and /c watch pages offer a player, download link, video tags and VideoObject only when playable, and otherwise say why (still being recorded, still processing, not available) and are noindex; a clip still being cut gets that page instead of a 404, its bytes stay a 404. The byte routes are unchanged (Live's DVR reads recordings through them). The sitemap lists only playable pages (readiness.playableSql). test/readiness.test.js

OP OpenVibers committed 25 Sept 2026 · 8b1abf0975 · 12 files, +419 −46


Changes

ddiff --git a/README.md b/README.md
iindex 9fc3450..da32bb4 100644
---- a/README.md
++++ b/README.md
@@@ -43,7 +43,8 @@ server/
public/routes.js public /v /c /p /t /f
objects/ canonical object model: model (projections, holds), routes (/api/v2 + /o),
backfill, reconcile, invariant, signing (presigned URLs), multipart,
-- content-type, verify-job (scheduled copy verification), copy-report —
++ content-type, verify-job (scheduled copy verification), copy-report,
++ readiness (metadata / bytes_verified / playable) —
see docs/object-model.md
jobs/ media_jobs: queue (states, idempotency, media.job.* events in the state
change's transaction; payload queue.jobEvent: ids, state, ISO times,
@@@ -158,7 +159,7 @@ MEDIA_APP_KEYS="live:key1,games:key2"
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
| POST | `/vods/:id/complete` | finalize chunked upload (user JWT ok) |
| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, webhook |
--| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, … }` |
++| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, thumbnail_url, storage_provider, readiness, … }` (`readiness`: [below](#readiness)) |
| GET | `/vods?limit&offset&user_id&stream_id&managed_stream_id&include_private&order&since` | list; `include_private` app-key only; `order` = newest\|oldest\|views; `since` = created at or after (ISO 8601 or `YYYY-MM-DD HH:MM:SS`, UTC) |
| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
@@@ -166,6 +167,13 @@ MEDIA_APP_KEYS="live:key1,games:key2"
`status`: `pending → recording → ready | failed` (derived; failures come from
health quarantine: corrupt / zero-byte / missing file).
++<a id="readiness"></a>**Readiness.** VOD and clip answers (and v2 objects) also carry `readiness`
++`{ metadata, bytes_verified, playable, hash_verified, verified_copies, reason }`, read from the object
++model: `bytes_verified` needs a copy whose bytes a check found (local file, B2/R2 HEAD, sha256 when
++known), and `playable` adds a finished, not failed, not deleted VOD or clip. A B2 copy nobody has
++checked yet is not verified. The watch pages offer a player only when `playable` (see
++[docs/object-model.md](docs/object-model.md#readiness)).
++
**Durations are measured, never estimated.** Finalize stores what the file says and
records where it came from in `duration_source`: `probe` (ffprobe's container
duration), `remux` (the last packet time of a stream-copy pass, used when the header
@@@ -352,12 +360,14 @@ never the headers (`test/trust-proxy.test.js`).
| `GET /live/:sel/chat-insight.json` | **chat insight API** — a user's chat-related AI insight/timeline (`:sel` = `@username` or numeric user id): today-vs-alltime chat overviews, condensed memory, event timeline, plus their streamer overview + stream memories when they stream. Proxied from the app's public chat-AI API over loopback; cached **30s**, CORS-open. |
| `GET /v/:id/transcript.json` | **VOD transcript API** — transcript + AI overview for one existing VOD id (`{ vod_id, title, duration_seconds, ai_overview, transcript, ai_analyzed_at }`). Private VODs → 404. Cached 30s, CORS-open. |
| `GET /robots.txt` | crawler policy (`openvibe-shared/seo.robotsTxt`: AI and search crawlers named, `/api/`, `/auth/`, `/internal/`, `/o/`, `/metrics`, `/live/` disallowed) and the sitemap |
--| `GET /sitemap.xml` | the media index and the watch pages Media is the canonical home of: public, ready VODs and clips of apps other than Live (Live lists its own `/vod` and `/clip` pages), never AI clips, private, unlisted, sandbox, recording or failed items; thumbnails as image entries. Every listed page renders `index, follow` |
++| `GET /sitemap.xml` | the media index and the watch pages Media is the canonical home of: public, playable VODs and clips of apps other than Live (Live lists its own `/vod` and `/clip` pages), never AI clips, private, unlisted, sandbox, recording or failed items; thumbnails as image entries. Every listed page renders `index, follow` |
| `GET /llms.txt` | a map of the site for language-model crawlers |
| `GET /live/:sel/frame.jpg` | **live frame API** — near-realtime JPEG frame of an actively-live stream slot, extracted from its in-progress recording. `:sel` = slot id (`1`), slot **slug** (`whip`), or **`@username`** (that streamer's top-viewed live slot; slug/username resolve via the app's `/api/streams` listing, cached 5s). Optional `?w=64..1920` scales the width, `?app=` selects the tenant (default `live`; internal base URLs from `APP_INTERNAL_URLS` JSON env or `LIVE_APP_INTERNAL_URL`). Cached **5s per slot** (that cache is the rate limit), CORS-open for external APIs/bots/dashboards. Not live → **404 with a styled OFFLINE card** (real JPEG bytes — dev pipelines decode the body as image/jpeg) so `<img>` embeds degrade nicely (`?format=json` for JSON errors); `503` + card when live but a frame can't be cut. |
--Watch pages (`/v/:id`, `/c/:id` on a browser navigation) carry a schema.org `VideoObject` when the
--item is public (none on unlisted or private pages). AI clips (`auto_generated`) are `noindex, follow`
++Watch pages (`/v/:id`, `/c/:id` on a browser navigation) offer a player only when the item's
++[readiness](#readiness) is `playable`; otherwise they say why (still being recorded, still processing,
++not available) and are `noindex`. They carry a schema.org `VideoObject` when the item is public and
++playable (none on unlisted or private pages). AI clips (`auto_generated`) are `noindex, follow`
wherever they belong, labelled "AI clip", attributed to no person (`creator` OpenVibe AI) and
`isBasedOn` their source VOD, as on Live.
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 13059da..42728b8 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -7,7 +7,7 @@ projection over an object through its `object_id` column. Every existing URL and
unchanged. New code talks to objects through the [v2 API](#object-api-v2).
Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`, `invariant.js`,
--`signing.js`, `verify-job.js`, `copy-report.js`, `multipart.js`, `content-type.js`) and the job system in
++`signing.js`, `verify-job.js`, `copy-report.js`, `multipart.js`, `content-type.js`, `readiness.js`) and the job system in
`server/jobs/` (`queue.js`, `worker.js`, `routes.js`, `types.js`, `thumbnail.js`, `invariant-scan.js`,
`derive.js`). Schema: the bottom of `server/db/schema.sql`. Tests: `test/objects-*.test.js`, `test/jobs*.test.js`,
`test/r2-eviction-drill.test.js`.
@@@ -193,7 +193,7 @@ All routes live under `/api/v2/:app/objects`.
| PUT | `/:id/multipart/:uploadId/parts/:n` | one part, raw bytes of exactly its size; optional `X-Content-SHA256` |
| POST | `/:id/multipart/:uploadId/complete` | `{ content_hash, parts: [{ part_number, sha256 }] }` (both optional): assembles, then the checks of `/complete` |
| DELETE | `/:id/multipart/:uploadId` | abort: parts deleted, the object stays `uploading` |
--| GET | `/:id` | metadata: providers and states of each copy, never paths or keys |
++| GET | `/:id` | metadata: providers and states of each copy, never paths or keys, and its [readiness](#readiness) |
| GET | `/` | cursor list, newest first. `?limit (≤200)&cursor&kind&visibility&status&owner (usr_…)&user_id&include_deleted` returns `{ objects, next_cursor }` |
| DELETE | `/:id` | soft delete: `lifecycle_status = deleted`, and the bytes are kept for `MEDIA_DELETE_RETENTION_DAYS` (default 30). 409 `media.object.held` under a hold. 409 `media.object.legacy_managed` for projected objects, which are deleted through their v1 route |
| POST | `/:id/restore` | undo a soft delete within the retention period (410 once purged) |
@@@ -285,6 +285,44 @@ count, except in developer-project tenants, where they count until their bytes a
passed and that have no active hold. The object row stays `deleted` with `metadata.purged_at`.
Projected objects are never purged by this job.
++## Readiness
++
++Code: `server/objects/readiness.js`. Test: `test/readiness.test.js`.
++
++Every object answer (v2 `GET /:id`, the v2 list, the `media.object.uploaded` payload) and the v1 VOD
++and clip answers (`GET`, list, update, finalize, upload; not the `vod.*`/`clip.*` webhook payloads,
++which are sent before the object is re-projected) carry `readiness`:
++
++```json
++{ "metadata": true, "bytes_verified": true, "playable": true, "hash_verified": false, "verified_copies": ["local"], "reason": null }
++```
++
++Each fact is read from what the database records, never assumed:
++
++- `metadata`: the object row exists and its metadata is readable. A v1 row with no object yet has `false`
++ everywhere and `reason: no_object`.
++- `bytes_verified`: at least one copy is `present` with a `verified_at`, which only a check of the bytes
++ writes: the local file found at its path (sha256-checked against `content_hash` by the
++ [scheduled verification](#scheduled-verification) when the object has one), a B2/R2 HEAD with the
++ right size (a tier move, the verification, `reconcile-objects.js --verify`), or a v2 upload hashed on
++ receipt. A copy whose recorded checksum contradicts `content_hash` does not count; neither do
++ `pending` (never checked), `missing` or `corrupt` copies. `hash_verified` says whether a verified
++ copy's sha256 equals `content_hash`; `verified_copies` names their providers.
++- `playable`: `bytes_verified`, a playback kind (`vod`, `clip`) and lifecycle `ready`: the recording is
++ finished or the clip cut, nothing failed, nothing deleted.
++
++`reason` says why it is not playable: `no_object`, `deleted`, `metadata_unreadable`, `failed`,
++`archived`, `recording`, `processing`, `verification_pending` (only unchecked copies),
++`no_verified_copy`, `not_playback_kind`; `null` when it is. The public-size invariant does not gate
++`playable`: it refuses oversized public v2 uploads (they never become ready) and only reports
++recordings.
++
++**Public players.** The `/v/:id` and `/c/:id` watch pages offer a player (and a download link, video
++preview tags and a `VideoObject`) only when `playable`. Otherwise the page says why (still being
++recorded, still processing, not available), is `noindex`, and a clip still being cut has a page
++instead of a 404. The sitemap lists only playable watch pages. The byte routes (`?raw=1`, `<video>`
++requests, `/o/:id`) are unchanged: Live's DVR player reads a recording while it is being written.
++
## Retention holds
`media_holds(object_id, kind, reason, created_by, created_at, released_at, released_by)`. The
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex ccf557e..2ed7f32 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -543,6 +543,7 @@ function legacyPublicUrl(obj) {
function objectPublic(obj, { locations = true } = {}) {
if (!obj) return null;
++ const locs = listLocations(obj.id);
const out = {
id: obj.id,
media_ref: { media_id: obj.id },
@@@ -558,6 +559,8 @@ function objectPublic(obj, { locations = true } = {}) {
content_hash: obj.content_hash || null,
metadata: parseJson(obj.metadata, {}),
held: isHeld(obj.id),
++ // metadata / bytes_verified / playable, from this row and its copies' recorded checks (objects/readiness.js).
++ readiness: require('./readiness').compute(obj, locs),
// Developer-project sandbox objects have no public URL: /download hands out signed ones.
public_url: obj.visibility !== 'private' && obj.lifecycle_status === 'ready' && !db.isSandboxTenant(obj.app_id)
? (legacyPublicUrl(obj) || `${config.publicUrl}/o/${obj.id}`) : null,
@@@ -568,7 +571,7 @@ function objectPublic(obj, { locations = true } = {}) {
if (db.isSandboxTenant(obj.app_id)) out.sandbox = true;
// Where the bytes are, never the keys or paths themselves.
if (locations) {
-- out.locations = listLocations(obj.id).map(l => ({
++ out.locations = locs.map(l => ({
provider: l.provider, storage_class: l.storage_class, state: l.state, size_bytes: l.size_bytes,
verified_at: l.verified_at, canonical: l.provider === obj.canonical_provider,
}));
ddiff --git a/server/objects/readiness.js b/server/objects/readiness.js
nnew file mode 100644
iindex 0000000..0b811e1
---- /dev/null
++++ b/server/objects/readiness.js
@@@ -0,0 +1,82 @@
++/**
++ * OpenVibe.Media — readiness levels of a media object (roadmap WS-G task 5; docs/object-model.md#readiness).
++ *
++ * Three facts, each read from what the database records about the object and its copies, never assumed:
++ *
++ * metadata the media_objects row exists and its metadata is readable JSON
++ * bytes_verified at least one copy is `present` with a `verified_at`: a check of the bytes found it
++ * (the local file at its path, sha256-checked against content_hash by the scheduled
++ * verification when the object has one; a B2/R2 HEAD with the right size; a v2 upload
++ * hashed on receipt), and no checksum recorded on that copy contradicts the object's
++ * content_hash. `pending` copies (believed there, never checked), `missing` and
++ * `corrupt` ones do not count
++ * playable bytes_verified, a playback kind (vod, clip), and lifecycle `ready`: the recording is
++ * finished or the clip cut, nothing failed, nothing deleted
++ *
++ * plus `hash_verified` (a verified copy's sha256 equals content_hash), `verified_copies` (the providers
++ * of the verified copies) and `reason` (why it is not playable, null when it is).
++ *
++ * The public-size invariant (server/objects/invariant.js) does not gate `playable`: in code it refuses
++ * oversized public v2 uploads (so such an object never becomes ready) and only reports recordings.
++ *
++ * It reads media_objects and media_locations only: no file is opened and no provider is asked, so it
++ * answers in a restore drill too. playableSql() is the same rule as SQL (the sitemap uses it).
++ */
++'use strict';
++
++const db = require('../db/database');
++
++const PLAYBACK_KINDS = ['vod', 'clip'];
++
++/** Why an object is not playable, in the order the watch page explains it. */
++const REASONS = ['no_object', 'deleted', 'metadata_unreadable', 'failed', 'archived', 'recording', 'processing', 'verification_pending', 'no_verified_copy', 'not_playback_kind'];
++
++function metadataOf(obj) {
++ try { const md = JSON.parse(obj.metadata == null ? '{}' : obj.metadata); return md && typeof md === 'object' && !Array.isArray(md) ? md : null; } catch { return null; }
++}
++
++/** A copy counts as verified: present, checked, and no recorded checksum that disagrees with the object's hash. */
++function isVerifiedCopy(obj, l) {
++ return l.state === 'present' && !!l.verified_at && !(l.checksum && obj.content_hash && l.checksum !== obj.content_hash);
++}
++
++/** Readiness of one object row (null → nothing known). `locations` may be passed when already loaded. */
++function compute(obj, locations = null) {
++ if (!obj) return { metadata: false, bytes_verified: false, playable: false, hash_verified: false, verified_copies: [], reason: 'no_object' };
++ const locs = locations || db.all('SELECT provider, state, checksum, verified_at FROM media_locations WHERE object_id = ? ORDER BY id', [obj.id]);
++ const md = metadataOf(obj);
++ const verified = locs.filter(l => isVerifiedCopy(obj, l));
++ const bytesVerified = verified.length > 0;
++ let reason = null;
++ if (obj.lifecycle_status === 'deleted') reason = 'deleted';
++ else if (!md) reason = 'metadata_unreadable';
++ else if (obj.lifecycle_status === 'failed') reason = 'failed';
++ else if (obj.lifecycle_status === 'archived') reason = 'archived';
++ else if (obj.lifecycle_status !== 'ready') reason = md.recording ? 'recording' : 'processing';
++ else if (!bytesVerified) reason = locs.some(l => l.state === 'pending') ? 'verification_pending' : 'no_verified_copy';
++ else if (!PLAYBACK_KINDS.includes(obj.kind)) reason = 'not_playback_kind';
++ return {
++ metadata: !!md,
++ bytes_verified: bytesVerified,
++ playable: reason === null,
++ hash_verified: !!obj.content_hash && verified.some(l => l.checksum === obj.content_hash),
++ verified_copies: verified.map(l => l.provider),
++ reason,
++ };
++}
++
++/** Readiness of an inherited vods/clips row, through its object (object_id). */
++function forRow(row) {
++ if (!row || !row.object_id) return compute(null);
++ return compute(db.get('SELECT * FROM media_objects WHERE id = ?', [row.object_id]));
++}
++
++/** SQL condition: the object named by the SQL expression `ref` is playable (the rule of compute()). */
++function playableSql(ref) {
++ return `EXISTS (SELECT 1 FROM media_objects ro WHERE ro.id = ${ref} AND ro.lifecycle_status = 'ready'
++ AND ro.kind IN (${PLAYBACK_KINDS.map(k => `'${k}'`).join(', ')}) AND (CASE WHEN json_valid(ro.metadata) THEN json_type(ro.metadata) END) = 'object'
++ AND EXISTS (SELECT 1 FROM media_locations rl WHERE rl.object_id = ro.id AND rl.state = 'present' AND rl.verified_at IS NOT NULL
++ AND (rl.checksum IS NULL OR ro.content_hash IS NULL OR rl.checksum = ro.content_hash)))`;
++}
++
++module.exports = { compute, forRow, playableSql, isVerifiedCopy, PLAYBACK_KINDS, REASONS };
ddiff --git a/server/public/crawl.js b/server/public/crawl.js
iindex 892f13b..54f5749 100644
---- a/server/public/crawl.js
++++ b/server/public/crawl.js
@@@ -4,7 +4,9 @@
* The sitemap lists the pages search engines may index here: the media index (/) and the watch
* pages of public, ready VODs and clips that Media is the canonical home of (pages.watchIndexable:
* not Live's, which Live lists under its own /vod and /clip pages; never AI clips; never private or
-- * unlisted; never a developer project's sandbox). A URL here always renders `index, follow`.
++ * unlisted; never a developer project's sandbox), and only while they are playable (their object is
++ * ready and has a copy whose bytes were verified: server/objects/readiness.js). A URL here always
++ * renders `index, follow` with a player.
*/
'use strict';
@@@ -13,6 +15,7 @@ const config = require('../config');
const db = require('../db/database');
const seo = require('openvibe-shared/seo');
const pages = require('./pages');
++const readiness = require('../objects/readiness');
const MAX_URLS = 45000; // under the 50,000-URL sitemap limit
const router = express.Router();
@@@ -36,7 +39,7 @@ function sitemapEntries() {
const vods = db.all(`SELECT id, app_id, visibility, is_public, thumbnail_url, created_at FROM vods
WHERE COALESCE(is_recording, 0) = 0 AND COALESCE(clips_only, 0) = 0 AND is_public = 1 AND COALESCE(visibility, 'public') = 'public'
AND quarantined_at IS NULL AND COALESCE(health_status, 'ok') NOT IN ('corrupt', 'zero_byte', 'missing_file', 'needs_review')
-- AND file_path IS NOT NULL AND app_id != 'live'
++ AND file_path IS NOT NULL AND app_id != 'live' AND ${readiness.playableSql('vods.object_id')}
ORDER BY created_at DESC LIMIT ?`, [MAX_URLS]);
for (const v of vods) {
if (sandbox.has(v.app_id) || !pages.watchIndexable('vod', v)) continue;
@@@ -44,7 +47,7 @@ function sitemapEntries() {
}
const clips = db.all(`SELECT id, app_id, visibility, is_public, auto_generated, thumbnail_url, created_at FROM clips
WHERE COALESCE(status, 'ready') = 'ready' AND COALESCE(is_public, 1) = 1 AND COALESCE(visibility, 'public') = 'public'
-- AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live'
++ AND COALESCE(auto_generated, 0) = 0 AND COALESCE(file_path, '') != '' AND app_id != 'live' AND ${readiness.playableSql('clips.object_id')}
ORDER BY created_at DESC LIMIT ?`, [MAX_URLS]);
for (const c of clips) {
if (sandbox.has(c.app_id) || !pages.watchIndexable('clip', c)) continue;
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 7f65e34..7293b80 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -67,12 +67,34 @@ function watchIndexable(kind, record) {
return visibility === 'public' && !ownedByLive && !isAiClip(kind, record);
}
++/**
++ * What the watch page says instead of a player when the item is not playable (readiness.reason,
++ * server/objects/readiness.js): [heading, detail].
++ */
++function unavailableText(kind, reason) {
++ const isVod = kind === 'vod';
++ const thing = isVod ? 'recording' : 'clip';
++ switch (reason) {
++ case 'recording': return ['Still being recorded', 'This stream is still being recorded. It can be watched here once the recording is finished.'];
++ case 'processing': return ['Still processing', isVod ? 'This recording is still being processed. Check back in a few minutes.' : 'This clip is still being cut. Check back in a minute.'];
++ case 'verification_pending': return ['Still processing', `The stored copy of this ${thing} has not been checked yet. Check back soon.`];
++ case 'failed': return ['Not available', isVod ? 'This recording could not be processed.' : 'This clip could not be cut.'];
++ case 'no_verified_copy': return ['Not available', `No verified copy of this ${thing} can be found right now.`];
++ default: return ['Not available', `This ${thing} cannot be played right now.`];
++ }
++}
++
/**
* /v/:id and /c/:id as a page. Canonical stays with the owning app when that
* app serves the item (Live's /vod/:id and /clip/:id); anything else is
* self-canonical here.
++ *
++ * `readiness` (server/objects/readiness.js, computed by the route) decides whether a player is
++ * offered: when it is not playable the page says why (still recording, still processing, not
++ * available), offers no player or download, carries no video metadata and is noindex. Without it
++ * (callers that vouch for the bytes themselves) the player is rendered.
*/
--function renderWatchPage(kind, record) {
++function renderWatchPage(kind, record, readiness = null) {
const isVod = kind === 'vod';
const id = record.id;
const self = `${config.publicUrl}/${isVod ? 'v' : 'c'}/${id}`;
@@@ -85,8 +107,9 @@ function renderWatchPage(kind, record) {
const thumb = abs(record.thumbnail_url);
const ownedByLive = (record.app_id || 'live') === 'live';
const canonical = ownedByLive ? sourcePage(kind, record) : self;
-- // The owning app has the canonical page; unlisted items and AI clips never index.
-- const robots = watchIndexable(kind, record) ? 'index, follow' : 'noindex, follow';
++ const playable = !readiness || !!readiness.playable;
++ // The owning app has the canonical page; unlisted items, AI clips and pages without a player never index.
++ const robots = playable && watchIndexable(kind, record) ? 'index, follow' : 'noindex, follow';
const sourceVod = ai && record.vod_id ? `${config.publicUrl}/v/${record.vod_id}` : null;
const mime = VIDEO_MIME[path.extname(record.file_path || '').toLowerCase()] || 'video/mp4';
const uploadDate = isoDate(record.created_at);
@@@ -119,17 +142,25 @@ function renderWatchPage(kind, record) {
by ? `by ${esc(by)}` : null,
].filter(Boolean);
-- const body = `
-- <h1>${esc(title)}</h1>
-- <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
-- <div class="player">
++ const [stateTitle, stateText] = playable ? [null, null] : unavailableText(kind, readiness.reason);
++ const player = playable
++ ? `<div class="player">
<video controls playsinline preload="metadata"${thumb ? ` poster="${esc(thumb)}"` : ''} src="${esc(rawUrl)}">
Your browser cannot play this video. <a href="${esc(rawUrl)}">Download it</a> instead.
</video>
-- </div>
++ </div>`
++ : `<div class="player unavailable" role="status" data-readiness="${esc(readiness.reason || 'unavailable')}">
++ <p class="state">${esc(stateTitle)}</p>
++ <p class="state-detail">${esc(stateText)}</p>
++ </div>`;
++
++ const body = `
++ <h1>${esc(title)}</h1>
++ <p class="meta">${metaBits.map(b => `<span>${b}</span>`).join('<span aria-hidden="true">·</span>')}</p>
++ ${player}
<div class="actions">
${ownedByLive ? `<a class="btn" href="${esc(canonical)}">${isVod ? 'Watch on OpenVibe.Live' : 'Watch on OpenVibe.Live'} — chat, transcript &amp; more</a>` : ''}
-- <a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</a>
++ ${playable ? `<a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</a>` : ''}
${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}
</div>
${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
@@@ -138,15 +169,19 @@ function renderWatchPage(kind, record) {
const css = `
.player { background: #000; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); margin: 0 0 1rem; }
.player video { display: block; width: 100%; max-height: 72vh; background: #000; }
++ .player.unavailable { background: var(--panel); padding: 2.5rem 1.2rem; text-align: center; }
++ .player .state { font-size: 1.15rem; font-weight: 600; margin: 0 0 .4rem; color: var(--text); }
++ .player .state-detail { margin: 0; color: var(--muted); }
.h2 { font-size: 1rem; margin: 1.2rem 0 .3rem; color: var(--text); }`;
return pageFrame.page({
seo: {
title: `${title} — ${SITE_NAME}`, description, canonical, robots,
-- image: thumb || undefined, ogType: 'video.other',
-- video: { url: rawUrl, type: mime },
++ image: thumb || undefined, ogType: playable ? 'video.other' : 'website',
++ // No playable bytes: no video URL for previews and no VideoObject that names them.
++ video: playable ? { url: rawUrl, type: mime } : undefined,
// Structured data describes public items only; unlisted and private pages carry none.
-- jsonLd: (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' ? [videoObject] : [],
++ jsonLd: playable && (record.visibility || (record.is_public ? 'public' : 'private')) === 'public' ? [videoObject] : [],
},
css, body,
history: { type: isVod ? 'vod' : 'clip', title },
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex d059e09..da690f8 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -133,10 +133,13 @@ async function serveMediaRecord(kind, record, req, res) {
if (visibility === 'private' && !canAccessPrivate(record, req)) return notFound(res);
// A person (or a link-preview crawler) landing on the URL gets the watch
-- // page; its <video> comes back here with ?raw=1 for the bytes.
++ // page; its <video> comes back here with ?raw=1 for the bytes. The page offers a player only when
++ // the object is playable (server/objects/readiness.js: finished, and a copy whose bytes were
++ // verified); otherwise it says why. The bytes route below is unchanged: Live's DVR player reads a
++ // recording's sidecar through it while the stream is still being recorded.
if (pages.wantsHtmlPage(req)) {
res.set('Cache-Control', visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
-- return res.type('html').send(pages.renderWatchPage(kind, record));
++ return res.type('html').send(pages.renderWatchPage(kind, record, require('../objects/readiness').forRow(record)));
}
if (drill.refuseBytes(res)) return;
@@@ -217,7 +220,9 @@ router.get('/c/:id', optionalIdentity, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(res);
const clip = db.getClipById(parseInt(req.params.id, 10));
-- if (!clip || !clip.file_path) return notFound(res);
++ // A clip still being cut (or whose cut failed) has no file yet: its bytes are a 404, and a
++ // browser navigation gets the watch page saying so (private clips still look missing).
++ if (!clip || (!clip.file_path && !pages.wantsHtmlPage(req))) return notFound(res);
await serveMediaRecord('clip', clip, req, res);
} catch (err) {
console.error('[Public] /c error:', err.message);
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 539a546..fe90f73 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -54,9 +54,11 @@ const clipUpload = multer({
},
});
--function clipPublic(clip) {
++// { readiness: true } (API responses) adds the object's readiness levels (server/objects/readiness.js);
++// the clip.ready / clip.failed payloads leave them out, as vodPublic's do.
++function clipPublic(clip, { readiness = false } = {}) {
if (!clip) return null;
-- return {
++ const out = {
unique_views: clip.unique_views || 0,
id: clip.id,
@@@ -90,6 +92,8 @@ function clipPublic(clip) {
view_count: clip.view_count || 0,
created_at: clip.created_at,
};
++ if (readiness) out.readiness = require('../objects/readiness').forRow(clip);
++ return out;
}
/** Sanitize user-provided title: strip HTML tags, limit length */
@@@ -294,7 +298,7 @@ async function _createUploadedClip(req, res) {
.catch(err => console.warn(`[Clips] Thumbnail failed for clip ${clipId}:`, err.message));
console.log(`[Clips] Direct upload: clip ${clipId} (${req.appId}, ${duration.toFixed ? duration.toFixed(1) : duration}s)`);
-- res.status(201).json({ ...clipPublic(db.getClipById(clipId, req.appId)) });
++ res.status(201).json({ ...clipPublic(db.getClipById(clipId, req.appId), { readiness: true }) });
} catch (err) {
console.error('[Clips] Upload error:', err.message);
tools.cleanupTempFile(clipPath);
@@@ -330,7 +334,7 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
};
const clips = db.listClips(req.appId, filters);
const total = db.countClips(req.appId, filters);
-- res.json({ clips: clips.map(clipPublic), total, limit, offset, hasMore: offset + clips.length < total });
++ res.json({ clips: clips.map(c => clipPublic(c, { readiness: true })), total, limit, offset, hasMore: offset + clips.length < total });
} catch (err) {
console.error('[Clips] List error:', err.message);
res.status(500).json({ error: 'Failed to list clips' });
@@@ -348,7 +352,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), (req, res) => {
}
// Bare object, mirroring GET /vods/:id per CONTRACTS.md.
// Detail responses carry the transcript too (lists stay light).
-- res.json({ ...clipPublic(clip), ai_transcript: clip.ai_transcript || null });
++ res.json({ ...clipPublic(clip, { readiness: true }), ai_transcript: clip.ai_transcript || null });
} catch (err) {
res.status(500).json({ error: 'Failed to get clip' });
}
@@@ -395,7 +399,7 @@ router.put('/:id', tenantAuth(), (req, res) => {
db.run('UPDATE clips SET auto_generated = ? WHERE id = ?', [flag(autoGen), clip.id]);
}
if (visibility !== undefined) db.setClipVisibility(clip.id, visibility);
-- res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId)) });
++ res.json({ clip: clipPublic(db.getClipById(clip.id, req.appId), { readiness: true }) });
} catch (err) {
res.status(500).json({ error: 'Failed to update clip' });
}
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex f6c5a8c..79f6470 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -34,10 +34,12 @@ function isFinalizing(vodId) {
return _finalizing.has(Number(vodId));
}
--// Public shape of a vod row for API responses + webhooks.
--function vodPublic(vod) {
++// Public shape of a vod row for API responses + webhooks. { readiness: true } (API responses) adds the
++// object's readiness levels (server/objects/readiness.js); webhook payloads leave them out, because a
++// finalize announces before it re-projects the object.
++function vodPublic(vod, { readiness = false } = {}) {
if (!vod) return null;
-- return {
++ const out = {
unique_views: vod.unique_views || 0,
id: vod.id,
@@@ -75,6 +77,8 @@ function vodPublic(vod) {
created_at: vod.created_at,
meta: (() => { try { return JSON.parse(vod.meta_json || '{}'); } catch { return {}; } })(),
};
++ if (readiness) out.readiness = require('../objects/readiness').forRow(vod);
++ return out;
}
// Re-encode the lossless master into the served WebM format. Heavy (a real
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex e2560b6..dc2a684 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -256,7 +256,7 @@ async function _finalizeHandler(req, res) {
segmentPath: chunkRec && chunkRec.currentSegmentPath !== chunkRec.filePath ? chunkRec.currentSegmentPath : null,
});
if (!result) return res.status(409).json({ error: 'Nothing to finalize (finalization already in progress or VOD discarded)' });
-- res.json({ vod: vodPublic(result) });
++ res.json({ vod: vodPublic(result, { readiness: true }) });
} catch (err) {
console.error('[VOD] Finalize error:', err.message);
res.status(500).json({ error: 'Failed to finalize VOD' });
@@@ -291,7 +291,7 @@ router.get('/', tenantAuth({ allowUser: true }), (req, res) => {
};
const vods = db.listVods(req.appId, filters);
const total = db.countVods(req.appId, filters);
-- res.json({ vods: vods.map(vodPublic), total, limit, offset, hasMore: offset + vods.length < total });
++ res.json({ vods: vods.map(v => vodPublic(v, { readiness: true })), total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
console.error('[VOD] List error:', err.message);
res.status(500).json({ error: 'Failed to list VODs' });
@@@ -335,7 +335,7 @@ router.get('/:id', tenantAuth({ allowUser: true }), async (req, res) => {
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
// Detail responses carry the transcript too (lists stay light).
-- res.json({ ...vodPublic(vod), ai_transcript: vod.ai_transcript || null });
++ res.json({ ...vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null });
} catch (err) {
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });
@@@ -358,7 +358,7 @@ router.put('/:id', tenantAuth(), (req, res) => {
}
if (visibility !== undefined) db.setVodVisibility(vod.id, visibility);
else if (updates.length) objects.safeSync('vod', vod.id);
-- res.json({ vod: vodPublic(db.getVodById(vod.id, req.appId)) });
++ res.json({ vod: vodPublic(db.getVodById(vod.id, req.appId), { readiness: true }) });
} catch (err) {
console.error('[VOD] Update error:', err.message);
res.status(500).json({ error: 'Failed to update VOD' });
ddiff --git a/test/crawl.test.js b/test/crawl.test.js
iindex 4051bde..24e818d 100644
---- a/test/crawl.test.js
++++ b/test/crawl.test.js
@@@ -1,9 +1,9 @@
'use strict';
// Crawler basics at the origin (server/public/crawl.js, pages.js): /robots.txt names the sitemap and
// keeps APIs out; /sitemap.xml lists the home and the watch pages Media is canonical for (public,
--// ready, not Live's, not AI clips, never private/unlisted/sandbox), and every listed page renders
--// `index, follow`; /llms.txt maps the site; public watch pages carry a VideoObject; AI clips are
--// noindex with no Person author, like on Live.
++// ready, playable: a copy whose bytes were verified; not Live's, not AI clips, never
++// private/unlisted/sandbox), and every listed page renders `index, follow`; /llms.txt maps the site;
++// public watch pages carry a VideoObject; AI clips are noindex with no Person author, like on Live.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -38,6 +38,18 @@ clip.run(12, 'games', 1, 'AI moment', 1, 'public', 1, 'ready', '2026-09-22 11:00
clip.run(13, 'games', 1, 'Cutting', 1, 'public', 0, 'processing', '2026-09-22 12:00:00');
clip.run(14, 'games', 1, 'Unlisted clip', 0, 'unlisted', 0, 'ready', '2026-09-22 13:00:00');
clip.run(15, 'live', 4, 'Live clip', 1, 'public', 0, 'ready', '2026-09-22 14:00:00');
++// Public and finished, but its only copy (B2) has never been checked: not playable, never listed.
++raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, is_public, visibility, health_status, storage_provider, created_at, duration_seconds)
++ VALUES (8, 'games', 'Games unverified', '/gone.mp4', 1, 'public', 'ok', 'b2', '2026-09-21 14:00:00', 60)`).run();
++// The files behind the rows, then their objects: the local copies are checked (present) when projected.
++fs.mkdirSync(path.join(tmp, 'vods'), { recursive: true });
++fs.mkdirSync(path.join(tmp, 'clips'), { recursive: true });
++fs.writeFileSync(path.join(tmp, 'vods', 'x.mp4'), 'video bytes');
++fs.writeFileSync(path.join(tmp, 'clips', 'c.webm'), 'clip bytes');
++const model = require('../server/objects/model');
++const readiness = require('../server/objects/readiness');
++for (const id of [1, 2, 3, 4, 5, 6, 7, 8]) model.sync('vod', id);
++for (const id of [11, 12, 13, 14, 15]) model.sync('clip', id);
const app = express();
app.use('/', require('../server/public/routes'));
@@@ -66,14 +78,17 @@ const get = (p, headers = {}) => new Promise((resolve, reject) => {
const locs = [...r.body.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
assert.deepStrictEqual(locs.filter(l => !l.includes('/t/')).sort(), ['https://media.test/', 'https://media.test/c/11', 'https://media.test/v/1'].sort());
assert.ok(r.body.includes('<image:loc>https://media.test/t/g1.jpg</image:loc>'), 'thumbnails as image entries');
-- for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/c/12', '/c/13', '/c/14', '/c/15']) {
++ for (const never of ['/v/2', '/v/3', '/v/4', '/v/5', '/v/6', '/v/7', '/v/8', '/c/12', '/c/13', '/c/14', '/c/15']) {
assert.ok(!locs.includes(`https://media.test${never}`), `${never} is not listed`);
}
// Every listed watch page renders index, follow (no "submitted URL marked noindex").
const byId = (t, id) => db.get(`SELECT * FROM ${t} WHERE id = ?`, [id]);
-- assert.ok(pages.renderWatchPage('vod', byId('vods', 1)).includes('content="index, follow"'));
-- assert.ok(pages.renderWatchPage('clip', byId('clips', 11)).includes('content="index, follow"'));
-- console.log('✅ /sitemap.xml: public ready items Media is canonical for; private, unlisted, sandbox, Live-owned, AI, recording and failed never');
++ const page = (kind, row) => pages.renderWatchPage(kind, row, readiness.forRow(row));
++ assert.ok(page('vod', byId('vods', 1)).includes('content="index, follow"'));
++ assert.ok(page('clip', byId('clips', 11)).includes('content="index, follow"'));
++ assert.strictEqual(readiness.forRow(byId('vods', 8)).reason, 'verification_pending');
++ assert.ok(page('vod', byId('vods', 8)).includes('content="noindex, follow"'), 'a page without a player never indexes');
++ console.log('✅ /sitemap.xml: public playable items Media is canonical for; private, unlisted, sandbox, Live-owned, AI, recording, failed and unverified never');
// ── llms.txt ──
r = await get('/llms.txt');
ddiff --git a/test/readiness.test.js b/test/readiness.test.js
nnew file mode 100644
iindex 0000000..9895841
---- /dev/null
++++ b/test/readiness.test.js
@@@ -0,0 +1,174 @@
++'use strict';
++// Readiness levels (server/objects/readiness.js): metadata, bytes_verified and playable, computed from
++// the object row and its copies' recorded checks, never assumed. An unverified copy (a B2 copy still
++// `pending`, a missing or corrupt one, a checksum that contradicts the content hash) is not verified; a
++// recording, a clip still being cut, a failed or deleted object and a non-playback kind are not
++// playable. The same rule as SQL (the sitemap) agrees with compute(). The v1 VOD/clip JSON and the v2
++// object JSON carry `readiness`; the public watch page offers a player only when it is playable and
++// otherwise says why, while the bytes route keeps serving what is on disk (Live's DVR).
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-readiness-'));
++const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++Object.assign(process.env, {
++ DB_PATH: path.join(tmp, 'media.db'), VOD_PATH: dir('vods'), CLIPS_PATH: dir('clips'), FILES_PATH: dir('files'),
++ OBJECTS_PATH: dir('objects'), THUMBNAILS_PATH: dir('thumbnails'), PASTES_PATH: dir('pastes'),
++ MEDIA_PUBLIC_URL: 'https://media.test', OV_NETWORK_URL: 'https://openvibe.network',
++});
++for (const k of Object.keys(process.env)) if (/^MEDIA_(B2|R2)_/.test(k)) delete process.env[k];
++
++const db = require('../server/db/database');
++require('../server/views/service').ensureSchema();
++const model = require('../server/objects/model');
++const readiness = require('../server/objects/readiness');
++const verifyJob = require('../server/objects/verify-job');
++const express = require('express');
++
++db.upsertApp({ app_id: 'live', api_key: 'live-key' });
++db.upsertApp({ app_id: 'games', api_key: 'games-key' });
++const raw = db.getDb();
++const vodFile = (n, bytes = 64) => { const p = path.join(process.env.VOD_PATH, n); fs.writeFileSync(p, Buffer.alloc(bytes, 1)); return p; };
++const clipFile = (n) => { const p = path.join(process.env.CLIPS_PATH, n); fs.writeFileSync(p, Buffer.alloc(32, 2)); return p; };
++const insVod = raw.prepare(`INSERT INTO vods (id, app_id, title, file_path, file_size, is_public, visibility, duration_seconds, is_recording, storage_provider, health_status)
++ VALUES (?, ?, ?, ?, ?, 1, 'public', ?, ?, ?, ?)`);
++const insClip = raw.prepare(`INSERT INTO clips (id, app_id, vod_id, title, file_path, is_public, visibility, status, duration_seconds)
++ VALUES (?, 'games', 1, ?, ?, 1, 'public', ?, 5)`);
++
++insVod.run(1, 'games', 'Local and finished', vodFile('vod-games-1.webm'), 64, 60, 0, 'local', 'ok');
++insVod.run(2, 'games', 'Offloaded, never checked', '/old/vod-games-2.webm', 64, 60, 0, 'b2', 'ok');
++insVod.run(3, 'games', 'Still recording', vodFile('vod-games-3.webm'), 0, 0, 1, 'local', 'unknown');
++insVod.run(4, 'games', 'Broken', vodFile('vod-games-4.webm'), 64, 60, 0, 'local', 'corrupt');
++insVod.run(5, 'games', 'File gone', '/nowhere/vod-games-5.webm', 64, 60, 0, 'local', 'ok');
++insClip.run(11, 'Cut', clipFile('clip-11.webm'), 'ready');
++insClip.run(12, 'Being cut', '', 'processing');
++insClip.run(13, 'Cut failed', '', 'failed');
++for (const id of [1, 2, 3, 4, 5]) model.sync('vod', id);
++for (const id of [11, 12, 13]) model.sync('clip', id);
++const vod = (id) => db.get('SELECT * FROM vods WHERE id = ?', [id]);
++const clip = (id) => db.get('SELECT * FROM clips WHERE id = ?', [id]);
++const r = (row) => readiness.forRow(row);
++
++// ── compute(): from the object and its copies ──
++assert.deepStrictEqual(readiness.forRow({ id: 99 }), { metadata: false, bytes_verified: false, playable: false, hash_verified: false, verified_copies: [], reason: 'no_object' });
++assert.deepStrictEqual(r(vod(1)), { metadata: true, bytes_verified: true, playable: true, hash_verified: false, verified_copies: ['local'], reason: null },
++ 'a finished VOD whose local file was checked is playable');
++assert.deepStrictEqual([r(vod(2)).bytes_verified, r(vod(2)).playable, r(vod(2)).reason], [false, false, 'verification_pending'],
++ 'a B2 copy nobody has checked yet is not verified bytes');
++assert.deepStrictEqual([r(vod(3)).playable, r(vod(3)).reason], [false, 'recording'], 'a recording in progress is not complete');
++assert.strictEqual(r(vod(3)).bytes_verified, true, 'its bytes on disk are verified all the same');
++assert.deepStrictEqual([r(vod(4)).playable, r(vod(4)).reason], [false, 'failed']);
++assert.deepStrictEqual([r(vod(5)).bytes_verified, r(vod(5)).reason], [false, 'no_verified_copy'], 'a missing local file is no copy at all');
++assert.deepStrictEqual([r(clip(11)).playable, r(clip(12)).reason, r(clip(13)).reason], [true, 'processing', 'failed']);
++console.log('✅ readiness: playable only for a finished vod/clip with a verified copy; the reason otherwise');
++
++// Hashes: a checksum that contradicts content_hash is not a verified copy; a matching one is hash-verified.
++const o1 = vod(1).object_id;
++const loc1 = db.get("SELECT * FROM media_locations WHERE object_id = ? AND provider = 'local'", [o1]);
++raw.prepare('UPDATE media_objects SET content_hash = ? WHERE id = ?').run('a'.repeat(64), o1);
++raw.prepare('UPDATE media_locations SET checksum = ? WHERE id = ?').run('b'.repeat(64), loc1.id);
++assert.deepStrictEqual([r(vod(1)).bytes_verified, r(vod(1)).playable, r(vod(1)).reason], [false, false, 'no_verified_copy'], 'a copy whose sha256 disagrees is not verified');
++raw.prepare('UPDATE media_locations SET checksum = ? WHERE id = ?').run('a'.repeat(64), loc1.id);
++assert.deepStrictEqual([r(vod(1)).playable, r(vod(1)).hash_verified], [true, true]);
++raw.prepare("UPDATE media_locations SET state = 'corrupt' WHERE id = ?").run(loc1.id);
++assert.strictEqual(r(vod(1)).playable, false, 'a copy the verification found corrupt is not verified');
++// Back to the truth: the file's real sha256 on the object and its copy (what the object.hash job records).
++const realHash = require('crypto').createHash('sha256').update(fs.readFileSync(loc1.key)).digest('hex');
++raw.prepare("UPDATE media_objects SET content_hash = ? WHERE id = ?").run(realHash, o1);
++raw.prepare("UPDATE media_locations SET state = 'present', checksum = ? WHERE id = ?").run(realHash, loc1.id);
++
++// Deleted, and kinds that do not play.
++const fileId = model.createObject({ app_id: 'games', kind: 'file', lifecycle_status: 'ready', visibility: 'public', size_bytes: 3 });
++const fp = model.objectFilePath(model.getObject(fileId));
++fs.mkdirSync(path.dirname(fp), { recursive: true }); fs.writeFileSync(fp, 'abc');
++model.upsertLocation(fileId, { provider: 'local', key: fp, state: 'present', size_bytes: 3, verified: true });
++assert.deepStrictEqual([readiness.compute(model.getObject(fileId)).bytes_verified, readiness.compute(model.getObject(fileId)).reason], [true, 'not_playback_kind']);
++const gone = model.createObject({ app_id: 'games', kind: 'vod', lifecycle_status: 'deleted', visibility: 'public', size_bytes: 3 });
++assert.strictEqual(readiness.compute(model.getObject(gone)).reason, 'deleted');
++
++// The SQL form (sitemap) agrees with compute() on every object.
++for (const o of db.all('SELECT * FROM media_objects')) {
++ const sql = !!db.get(`SELECT ${readiness.playableSql('?')} AS p`, [o.id]).p;
++ assert.strictEqual(sql, readiness.compute(o).playable, `playableSql agrees for ${o.legacy_ref || o.id}`);
++}
++console.log('✅ readiness: hash contradictions, corrupt copies, deletion and non-playback kinds; playableSql() agrees');
++
++(async () => {
++ // ── The scheduled verification is what makes an offloaded VOD playable ──
++ const o2 = vod(2).object_id;
++ await verifyJob.runOnce({ batch: 50, head: async (provider) => (provider === 'b2' ? { size: 64 } : null), upload: async () => undefined });
++ assert.deepStrictEqual([r(vod(2)).bytes_verified, r(vod(2)).playable, r(vod(2)).verified_copies], [true, true, ['b2']],
++ 'a HEAD with the right size verifies the B2 copy');
++ raw.prepare("UPDATE media_locations SET state = 'pending', verified_at = NULL WHERE object_id = ?").run(o2);
++ console.log('✅ readiness: an offloaded VOD becomes playable once its copy is verified');
++
++ // ── HTTP: v1 JSON, v2 JSON, watch pages ──
++ const app = express();
++ app.use(express.json());
++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++ app.use('/api/v2/:app/objects', require('../server/objects/routes'));
++ app.use('/', require('../server/public/routes'));
++ const server = await new Promise((res) => { const s = app.listen(0, '127.0.0.1', () => res(s)); });
++ const get = (p, headers = {}) => new Promise((resolve, reject) => {
++ http.get({ host: '127.0.0.1', port: server.address().port, path: p, headers }, (res) => {
++ let body = ''; res.setEncoding('utf8'); res.on('data', c => { body += c; });
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body, json: () => JSON.parse(body) }));
++ }).on('error', reject);
++ });
++ const key = { authorization: 'Bearer games-key' };
++ const nav = { accept: 'text/html,*/*;q=0.8', 'sec-fetch-dest': 'document' };
++
++ let res = await get('/api/v1/games/vods/1', key);
++ assert.strictEqual(res.status, 200);
++ assert.deepStrictEqual([res.json().status, res.json().readiness.playable], ['ready', true], 'GET /vods/:id carries readiness');
++ res = await get('/api/v1/games/vods/2', key);
++ assert.deepStrictEqual([res.json().status, res.json().readiness.playable, res.json().readiness.reason], ['ready', false, 'verification_pending'],
++ 'status stays as it was; readiness says the bytes are not verified');
++ assert.ok(res.json().playback_url, 'existing fields are unchanged');
++ res = await get('/api/v1/games/vods?include_recording=1', key);
++ assert.ok(res.json().vods.every(v => v.readiness && typeof v.readiness.playable === 'boolean'), 'lists carry it too');
++ res = await get('/api/v1/games/clips/12', key);
++ assert.deepStrictEqual([res.json().status, res.json().readiness.reason], ['processing', 'processing']);
++ res = await get(`/api/v2/games/objects/${vod(1).object_id}`, key);
++ assert.deepStrictEqual(res.json().readiness, { metadata: true, bytes_verified: true, playable: true, hash_verified: true, verified_copies: ['local'], reason: null });
++ res = await get('/api/v2/games/objects?kind=vod', key);
++ assert.ok(res.json().objects.find(o => o.id === vod(2).object_id).readiness.reason === 'verification_pending', 'the v2 list carries it');
++ console.log('✅ v1 VOD/clip JSON and v2 object JSON carry readiness (existing fields unchanged)');
++
++ const hasPlayer = (html) => /<video[\s>]/.test(html);
++ res = await get('/v/1', nav);
++ assert.ok(hasPlayer(res.body) && res.body.includes('<meta property="og:video"'), 'a playable VOD gets its player');
++ res = await get('/v/2', nav);
++ assert.strictEqual(res.status, 200);
++ assert.ok(!hasPlayer(res.body), 'no player for bytes nobody verified');
++ assert.ok(res.body.includes('Still processing') && res.body.includes('data-readiness="verification_pending"'));
++ assert.ok(res.body.includes('content="noindex, follow"') && !res.body.includes('og:video') && !res.body.includes('application/ld+json') && !res.body.includes(' download>'),
++ 'no index, no video metadata, no structured data, no download');
++ res = await get('/v/3', nav);
++ assert.ok(!hasPlayer(res.body) && res.body.includes('Still being recorded'));
++ res = await get('/v/3?raw=1', {});
++ assert.strictEqual(res.status, 200, 'the bytes of a recording still serve (Live\'s DVR reads them)');
++ res = await get('/v/4', nav);
++ assert.ok(!hasPlayer(res.body) && res.body.includes('could not be processed'));
++ res = await get('/v/5', nav);
++ assert.ok(!hasPlayer(res.body) && res.body.includes('No verified copy'));
++ res = await get('/c/11', nav);
++ assert.ok(hasPlayer(res.body), 'a cut clip plays');
++ res = await get('/c/12', nav);
++ assert.deepStrictEqual([res.status, hasPlayer(res.body), res.body.includes('still being cut')], [200, false, true], 'a clip being cut says so');
++ res = await get('/c/12', { accept: 'video/webm,*/*;q=0.5', 'sec-fetch-dest': 'video' });
++ assert.deepStrictEqual([res.status, res.json().error], [404, 'Not found'], 'its bytes are still a plain 404');
++ res = await get('/c/13', nav);
++ assert.ok(!hasPlayer(res.body) && res.body.includes('could not be cut'));
++ console.log('✅ watch pages offer a player only when playable, and say why otherwise; the bytes route is unchanged');
++
++ server.close();
++ db.close();
++ fs.rmSync(tmp, { recursive: true, force: true });
++ console.log('readiness: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });