openvibers/openvibe.media Commits

Watch ids: every VOD and clip answers to an 11-character random id (#340)

OP OpenVibers committed 6 days ago · 195ce71017 · 14 files, +420 −16

A watch page on openvibe.video becomes /watch?v=<watch id>, YouTube's shape, so a
page cannot be found by counting: 8 random bytes as URL-safe base64 has 64 bits of
entropy and is never derived from the numeric id.

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 the rows that predate it, at boot,
only where watch_id IS NULL. One id space across both tables, so a watch id has
exactly one meaning.

watch_id is on every VOD and clip Media serves, and GET
/api/v1/:app/watch/:watch_id answers { kind, id }. A private item still resolves;
its own read applies the visibility rule it always did.

Migration 0024_watch_ids.sql is expand-only: a nullable column and a partial unique
index per table, no table rewrite, and an older release never reads it.

Tests: test/watch-ids.test.js.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cfe3af4..72915c6 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 6ef812e..6fb93db 100644
---- a/README.md
++++ b/README.md
@@@ -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 from
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`
`{ 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
ddiff --git a/STATUS.json b/STATUS.json
iindex ad3e46f..fa49a92 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0024_watch_ids.sql b/migrations/0024_watch_ids.sql
nnew file mode 100644
iindex 0000000..96f0bbf
---- /dev/null
++++ b/migrations/0024_watch_ids.sql
@@@ -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;
ddiff --git a/server/db/database.js b/server/db/database.js
iindex ed3bdae..8fafe51 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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]
));
}
ddiff --git a/server/index.js b/server/index.js
iindex cdd81f5..bcdad6d 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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.
ddiff --git a/server/public/page-frame.js b/server/public/page-frame.js
iindex eccf7fd..2bba3bb 100644
---- a/server/public/page-frame.js
++++ b/server/public/page-frame.js
@@@ -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}`;
}
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 874e3e1..71eac18 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -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);
}
/**
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex a679c8e..7164274 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -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;
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex e704d94..3a1ac84 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -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' }),
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 6168817..44d288b 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -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,
ddiff --git a/server/watch-ids.js b/server/watch-ids.js
nnew file mode 100644
iindex 0000000..7a1514a
---- /dev/null
++++ b/server/watch-ids.js
@@@ -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 };
ddiff --git a/server/watch-routes.js b/server/watch-routes.js
nnew file mode 100644
iindex 0000000..7c3cc03
---- /dev/null
++++ b/server/watch-routes.js
@@@ -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;
ddiff --git a/test/watch-ids.test.js b/test/watch-ids.test.js
nnew file mode 100644
iindex 0000000..ada08eb
---- /dev/null
++++ b/test/watch-ids.test.js
@@@ -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); });