openvibers/openvibe.media Commits
Watch ids: every VOD and clip answers to an 11-character random id (#340)
Changes
@@@ -2,6 +2,18 @@## Unreleased++- **Watch ids (plan track U, 2026-11-07): every VOD and clip answers to an 11-character random id.** YouTube's++ `/watch?v=` shape, so a watch page cannot be found by counting: 8 random bytes as URL-safe base64++ (`crypto.randomBytes(8).toString('base64url')`), never derived from the numeric id. Media owns VODs and clips, so++ Media mints it (`server/watch-ids.js`): at insert for every new row, and in idempotent batches — only where++ `watch_id IS NULL` — for the rows that predate it, at boot. One id space across both tables (`mintUnique()` checks++ both), so a watch id has exactly one meaning. `watch_id` is on every VOD and clip Media serves (`vodPublic`,++ `clipPublic`, so every API read and list), and `GET /api/v1/:app/watch/:watch_id` → `{ kind, id }` resolves one; a++ private item still resolves, and its own read applies the visibility rule it always did. Migration++ `0024_watch_ids.sql` (expand: a nullable column and a partial unique index per table; no table rewrite). Tests:++ `test/watch-ids.test.js` (format, insert, the resolver, the backfill idempotent and leaving none NULL, uniqueness++ across both tables, the route).++- **Audio description on a watch page (X1, consent plan 2026-10-31, "AI and data")**: the player now carries a`<track kind="descriptions">` for Live's VODs and clips, served same-origin at `/v/:id/descriptions.vtt` (and`/c/:id/descriptions.vtt`) from the WebVTT Live builds out of its scene notes and speech timeline. Public and link@@@ -245,7 +245,7 @@ and in `scripts/smoke-test.sh`.| 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, event |--| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, hls_url, thumbnail_url, storage_provider, readiness, … }` (`readiness`: [below](#readiness); `hls_url`, with `hls_expires_at` when signed: the object's master playlist with `MEDIA_HLS_ENABLED`, docs/media-fabric.md §3) |++| GET | `/vods/:id` | `{ id, watch_id, title, status, duration, playback_url, hls_url, thumbnail_url, storage_provider, readiness, … }` (`watch_id`: the item's 11-character id on openvibe.video's `/watch?v=` page, [below](#watch-ids); `readiness`: [below](#readiness); `hls_url`, with `hls_expires_at` when signed: the object's master playlist with `MEDIA_HLS_ENABLED`, docs/media-fabric.md §3) || 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 |@@@ -253,6 +253,18 @@ and in `scripts/smoke-test.sh`.`status`: `pending → recording → ready | failed` (derived; failures come fromhealth quarantine: corrupt / zero-byte / missing file).++<a id="watch-ids"></a>**Watch ids.** Every VOD and clip also answers to an 11-character watch id:++8 random bytes as URL-safe base64 (`crypto.randomBytes(8).toString('base64url')`), YouTube's shape, never++derived from the numeric id. It is what openvibe.video's `/watch?v=<watch_id>` page is addressed by, and it++exists so a watch page cannot be found by counting. Media owns VODs and clips, so Media mints the id++(`server/watch-ids.js`): at insert for every new row, and in idempotent batches for rows that predate it (a++boot backfill, only where `watch_id IS NULL`). One id space across both tables: `mintUnique()` checks both++before it answers, so a watch id has exactly one meaning.++++`GET /api/v1/:app/watch/:watch_id` → `{ kind: 'vod' | 'clip', id: <numeric id> }` (404 when no row holds++it). Visibility is unchanged: a private item still resolves here, and the item's own read applies the rule it++always did. An unlisted item stays reachable only by its watch id, never listed.++<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 objectmodel: `bytes_verified` needs a copy whose bytes a check found (local file, B2/R2 HEAD, sha256 when@@@ -48,7 +48,8 @@"on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it","timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows — each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 — with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame","HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",-- "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"++ "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input",++ "watch ids: every VOD and clip answers to an 11-character random id, resolved by GET /api/v1/:app/watch/:watch_id"],"updated": "2026-11-07"}nnew file mode 100644@@@ -0,0 +1,16 @@++-- phase: expand++-- A watch id for every VOD and clip (plan track U, 2026-11-07): 11 characters of URL-safe base64 from 8 random bytes++-- (crypto.randomBytes(8).toString('base64url')), YouTube's shape, so a watch page is openvibe.video/watch?v=<watch id>++-- instead of /v/<number>, which anyone could walk. The id is minted here, by the service that owns VODs and clips, and is++-- never derived from the numeric id.++--++-- Nullable on purpose: rows written before this release have none, and they are filled in batches afterwards++-- (server/watch-ids.js backfill, at boot, only where watch_id IS NULL) so the migration itself takes no table rewrite++-- and holds no lock over the whole table. Reads fall back to the number until a row has one. Additive only: an older++-- release never reads or writes the column.++ALTER TABLE vods ADD COLUMN IF NOT EXISTS watch_id text COLLATE "C";++ALTER TABLE clips ADD COLUMN IF NOT EXISTS watch_id text COLLATE "C";++++-- The lookup is by id alone and must be one index probe. Partial, so the backfill's many NULLs index nothing.++CREATE UNIQUE INDEX IF NOT EXISTS ux_vods_watch_id ON vods (watch_id) WHERE watch_id IS NOT NULL;++CREATE UNIQUE INDEX IF NOT EXISTS ux_clips_watch_id ON clips (watch_id) WHERE watch_id IS NOT NULL;@@@ -221,15 +221,17 @@ async function ensureRootNamespace(app) {// ── VOD helpers ──────────────────────────────────────────────--// The row and its media_object in one transaction (a clips-only recording has no object).++// The row and its media_object in one transaction (a clips-only recording has no object). Every new row gets its watch++// id here (server/watch-ids.js): the id openvibe.video's /watch?v= page is addressed by.async function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta, visibility, clips_only, audio_only }) {const vis = visibility ? _normVisibility(visibility) : 'public';++ const watchId = await require('../watch-ids').mintUnique();return await withObject('vod', (r) => r.lastInsertRowid, async () => await run(-- `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only, audio_only)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,++ `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only, audio_only, watch_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,[app_id, stream_id || null, stream_key || null, managed_stream_id || null, user_id || null, title || 'Recording', description || '',file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,-- JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0, audio_only ? 1 : 0]++ JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0, audio_only ? 1 : 0, watchId]));}@@@ -452,16 +454,17 @@ function vodStatus(vod) {// ── Clip helpers ─────────────────────────────────────────────// The row and its media_object in one transaction. `visibility` (public | unlisted | private), when--// given, sets both columns; otherwise is_public picks public or unlisted as before.++// given, sets both columns; otherwise is_public picks public or unlisted as before. A new row gets its watch id here.async function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only }) {const pub = (is_public === 0 || is_public === false) ? 0 : 1;const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');++ const watchId = await require('../watch-ids').mintUnique();return await withObject('clip', (r) => r.lastInsertRowid, async () => await run(-- `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,++ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only, watch_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,[app_id, vod_id || null, stream_id || null, user_id || null, channel_user_id || null, title || 'Untitled Clip', description || '',file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,-- vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null, audio_only ? 1 : 0]++ vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null, audio_only ? 1 : 0, watchId]));}@@@ -297,6 +297,8 @@ const ready = (async () => {}app.use('/api/v1/:app/views', require('./views/routes'));++ // GET /api/v1/:app/watch/:watch_id: which item a watch id is (server/watch-routes.js, plan track U).++ app.use('/api/v1/:app/watch', require('./watch-routes'));app.use('/api/v1/:app/vods', require('./vod/routes'));app.use('/api/v1/:app/recordings', require('./vod/recordings').router);app.use('/api/v1/:app/clips', require('./vod/clips-routes'));@@@ -467,6 +469,12 @@ const ready = (async () => {try { require('./events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }// Media's own watch pages in OpenVibe.Search (WS-O task 10): media.index_document.* through the same outbox.require('./public/search-documents').start().catch((err) => console.warn('[Search] documents not started:', err.message));++ // Watch ids (plan track U): fill the rows that predate them, a batch at a time, until none is left. It only++ // writes where watch_id IS NULL, so a restart resumes and a second run changes nothing; a read that needs an++ // id sooner fills its own row (watch-ids.ensure). A drill writes nothing and skips it.++ require('./watch-ids').backfillAll({ log: console })++ .then((r) => console.log(`[WatchId] backfill done: ${r.filled} row(s) filled, ${r.remaining} left without an id`))++ .catch((err) => console.warn('[WatchId] backfill stopped:', err.message));});// A drill whose port is taken stops instead of running unready.@@@ -41,10 +41,16 @@ const APP_PUBLIC_URLS = (() => {const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;// Live's recordings and clips are watched on OpenVibe.Video since 2026-10-19: Live's /vod/<id> and /clip/<id> 301 there.const VIDEO_PUBLIC_URL = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');--/** The canonical watch page of an app's VOD or clip: OpenVibe.Video's for Live's, the app's own /vod or /clip otherwise. */--function watchPageUrl(kind, appId, id) {++/**++ * The canonical watch page of an app's VOD or clip: OpenVibe.Video's /watch?v=<watch id> for Live's (YouTube's shape,++ * plan track U), the app's own /vod or /clip otherwise. A Live item with no watch id yet (a row the backfill has not++ * reached and the caller did not fill) falls back to the numbered page Video still answers and redirects.++ */++function watchPageUrl(kind, appId, id, watchId = null) {const app = appId || 'live';-- if (app === 'live') return `${VIDEO_PUBLIC_URL}/${kind === 'clip' ? 'c' : 'v'}/${id}`;++ if (app === 'live') {++ return watchId ? `${VIDEO_PUBLIC_URL}/watch?v=${watchId}` : `${VIDEO_PUBLIC_URL}/${kind === 'clip' ? 'c' : 'v'}/${id}`;++ }return `${appUrl(app)}/${kind === 'clip' ? 'clip' : 'vod'}/${id}`;}@@@ -46,9 +46,9 @@ function creatorLine(record) {return record.username || record.display_name || null;}--/** Where this item is watched: OpenVibe.Video for Live's, the owning app's own page otherwise. */++/** Where this item is watched: OpenVibe.Video for Live's (on its /watch?v=<watch id> page), the owning app's own page otherwise. */function sourcePage(kind, record) {-- return watchPageUrl(kind, record.app_id, record.id);++ return watchPageUrl(kind, record.app_id, record.id, record.watch_id || null);}/**@@@ -291,7 +291,12 @@ async function serveMediaRecord(kind, record, req, res) {if (pages.wantsHtmlPage(req)) {// A watch page names media that can become private at any moment: no stale serving, or a cache// could still hand out its title and description afterwards.-- res.set('Cache-Control', cache.htmlHeaders(audience === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));++ // A Live item's canonical is Video's /watch?v=<watch id>: fill an id the backfill has not reached yet, so the++ // canonical names the page rather than a numbered URL that only redirects to it.++ if ((record.app_id || 'live') === 'live' && !record.watch_id) {++ record.watch_id = await require('../watch-ids').ensure(kind, record.id);++ }++ res.set('Cache-Control', cache.htmlHeaders(visibility === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));}if (drill.refuseBytes(res)) return;@@@ -74,6 +74,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {id: clip.id,app_id: clip.app_id,++ // The id its watch page is addressed by (openvibe.video/watch?v=<watch_id>), in the same id space as a VOD's.++ watch_id: clip.watch_id || await require('../watch-ids').ensure('clip', clip.id),vod_id: clip.vod_id,// A clip over a recording (docs/recordings.md): start_time/end_time are in the recording's time....(clip.recording_object_id && { recording_id: clip.recording_object_id, time_base: 'recording' }),@@@ -48,6 +48,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {id: vod.id,app_id: vod.app_id,++ // The id its watch page is addressed by (openvibe.video/watch?v=<watch_id>), minted by Media and fixed for the++ // row's life. A row that predates the ids has one filled here if the backfill has not reached it yet.++ watch_id: vod.watch_id || await require('../watch-ids').ensure('vod', vod.id),stream_id: vod.stream_id,managed_stream_id: vod.managed_stream_id || null,user_id: vod.user_id,nnew file mode 100644@@@ -0,0 +1,143 @@++/**++ * OpenVibe.Media — watch ids (plan track U, 2026-11-07).++ *++ * A VOD and a clip are watched at openvibe.video/watch?v=<watch id>, YouTube's shape: 11 characters of URL-safe base64++ * from 8 random bytes (64 bits), so a page cannot be found by counting. Media owns VODs and clips, so Media mints the++ * id: at insert for every new row (server/db/database.js createVod/createClip), and in batches for rows that predate++ * this (backfill, below, only where watch_id IS NULL, idempotent).++ *++ * One id space across the two tables. Each table has its own partial unique index (migrations/0024_watch_ids.sql), and++ * mint() checks both tables before it answers, so the same id is never handed to a VOD and a clip — resolve() then has++ * one answer to give, never a choice.++ */++'use strict';++++const crypto = require('crypto');++++const WATCH_ID_RE = /^[A-Za-z0-9_-]{11}$/;++const BACKFILL_BATCH = 500;++const MINT_ATTEMPTS = 5;++++/** A fresh watch id: 64 random bits as 11 URL-safe base64 characters (never derived from the numeric id). */++function mint() {++ return crypto.randomBytes(8).toString('base64url');++}++++/** Is this a watch id's shape? (The format check a route does before it queries anything.) */++function isWatchId(v) {++ return WATCH_ID_RE.test(String(v || ''));++}++++/** The database handle, late-bound (a test process opens it through test/helpers/pg-preload.mjs). */++function db() {++ return require('./db/database');++}++++/**++ * A watch id no VOD and no clip holds yet. Checks both tables, so the two tables share one id space; retries on the++ * vanishing chance of a clash. Throws after MINT_ATTEMPTS rather than answer a duplicate.++ */++async function mintUnique(query = db()) {++ for (let i = 0; i < MINT_ATTEMPTS; i++) {++ const id = mint();++ const taken = await query.get(++ 'SELECT 1 FROM vods WHERE watch_id = ? UNION ALL SELECT 1 FROM clips WHERE watch_id = ? LIMIT 1',++ [id, id]);++ if (!taken) return id;++ }++ throw new Error('could not mint a watch id that is free in both vods and clips');++}++++/**++ * The row's watch id, minting and storing one when it has none yet.++ *++ * The boot backfill fills existing rows in the background, but a row read before it is reached (or written by an older++ * release mid-deploy) still has an id to answer with: this fills it, once, with the same "only where NULL" write the++ * backfill uses, so a concurrent caller cannot overwrite an id already written. A restore drill reads its copy as it++ * is and writes nothing, so there it answers null rather than fail the read.++ */++async function ensure(kind, id, query = db()) {++ const table = kind === 'clip' ? 'clips' : 'vods';++ const row = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);++ if (!row) return null;++ if (row.watch_id) return row.watch_id;++ if (require('./drill').enabled) return null;++ const minted = await mintUnique(query);++ await query.run(`UPDATE ${table} SET watch_id = ? WHERE id = ? AND watch_id IS NULL`, [minted, id]);++ const after = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);++ return after ? after.watch_id || null : null;++}++++/**++ * Where a watch id points: { kind: 'vod' | 'clip', id: <numeric id> } or null.++ *++ * Visibility is the caller's business and unchanged by this: a private row still resolves here (the route answers it++ * exactly as it answered its number before), and an unlisted row is reachable only by its watch id, never listed.++ */++async function resolve(watchId, query = db()) {++ if (!isWatchId(watchId)) return null;++ const vod = await query.get('SELECT id FROM vods WHERE watch_id = ?', [watchId]);++ if (vod) return { kind: 'vod', id: Number(vod.id) };++ const clip = await query.get('SELECT id FROM clips WHERE watch_id = ?', [watchId]);++ if (clip) return { kind: 'clip', id: Number(clip.id) };++ return null;++}++++/** The watch id of one row, or null when it has none yet (a read before the backfill reached it). */++async function of(kind, id, query = db()) {++ const table = kind === 'clip' ? 'clips' : 'vods';++ const row = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);++ return row ? row.watch_id || null : null;++}++++/**++ * Fill the watch ids of rows that have none, in bounded batches, and say how many were filled.++ *++ * Idempotent and resumable: it only ever writes where watch_id IS NULL, so running it again after a partial pass (or++ * twice at once) fills what is left and changes nothing already judged. `limit` bounds one call (the boot backfill++ * passes a batch; a job or the CLI loops until `remaining` is 0). Each batch is its own transaction, so nothing holds a++ * lock over a whole table.++ *++ * Returns { vods, clips, remaining, filled }.++ */++async function backfill({ limit = BACKFILL_BATCH, query = db(), log = null } = {}) {++ const filled = { vods: 0, clips: 0 };++ let budget = Math.max(0, Number(limit) || 0);++ for (const [kind, table] of [['vod', 'vods'], ['clip', 'clips']]) {++ if (!budget) break;++ const rows = await query.all(++ `SELECT id FROM ${table} WHERE watch_id IS NULL ORDER BY id LIMIT ?`, [budget]);++ for (const row of rows) {++ // The id is chosen per row and written only where the row is still empty: two callers racing cannot both++ // fill the same row, and neither overwrites an id another caller just wrote.++ const id = await mintUnique(query);++ const out = await query.run(++ `UPDATE ${table} SET watch_id = ? WHERE id = ? AND watch_id IS NULL`, [id, row.id]);++ if (out && Number(out.changes) > 0) filled[table] += 1;++ }++ budget = Math.max(0, budget - rows.length);++ }++ const left = async (table) => Number((await query.get(`SELECT COUNT(*) AS n FROM ${table} WHERE watch_id IS NULL`))?.n) || 0;++ const remaining = await left('vods') + await left('clips');++ if (log && (filled.vods || filled.clips)) {++ log.log(`[WatchId] backfilled ${filled.vods} VOD(s) and ${filled.clips} clip(s); ${remaining} row(s) still without one`);++ }++ return { vods: filled.vods, clips: filled.clips, filled: filled.vods + filled.clips, remaining };++}++++/**++ * Fill every row that has none, batch after batch, until none is left. The boot calls this once; a big estate is++ * filled in bounded transactions rather than one long write. Returns the totals of this run.++ */++async function backfillAll({ batch = BACKFILL_BATCH, query = db(), log = null, maxBatches = 10_000 } = {}) {++ const total = { vods: 0, clips: 0, filled: 0, remaining: 0 };++ for (let i = 0; i < maxBatches; i++) {++ const out = await backfill({ limit: batch, query, log: null });++ total.vods += out.vods; total.clips += out.clips; total.filled += out.filled; total.remaining = out.remaining;++ if (log && out.filled) log.log(`[WatchId] ${total.filled} row(s) filled so far, ${out.remaining} left`);++ if (!out.filled) break; // nothing left, or a batch that could fill nothing (do not spin)++ }++ return total;++}++++module.exports = { mint, mintUnique, isWatchId, resolve, of, ensure, backfill, backfillAll, WATCH_ID_RE, BACKFILL_BATCH };nnew file mode 100644@@@ -0,0 +1,34 @@++/**++ * OpenVibe.Media — the watch-id API (plan track U, 2026-11-07).++ *++ * GET /api/v1/:app/watch/:watch_id → { kind: 'vod' | 'clip', id: <numeric id> } (404 when no row holds it)++ *++ * An app that has a watch id — from a link, a share sheet, a stored canonical URL — asks Media which item it is. The++ * answer carries the kind and the numeric id only: the caller then reads the item through its own scoped read++ * (GET /vods/:id, GET /clips/:id), so a watch id belonging to another tenant resolves to an id that caller's read++ * answers 404 for, exactly as it did before the watch ids existed. Media's own visibility rules are unchanged.++ *++ * Ids are minted by this service (server/watch-ids.js) and are one space across VODs and clips, so a watch id has++ * exactly one answer.++ */++'use strict';++++const express = require('express');++const { tenantAuth } = require('./auth');++const watchIds = require('./watch-ids');++++const router = express.Router({ mergeParams: true });++++router.get('/:watchId', tenantAuth({ verb: 'read', allowUser: true }), async (req, res) => {++ try {++ const ref = await watchIds.resolve(String(req.params.watchId || ''));++ if (!ref) return res.status(404).json({ error: 'Not found', code: 'media.watch.not_found' });++ res.set('Cache-Control', 'private, max-age=0');++ res.json(ref);++ } catch (err) {++ console.error('[Watch] resolve error:', err.message);++ res.status(500).json({ error: 'Failed to resolve the watch id' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,159 @@++'use strict';++// Watch ids (plan track U, 2026-11-07): every VOD and clip is addressed by an 11-character random id as well as its++// number, minted by Media (which owns both tables), in one id space across the two.++// - the format: 11 URL-safe base64 characters from 8 random bytes, never derived from the numeric id;++// - a new VOD and a new clip each get one at insert, distinct, and no two rows share an id;++// - a resolver answers { kind, id } for one and 404 for an unknown one;++// - the backfill fills rows written before the column, is idempotent (a second pass fills nothing, changes nothing),++// and leaves no row without an id;++// - watch_id is on the public shapes Media serves (vodPublic / clipPublic, and so every API read and list) and on a++// private row too: visibility is unchanged, so a private item still resolves and still answers as before.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const crypto = require('crypto');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-watch-ids-'));++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };++ Object.assign(process.env, {++ 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', MEDIA_SIGNING_SECRET: 'test-signing-secret',++ });++ delete process.env.MEDIA_DRILL;++++ const express = require('express');++ const db = require('../server/db/database');++ const watch = require('../server/watch-ids');++ const { vodPublic } = require('../server/vod/finalize');++ const { clipPublic } = require('../server/vod/clips-routes');++++ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });++ await db.upsertApp({ app_id: 'games', api_key: 'games-key' });++++ // ── The format ─────────────────────────────────────────────++ for (let i = 0; i < 200; i++) {++ const id = watch.mint();++ assert.match(id, /^[A-Za-z0-9_-]{11}$/, `mint() gave ${id}`);++ assert.strictEqual(Buffer.from(id, 'base64url').length, 8, '11 base64url characters are 8 bytes (64 bits)');++ assert.ok(watch.isWatchId(id));++ }++ assert.ok(!watch.isWatchId('6203'), 'a number is not a watch id');++ assert.ok(!watch.isWatchId('short'), 'a short string is not a watch id');++ assert.ok(!watch.isWatchId(''), 'an empty string is not a watch id');++ console.log('✅ the id: 11 URL-safe base64 characters from 8 random bytes, never a number');++++ // ── A new row gets one, and it is not its number ──────────++ const vodId = Number((await db.createVod({ app_id: 'live', title: 'A stream', visibility: 'public' })).lastInsertRowid);++ const clipId = Number((await db.createClip({ app_id: 'live', vod_id: vodId, title: 'A moment', visibility: 'public' })).lastInsertRowid);++ const vodRow = await db.getVodById(vodId);++ const clipRow = await db.getClipById(clipId);++ assert.ok(vodRow.watch_id, 'a new VOD has a watch id at insert');++ assert.ok(clipRow.watch_id, 'a new clip has a watch id at insert');++ assert.match(vodRow.watch_id, /^[A-Za-z0-9_-]{11}$/);++ assert.notStrictEqual(vodRow.watch_id, String(vodId), 'the id is not the number');++ assert.notStrictEqual(vodRow.watch_id, clipRow.watch_id, 'a VOD and a clip never share one');++ assert.strictEqual((await db.getVodById(vodId)).watch_id, vodRow.watch_id, 'the id never changes');++ console.log('✅ a new VOD and a new clip each get one at insert, distinct from each other and from their numbers');++++ // ── The resolver ─────────────────────────────────────────++ assert.deepStrictEqual(await watch.resolve(vodRow.watch_id), { kind: 'vod', id: vodId });++ assert.deepStrictEqual(await watch.resolve(clipRow.watch_id), { kind: 'clip', id: clipId });++ assert.strictEqual(await watch.resolve('nosuchidxyz'), null, 'an unknown id resolves to nothing');++ assert.strictEqual(await watch.resolve('6203'), null, 'a number is not looked up as a watch id');++ console.log('✅ the resolver answers { kind, id } for a VOD and a clip, and nothing for an unknown id');++++ // ── The public shapes carry it ────────────────────────────++ const vodShape = await vodPublic(await db.getVodById(vodId), { hls: false });++ const clipShape = await clipPublic(await db.getClipById(clipId), { hls: false });++ assert.strictEqual(vodShape.watch_id, vodRow.watch_id, 'vodPublic carries the watch id');++ assert.strictEqual(clipShape.watch_id, clipRow.watch_id, 'clipPublic carries the watch id');++ console.log('✅ the public shapes Media serves carry watch_id');++++ // ── A private row resolves too; visibility is the route's business ──++ const privId = Number((await db.createVod({ app_id: 'live', title: 'A private stream', visibility: 'private' })).lastInsertRowid);++ const privRow = await db.getVodById(privId);++ assert.ok(privRow.watch_id, 'a private VOD has a watch id too');++ assert.deepStrictEqual(await watch.resolve(privRow.watch_id), { kind: 'vod', id: privId },++ 'the resolver answers; the route then applies the same visibility rule the number had');++ console.log('✅ a private item resolves by its watch id, and its visibility is still the route\'s to enforce');++++ // ── The backfill: rows written before the column ──────────++ // Simulate the pre-column estate: clear the ids the inserts minted, and add rows with none at all.++ await db.run('UPDATE vods SET watch_id = NULL');++ await db.run('UPDATE clips SET watch_id = NULL');++ const legacyVods = [];++ for (let i = 0; i < 12; i++) {++ legacyVods.push(Number((await db.run(++ `INSERT INTO vods (app_id, title, is_public, visibility) VALUES ('live', ?, 1, 'public') RETURNING id`,++ [`legacy ${i}`])).lastInsertRowid));++ }++ const before = await db.get('SELECT COUNT(*) AS n FROM vods WHERE watch_id IS NULL');++ assert.ok(Number(before.n) >= 13, 'the estate is all NULL before the backfill');++++ const first = await watch.backfillAll({ batch: 4 });++ assert.ok(first.filled >= 13, `the backfill filled every row (filled ${first.filled})`);++ assert.strictEqual(first.remaining, 0, 'no row is left without an id');++ const leftVods = Number((await db.get('SELECT COUNT(*) AS n FROM vods WHERE watch_id IS NULL'))?.n);++ const leftClips = Number((await db.get('SELECT COUNT(*) AS n FROM clips WHERE watch_id IS NULL'))?.n);++ assert.strictEqual(leftVods, 0, 'every VOD has an id');++ assert.strictEqual(leftClips, 0, 'every clip has an id');++ console.log(`✅ the backfill: ${first.filled} row(s) filled, 0 left NULL`);++++ // ── Idempotent: a second pass writes nothing and changes nothing ──++ const snapshot = async () => {++ const v = await db.all('SELECT id, watch_id FROM vods ORDER BY id');++ const c = await db.all('SELECT id, watch_id FROM clips ORDER BY id');++ return JSON.stringify({ v, c });++ };++ const was = await snapshot();++ const second = await watch.backfillAll({ batch: 4 });++ assert.strictEqual(second.filled, 0, 'a second backfill fills nothing');++ assert.strictEqual(second.remaining, 0);++ assert.strictEqual(await snapshot(), was, 'and changes no id that was already there');++ console.log('✅ the backfill is idempotent: a second pass fills nothing and changes nothing');++++ // ── Uniqueness across the whole estate, both tables ────────++ const allIds = [++ ...(await db.all('SELECT id, watch_id FROM vods')).map((r) => r.watch_id),++ ...(await db.all('SELECT id, watch_id FROM clips')).map((r) => r.watch_id),++ ];++ for (const id of allIds) assert.match(id, /^[A-Za-z0-9_-]{11}$/, `every row has a well-formed id (${id})`);++ assert.strictEqual(new Set(allIds).size, allIds.length, 'every id is unique across vods and clips');++ console.log(`✅ ${allIds.length} rows, every id unique across both tables`);++++ // ── The API route: GET /api/v1/:app/watch/:watch_id ────────++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/watch', require('../server/watch-routes'));++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ try {++ const get = async (p) => {++ const r = await fetch(base + p, { headers: { authorization: 'Bearer live-key' } });++ const text = await r.text();++ let body = null; try { body = JSON.parse(text); } catch { /* */ }++ return { status: r.status, body };++ };++ const someVod = await db.get('SELECT id, watch_id FROM vods WHERE watch_id IS NOT NULL LIMIT 1');++ const api = await get(`/api/v1/live/watch/${someVod.watch_id}`);++ assert.strictEqual(api.status, 200, 'a known watch id answers 200');++ assert.deepStrictEqual(api.body, { kind: 'vod', id: Number(someVod.id) });++ const missing = await get('/api/v1/live/watch/zzzzzzzzzzz');++ assert.strictEqual(missing.status, 404, 'an unknown watch id is the same 404 a missing id gets');++ const numeric = await get(`/api/v1/live/watch/${someVod.id}`);++ assert.strictEqual(numeric.status, 404, 'a number is not a watch id');++ console.log('✅ GET /api/v1/:app/watch/:watch_id resolves one, and answers 404 for an unknown id');++ } finally {++ await new Promise((r) => server.close(r));++ }++++ await db.close();++ console.log('\nAll watch-id checks passed.');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -2,6 +2,18 @@
@@ -2,6 +2,18 @@
## Unreleased
## Unreleased
+- **Watch ids (plan track U, 2026-11-07): every VOD and clip answers to an 11-character random id.** YouTube's
+ `/watch?v=` shape, so a watch page cannot be found by counting: 8 random bytes as URL-safe base64
+ (`crypto.randomBytes(8).toString('base64url')`), never derived from the numeric id. Media owns VODs and clips, so
+ Media mints it (`server/watch-ids.js`): at insert for every new row, and in idempotent batches — only where
+ `watch_id IS NULL` — for the rows that predate it, at boot. One id space across both tables (`mintUnique()` checks
+ both), so a watch id has exactly one meaning. `watch_id` is on every VOD and clip Media serves (`vodPublic`,
+ `clipPublic`, so every API read and list), and `GET /api/v1/:app/watch/:watch_id` → `{ kind, id }` resolves one; a
+ private item still resolves, and its own read applies the visibility rule it always did. Migration
+ `0024_watch_ids.sql` (expand: a nullable column and a partial unique index per table; no table rewrite). Tests:
+ `test/watch-ids.test.js` (format, insert, the resolver, the backfill idempotent and leaving none NULL, uniqueness
+ across both tables, the route).
+
- **Audio description on a watch page (X1, consent plan 2026-10-31, "AI and data")**: the player now carries a
- **Audio description on a watch page (X1, consent plan 2026-10-31, "AI and data")**: the player now carries a
`<track kind="descriptions">` for Live's VODs and clips, served same-origin at `/v/:id/descriptions.vtt` (and
`<track kind="descriptions">` for Live's VODs and clips, served same-origin at `/v/:id/descriptions.vtt` (and
`/c/:id/descriptions.vtt`) from the WebVTT Live builds out of its scene notes and speech timeline. Public and link
`/c/:id/descriptions.vtt`) from the WebVTT Live builds out of its scene notes and speech timeline. Public and link
@@ -245,7 +245,7 @@ and in `scripts/smoke-test.sh`.
@@ -245,7 +245,7 @@ and in `scripts/smoke-test.sh`.
| POST | `/vods/:id/chunks` | multipart `chunk` (+`segmentId`), user JWT ok — browser MediaRecorder append flow |
| 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/complete` | finalize chunked upload (user JWT ok) |
| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, event |
| POST | `/vods/:id/finalize` | close recording; remux, probe, thumbnail, event |
-| GET | `/vods/:id` | `{ id, title, status, duration, playback_url, hls_url, thumbnail_url, storage_provider, readiness, … }` (`readiness`: [below](#readiness); `hls_url`, with `hls_expires_at` when signed: the object's master playlist with `MEDIA_HLS_ENABLED`, docs/media-fabric.md §3) |
+| GET | `/vods/:id` | `{ id, watch_id, title, status, duration, playback_url, hls_url, thumbnail_url, storage_provider, readiness, … }` (`watch_id`: the item's 11-character id on openvibe.video's `/watch?v=` page, [below](#watch-ids); `readiness`: [below](#readiness); `hls_url`, with `hls_expires_at` when signed: the object's master playlist with `MEDIA_HLS_ENABLED`, docs/media-fabric.md §3) |
| 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) |
| 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? }` |
| PUT | `/vods/:id` | `{ title?, description?, visibility? }` |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
| DELETE | `/vods/:id` | deletes local + B2 + R2 objects + row |
@@ -253,6 +253,18 @@ and in `scripts/smoke-test.sh`.
@@ -253,6 +253,18 @@ and in `scripts/smoke-test.sh`.
`status`: `pending → recording → ready | failed` (derived; failures come from
`status`: `pending → recording → ready | failed` (derived; failures come from
health quarantine: corrupt / zero-byte / missing file).
health quarantine: corrupt / zero-byte / missing file).
+<a id="watch-ids"></a>**Watch ids.** Every VOD and clip also answers to an 11-character watch id:
+8 random bytes as URL-safe base64 (`crypto.randomBytes(8).toString('base64url')`), YouTube's shape, never
+derived from the numeric id. It is what openvibe.video's `/watch?v=<watch_id>` page is addressed by, and it
+exists so a watch page cannot be found by counting. Media owns VODs and clips, so Media mints the id
+(`server/watch-ids.js`): at insert for every new row, and in idempotent batches for rows that predate it (a
+boot backfill, only where `watch_id IS NULL`). One id space across both tables: `mintUnique()` checks both
+before it answers, so a watch id has exactly one meaning.
+
+`GET /api/v1/:app/watch/:watch_id` → `{ kind: 'vod' | 'clip', id: <numeric id> }` (404 when no row holds
+it). Visibility is unchanged: a private item still resolves here, and the item's own read applies the rule it
+always did. An unlisted item stays reachable only by its watch id, never listed.
+
<a id="readiness"></a>**Readiness.** VOD and clip answers (and v2 objects) also carry `readiness`
<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
`{ 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
model: `bytes_verified` needs a copy whose bytes a check found (local file, B2/R2 HEAD, sha256 when
@@ -48,7 +48,8 @@
@@ -48,7 +48,8 @@
"on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it",
"on-demand renditions (F4 slice 1, behind MEDIA_HLS_ENABLED + MEDIA_RENDITIONS): the rendition.create job transcodes one 720p H.264/AAC rung of a ready media object into media_timeline through the same content-addressed rows, placement-router durable provider and two-phase commit as object.cmaf, forcing the source timeline's segment boundaries as keyframes; a source already 720p or smaller is skipped and a held object is left alone. The master playlist lists a rendition only once its rows exist and queues the missing ones lazily from /o/:id/master.m3u8 and download?format=json (dedupeActive + an object/rendition idempotency key), and /o/:id/<rendition>/index.m3u8 plus /o/:id/<rendition>/<name> serve it",
"timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows — each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 — with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",
"timeline sprites (F3.1, behind MEDIA_HLS_ENABLED): object.sprite cuts its sheet from the object's media_timeline rows — each sample time picks the row covering it and one frame is decoded from that row's bytes (this node's segment, or a packed row's byte range in its chunk), checked against the row's sha256 — with the same layout and metadata as the seek path, so a packed long VOD is never decoded end to end; a source without a timeline keeps one fast seek per frame",
"HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
"HLS discovery on the v1 shapes (F3.c, behind MEDIA_HLS_ENABLED): the VOD and clip answers (vodPublic/clipPublic — get, list, create, update) carry hls_url (and hls_expires_at when the object is private or sandbox), the same field and access rules as GET /o/:id/download?format=json, read through one shared discovery (server/objects/hls.js); an object without a timeline gets object.cmaf queued and the field appears once it ran, and a virtual clip plays its source's window without being cut a timeline of its own",
- "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input"
+ "materialized clips (F3.6, behind MEDIA_HLS_ENABLED + MEDIA_MATERIALIZED_CLIPS, off by default): clip.cut over a source timeline writes the clip's own media_timeline rows — the interior copies the source's segments (packed chunk ranges included), only the two window edges are re-encoded into fMP4 with their own inits (init-head.mp4/init-tail.mp4, negative seq, EXT-X-MAP + EXT-X-DISCONTINUITY in the playlist) — inserting them in one transaction that locks the named source rows FOR SHARE and aborts with a retryable error when one is gone or any location column moved (a pack), so a racing delete either waits and keeps the bytes or aborts the insert; the projection keeps virtual: true and gains materialized: true (readiness, playableSql and the verify job treat it as a virtual clip), sprites decode each edge with its own init, deleting the clip removes its rows and edge bytes while the source's bytes stay, and download?format=mp4 answers the playlist for a timeline-only clip instead of queueing a remux that cannot read its input",
+ "watch ids: every VOD and clip answers to an 11-character random id, resolved by GET /api/v1/:app/watch/:watch_id"
],
],
"updated": "2026-11-07"
"updated": "2026-11-07"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+-- phase: expand
+-- A watch id for every VOD and clip (plan track U, 2026-11-07): 11 characters of URL-safe base64 from 8 random bytes
+-- (crypto.randomBytes(8).toString('base64url')), YouTube's shape, so a watch page is openvibe.video/watch?v=<watch id>
+-- instead of /v/<number>, which anyone could walk. The id is minted here, by the service that owns VODs and clips, and is
+-- never derived from the numeric id.
+--
+-- Nullable on purpose: rows written before this release have none, and they are filled in batches afterwards
+-- (server/watch-ids.js backfill, at boot, only where watch_id IS NULL) so the migration itself takes no table rewrite
+-- and holds no lock over the whole table. Reads fall back to the number until a row has one. Additive only: an older
+-- release never reads or writes the column.
+ALTER TABLE vods ADD COLUMN IF NOT EXISTS watch_id text COLLATE "C";
+ALTER TABLE clips ADD COLUMN IF NOT EXISTS watch_id text COLLATE "C";
+
+-- The lookup is by id alone and must be one index probe. Partial, so the backfill's many NULLs index nothing.
+CREATE UNIQUE INDEX IF NOT EXISTS ux_vods_watch_id ON vods (watch_id) WHERE watch_id IS NOT NULL;
+CREATE UNIQUE INDEX IF NOT EXISTS ux_clips_watch_id ON clips (watch_id) WHERE watch_id IS NOT NULL;
@@ -221,15 +221,17 @@ async function ensureRootNamespace(app) {
@@ -221,15 +221,17 @@ async function ensureRootNamespace(app) {
// ── VOD helpers ──────────────────────────────────────────────
// ── VOD helpers ──────────────────────────────────────────────
-// The row and its media_object in one transaction (a clips-only recording has no object).
+// The row and its media_object in one transaction (a clips-only recording has no object). Every new row gets its watch
+// id here (server/watch-ids.js): the id openvibe.video's /watch?v= page is addressed by.
async function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta, visibility, clips_only, audio_only }) {
async function createVod({ app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, file_size, duration_seconds, thumbnail_url, master_file_path, meta, visibility, clips_only, audio_only }) {
const vis = visibility ? _normVisibility(visibility) : 'public';
const vis = visibility ? _normVisibility(visibility) : 'public';
+ const watchId = await require('../watch-ids').mintUnique();
return await withObject('vod', (r) => r.lastInsertRowid, async () => await run(
return await withObject('vod', (r) => r.lastInsertRowid, async () => await run(
- `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only, audio_only)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ `INSERT INTO vods (app_id, stream_id, stream_key, managed_stream_id, user_id, title, description, file_path, master_file_path, file_size, duration_seconds, thumbnail_url, meta_json, is_public, visibility, clips_only, audio_only, watch_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
[app_id, stream_id || null, stream_key || null, managed_stream_id || null, user_id || null, title || 'Recording', description || '',
[app_id, stream_id || null, stream_key || null, managed_stream_id || null, user_id || null, title || 'Recording', description || '',
file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
file_path || null, master_file_path || null, file_size || 0, duration_seconds || 0, thumbnail_url || null,
- JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0, audio_only ? 1 : 0]
+ JSON.stringify(meta || {}), vis === 'public' ? 1 : 0, vis, clips_only ? 1 : 0, audio_only ? 1 : 0, watchId]
));
));
}
}
@@ -452,16 +454,17 @@ function vodStatus(vod) {
@@ -452,16 +454,17 @@ function vodStatus(vod) {
// ── Clip helpers ─────────────────────────────────────────────
// ── Clip helpers ─────────────────────────────────────────────
// The row and its media_object in one transaction. `visibility` (public | unlisted | private), when
// The row and its media_object in one transaction. `visibility` (public | unlisted | private), when
-// given, sets both columns; otherwise is_public picks public or unlisted as before.
+// given, sets both columns; otherwise is_public picks public or unlisted as before. A new row gets its watch id here.
async function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only }) {
async function createClip({ app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only }) {
const pub = (is_public === 0 || is_public === false) ? 0 : 1;
const pub = (is_public === 0 || is_public === false) ? 0 : 1;
const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');
const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');
+ const watchId = await require('../watch-ids').mintUnique();
return await withObject('clip', (r) => r.lastInsertRowid, async () => await run(
return await withObject('clip', (r) => r.lastInsertRowid, async () => await run(
- `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ `INSERT INTO clips (app_id, vod_id, stream_id, user_id, channel_user_id, title, description, file_path, thumbnail_url, start_time, end_time, duration_seconds, is_public, visibility, auto_generated, status, recording_object_id, audio_only, watch_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
[app_id, vod_id || null, stream_id || null, user_id || null, channel_user_id || null, title || 'Untitled Clip', description || '',
[app_id, vod_id || null, stream_id || null, user_id || null, channel_user_id || null, title || 'Untitled Clip', description || '',
file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
file_path || '', thumbnail_url || null, start_time || 0, end_time || 0, duration_seconds || 0,
- vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null, audio_only ? 1 : 0]
+ vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null, audio_only ? 1 : 0, watchId]
));
));
}
}
@@ -297,6 +297,8 @@ const ready = (async () => {
@@ -297,6 +297,8 @@ const ready = (async () => {
}
}
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/routes'));
+ // GET /api/v1/:app/watch/:watch_id: which item a watch id is (server/watch-routes.js, plan track U).
+ app.use('/api/v1/:app/watch', require('./watch-routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/recordings', require('./vod/recordings').router);
app.use('/api/v1/:app/recordings', require('./vod/recordings').router);
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
@@ -467,6 +469,12 @@ const ready = (async () => {
@@ -467,6 +469,12 @@ const ready = (async () => {
try { require('./events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }
try { require('./events').init(); } catch (err) { console.warn('[Events] not started:', err.message); }
// Media's own watch pages in OpenVibe.Search (WS-O task 10): media.index_document.* through the same outbox.
// Media's own watch pages in OpenVibe.Search (WS-O task 10): media.index_document.* through the same outbox.
require('./public/search-documents').start().catch((err) => console.warn('[Search] documents not started:', err.message));
require('./public/search-documents').start().catch((err) => console.warn('[Search] documents not started:', err.message));
+ // Watch ids (plan track U): fill the rows that predate them, a batch at a time, until none is left. It only
+ // writes where watch_id IS NULL, so a restart resumes and a second run changes nothing; a read that needs an
+ // id sooner fills its own row (watch-ids.ensure). A drill writes nothing and skips it.
+ require('./watch-ids').backfillAll({ log: console })
+ .then((r) => console.log(`[WatchId] backfill done: ${r.filled} row(s) filled, ${r.remaining} left without an id`))
+ .catch((err) => console.warn('[WatchId] backfill stopped:', err.message));
});
});
// A drill whose port is taken stops instead of running unready.
// A drill whose port is taken stops instead of running unready.
@@ -41,10 +41,16 @@ const APP_PUBLIC_URLS = (() => {
@@ -41,10 +41,16 @@ const APP_PUBLIC_URLS = (() => {
const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
const appUrl = (appId) => APP_PUBLIC_URLS[appId] || APP_PUBLIC_URLS.live;
// Live's recordings and clips are watched on OpenVibe.Video since 2026-10-19: Live's /vod/<id> and /clip/<id> 301 there.
// Live's recordings and clips are watched on OpenVibe.Video since 2026-10-19: Live's /vod/<id> and /clip/<id> 301 there.
const VIDEO_PUBLIC_URL = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');
const VIDEO_PUBLIC_URL = String(process.env.VIDEO_PUBLIC_URL || 'https://openvibe.video').replace(/\/+$/, '');
-/** The canonical watch page of an app's VOD or clip: OpenVibe.Video's for Live's, the app's own /vod or /clip otherwise. */
-function watchPageUrl(kind, appId, id) {
+/**
+ * The canonical watch page of an app's VOD or clip: OpenVibe.Video's /watch?v=<watch id> for Live's (YouTube's shape,
+ * plan track U), the app's own /vod or /clip otherwise. A Live item with no watch id yet (a row the backfill has not
+ * reached and the caller did not fill) falls back to the numbered page Video still answers and redirects.
+ */
+function watchPageUrl(kind, appId, id, watchId = null) {
const app = appId || 'live';
const app = appId || 'live';
- if (app === 'live') return `${VIDEO_PUBLIC_URL}/${kind === 'clip' ? 'c' : 'v'}/${id}`;
+ if (app === 'live') {
+ return watchId ? `${VIDEO_PUBLIC_URL}/watch?v=${watchId}` : `${VIDEO_PUBLIC_URL}/${kind === 'clip' ? 'c' : 'v'}/${id}`;
+ }
return `${appUrl(app)}/${kind === 'clip' ? 'clip' : 'vod'}/${id}`;
return `${appUrl(app)}/${kind === 'clip' ? 'clip' : 'vod'}/${id}`;
}
}
@@ -46,9 +46,9 @@ function creatorLine(record) {
@@ -46,9 +46,9 @@ function creatorLine(record) {
return record.username || record.display_name || null;
return record.username || record.display_name || null;
}
}
-/** Where this item is watched: OpenVibe.Video for Live's, the owning app's own page otherwise. */
+/** Where this item is watched: OpenVibe.Video for Live's (on its /watch?v=<watch id> page), the owning app's own page otherwise. */
function sourcePage(kind, record) {
function sourcePage(kind, record) {
- return watchPageUrl(kind, record.app_id, record.id);
+ return watchPageUrl(kind, record.app_id, record.id, record.watch_id || null);
}
}
/**
/**
@@ -291,7 +291,12 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -291,7 +291,12 @@ async function serveMediaRecord(kind, record, req, res) {
if (pages.wantsHtmlPage(req)) {
if (pages.wantsHtmlPage(req)) {
// A watch page names media that can become private at any moment: no stale serving, or a cache
// A watch page names media that can become private at any moment: no stale serving, or a cache
// could still hand out its title and description afterwards.
// could still hand out its title and description afterwards.
- res.set('Cache-Control', cache.htmlHeaders(audience === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));
+ // A Live item's canonical is Video's /watch?v=<watch id>: fill an id the backfill has not reached yet, so the
+ // canonical names the page rather than a numbered URL that only redirects to it.
+ if ((record.app_id || 'live') === 'live' && !record.watch_id) {
+ record.watch_id = await require('../watch-ids').ensure(kind, record.id);
+ }
+ res.set('Cache-Control', cache.htmlHeaders(visibility === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));
return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));
return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));
}
}
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
@@ -74,6 +74,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -74,6 +74,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
id: clip.id,
id: clip.id,
app_id: clip.app_id,
app_id: clip.app_id,
+ // The id its watch page is addressed by (openvibe.video/watch?v=<watch_id>), in the same id space as a VOD's.
+ watch_id: clip.watch_id || await require('../watch-ids').ensure('clip', clip.id),
vod_id: clip.vod_id,
vod_id: clip.vod_id,
// A clip over a recording (docs/recordings.md): start_time/end_time are in the recording's time.
// A clip over a recording (docs/recordings.md): start_time/end_time are in the recording's time.
...(clip.recording_object_id && { recording_id: clip.recording_object_id, time_base: 'recording' }),
...(clip.recording_object_id && { recording_id: clip.recording_object_id, time_base: 'recording' }),
@@ -48,6 +48,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
@@ -48,6 +48,9 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
id: vod.id,
id: vod.id,
app_id: vod.app_id,
app_id: vod.app_id,
+ // The id its watch page is addressed by (openvibe.video/watch?v=<watch_id>), minted by Media and fixed for the
+ // row's life. A row that predates the ids has one filled here if the backfill has not reached it yet.
+ watch_id: vod.watch_id || await require('../watch-ids').ensure('vod', vod.id),
stream_id: vod.stream_id,
stream_id: vod.stream_id,
managed_stream_id: vod.managed_stream_id || null,
managed_stream_id: vod.managed_stream_id || null,
user_id: vod.user_id,
user_id: vod.user_id,
new file mode 100644
new file mode 100644
@@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
+/**
+ * OpenVibe.Media — watch ids (plan track U, 2026-11-07).
+ *
+ * A VOD and a clip are watched at openvibe.video/watch?v=<watch id>, YouTube's shape: 11 characters of URL-safe base64
+ * from 8 random bytes (64 bits), so a page cannot be found by counting. Media owns VODs and clips, so Media mints the
+ * id: at insert for every new row (server/db/database.js createVod/createClip), and in batches for rows that predate
+ * this (backfill, below, only where watch_id IS NULL, idempotent).
+ *
+ * One id space across the two tables. Each table has its own partial unique index (migrations/0024_watch_ids.sql), and
+ * mint() checks both tables before it answers, so the same id is never handed to a VOD and a clip — resolve() then has
+ * one answer to give, never a choice.
+ */
+'use strict';
+
+const crypto = require('crypto');
+
+const WATCH_ID_RE = /^[A-Za-z0-9_-]{11}$/;
+const BACKFILL_BATCH = 500;
+const MINT_ATTEMPTS = 5;
+
+/** A fresh watch id: 64 random bits as 11 URL-safe base64 characters (never derived from the numeric id). */
+function mint() {
+ return crypto.randomBytes(8).toString('base64url');
+}
+
+/** Is this a watch id's shape? (The format check a route does before it queries anything.) */
+function isWatchId(v) {
+ return WATCH_ID_RE.test(String(v || ''));
+}
+
+/** The database handle, late-bound (a test process opens it through test/helpers/pg-preload.mjs). */
+function db() {
+ return require('./db/database');
+}
+
+/**
+ * A watch id no VOD and no clip holds yet. Checks both tables, so the two tables share one id space; retries on the
+ * vanishing chance of a clash. Throws after MINT_ATTEMPTS rather than answer a duplicate.
+ */
+async function mintUnique(query = db()) {
+ for (let i = 0; i < MINT_ATTEMPTS; i++) {
+ const id = mint();
+ const taken = await query.get(
+ 'SELECT 1 FROM vods WHERE watch_id = ? UNION ALL SELECT 1 FROM clips WHERE watch_id = ? LIMIT 1',
+ [id, id]);
+ if (!taken) return id;
+ }
+ throw new Error('could not mint a watch id that is free in both vods and clips');
+}
+
+/**
+ * The row's watch id, minting and storing one when it has none yet.
+ *
+ * The boot backfill fills existing rows in the background, but a row read before it is reached (or written by an older
+ * release mid-deploy) still has an id to answer with: this fills it, once, with the same "only where NULL" write the
+ * backfill uses, so a concurrent caller cannot overwrite an id already written. A restore drill reads its copy as it
+ * is and writes nothing, so there it answers null rather than fail the read.
+ */
+async function ensure(kind, id, query = db()) {
+ const table = kind === 'clip' ? 'clips' : 'vods';
+ const row = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);
+ if (!row) return null;
+ if (row.watch_id) return row.watch_id;
+ if (require('./drill').enabled) return null;
+ const minted = await mintUnique(query);
+ await query.run(`UPDATE ${table} SET watch_id = ? WHERE id = ? AND watch_id IS NULL`, [minted, id]);
+ const after = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);
+ return after ? after.watch_id || null : null;
+}
+
+/**
+ * Where a watch id points: { kind: 'vod' | 'clip', id: <numeric id> } or null.
+ *
+ * Visibility is the caller's business and unchanged by this: a private row still resolves here (the route answers it
+ * exactly as it answered its number before), and an unlisted row is reachable only by its watch id, never listed.
+ */
+async function resolve(watchId, query = db()) {
+ if (!isWatchId(watchId)) return null;
+ const vod = await query.get('SELECT id FROM vods WHERE watch_id = ?', [watchId]);
+ if (vod) return { kind: 'vod', id: Number(vod.id) };
+ const clip = await query.get('SELECT id FROM clips WHERE watch_id = ?', [watchId]);
+ if (clip) return { kind: 'clip', id: Number(clip.id) };
+ return null;
+}
+
+/** The watch id of one row, or null when it has none yet (a read before the backfill reached it). */
+async function of(kind, id, query = db()) {
+ const table = kind === 'clip' ? 'clips' : 'vods';
+ const row = await query.get(`SELECT watch_id FROM ${table} WHERE id = ?`, [id]);
+ return row ? row.watch_id || null : null;
+}
+
+/**
+ * Fill the watch ids of rows that have none, in bounded batches, and say how many were filled.
+ *
+ * Idempotent and resumable: it only ever writes where watch_id IS NULL, so running it again after a partial pass (or
+ * twice at once) fills what is left and changes nothing already judged. `limit` bounds one call (the boot backfill
+ * passes a batch; a job or the CLI loops until `remaining` is 0). Each batch is its own transaction, so nothing holds a
+ * lock over a whole table.
+ *
+ * Returns { vods, clips, remaining, filled }.
+ */
+async function backfill({ limit = BACKFILL_BATCH, query = db(), log = null } = {}) {
+ const filled = { vods: 0, clips: 0 };
+ let budget = Math.max(0, Number(limit) || 0);
+ for (const [kind, table] of [['vod', 'vods'], ['clip', 'clips']]) {
+ if (!budget) break;
+ const rows = await query.all(
+ `SELECT id FROM ${table} WHERE watch_id IS NULL ORDER BY id LIMIT ?`, [budget]);
+ for (const row of rows) {
+ // The id is chosen per row and written only where the row is still empty: two callers racing cannot both
+ // fill the same row, and neither overwrites an id another caller just wrote.
+ const id = await mintUnique(query);
+ const out = await query.run(
+ `UPDATE ${table} SET watch_id = ? WHERE id = ? AND watch_id IS NULL`, [id, row.id]);
+ if (out && Number(out.changes) > 0) filled[table] += 1;
+ }
+ budget = Math.max(0, budget - rows.length);
+ }
+ const left = async (table) => Number((await query.get(`SELECT COUNT(*) AS n FROM ${table} WHERE watch_id IS NULL`))?.n) || 0;
+ const remaining = await left('vods') + await left('clips');
+ if (log && (filled.vods || filled.clips)) {
+ log.log(`[WatchId] backfilled ${filled.vods} VOD(s) and ${filled.clips} clip(s); ${remaining} row(s) still without one`);
+ }
+ return { vods: filled.vods, clips: filled.clips, filled: filled.vods + filled.clips, remaining };
+}
+
+/**
+ * Fill every row that has none, batch after batch, until none is left. The boot calls this once; a big estate is
+ * filled in bounded transactions rather than one long write. Returns the totals of this run.
+ */
+async function backfillAll({ batch = BACKFILL_BATCH, query = db(), log = null, maxBatches = 10_000 } = {}) {
+ const total = { vods: 0, clips: 0, filled: 0, remaining: 0 };
+ for (let i = 0; i < maxBatches; i++) {
+ const out = await backfill({ limit: batch, query, log: null });
+ total.vods += out.vods; total.clips += out.clips; total.filled += out.filled; total.remaining = out.remaining;
+ if (log && out.filled) log.log(`[WatchId] ${total.filled} row(s) filled so far, ${out.remaining} left`);
+ if (!out.filled) break; // nothing left, or a batch that could fill nothing (do not spin)
+ }
+ return total;
+}
+
+module.exports = { mint, mintUnique, isWatchId, resolve, of, ensure, backfill, backfillAll, WATCH_ID_RE, BACKFILL_BATCH };
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+/**
+ * OpenVibe.Media — the watch-id API (plan track U, 2026-11-07).
+ *
+ * GET /api/v1/:app/watch/:watch_id → { kind: 'vod' | 'clip', id: <numeric id> } (404 when no row holds it)
+ *
+ * An app that has a watch id — from a link, a share sheet, a stored canonical URL — asks Media which item it is. The
+ * answer carries the kind and the numeric id only: the caller then reads the item through its own scoped read
+ * (GET /vods/:id, GET /clips/:id), so a watch id belonging to another tenant resolves to an id that caller's read
+ * answers 404 for, exactly as it did before the watch ids existed. Media's own visibility rules are unchanged.
+ *
+ * Ids are minted by this service (server/watch-ids.js) and are one space across VODs and clips, so a watch id has
+ * exactly one answer.
+ */
+'use strict';
+
+const express = require('express');
+const { tenantAuth } = require('./auth');
+const watchIds = require('./watch-ids');
+
+const router = express.Router({ mergeParams: true });
+
+router.get('/:watchId', tenantAuth({ verb: 'read', allowUser: true }), async (req, res) => {
+ try {
+ const ref = await watchIds.resolve(String(req.params.watchId || ''));
+ if (!ref) return res.status(404).json({ error: 'Not found', code: 'media.watch.not_found' });
+ res.set('Cache-Control', 'private, max-age=0');
+ res.json(ref);
+ } catch (err) {
+ console.error('[Watch] resolve error:', err.message);
+ res.status(500).json({ error: 'Failed to resolve the watch id' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
+'use strict';
+// Watch ids (plan track U, 2026-11-07): every VOD and clip is addressed by an 11-character random id as well as its
+// number, minted by Media (which owns both tables), in one id space across the two.
+// - the format: 11 URL-safe base64 characters from 8 random bytes, never derived from the numeric id;
+// - a new VOD and a new clip each get one at insert, distinct, and no two rows share an id;
+// - a resolver answers { kind, id } for one and 404 for an unknown one;
+// - the backfill fills rows written before the column, is idempotent (a second pass fills nothing, changes nothing),
+// and leaves no row without an id;
+// - watch_id is on the public shapes Media serves (vodPublic / clipPublic, and so every API read and list) and on a
+// private row too: visibility is unchanged, so a private item still resolves and still answers as before.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const crypto = require('crypto');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-watch-ids-'));
+ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
+ Object.assign(process.env, {
+ 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', MEDIA_SIGNING_SECRET: 'test-signing-secret',
+ });
+ delete process.env.MEDIA_DRILL;
+
+ const express = require('express');
+ const db = require('../server/db/database');
+ const watch = require('../server/watch-ids');
+ const { vodPublic } = require('../server/vod/finalize');
+ const { clipPublic } = require('../server/vod/clips-routes');
+
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key' });
+ await db.upsertApp({ app_id: 'games', api_key: 'games-key' });
+
+ // ── The format ─────────────────────────────────────────────
+ for (let i = 0; i < 200; i++) {
+ const id = watch.mint();
+ assert.match(id, /^[A-Za-z0-9_-]{11}$/, `mint() gave ${id}`);
+ assert.strictEqual(Buffer.from(id, 'base64url').length, 8, '11 base64url characters are 8 bytes (64 bits)');
+ assert.ok(watch.isWatchId(id));
+ }
+ assert.ok(!watch.isWatchId('6203'), 'a number is not a watch id');
+ assert.ok(!watch.isWatchId('short'), 'a short string is not a watch id');
+ assert.ok(!watch.isWatchId(''), 'an empty string is not a watch id');
+ console.log('✅ the id: 11 URL-safe base64 characters from 8 random bytes, never a number');
+
+ // ── A new row gets one, and it is not its number ──────────
+ const vodId = Number((await db.createVod({ app_id: 'live', title: 'A stream', visibility: 'public' })).lastInsertRowid);
+ const clipId = Number((await db.createClip({ app_id: 'live', vod_id: vodId, title: 'A moment', visibility: 'public' })).lastInsertRowid);
+ const vodRow = await db.getVodById(vodId);
+ const clipRow = await db.getClipById(clipId);
+ assert.ok(vodRow.watch_id, 'a new VOD has a watch id at insert');
+ assert.ok(clipRow.watch_id, 'a new clip has a watch id at insert');
+ assert.match(vodRow.watch_id, /^[A-Za-z0-9_-]{11}$/);
+ assert.notStrictEqual(vodRow.watch_id, String(vodId), 'the id is not the number');
+ assert.notStrictEqual(vodRow.watch_id, clipRow.watch_id, 'a VOD and a clip never share one');
+ assert.strictEqual((await db.getVodById(vodId)).watch_id, vodRow.watch_id, 'the id never changes');
+ console.log('✅ a new VOD and a new clip each get one at insert, distinct from each other and from their numbers');
+
+ // ── The resolver ─────────────────────────────────────────
+ assert.deepStrictEqual(await watch.resolve(vodRow.watch_id), { kind: 'vod', id: vodId });
+ assert.deepStrictEqual(await watch.resolve(clipRow.watch_id), { kind: 'clip', id: clipId });
+ assert.strictEqual(await watch.resolve('nosuchidxyz'), null, 'an unknown id resolves to nothing');
+ assert.strictEqual(await watch.resolve('6203'), null, 'a number is not looked up as a watch id');
+ console.log('✅ the resolver answers { kind, id } for a VOD and a clip, and nothing for an unknown id');
+
+ // ── The public shapes carry it ────────────────────────────
+ const vodShape = await vodPublic(await db.getVodById(vodId), { hls: false });
+ const clipShape = await clipPublic(await db.getClipById(clipId), { hls: false });
+ assert.strictEqual(vodShape.watch_id, vodRow.watch_id, 'vodPublic carries the watch id');
+ assert.strictEqual(clipShape.watch_id, clipRow.watch_id, 'clipPublic carries the watch id');
+ console.log('✅ the public shapes Media serves carry watch_id');
+
+ // ── A private row resolves too; visibility is the route's business ──
+ const privId = Number((await db.createVod({ app_id: 'live', title: 'A private stream', visibility: 'private' })).lastInsertRowid);
+ const privRow = await db.getVodById(privId);
+ assert.ok(privRow.watch_id, 'a private VOD has a watch id too');
+ assert.deepStrictEqual(await watch.resolve(privRow.watch_id), { kind: 'vod', id: privId },
+ 'the resolver answers; the route then applies the same visibility rule the number had');
+ console.log('✅ a private item resolves by its watch id, and its visibility is still the route\'s to enforce');
+
+ // ── The backfill: rows written before the column ──────────
+ // Simulate the pre-column estate: clear the ids the inserts minted, and add rows with none at all.
+ await db.run('UPDATE vods SET watch_id = NULL');
+ await db.run('UPDATE clips SET watch_id = NULL');
+ const legacyVods = [];
+ for (let i = 0; i < 12; i++) {
+ legacyVods.push(Number((await db.run(
+ `INSERT INTO vods (app_id, title, is_public, visibility) VALUES ('live', ?, 1, 'public') RETURNING id`,
+ [`legacy ${i}`])).lastInsertRowid));
+ }
+ const before = await db.get('SELECT COUNT(*) AS n FROM vods WHERE watch_id IS NULL');
+ assert.ok(Number(before.n) >= 13, 'the estate is all NULL before the backfill');
+
+ const first = await watch.backfillAll({ batch: 4 });
+ assert.ok(first.filled >= 13, `the backfill filled every row (filled ${first.filled})`);
+ assert.strictEqual(first.remaining, 0, 'no row is left without an id');
+ const leftVods = Number((await db.get('SELECT COUNT(*) AS n FROM vods WHERE watch_id IS NULL'))?.n);
+ const leftClips = Number((await db.get('SELECT COUNT(*) AS n FROM clips WHERE watch_id IS NULL'))?.n);
+ assert.strictEqual(leftVods, 0, 'every VOD has an id');
+ assert.strictEqual(leftClips, 0, 'every clip has an id');
+ console.log(`✅ the backfill: ${first.filled} row(s) filled, 0 left NULL`);
+
+ // ── Idempotent: a second pass writes nothing and changes nothing ──
+ const snapshot = async () => {
+ const v = await db.all('SELECT id, watch_id FROM vods ORDER BY id');
+ const c = await db.all('SELECT id, watch_id FROM clips ORDER BY id');
+ return JSON.stringify({ v, c });
+ };
+ const was = await snapshot();
+ const second = await watch.backfillAll({ batch: 4 });
+ assert.strictEqual(second.filled, 0, 'a second backfill fills nothing');
+ assert.strictEqual(second.remaining, 0);
+ assert.strictEqual(await snapshot(), was, 'and changes no id that was already there');
+ console.log('✅ the backfill is idempotent: a second pass fills nothing and changes nothing');
+
+ // ── Uniqueness across the whole estate, both tables ────────
+ const allIds = [
+ ...(await db.all('SELECT id, watch_id FROM vods')).map((r) => r.watch_id),
+ ...(await db.all('SELECT id, watch_id FROM clips')).map((r) => r.watch_id),
+ ];
+ for (const id of allIds) assert.match(id, /^[A-Za-z0-9_-]{11}$/, `every row has a well-formed id (${id})`);
+ assert.strictEqual(new Set(allIds).size, allIds.length, 'every id is unique across vods and clips');
+ console.log(`✅ ${allIds.length} rows, every id unique across both tables`);
+
+ // ── The API route: GET /api/v1/:app/watch/:watch_id ────────
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/watch', require('../server/watch-routes'));
+ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
+ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ try {
+ const get = async (p) => {
+ const r = await fetch(base + p, { headers: { authorization: 'Bearer live-key' } });
+ const text = await r.text();
+ let body = null; try { body = JSON.parse(text); } catch { /* */ }
+ return { status: r.status, body };
+ };
+ const someVod = await db.get('SELECT id, watch_id FROM vods WHERE watch_id IS NOT NULL LIMIT 1');
+ const api = await get(`/api/v1/live/watch/${someVod.watch_id}`);
+ assert.strictEqual(api.status, 200, 'a known watch id answers 200');
+ assert.deepStrictEqual(api.body, { kind: 'vod', id: Number(someVod.id) });
+ const missing = await get('/api/v1/live/watch/zzzzzzzzzzz');
+ assert.strictEqual(missing.status, 404, 'an unknown watch id is the same 404 a missing id gets');
+ const numeric = await get(`/api/v1/live/watch/${someVod.id}`);
+ assert.strictEqual(numeric.status, 404, 'a number is not a watch id');
+ console.log('✅ GET /api/v1/:app/watch/:watch_id resolves one, and answers 404 for an unknown id');
+ } finally {
+ await new Promise((r) => server.close(r));
+ }
+
+ await db.close();
+ console.log('\nAll watch-id checks passed.');
+})().catch((err) => { console.error(err); process.exit(1); });