openvibers/openvibe.media Commits
Recordings: one playable video over a stream's parts (a virtual object over their joined timelines), lists show it once, clips in recording time across a join; design in docs/recordings.md (#184)
Changes
nnew file mode 100644@@@ -0,0 +1,227 @@++# Recordings: one video per stream, however it was stored++++Owner, 2026-10-27: "instead of having it list like 'The stream was recorded in 2 parts: Part 1 (this one) · Part 2' it++should automatically just have them as a single vod … appears to users like it's one big vod that seeks just fine but++behind the scenes it has this smart chunked / multi file system … available and shared for other things across our++network … be aware of server storage … scalable … also consider how we can implement this into our sdk/api", and "it++needs to combine 'clips from this stream' across all the vods", and on clipping: "the system used for clipping videos and++clipping active live streams across the different services [should be] shared / multi purpose / intelligent".++++This is the design of record for that. It builds on the [Media Fabric](media-fabric.md), whose segment-native video++already does most of the storage side: CMAF segments, the timeline index, ~60 s packed chunks, virtual and materialized++clips, and the placement engine. What it adds is the **recording**: one playable video over any number of stored parts.++++## 1. How it works today (review, 2026-10-27)++++**What is stored.** Live's recorder writes one VOD per *recording session*. A broadcaster who drops and comes back gets++a second VOD for the same stream: BIRD DRAMA (stream 2795) is VOD 6025 (0–11:17) and VOD 6026 (11:21–43:18). A recorder++restart or a deploy does the same. After finalize, `object.cmaf` cuts each VOD's file into a CMAF timeline: 175 rows for++6025 and 487 for 6026. `object.pack` packs those into ~60 s chunks: 11 and 31 objects. The picture turning (portrait ↔++landscape) is already *not* a new VOD: it is a new shape period inside one timeline (Media#182).++++**What people see.**++- openvibe.video shows two watch pages and two cards. Each has its own transcript, chapters, chat replay, downloads,++ view count, comments thread and AI overview, and an "About" line linking the other part (MediaHub#143).++- Live's channel lists both VODs.++- Seeking stops at a part's end. The player jumps to the next part at the end (Live#380), but the scrubber never covers++ the whole stream.++- Clips are already gathered per stream: Live lists them by `stream_id`. A clip's times are in its part's time, though,++ and a clip cannot cross the join.++- AI analysis, transcripts, moments and auto-clips all run per part.++++**Clipping today: five ways in, two engines.**++++| where | how | engine |++|---|---|---|++| Live player, the Clip button while live | Live `POST /api/vods/clips` `{ stream_id, live: true, at, duration }` | Media cuts from the active recording's file |++| Live studio (broadcaster), "Clip that" | the same, with `stream_time: true` (Live#379) | the same, placed on the right part through stream history |++| Live player, rewinding (DVR) | the same, with `stream_time` | the same |++| openvibe.video, Clip menu | MediaHub → Live `POST /api/vods/clips` `{ vod_id, start_time, end_time }` | a virtual clip when the VOD has a timeline, else `clip.cut` |++| auto-clips (chat spikes live, AI moments after) | Live's auto-clip job → Media | the same two |++| uploaded clip (old client) | the browser's own recording, uploaded | a file |++++**AI titles.** A clip with no title gets Live's placeholder "Clip: <stream title>". Live's `clip-title` job renames it from++the clip's AI overview, once the clip's own analysis has run. The live auto-clip path writes an AI title through++`publicTitle`. The weak spots, from production on 2026-10-27:++- Clips whose analysis failed are never named. Clips 494, 497 and 498 are still "Clip: Seattle".++- The VOD auto-clip path keeps a frame-caption fallback as a final title: "A Person Is Standing In A Living Room,++ Facing".++- A clip of an upload (no stream) keeps "Clip: <video title>", because the placeholder check only knows stream titles.++++## 2. The model++++A **recording** is one video made of ordered **parts**. Every part is an ordinary stored video object with its own++timeline: Live's VOD of a session, a rolled-over file, an uploaded file. The recording is a **virtual object**. Its++timeline is the parts' rows joined end to end, so no byte is copied, moved or re-encoded to make it.++++```++media_recordings id rec_…, app_id, group_key (the app's own key: "stream:2795"), vod_id/object_id (the++ recording's own VOD row and object: its public /v/<id>), state open|closed++media_recording_parts recording_id, seq, vod_id, object_id, gap_before_ms (wall time not recorded before it)++```++++**Playback.** `timeline.joinRows(parts)` builds the recording's rows from the parts' rows. Part k's media segments keep++their bytes (key, local path, packed chunk and byte range) and get:++- times shifted by the parts before it;++- names prefixed `p<k>-`, so names never collide;++- `init_name` `p<k>-init.mp4`, or `p<k>-init-<n>.mp4` for a shape period.++++`mediaPlaylist()` already writes `#EXT-X-DISCONTINUITY` + `#EXT-X-MAP` wherever the init changes, so the join is a clean++decoder reset. hls.js and Safari treat it exactly like a shape change, which they already play (VOD 6026). The gap between++parts (4 s for 2795) is not played. Its wall time is kept in `gap_before_ms`, so stream time ↔ recording time stays++exact.++++**Time.** Three clocks, and one place that converts them.++++| clock | zero | used by |++|---|---|---|++| part time | the part's first frame | Media's files, old clip windows |++| recording time | the recording's first frame, gaps removed | the player, clips, transcripts, chapters, links (`?t=`) |++| stream time | Live's `streams.started_at` | chat replay, live captions, stream history, the Rundown |++++`GET /api/v1/:app/recordings/:id` answers the parts with `start_ms` (recording time), `duration_ms`, `gap_before_ms` and,++for Live, `stream_offset_ms`. Every service converts with that one table: Live's `stream-history.js`, MediaHub's chat++replay, the transcript merge.++++**Ids.** The recording's VOD row is the one public id: `/v/<id>`, on openvibe.media, openvibe.video and Live.++- A part keeps its id and its own playback, so nothing that linked it breaks.++- Pages redirect a part to the recording at the right second: `/v/6026` → `/v/<recording>?t=681`.++- Lists show recordings, not parts. `include_parts=1` lists the parts too, for staff and tools.++++**When a recording forms.** A part is created with the app's group key (Live: `stream:<id>`; any app may send its own).++- **The first part** is the recording, with no new row: one id, nothing new to show.++- **The second part** turns it into a recording. Media creates the recording's VOD row and object, adds both parts, and++ sends `media.recording.updated`.++- **A reconnect window decides "same recording":** a part whose group key matches a recording still `open` joins it. Live++ closes a recording when the stream ends; Mux's equivalent is a 60 s reconnect window, configurable to 30 min.++- **A stream that ends and goes live again** is a new stream, and so a new recording.++++**When a part forms (splitting).** A new part is only needed when a session really ends: a disconnect, or a recorder or++host restart. A shape change is a period inside a part. Storage is no reason to split. Packing already makes ~60 s chunk++objects, and the timeline index finds any second without opening a file, so a ten-hour part costs no more to serve than++ten one-hour parts.++++One storage rule does cut parts: **rollover**. A part is closed and a new one opened every `MEDIA_PART_MAX_HOURS` (4)++or `MEDIA_PART_MAX_GB` (8). That bounds the work a crash can lose: the unfinalized tail, the DVR copy (Media#162's disk++pressure), and a single remux, upload or move. A rolled part joins the recording like any other, so viewers never see it.++++## 3. Storage, cost and scale++++- **Bytes are written once.** Joining, clipping and re-cutting are row operations. Locations are content-addressed and++ reference-counted by the rows that name them ([media-fabric §4](media-fabric.md#4-clips-reuse-the-source)).++- **Object count.** Two-second CMAF segments are packed after finalize into ~60 s chunks, about 30× fewer objects, PUTs++ and HEADs. The player reads them with `EXT-X-BYTERANGE`.++ - Byte ranges over one big file are the cheapest for object count, but a CDN may fetch more from the origin than the++ range asked for.++ - Many small files cache best, but cost one operation each.++ - 60 s chunks sit between the two.++- **Tiers** (the placement engine, [media-fabric §6](media-fabric.md#6-placement-one-engine-every-class-down-to-the-segment-bucket)):++++ | tier | holds | cost |++ |---|---|---|++ | local NVMe | live, new and hot video | |++ | Cloudflare R2 | what is watched | $0.015/GB-month, $0.36 per million reads, no egress fee |++ | Backblaze B2 | everything durable | about $6.95/TB-month; egress free up to 3× the stored volume, and free through Cloudflare's Bandwidth Alliance |++++ For comparison, S3 egress is about $0.09/GB. A recording's parts tier independently, chunk by chunk. A long stream++ whose first hour is never watched keeps that hour on B2 only.++- **Derivatives are caches.**++ - Lower renditions are made on demand (F4).++ - The download MP4 of a whole recording is a lazy remux of its joined playlist, kept like any derivative and++ dropped when cold.++ - When the parts' shapes differ, the download is one MP4 per shape period, offered together in the Download menu,++ because one MP4 track cannot change its picture size.++- **The live DVR copy** (2–3× the recording's size while live, Media#162) is deleted at finalize, once the timeline is++ durable.++++## 4. Clips: one system over recordings++++A clip is a window over a recording: `{ recording, start_ms, end_ms }`. Every way in becomes the same call:++++| way in | what it sends |++|---|---|++| Live player, live | "the last N seconds", resolved to the open recording's live edge |++| Live player, rewinding; Live studio | stream time → recording time (one conversion, §2) |++| openvibe.video | recording time from the player |++| auto-clips | recording time from the moment |++| any app (SDK) | `media.clips.create({ recording, start, end })` |++++- **Instant first.** The clip is virtual at once: a manifest over the recording's segments, segment-accurate. This is++ what Mux calls an instant clip and what MediaPackage's harvest does from a live window. It works while the stream is++ still live, because the open recording's timeline grows, and it can cross a part boundary: the window simply covers++ rows of two parts.++- **Exact when it matters.** A clip that is downloaded, shared outside, or watched past a threshold is materialized:++ only its two edge segments are re-encoded, frame-accurate ([materialized-clips.md](materialized-clips.md)).++- **Named by AI when nobody named it.** The form says so ("Leave empty: AI names it").++ - **Placeholders.** Live's placeholder titles are a closed set, plus "Clip: <the stream's or the video's title>".++ - **Provisional titles.** An auto-clip's title is marked `title_provisional` and is replaced the same way.++ - **When the analysis fails, the name comes from the recording's transcript.** The transcript lines inside the window,++ from the full pass or the live captions, plus the stream's title, go to the same `live.stream.titles` template. A++ clip with no speech and no overview is named "<stream title> at <time>". No clip keeps a placeholder for more than++ one sweep after its window has a transcript.++- **One maker in the UI.** The shared player gets the clip maker: start and end from the current time, the AI-title++ note, and keyboard marks. Live's player, Live's studio and openvibe.video all use it, so it looks and behaves the same.++- **Clips from this stream** are the recording's clips. Their places are drawn on the recording's scrubber.++++## 5. Uploads and other apps++++- **Big uploads** use Media's existing multipart sessions (`media_uploads`), which are resumable part by part:++ openvibe.video's upload page moves to them. The finished file is cut into a CMAF timeline like a recording part.++ Several files of one event (a camera that splits at 4 GB) are one recording, by group key.++- **Any app can record.** A Space, a Bot robot camera or an OpenRestream session sends parts with its own group key++ and gets the same playback, clips, tiers and AI.++++## 6. API, events and SDK++++Media (`/api/v1/:app`, tenant token; capability `media.vod.write` for writes, `media.vod.read` for reads):++++| route | what |++|---|---|++| `POST /vods` (`recording_key`) | a part, joined to the open recording with that key (Live sends `stream:<id>`) |++| `GET /recordings/:id` | the recording: its VOD row, `duration_ms`, `parts[]` (vod_id, start_ms, duration_ms, gap_before_ms, stream_offset_ms), state |++| `GET /recordings/by-key/:key` | the same by group key |++| `GET /vods/:id/recording` | a part's recording and its place in it (where to redirect, `?t=`) |++| `POST /recordings/:id/close` | no more parts (Live: the stream ended) |++| `POST /recordings/:id/clips` | `{ start_ms, end_ms, title?, materialize? }`: a clip over the recording (§4) |++| public `/o/<recording object>/master.m3u8` | the joined playlist; `/v/<recording vod>` plays it |++++Events on OpenVibe.Events: `media.recording.updated` (a part joined, the recording closed, its duration changed).++++SDK (`openvibe-sdk/media`): `recordings.get(id | { key })`, `recordings.close(id)`, `recordings.clip(id, { start, end })`,++`recordings.toRecordingTime(parts, streamMs)` and `toStreamTime(…)` (the one conversion, shared), and `vods.create({++recordingKey })`. Contracts: the routes above in `media.json`, and the event in `events.json`.++++## 7. Build order++++1. **Media: recordings.**++ - Migration `0015_recordings`; `timeline.joinRows`; the recording's VOD row and object.++ - Joined HLS playback; clips over a recording, including across a join.++ - The routes and the event.++ - Existing multi-part streams joined by a backfill script (a dry run first).++2. **Live.**++ - Recorder parts carry `recording_key`, and the recording is closed at stream end.++ - The channel's videos list recordings.++ - The VOD route answers a recording: its transcript, AI state and chat replay over the parts in recording time.++ - Clips are created over recordings.++ - AI titles: provisional titles and the transcript-window fallback.++3. **MediaHub.**++ - A recording's watch page: one player, one transcript, chat replay, chapters and clips on the whole scrubber.++ - Parts redirect to it; downloads offer the recording.++4. **Shared player: the clip maker** (openvibe-shared), used by Live and MediaHub.++5. **SDK and Contracts.**++6. **Rollover and uploads.**++ - Live's recorder rolls parts at the limits.++ - openvibe.video uploads use multipart sessions.++++Research behind the choices (2026-10-27):++- Mux's [reconnect windows](https://www.mux.com/docs/guides/handle-live-stream-disconnects) (one asset across++ reconnects, 60 s default, up to 1800 s) and [instant clips](https://www.mux.com/docs/guides/create-instant-clips)++ (trimmed manifests, segment-accurate, no re-encode);++- AWS MediaPackage [live-to-VOD harvest jobs](https://docs.aws.amazon.com/mediapackage/latest/ug/ltov.html) (clips from++ the live window, segment-accurate);++- R2, B2 and S3 prices ([R2 vs S3 vs B2](https://mecanik.dev/en/posts/cloudflare-r2-pricing-explained-real-costs-vs-s3-and-backblaze/),++ [B2 pricing](https://www.backblaze.com/cloud-storage/pricing));++- byte-range against segment-file trade-offs for HLS++ ([single-file HLS](https://command-tab.com/2026/08/14/single-file-http-live-streaming/));++- `EXT-X-DISCONTINUITY` + `EXT-X-MAP` for joining parts++ ([the discontinuity tag](https://www.forasoft.com/learn/video-streaming/glossary/terms-streaming/discontinuity-tag)).nnew file mode 100644@@@ -0,0 +1,30 @@++-- phase: expand++-- Recordings (docs/recordings.md): one playable video over a stream's parts. A stream recorded in several sessions++-- (a broadcaster who dropped and came back, a recorder restart) is several VODs; a recording joins them, so people see++-- one video that seeks across them. The recording is a native media object (kind 'asset', metadata.recording) whose++-- timeline is its parts' rows joined on every read (objects/timeline.js joinRows): no bytes are copied, and no VOD row++-- is added, so counts of videos and hours, tiering and health scans see each stored part exactly once.++-- Additive only: an older release never reads these tables, and lists every part as before.++CREATE TABLE media_recordings (++ object_id text COLLATE "C" PRIMARY KEY REFERENCES media_objects(id),++ app_id text COLLATE "C" NOT NULL,++ group_key text COLLATE "C" NOT NULL, -- the app's own key for what makes one recording ("stream:2795" for Live)++ state text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),++ created_at text COLLATE "C" DEFAULT ov_now(),++ updated_at text COLLATE "C" DEFAULT ov_now(),++ UNIQUE (app_id, group_key)++);++++CREATE TABLE media_recording_parts (++ recording_object_id text COLLATE "C" NOT NULL REFERENCES media_recordings(object_id) ON DELETE CASCADE,++ seq integer NOT NULL CHECK (seq >= 1),++ vod_id bigint NOT NULL,++ gap_before_ms bigint NOT NULL DEFAULT 0, -- wall time between the previous part's end and this one's start (not played)++ stream_offset_ms bigint, -- where the part starts in the app's own clock (Live: stream time), when sent++ PRIMARY KEY (recording_object_id, seq),++ UNIQUE (vod_id)++);++++-- A clip over a recording (POST /clips with recording_time): its window is in recording time and its clip_of edge goes++-- to the recording's object; vod_id stays the recording's first part, the video people know.++ALTER TABLE clips ADD COLUMN recording_object_id text COLLATE "C";@@@ -277,9 +277,11 @@ function sinceParam(value) {return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;}--function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true, since = null } = {}) {++function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true, since = null, includeParts = false } = {}) {const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];const params = [appId];++ // A recording's later parts (docs/recordings.md): its first part stands for the whole video in a list.++ if (!includeParts) conds.push('NOT EXISTS (SELECT 1 FROM media_recording_parts rp WHERE rp.vod_id = vods.id AND rp.seq > 1)');if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');if (!include_private) conds.push('is_public = 1');if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }@@@ -445,15 +447,15 @@ function vodStatus(vod) {// 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.--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 }) {++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 }) {const pub = (is_public === 0 || is_public === false) ? 0 : 1;const vis = visibility ? _normVisibility(visibility) : (pub ? 'public' : 'unlisted');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)-- 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)++ 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']++ vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null]));}@@@ -235,6 +235,7 @@ const ready = (async () => {app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));app.use('/api/v1/:app/views', require('./views/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'));app.use('/api/v1/:app/files', require('./files/routes'));app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));@@@ -45,11 +45,18 @@ async function clipWindow(obj) {*/async function timelineOf(obj) {const timeline = require('./timeline');++ const recordings = require('../vod/recordings');++ // A recording (docs/recordings.md): its parts' rows joined, never its own; `recording` tells the callers not to++ // queue a cut of it (each part is cut on its own).++ if (recordings.isRecording(obj)) return { rows: await recordings.rowsOf(obj), window: null, recording: true };const own = await timeline.list(obj.id, timeline.SOURCE);if (own.some((r) => Number(r.seq) > 0)) return { rows: own, window: null };const window = await clipWindow(obj);if (!window) return { rows: [], window: null };-- return { rows: timeline.clipRows(await timeline.list(window.sourceId, timeline.SOURCE), window.startMs, window.endMs), window };++ // A clip over a recording is a window in recording time over the joined rows (it may cross a join).++ const src = await model.getObject(window.sourceId);++ const srcRows = recordings.isRecording(src) ? await recordings.rowsOf(src) : await timeline.list(window.sourceId, timeline.SOURCE);++ return { rows: timeline.clipRows(srcRows, window.startMs, window.endMs), window };}/** Whether an object is served openly: not private and not in a developer-project sandbox tenant (GET /o/:id's rule). */@@@ -127,13 +134,13 @@ async function discoveryForObject(obj) {try {const tl = await timelineOf(obj);if (!tl.rows.some((r) => Number(r.seq) > 0)) {-- if (!tl.window) await queueCmafOnce(obj.app_id, obj.id);++ if (!tl.window && !tl.recording) await queueCmafOnce(obj.app_id, obj.id);return null;}// The source timeline exists: queue any missing rendition (F4) so the master playlist can list it next time.// A clip that plays without a file of its own (a virtual or materialized one) is never queued one — the job// would fail with media_unavailable — so the lazy path leaves it alone, as queueRendition itself does.-- if (!tl.window && !require('./timeline-queue').isVirtualClip(obj)) await queueRenditionsOnce(obj);++ if (!tl.window && !tl.recording && !require('./timeline-queue').isVirtualClip(obj)) await queueRenditionsOnce(obj);return playlistFor(obj, await isOpen(obj));} catch (err) {console.warn(`[HLS] Discovery failed for ${obj.id}:`, err.message);@@@ -225,4 +232,4 @@ async function discoveryMany(rows) {return out;}--module.exports = { timelineOf, renditionsFor, queueRenditionsOnce, discoveryForObject, discovery, discoveryMany };++module.exports = { timelineOf, renditionsFor, queueCmafOnce, queueRenditionsOnce, discoveryForObject, discovery, discoveryMany };@@@ -384,7 +384,10 @@ async function syncClip(idOrRow) {const p = clipProjection(row);const r = await project(p);await linkRow('clips', 'id', row.id, r.id);-- if (row.vod_id) {++ if (row.recording_object_id) {++ // A clip over a recording (docs/recordings.md): its window is in recording time, over the recording's object.++ await setRelationship(r.id, 'clip_of', row.recording_object_id, { start_time: row.start_time, end_time: row.end_time, recording: true });++ } else if (row.vod_id) {const vod = await db.get('SELECT object_id FROM vods WHERE id = ?', [row.vod_id]);if (vod && vod.object_id) await setRelationship(r.id, 'clip_of', vod.object_id, { start_time: row.start_time, end_time: row.end_time });}@@@ -808,7 +808,8 @@ const HLS_TYPE = 'application/vnd.apple.mpegurl';// A rendition's seq 0 init plus the two edge inits of a materialized clip's source rendition (negative seq,// docs/materialized-clips.md F3.6) and the init of each shape period after the first (init-<n>.mp4, jobs/shapes.js);// every init row (seq <= 0) is served as video/mp4, a media segment as iso.segment.--const SEGMENT_NAME = /^(init\.mp4|init-head\.mp4|init-tail\.mp4|init-\d{1,4}\.mp4|\d{6,9}\.m4s)$/;++// A recording's joined rows (docs/recordings.md) name part k's segments and inits `p<k>-<name>`.++const SEGMENT_NAME = /^(p\d{1,4}-)?(init\.mp4|init-head\.mp4|init-tail\.mp4|init-\d{1,4}\.mp4|\d{6,9}\.m4s)$/;const RENDITION_NAME = /^[a-z0-9_-]{1,32}$/;const SLICE_FETCH_MS = 15000; // a ranged read of one packed segment from B2/R2// clipWindow/timelineOf (a clip's window over its source, and the rows its playlists are written from) live in@@@ -950,7 +951,10 @@ publicRouter.get('/:id/source/:name', hlsRoute(async (req, res, h) => {let row = SEGMENT_NAME.test(name) ? await timeline.byName(h.obj.id, timeline.SOURCE, name) : null;// A virtual clip serves only the source segments inside its window (timelineOf): any other name is a 404, whatever// the signature, so a clip's token never reaches the rest of its source.-- if (!row && SEGMENT_NAME.test(name) && h.obj.kind === 'clip') row = (await hls.timelineOf(h.obj)).rows.find((r) => r.name === name) || null;++ // A recording serves its parts' segments the same way, through its joined rows (and a clip over it, its window).++ if (!row && SEGMENT_NAME.test(name) && (h.obj.kind === 'clip' || require('../vod/recordings').isRecording(h.obj))) {++ row = (await hls.timelineOf(h.obj)).rows.find((r) => r.name === name) || null;++ }if (!row) return res.status(404).json({ error: 'Not found' });return sendSegmentRow(req, res, h, row, timeline.SOURCE);}));@@@ -111,7 +111,10 @@ function renditionNames() { return require('../jobs/rendition').renditionNames()* without file_path), so neither one can ever be cut a rendition.*/function isVirtualClip(obj) {-- return !!obj && obj.kind === 'clip' && require('./model').parseJson(obj.metadata, {}).virtual === true;++ if (!obj) return false;++ const md = require('./model').parseJson(obj.metadata, {});++ // A recording (docs/recordings.md) is virtual the same way: its parts are cut, renditioned and moved, never it.++ return md.virtual === true && (obj.kind === 'clip' || md.recording === true);}/**@@@ -50,6 +50,10 @@ const TAIL_INIT = 'init-tail.mp4';// materialized clip's -1/-2 edges. Its media rows name it in init_name.const SHAPE_INIT = /^init-(\d{1,4})\.mp4$/;function shapeInitName(n) { return `init-${n}.mp4`; }++// A recording's joined timeline (docs/recordings.md, joinRows): part k's names are prefixed `p<k>-`, its init++// `p<k>-init.mp4` (k >= 2; part 1's is the rendition's seq 0) and its shape periods' `p<k>-init-<n>.mp4`. Never stored:++// the rows are built from the parts' own rows on every read.++const PART_INIT = /^p\d{1,4}-init(-\d{1,4})?\.mp4$/;/** The seq of a shape period's init row, or null for any other name. */function shapeInitSeq(name) {const m = SHAPE_INIT.exec(String(name || ''));@@@ -421,7 +425,7 @@ function masterPlaylist(renditions, { query = '' } = {}) {*/function clipRows(rows, startMs, endMs) {const edge = (name) => name === HEAD_INIT || name === TAIL_INIT;-- if (rows.some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null) || edge(r.init_name))) return [];++ if (rows.some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null && !PART_INIT.test(r.name)) || edge(r.init_name))) return [];const init = rows.find((r) => Number(r.seq) === 0);const segs = rows.filter((r) => Number(r.seq) > 0 && Number(r.end_ms) > startMs && Number(r.start_ms) < endMs)@@@ -433,8 +437,49 @@ function clipRows(rows, startMs, endMs) {return segs.length && init ? [{ ...init, seq: 0, start_ms: 0, end_ms: 0 }, ...shapeInits, ...segs] : segs;}++// ── Recordings (docs/recordings.md) ─────────────────────────++/**++ * A recording's timeline: its parts' source rows end to end, each part shifted by the length of the parts before it++ * (the time between parts is not played). Every row keeps its bytes (key, local_path, durable_provider, packed chunk and++ * byte range, sha256), so nothing is copied; what changes is its name and its init:++ * - part 1's seq 0 init stays the rendition's seq 0 init (`p1-init.mp4`), so a recording plays from its first frame;++ * - part k >= 2's seq 0 init becomes a named init `p<k>-init.mp4` (seq -(10000 k)), and its media rows name it;++ * - a part's shape-period init `init-<n>.mp4` becomes `p<k>-init-<n>.mp4` (seq -(10000 k + n)), and its rows name it;++ * - media rows are named `p<k>-<name>` and renumbered 1..N.++ * mediaPlaylist() then writes #EXT-X-DISCONTINUITY + #EXT-X-MAP at each join, exactly as at a change of shape.++ * `parts` is [rows of part 1, rows of part 2, …]. A part with no media row, or one with a materialized clip's edge inits++ * (parts are recordings, never clips), makes the whole join empty: a recording plays whole or not at all.++ */++function joinRows(parts) {++ const out = [];++ let offset = 0;++ let seq = 0;++ for (const [i, rows] of parts.entries()) {++ const k = i + 1;++ const media = (rows || []).filter((r) => Number(r.seq) > 0);++ const init = (rows || []).find((r) => Number(r.seq) === 0);++ if (!media.length || !init) return [];++ if ((rows || []).some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null) || r.init_name === HEAD_INIT || r.init_name === TAIL_INIT)) return [];++ const base = `p${k}-init.mp4`;++ const renamedInit = (name) => (!name || name === INIT_NAME ? (k === 1 ? null : base) : `p${k}-${name}`);++ out.push({ ...init, seq: k === 1 ? 0 : -(10000 * k), name: base, start_ms: 0, end_ms: 0, init_name: null });++ for (const r of rows.filter((x) => Number(x.seq) < 0)) {++ out.push({ ...r, seq: -(10000 * k + Number(SHAPE_INIT.exec(r.name)[1])), name: `p${k}-${r.name}`, start_ms: 0, end_ms: 0, init_name: null });++ }++ const start = Math.min(...media.map((r) => Number(r.start_ms)));++ let end = start;++ for (const r of media.sort((a, b) => Number(a.seq) - Number(b.seq))) {++ seq += 1;++ out.push({ ...r, seq, name: `p${k}-${r.name}`, start_ms: Number(r.start_ms) - start + offset, end_ms: Number(r.end_ms) - start + offset, init_name: renamedInit(r.init_name) });++ end = Math.max(end, Number(r.end_ms));++ }++ offset += end - start;++ }++ return out;++}++module.exports = {-- SOURCE, INIT_NAME, HEAD_INIT, TAIL_INIT, FIELDS, localRoot, localPathFor, keyFor, segmentName, shapeInitName, shapeInitSeq,++ SOURCE, INIT_NAME, HEAD_INIT, TAIL_INIT, FIELDS, PART_INIT, localRoot, localPathFor, keyFor, segmentName, shapeInitName, shapeInitSeq,list, segments, byName, has, segmentAt, replace, TimelineChanged, isNamed, namedElsewhere, deleteBytes, removeObject, sweepPendingRemovals, markPacked, durableProvider, locationsOf,-- mediaPlaylist, masterPlaylist, clipRows,++ mediaPlaylist, masterPlaylist, clipRows, joinRows,};@@@ -88,7 +88,8 @@ function thumbCard(name, mtimeMs) {}// ── Data per tab ─────────────────────────────────────────────--const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";++// A recording's later parts (docs/recordings.md) are not videos of their own here: its first part stands for it.++const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 AND NOT EXISTS (SELECT 1 FROM media_recording_parts rp WHERE rp.vod_id = vods.id AND rp.seq > 1)";const C_WHERE = "COALESCE(is_public,1) = 1";// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";@@@ -66,6 +66,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {id: clip.id,app_id: clip.app_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' }),stream_id: clip.stream_id,channel_user_id: clip.channel_user_id || null,user_id: clip.user_id,@@@ -168,9 +170,9 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.singtry {const body = req.body || {};if (req.file) return await _createUploadedClip(req, res);-- const vodId = parseInt(body.vod_id, 10);++ let vodId = parseInt(body.vod_id, 10);if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });-- const vod = await db.getVodById(vodId, req.appId);++ let vod = await db.getVodById(vodId, req.appId);if (!vod) return res.status(404).json({ error: 'VOD not found' });// An acting user cannot cut someone else's private VOD, and is not told it exists.if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {@@@ -189,6 +191,19 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.singif (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });++ // recording_time: the times are in the recording vod_id is part of (docs/recordings.md), the video people saw.++ if (flag(body.recording_time) === 1) {++ const made = await _createRecordingClip(req, res, vod, startTime, endTime, body);++ if (made && made.part) {++ // The joined timeline is not ready: the part the window sits in, in that part's own time.++ vod = await db.getVodById(made.part.vod_id, req.appId);++ vodId = vod.id;++ startTime -= made.part.start_ms / 1000;++ endTime -= made.part.start_ms / 1000;++ } else if (made !== false) return made;++ // false: not part of a recording, so the times are this VOD's own, as without the flag.++ }++// Duplicate detection — reuse a just-made clip for the same window.const duplicate = await db.findDuplicateClip({appId: req.appId, vodId, streamId: vod.stream_id || null,@@@ -274,6 +289,53 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing}});++/**++ * A clip over a recording (docs/recordings.md): a virtual window in recording time over the recording's object, which++ * may cross a join between parts. vod_id is the recording's first part (the video's public face); the row carries the++ * recording's object, so its clip_of edge and playlists use the joined timeline. Before every part has a timeline, a++ * window that sits inside one part is that part's ordinary clip, in its own time ({ part }: the caller moves its window++ * there). Answers false when vod is in no recording (the caller goes on with its own times).++ */++async function _createRecordingClip(req, res, vod, startTime, endTime, body) {++ const recordings = require('./recordings');++ const found = await recordings.forVod(req.appId, vod.id);++ if (!found) return false;++ const rec = found.recording;++ const total = rec.duration_ms / 1000;++ if (startTime > total + 5) return res.status(400).json({ error: 'Start time exceeds video duration' });++ if (endTime > total) endTime = total;++ if (endTime - startTime < 1) return res.status(400).json({ error: 'Clip window is outside the video' });++ const userId = body.user_id != null ? body.user_id : (req.userId ?? null);++ const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';++ const byApp = req.authType === 'app';++ const first = await db.getVodById(rec.vod_id, req.appId) || vod;++ const recObj = await require('../objects/model').getObject(rec.id);++ const timeline = require('../objects/timeline');++ const rows = config.hls.enabled ? timeline.clipRows(await recordings.rowsOf(recObj), Math.round(startTime * 1000), Math.round(endTime * 1000)) : [];++ if (!rows.some((r) => Number(r.seq) > 0)) {++ // The joined timeline is not ready (a part not cut yet): the part the window sits in, in its own time.++ const part = rec.parts.find((p) => startTime * 1000 >= p.start_ms && endTime * 1000 <= p.start_ms + p.duration_ms);++ if (!part) return res.status(409).json({ error: 'This recording is still being prepared; a clip across its parts works in a few minutes.', code: 'media.recording.not_ready' });++ return { part };++ }++ const duplicate = await db.findDuplicateClip({ appId: req.appId, vodId: first.id, streamId: first.stream_id || null, startTime, endTime });++ if (duplicate && duplicate.status !== 'failed') return res.status(200).json({ id: duplicate.id, status: duplicate.status, deduplicated: true });++ const last = rows[rows.length - 1];++ const result = await db.createClip({++ app_id: req.appId, vod_id: first.id, recording_object_id: rec.id,++ stream_id: first.stream_id || (body.stream_id != null ? parseInt(body.stream_id, 10) || null : null),++ user_id: userId, channel_user_id: body.channel_user_id != null ? body.channel_user_id : (first.user_id ?? null),++ title: sanitizeClipTitle(body.title, first.title ? `Clip: ${recordings.baseTitle(first.title)}`.slice(0, 200) : 'Untitled Clip'),++ description: typeof body.description === 'string' ? body.description.replace(/<[^>]*>/g, '').trim().slice(0, 1000) : '',++ auto_generated: byApp && flag(body.auto_generated) === 1,++ file_path: '', start_time: startTime, end_time: endTime, duration_seconds: endTime - startTime,++ is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing',++ });++ const clipId = result.lastInsertRowid;++ await require('./clip-jobs').makeVirtual(clipId, { objectId: rec.id, endTime: startTime + Number(last.end_ms) / 1000 });++ return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, req.appId), { readiness: true }) });++}++/*** Direct clip upload: the blob is already cut client-side — remux it for* seeking (rebases cluster timestamps hours from zero on long streams),nnew file mode 100644@@@ -0,0 +1,281 @@++/**++ * OpenVibe.Media — recordings (docs/recordings.md): one playable video over a stream's parts.++ *++ * A recording is a native media object (kind 'asset', metadata { recording: true, virtual: true }) with a row in++ * media_recordings and its parts, in order, in media_recording_parts. It stores no bytes and has no VOD row: its HLS++ * timeline is its parts' source rows joined on every read (objects/timeline.js joinRows), through the same routes and++ * rules as any object (/o/<id>/master.m3u8), and a clip may sit on it (a window in recording time, across a join too).++ * The first part is the video's public face: its VOD row stays, lists hide the parts after it (db._vodConds), and its++ * answer carries `recording` so an app plays and counts the whole.++ *++ * setParts(appId, key, parts, { title, state }) the recording for an app's key, with these parts (idempotent);++ * fewer than two parts: none (a part alone is its own video)++ * forVod(appId, vodId) the recording a VOD is part of, with its place in it, or null++ * get(appId, objectId) / byKey(appId, key) a recording with its parts++ * rowsOf(obj) the joined source rows of a recording object ([] when a part has++ * no timeline yet: a recording plays whole or not at all)++ * summaries(vodIds) the `recording` field of many VODs in one query (list answers)++ * syncPart(vodId, { visibility, title }) a part's visibility is every part's and the recording's; the first++ * part's title is the recording's++ * router GET/PUT /api/v1/:app/recordings/:key, POST …/:key/close,++ * GET /api/v1/:app/recordings/object/:id++ */++'use strict';++++const express = require('express');++const db = require('../db/database');++const model = require('../objects/model');++const { tenantAuth } = require('../auth');++++const KEY = /^[A-Za-z0-9:_.-]{1,120}$/;++const MAX_PARTS = 200;++++const isRecording = (obj) => !!obj && obj.kind === 'asset' && model.parseJson(obj.metadata, {}).recording === true;++++async function partsOf(objectId) {++ return await db.all(`SELECT p.seq, p.vod_id, p.gap_before_ms, p.stream_offset_ms, v.object_id, v.duration_seconds, v.title, v.visibility,++ v.thumbnail_url, v.created_at++ FROM media_recording_parts p LEFT JOIN vods v ON v.id = p.vod_id++ WHERE p.recording_object_id = ? ORDER BY p.seq`, [objectId]);++}++++/** Each part's length in ms: its timeline's (exact), else its row's duration. */++async function partLengths(parts) {++ const ids = parts.map((p) => p.object_id).filter(Boolean);++ const spans = new Map();++ if (ids.length) {++ for (const r of await db.all(`SELECT object_id, MIN(start_ms) AS a, MAX(end_ms) AS b FROM media_timeline++ WHERE rendition = 'source' AND seq > 0 AND object_id = ANY(?) GROUP BY object_id`, [ids])) {++ spans.set(r.object_id, Number(r.b) - Number(r.a));++ }++ }++ return parts.map((p) => (spans.has(p.object_id) ? spans.get(p.object_id) : Math.round(Number(p.duration_seconds || 0) * 1000)));++}++++/** A recording's public answer: its object id, state, length and parts with their place (recording time, ms). */++async function present(rec) {++ if (!rec) return null;++ const parts = await partsOf(rec.object_id);++ const lengths = await partLengths(parts);++ let at = 0;++ const out = parts.map((p, i) => {++ const part = { seq: p.seq, vod_id: Number(p.vod_id), start_ms: at, duration_ms: lengths[i], gap_before_ms: Number(p.gap_before_ms) || 0,++ ...(p.stream_offset_ms != null && { stream_offset_ms: Number(p.stream_offset_ms) }) };++ at += lengths[i];++ return part;++ });++ const obj = await model.getObject(rec.object_id);++ const hls = await require('../objects/hls').discoveryForObject(obj).catch(() => null);++ return {++ id: rec.object_id, key: rec.group_key, state: rec.state, duration_ms: at, duration_seconds: Math.round(at / 1000),++ vod_id: out.length ? out[0].vod_id : null, parts: out, ...(hls || {}),++ created_at: rec.created_at, updated_at: rec.updated_at,++ };++}++++async function byKey(appId, key) {++ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND group_key = ?', [appId, String(key)]);++ return rec ? await present(rec) : null;++}++++async function get(appId, objectId) {++ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND object_id = ?', [appId, String(objectId)]);++ return rec ? await present(rec) : null;++}++++async function forVod(appId, vodId) {++ const p = await db.get(`SELECT r.* FROM media_recording_parts p JOIN media_recordings r ON r.object_id = p.recording_object_id++ WHERE p.vod_id = ? AND r.app_id = ?`, [Number(vodId), appId]);++ if (!p) return null;++ const rec = await present(p);++ const part = rec.parts.find((x) => x.vod_id === Number(vodId)) || null;++ return { recording: rec, part };++}++++/**++ * The `recording` field of VOD answers, for many VODs in two queries: { recording_id, seq, parts, start_ms, duration_ms,++ * first_vod_id }. duration_ms is the whole recording's (its parts' row durations; the detail answer is exact).++ */++async function summaries(vodIds) {++ const ids = [...new Set((vodIds || []).map(Number).filter(Number.isFinite))];++ const out = new Map();++ if (!ids.length) return out;++ const mine = await db.all('SELECT recording_object_id, vod_id FROM media_recording_parts WHERE vod_id = ANY(?)', [ids]);++ if (!mine.length) return out;++ const recs = [...new Set(mine.map((r) => r.recording_object_id))];++ const all = await db.all(`SELECT p.recording_object_id, p.seq, p.vod_id, v.duration_seconds FROM media_recording_parts p++ LEFT JOIN vods v ON v.id = p.vod_id WHERE p.recording_object_id = ANY(?) ORDER BY p.recording_object_id, p.seq`, [recs]);++ const byRec = new Map();++ for (const r of all) { if (!byRec.has(r.recording_object_id)) byRec.set(r.recording_object_id, []); byRec.get(r.recording_object_id).push(r); }++ for (const m of mine) {++ const parts = byRec.get(m.recording_object_id) || [];++ let at = 0, start = 0, seq = 0;++ for (const p of parts) {++ if (Number(p.vod_id) === Number(m.vod_id)) { start = at; seq = p.seq; }++ at += Math.round(Number(p.duration_seconds || 0) * 1000);++ }++ out.set(Number(m.vod_id), { recording_id: m.recording_object_id, seq, parts: parts.length, start_ms: start, duration_ms: at,++ first_vod_id: parts.length ? Number(parts[0].vod_id) : null });++ }++ return out;++}++++// The joined rows of a recording, kept a short while: every segment request of a player reads them. setParts clears++// its recording's entry; a part re-cut or packed shows within ROWS_TTL_MS.++const ROWS_TTL_MS = 20000;++const ROWS_MAX = 200;++const rowsCache = new Map(); // object id → { at, rows }++++/** The joined source rows of a recording object, or [] (not a recording, a part missing, a part with no timeline). */++async function rowsOf(obj) {++ if (!isRecording(obj)) return [];++ const hit = rowsCache.get(obj.id);++ if (hit && Date.now() - hit.at < ROWS_TTL_MS) return hit.rows;++ const rows = await joinedRows(obj);++ if (rows.length) {++ if (rowsCache.size >= ROWS_MAX) rowsCache.delete(rowsCache.keys().next().value);++ rowsCache.set(obj.id, { at: Date.now(), rows });++ }++ return rows;++}++++async function joinedRows(obj) {++ const timeline = require('../objects/timeline');++ const parts = await partsOf(obj.id);++ if (parts.length < 2 || parts.some((p) => !p.object_id)) return [];++ const rows = [];++ for (const p of parts) rows.push(await timeline.list(p.object_id, timeline.SOURCE));++ if (rows.some((r) => !r.some((x) => Number(x.seq) > 0))) {++ // A part not cut yet: queue it (idempotent) and answer nothing until every part plays.++ const hls = require('../objects/hls');++ for (const [i, r] of rows.entries()) if (!r.some((x) => Number(x.seq) > 0)) await hls.queueCmafOnce(obj.app_id, parts[i].object_id);++ return [];++ }++ return timeline.joinRows(rows);++}++++/** The title a recording shows: the first part's, without a " (Part 1)" the app may have added. */++const baseTitle = (t) => String(t || 'Recording').replace(/\s*\(part \d+\)\s*$/i, '').slice(0, 200) || 'Recording';++++/**++ * Make the recording for (appId, key) hold exactly `parts` ([{ vod_id, gap_before_ms?, stream_offset_ms? }], in order).++ * Every VOD must be this app's, finished or recording, and in no other recording. Two or more parts: the recording is++ * created or updated; fewer: an existing one is removed (its object deleted) and nothing is answered. Idempotent.++ */++async function setParts(appId, key, parts, { title = null, state = null } = {}) {++ if (!KEY.test(String(key || ''))) throw Object.assign(new Error('the key is 1-120 letters, digits and : _ . -'), { status: 400, code: 'media.recording.bad_key' });++ const list = (parts || []).map((p) => (typeof p === 'object' && p ? p : { vod_id: p }))++ .map((p) => ({ vod_id: Number(p.vod_id), gap_before_ms: Math.max(0, Math.round(Number(p.gap_before_ms) || 0)),++ stream_offset_ms: p.stream_offset_ms == null || !Number.isFinite(Number(p.stream_offset_ms)) ? null : Math.round(Number(p.stream_offset_ms)) }));++ if (list.length > MAX_PARTS) throw Object.assign(new Error(`at most ${MAX_PARTS} parts`), { status: 400, code: 'media.recording.too_many_parts' });++ if (list.some((p) => !Number.isFinite(p.vod_id)) || new Set(list.map((p) => p.vod_id)).size !== list.length) {++ throw Object.assign(new Error('parts are distinct VOD ids'), { status: 400, code: 'media.recording.bad_parts' });++ }++ const vods = list.length ? await db.all('SELECT id, app_id, object_id, title, visibility, user_id, clips_only FROM vods WHERE id = ANY(?)', [list.map((p) => p.vod_id)]) : [];++ const byId = new Map(vods.map((v) => [Number(v.id), v]));++ for (const p of list) {++ const v = byId.get(p.vod_id);++ if (!v || v.app_id !== appId || v.clips_only) throw Object.assign(new Error(`VOD ${p.vod_id} is not one of this app's videos`), { status: 404, code: 'media.recording.vod_not_found' });++ }++ const raw = db.getDb();++ const objectId = await raw.tx(async () => {++ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND group_key = ? FOR UPDATE', [appId, String(key)]);++ const taken = list.length ? await db.all('SELECT vod_id, recording_object_id FROM media_recording_parts WHERE vod_id = ANY(?)', [list.map((p) => p.vod_id)]) : [];++ const other = taken.find((t) => !rec || t.recording_object_id !== rec.object_id);++ if (other) throw Object.assign(new Error(`VOD ${other.vod_id} is part of another recording`), { status: 409, code: 'media.recording.part_taken' });++ if (list.length < 2) {++ if (rec) {++ await db.run('DELETE FROM media_recordings WHERE object_id = ?', [rec.object_id]);++ await model.updateObject(rec.object_id, { lifecycle_status: 'deleted', deleted_at: new Date().toISOString().replace('T', ' ').slice(0, 19) });++ }++ return null;++ }++ const first = byId.get(list[0].vod_id);++ const metadata = { recording: true, virtual: true, group_key: String(key), title: title ? String(title).slice(0, 200) : baseTitle(first.title) };++ let id = rec && rec.object_id;++ if (!id) {++ id = await model.createObject({ app_id: appId, kind: 'asset', visibility: first.visibility || 'public', lifecycle_status: 'ready',++ owner_app: appId, owner_user_id: first.user_id ?? null, mime_type: 'application/vnd.apple.mpegurl', metadata });++ await db.run('INSERT INTO media_recordings (object_id, app_id, group_key, state) VALUES (?, ?, ?, ?)', [id, appId, String(key), state === 'closed' ? 'closed' : 'open']);++ } else {++ const obj = await model.getObject(id);++ await model.updateObject(id, { metadata: { ...model.parseJson(obj.metadata, {}), ...metadata }, visibility: first.visibility || obj.visibility });++ await db.run("UPDATE media_recordings SET state = COALESCE(?, state), updated_at = ov_now() WHERE object_id = ?", [state === 'closed' || state === 'open' ? state : null, id]);++ }++ await db.run('DELETE FROM media_recording_parts WHERE recording_object_id = ?', [id]);++ for (const [i, p] of list.entries()) {++ await db.run('INSERT INTO media_recording_parts (recording_object_id, seq, vod_id, gap_before_ms, stream_offset_ms) VALUES (?, ?, ?, ?, ?)',++ [id, i + 1, p.vod_id, p.gap_before_ms, p.stream_offset_ms]);++ }++ return id;++ });++ for (const id of [...rowsCache.keys()]) rowsCache.delete(id); // a recording's parts changed: read them again++ return objectId ? await get(appId, objectId) : null;++}++++async function close(appId, key) {++ await db.run("UPDATE media_recordings SET state = 'closed', updated_at = ov_now() WHERE app_id = ? AND group_key = ?", [appId, String(key)]);++ return await byKey(appId, key);++}++++/**++ * A part changed: its visibility is the whole recording's (every part and the recording object follow; the app sets it++ * on one), and the first part's title is the recording's.++ */++async function syncPart(vodId, { visibility, title } = {}) {++ const p = await db.get('SELECT recording_object_id, seq FROM media_recording_parts WHERE vod_id = ?', [Number(vodId)]);++ if (!p) return;++ if (visibility !== undefined) {++ const vis = ['public', 'unlisted', 'private'].includes(visibility) ? visibility : 'public';++ const others = await db.all('SELECT vod_id FROM media_recording_parts WHERE recording_object_id = ? AND vod_id <> ?', [p.recording_object_id, Number(vodId)]);++ for (const o of others) await db.setVodVisibility(o.vod_id, vis); // the row and its object together++ await model.updateObject(p.recording_object_id, { visibility: vis });++ }++ if (title !== undefined && Number(p.seq) === 1) {++ const obj = await model.getObject(p.recording_object_id);++ await model.updateObject(p.recording_object_id, { metadata: { ...model.parseJson(obj.metadata, {}), title: baseTitle(title) } });++ }++}++++// ── Routes (/api/v1/:app/recordings) ──++const router = express.Router({ mergeParams: true });++const fail = (res, err) => {++ if (err && err.status) return res.status(err.status).json({ error: err.message, code: err.code });++ console.error('[Recordings]', err && err.message);++ return res.status(500).json({ error: 'Failed' });++};++++router.get('/object/:id', tenantAuth({ verb: 'read' }), async (req, res) => {++ try {++ const rec = await get(req.appId, req.params.id);++ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });++ } catch (err) { return fail(res, err); }++});++++router.get('/:key', tenantAuth({ verb: 'read' }), async (req, res) => {++ try {++ const rec = await byKey(req.appId, req.params.key);++ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });++ } catch (err) { return fail(res, err); }++});++++// Body: { parts: [vod_id | { vod_id, gap_before_ms?, stream_offset_ms? }], title?, state? }. Answers the recording, or++// { recording: null } when fewer than two parts were given (a part alone is its own video).++router.put('/:key', tenantAuth({ verb: 'write' }), async (req, res) => {++ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app groups its videos' });++ try {++ const b = req.body || {};++ const rec = await setParts(req.appId, req.params.key, Array.isArray(b.parts) ? b.parts : [], { title: b.title || null, state: b.state || null });++ return res.json(rec || { recording: null });++ } catch (err) { return fail(res, err); }++});++++router.post('/:key/close', tenantAuth({ verb: 'write' }), async (req, res) => {++ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app closes a recording' });++ try {++ const rec = await close(req.appId, req.params.key);++ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });++ } catch (err) { return fail(res, err); }++});++++module.exports = { isRecording, setParts, close, get, byKey, forVod, summaries, rowsOf, syncPart, present, baseTitle, router };@@@ -289,11 +289,15 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)order: req.query.order || req.query.sort, // `sort` = inherited alias// Created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC): "top this week".since: req.query.since || null,++ // A recording's parts after its first (docs/recordings.md) are listed only when asked for.++ includeParts: ['1', 'true'].includes(String(req.query.include_parts || '')),};const vods = await db.listVods(req.appId, filters);const total = await db.countVods(req.appId, filters);const hls = await require('../objects/hls').discoveryMany(vods);-- res.json({ vods: (await Promise.all(vods.map(async v => await vodPublic(v, { readiness: true, hls: hls.get(String(v.object_id)) || null })))), total, limit, offset, hasMore: offset + vods.length < total });++ const recs = await require('./recordings').summaries(vods.map((v) => v.id));++ res.json({ vods: (await Promise.all(vods.map(async v => ({ ...await vodPublic(v, { readiness: true, hls: hls.get(String(v.object_id)) || null }),++ ...(recs.has(Number(v.id)) && { recording: recs.get(Number(v.id)) }) })))), total, limit, offset, hasMore: offset + vods.length < total });} catch (err) {console.error('[VOD] List error:', err.message);res.status(500).json({ error: 'Failed to list VODs' });@@@ -349,7 +353,10 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re// Bare object per CONTRACTS.md: { id, title, status, duration, … }// Detail responses carry the transcript too (lists stay light).-- res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null });++ // A part of a recording (docs/recordings.md) names it: the whole video's id, length, parts and playlist.++ const rec = await require('./recordings').forVod(req.appId, vod.id);++ res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null,++ ...(rec && { recording: { ...rec.recording, this_part: rec.part } }) });} catch (err) {console.error('[VOD] Get error:', err.message);res.status(500).json({ error: 'Failed to get VOD' });@@@ -374,6 +381,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {}if (visibility !== undefined) await db.setVodVisibility(vod.id, visibility);});++ // A part of a recording (docs/recordings.md): who may see it is the whole video's, and the first part's title is++ // the recording's.++ if (visibility !== undefined || title !== undefined) await require('./recordings').syncPart(vod.id, { visibility, title });// Made public (or already public): its watch page and the sitemap are indexable now.if (visibility !== undefined) await require('../indexnow-notify').pingWatch('vod', await db.getVodById(vod.id));res.json({ vod: await vodPublic(await db.getVodById(vod.id, req.appId), { readiness: true }) });nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++// Recordings (docs/recordings.md; owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 · Part 2'++// it should automatically just have them as a single vod … that seeks just fine"):++// - timeline.joinRows puts the parts' rows end to end: names p<k>-…, times shifted, part k's init (and its shape++// periods') named, bytes untouched; the playlist puts a discontinuity and the next part's init at the join; a clip++// window may cross the join.++// - PUT /api/v1/:app/recordings/:key makes a recording of an app's VODs (a native object, no VOD row); its playlist and++// segments play both parts' bytes; lists show only its first part, which names the recording; visibility and the++// title follow the first part; a clip in recording time across the join is virtual over the recording.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const express = require('express');++++(async () => {++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-recordings-'));++ 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'), THUMBNAILS_PATH: dir('thumbnails'),++ PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_PUBLIC_URL: 'https://media.test',++ MEDIA_HLS_ENABLED: '1', MEDIA_INVARIANT_SCAN_HOURS: '0',++ });++ const db = require('../server/db/database');++ const model = require('../server/objects/model');++ const timeline = require('../server/objects/timeline');++ const tools = require('../server/vod/media-tools');++ const thumbService = require('../server/thumbnails/thumbnail-service');++ const vodStorage = require('../server/vod/vod-storage');++ await vodStorage.tierConfig.init(vodStorage.DEFAULTS);++ await require('../server/objects/tier-policy').init();++ const { finalizeVod } = require('../server/vod/finalize');++ await db.upsertApp({ app_id: 'live', api_key: 'live-key-recordings' });++ thumbService.generateVodThumbnail = async () => null;++ tools.remuxForSeekingDetailed = async () => ({ ok: true, seconds: 30, error: null });++ tools.probeDuration = async () => ({ ok: true, seconds: 30, format: {}, streams: [], error: null });++ tools.remuxForSeeking = async () => true;++ tools.probeVodInfo = async () => ({ duration: 30 });++++ // ── joinRows ──++ const row = (seq, start, end, extra = {}) => ({ seq, name: timeline.segmentName(seq), start_ms: start, end_ms: end, keyframe_ms: seq > 0 ? start : null,++ key: `x/source/${timeline.segmentName(seq)}`, local_path: null, durable_provider: null, packed_object_id: null, byte_offset: null,++ byte_length: 1000, sha256: String(Math.abs(seq) % 10).repeat(64), durability: 'local', init_name: null, ...extra });++ const a = [row(0, 0, 0), row(1, 0, 4000), row(2, 4000, 8000), row(3, 8000, 10500)];++ // Part 2 starts at its own 2000 ms and turns at 6000 ms (a shape period with init-2.mp4).++ const b = [row(0, 0, 0), { ...row(-102, 0, 0), name: 'init-2.mp4' }, row(1, 2000, 6000), row(2, 6000, 9000, { init_name: 'init-2.mp4' })];++ const joined = timeline.joinRows([a, b]);++ assert.deepStrictEqual(joined.map((r) => [r.seq, r.name, r.start_ms, r.end_ms, r.init_name]), [++ [0, 'p1-init.mp4', 0, 0, null],++ [1, 'p1-000001.m4s', 0, 4000, null], [2, 'p1-000002.m4s', 4000, 8000, null], [3, 'p1-000003.m4s', 8000, 10500, null],++ [-20000, 'p2-init.mp4', 0, 0, null], [-20002, 'p2-init-2.mp4', 0, 0, null],++ [4, 'p2-000001.m4s', 10500, 14500, 'p2-init.mp4'], [5, 'p2-000002.m4s', 14500, 17500, 'p2-init-2.mp4'],++ ], 'end to end, renamed, part 2 decoding with its own inits');++ assert.strictEqual(joined[6].key, b[2].key, 'bytes stay where they are');++ const mp = timeline.mediaPlaylist(joined);++ assert.ok(mp.includes('#EXT-X-MAP:URI="p1-init.mp4"\n#EXTINF:4.000,\np1-000001.m4s'), mp);++ assert.ok(mp.includes('p1-000003.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"\n#EXTINF:4.000,\np2-000001.m4s'), 'a clean decoder reset at the join');++ assert.ok(mp.includes('p2-000001.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init-2.mp4"'), 'and at part 2\'s turn');++ assert.deepStrictEqual(timeline.joinRows([a, [row(0, 0, 0)]]), [], 'a part with nothing to play: no recording yet');++ const across = timeline.clipRows(joined, 9000, 13000);++ assert.deepStrictEqual(across.filter((r) => r.seq > 0).map((r) => [r.name, r.start_ms, r.end_ms, r.init_name]),++ [['p1-000003.m4s', 0, 1500, null], ['p2-000001.m4s', 1500, 4000, 'p2-init.mp4']], 'a clip window across the join');++ assert.ok(across.some((r) => r.name === 'p2-init.mp4'), 'carrying part 2\'s init');++ assert.ok(timeline.mediaPlaylist(across).includes('#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"'));++ console.log('✅ joinRows: end to end, renamed inits, discontinuity at the join, a clip across it');++++ const app = express();++ app.use(express.json());++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));++ app.use('/api/v1/:app/recordings', require('../server/vod/recordings').router);++ app.use('/o', require('../server/objects/routes').publicRouter);++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const api = `http://127.0.0.1:${server.address().port}`;++ const auth = { authorization: 'Bearer live-key-recordings' };++ const call = async (method, p, body) => {++ const headers = { ...auth };++ if (body && !(body instanceof FormData)) headers['content-type'] = 'application/json';++ const r = await fetch(api + p, { method, headers, body: body && !(body instanceof FormData) ? JSON.stringify(body) : body, redirect: 'manual' });++ return { status: r.status, body: await r.json().catch(() => null) };++ };++ const get = async (url) => {++ const u = new URL(url, api);++ const r = await fetch(api + u.pathname + u.search, { redirect: 'manual' });++ return { status: r.status, text: Buffer.from(await r.arrayBuffer()) };++ };++++ // Two finished VODs of stream 2795 with timelines on this node's disk.++ const made = [];++ for (const [title, rows] of [['BIRD DRAMA (Part 1)', a], ['BIRD DRAMA (Part 2)', b]]) {++ const r = await call('POST', '/api/v1/live/vods', { title, user_id: 5, stream_id: 2795 });++ assert.strictEqual(r.status, 201);++ const f = new FormData();++ f.append('chunk', new Blob([Buffer.alloc(40000, 1)], { type: 'video/webm' }), 'a.webm');++ await call('POST', `/api/v1/live/vods/${r.body.id}/chunks`, f);++ const objectId = (await finalizeVod(r.body.id)).object_id;++ const obj = await model.getObject(objectId);++ const bytes = {};++ await timeline.replace(objectId, 'source', rows.map((s) => {++ const p = timeline.localPathFor(obj, 'source', s.name, 'v1');++ fs.mkdirSync(path.dirname(p), { recursive: true });++ bytes[s.name] = Buffer.from(`${title}:${s.name}`);++ fs.writeFileSync(p, bytes[s.name]);++ return { ...s, local_path: p, byte_length: bytes[s.name].length };++ }));++ made.push({ id: r.body.id, objectId, bytes });++ }++ const [p1, p2] = made;++++ // ── the recording ──++ let r = await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id, { vod_id: p2.id, gap_before_ms: 4000, stream_offset_ms: 709000 }] });++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ const rec = r.body;++ assert.match(rec.id, /^med_/);++ assert.deepStrictEqual([rec.key, rec.state, rec.duration_ms, rec.vod_id], ['stream:2795', 'open', 17500, p1.id]);++ assert.deepStrictEqual(rec.parts.map((x) => [x.seq, x.vod_id, x.start_ms, x.duration_ms, x.gap_before_ms]), [[1, p1.id, 0, 10500, 0], [2, p2.id, 10500, 7000, 4000]]);++ assert.strictEqual(rec.parts[1].stream_offset_ms, 709000);++ assert.strictEqual(rec.hls_url, `https://media.test/o/${rec.id}/master.m3u8`);++ assert.strictEqual((await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id, p2.id] })).body.id, rec.id, 'idempotent: the same recording');++ assert.strictEqual((await call('PUT', '/api/v1/live/recordings/stream:9', { parts: [p2.id, p1.id] })).status, 409, 'a part belongs to one recording');++ assert.strictEqual((await call('GET', '/api/v1/live/recordings/stream:2795')).body.id, rec.id);++ assert.strictEqual((await call('GET', `/api/v1/live/recordings/object/${rec.id}`)).body.key, 'stream:2795');++ assert.ok(!(await db.get('SELECT 1 FROM vods WHERE object_id = ?', [rec.id])), 'no VOD row of its own');++++ // Playback: both parts' bytes through the recording's playlists.++ const master = await get(`/o/${rec.id}/master.m3u8`);++ assert.strictEqual(master.status, 200, master.text.toString());++ const media = (await get(`/o/${rec.id}/source/index.m3u8`)).text.toString();++ assert.ok(media.includes('p1-000001.m4s') && media.includes('#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"') && media.includes('p2-000002.m4s'), media);++ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p2-000001.m4s`)).text, p2.bytes['000001.m4s'], 'part 2\'s segment, as stored');++ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p1-init.mp4`)).text, p1.bytes['init.mp4']);++ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p2-init-2.mp4`)).text, p2.bytes['init-2.mp4']);++ assert.strictEqual((await get(`/o/${rec.id}/source/p3-000001.m4s`)).status, 404);++ console.log('✅ PUT /recordings: one object over both parts, its playlist and segments play them');++++ // Lists: the first part stands for the video and names the recording; its later part only when asked.++ r = await call('GET', '/api/v1/live/vods?stream_id=2795');++ assert.deepStrictEqual(r.body.vods.map((v) => v.id), [p1.id], 'the later part is not a video of its own');++ assert.deepStrictEqual([r.body.vods[0].recording.recording_id, r.body.vods[0].recording.parts], [rec.id, 2]);++ assert.strictEqual((await call('GET', '/api/v1/live/vods?stream_id=2795&include_parts=1')).body.vods.length, 2);++ r = await call('GET', `/api/v1/live/vods/${p2.id}`);++ assert.deepStrictEqual([r.body.recording.id, r.body.recording.this_part.start_ms, r.body.recording.vod_id], [rec.id, 10500, p1.id], 'a part says where it is in the whole');++++ // Visibility and the title follow the first part.++ assert.strictEqual((await call('PUT', `/api/v1/live/vods/${p1.id}`, { visibility: 'private', title: 'BIRD DRAMA (Part 1)' })).status, 200);++ assert.strictEqual((await db.getVodById(p2.id)).visibility, 'private', 'every part');++ assert.strictEqual((await model.getObject(rec.id)).visibility, 'private', 'and the recording');++ assert.strictEqual((await get(`/o/${rec.id}/master.m3u8`)).status, 404, 'a private recording needs its signature');++ assert.strictEqual(model.parseJson((await model.getObject(rec.id)).metadata, {}).title, 'BIRD DRAMA', 'the title without "(Part 1)"');++ await call('PUT', `/api/v1/live/vods/${p1.id}`, { visibility: 'public' });++ assert.strictEqual((await db.getVodById(p2.id)).visibility, 'public');++++ // A clip in recording time across the join: virtual over the recording, on the first part.++ r = await call('POST', '/api/v1/live/clips', { vod_id: p2.id, start_s: 9, end_s: 13, recording_time: true, title: '' });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.deepStrictEqual([r.body.vod_id, r.body.recording_id, r.body.time_base, r.body.start_time, r.body.end_time], [p1.id, rec.id, 'recording', 9, 13]);++ const clipObj = (await db.getClipById(r.body.id)).object_id;++ const clipMedia = (await get(`/o/${clipObj}/source/index.m3u8`)).text.toString();++ assert.ok(clipMedia.includes('p1-000003.m4s') && clipMedia.includes('p2-000001.m4s') && clipMedia.includes('#EXT-X-DISCONTINUITY'), clipMedia);++ assert.deepStrictEqual((await get(`/o/${clipObj}/source/p2-000001.m4s`)).text, p2.bytes['000001.m4s'], 'the clip plays part 2\'s bytes');++ assert.strictEqual((await get(`/o/${clipObj}/source/p2-000002.m4s`)).status, 404, 'and nothing outside its window');++ console.log('✅ lists show the whole video once; visibility and title follow; a clip across the join');++++ // Fewer than two parts: no recording.++ assert.deepStrictEqual((await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id] })).body, { recording: null });++ assert.strictEqual((await call('GET', '/api/v1/live/recordings/stream:2795')).status, 404);++ assert.strictEqual((await call('GET', '/api/v1/live/vods?stream_id=2795')).body.vods.length, 2, 'both parts list again');++++ server.close();++ console.log('recordings: all passed');++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+# Recordings: one video per stream, however it was stored
+
+Owner, 2026-10-27: "instead of having it list like 'The stream was recorded in 2 parts: Part 1 (this one) · Part 2' it
+should automatically just have them as a single vod … appears to users like it's one big vod that seeks just fine but
+behind the scenes it has this smart chunked / multi file system … available and shared for other things across our
+network … be aware of server storage … scalable … also consider how we can implement this into our sdk/api", and "it
+needs to combine 'clips from this stream' across all the vods", and on clipping: "the system used for clipping videos and
+clipping active live streams across the different services [should be] shared / multi purpose / intelligent".
+
+This is the design of record for that. It builds on the [Media Fabric](media-fabric.md), whose segment-native video
+already does most of the storage side: CMAF segments, the timeline index, ~60 s packed chunks, virtual and materialized
+clips, and the placement engine. What it adds is the **recording**: one playable video over any number of stored parts.
+
+## 1. How it works today (review, 2026-10-27)
+
+**What is stored.** Live's recorder writes one VOD per *recording session*. A broadcaster who drops and comes back gets
+a second VOD for the same stream: BIRD DRAMA (stream 2795) is VOD 6025 (0–11:17) and VOD 6026 (11:21–43:18). A recorder
+restart or a deploy does the same. After finalize, `object.cmaf` cuts each VOD's file into a CMAF timeline: 175 rows for
+6025 and 487 for 6026. `object.pack` packs those into ~60 s chunks: 11 and 31 objects. The picture turning (portrait ↔
+landscape) is already *not* a new VOD: it is a new shape period inside one timeline (Media#182).
+
+**What people see.**
+- openvibe.video shows two watch pages and two cards. Each has its own transcript, chapters, chat replay, downloads,
+ view count, comments thread and AI overview, and an "About" line linking the other part (MediaHub#143).
+- Live's channel lists both VODs.
+- Seeking stops at a part's end. The player jumps to the next part at the end (Live#380), but the scrubber never covers
+ the whole stream.
+- Clips are already gathered per stream: Live lists them by `stream_id`. A clip's times are in its part's time, though,
+ and a clip cannot cross the join.
+- AI analysis, transcripts, moments and auto-clips all run per part.
+
+**Clipping today: five ways in, two engines.**
+
+| where | how | engine |
+|---|---|---|
+| Live player, the Clip button while live | Live `POST /api/vods/clips` `{ stream_id, live: true, at, duration }` | Media cuts from the active recording's file |
+| Live studio (broadcaster), "Clip that" | the same, with `stream_time: true` (Live#379) | the same, placed on the right part through stream history |
+| Live player, rewinding (DVR) | the same, with `stream_time` | the same |
+| openvibe.video, Clip menu | MediaHub → Live `POST /api/vods/clips` `{ vod_id, start_time, end_time }` | a virtual clip when the VOD has a timeline, else `clip.cut` |
+| auto-clips (chat spikes live, AI moments after) | Live's auto-clip job → Media | the same two |
+| uploaded clip (old client) | the browser's own recording, uploaded | a file |
+
+**AI titles.** A clip with no title gets Live's placeholder "Clip: <stream title>". Live's `clip-title` job renames it from
+the clip's AI overview, once the clip's own analysis has run. The live auto-clip path writes an AI title through
+`publicTitle`. The weak spots, from production on 2026-10-27:
+- Clips whose analysis failed are never named. Clips 494, 497 and 498 are still "Clip: Seattle".
+- The VOD auto-clip path keeps a frame-caption fallback as a final title: "A Person Is Standing In A Living Room,
+ Facing".
+- A clip of an upload (no stream) keeps "Clip: <video title>", because the placeholder check only knows stream titles.
+
+## 2. The model
+
+A **recording** is one video made of ordered **parts**. Every part is an ordinary stored video object with its own
+timeline: Live's VOD of a session, a rolled-over file, an uploaded file. The recording is a **virtual object**. Its
+timeline is the parts' rows joined end to end, so no byte is copied, moved or re-encoded to make it.
+
+```
+media_recordings id rec_…, app_id, group_key (the app's own key: "stream:2795"), vod_id/object_id (the
+ recording's own VOD row and object: its public /v/<id>), state open|closed
+media_recording_parts recording_id, seq, vod_id, object_id, gap_before_ms (wall time not recorded before it)
+```
+
+**Playback.** `timeline.joinRows(parts)` builds the recording's rows from the parts' rows. Part k's media segments keep
+their bytes (key, local path, packed chunk and byte range) and get:
+- times shifted by the parts before it;
+- names prefixed `p<k>-`, so names never collide;
+- `init_name` `p<k>-init.mp4`, or `p<k>-init-<n>.mp4` for a shape period.
+
+`mediaPlaylist()` already writes `#EXT-X-DISCONTINUITY` + `#EXT-X-MAP` wherever the init changes, so the join is a clean
+decoder reset. hls.js and Safari treat it exactly like a shape change, which they already play (VOD 6026). The gap between
+parts (4 s for 2795) is not played. Its wall time is kept in `gap_before_ms`, so stream time ↔ recording time stays
+exact.
+
+**Time.** Three clocks, and one place that converts them.
+
+| clock | zero | used by |
+|---|---|---|
+| part time | the part's first frame | Media's files, old clip windows |
+| recording time | the recording's first frame, gaps removed | the player, clips, transcripts, chapters, links (`?t=`) |
+| stream time | Live's `streams.started_at` | chat replay, live captions, stream history, the Rundown |
+
+`GET /api/v1/:app/recordings/:id` answers the parts with `start_ms` (recording time), `duration_ms`, `gap_before_ms` and,
+for Live, `stream_offset_ms`. Every service converts with that one table: Live's `stream-history.js`, MediaHub's chat
+replay, the transcript merge.
+
+**Ids.** The recording's VOD row is the one public id: `/v/<id>`, on openvibe.media, openvibe.video and Live.
+- A part keeps its id and its own playback, so nothing that linked it breaks.
+- Pages redirect a part to the recording at the right second: `/v/6026` → `/v/<recording>?t=681`.
+- Lists show recordings, not parts. `include_parts=1` lists the parts too, for staff and tools.
+
+**When a recording forms.** A part is created with the app's group key (Live: `stream:<id>`; any app may send its own).
+- **The first part** is the recording, with no new row: one id, nothing new to show.
+- **The second part** turns it into a recording. Media creates the recording's VOD row and object, adds both parts, and
+ sends `media.recording.updated`.
+- **A reconnect window decides "same recording":** a part whose group key matches a recording still `open` joins it. Live
+ closes a recording when the stream ends; Mux's equivalent is a 60 s reconnect window, configurable to 30 min.
+- **A stream that ends and goes live again** is a new stream, and so a new recording.
+
+**When a part forms (splitting).** A new part is only needed when a session really ends: a disconnect, or a recorder or
+host restart. A shape change is a period inside a part. Storage is no reason to split. Packing already makes ~60 s chunk
+objects, and the timeline index finds any second without opening a file, so a ten-hour part costs no more to serve than
+ten one-hour parts.
+
+One storage rule does cut parts: **rollover**. A part is closed and a new one opened every `MEDIA_PART_MAX_HOURS` (4)
+or `MEDIA_PART_MAX_GB` (8). That bounds the work a crash can lose: the unfinalized tail, the DVR copy (Media#162's disk
+pressure), and a single remux, upload or move. A rolled part joins the recording like any other, so viewers never see it.
+
+## 3. Storage, cost and scale
+
+- **Bytes are written once.** Joining, clipping and re-cutting are row operations. Locations are content-addressed and
+ reference-counted by the rows that name them ([media-fabric §4](media-fabric.md#4-clips-reuse-the-source)).
+- **Object count.** Two-second CMAF segments are packed after finalize into ~60 s chunks, about 30× fewer objects, PUTs
+ and HEADs. The player reads them with `EXT-X-BYTERANGE`.
+ - Byte ranges over one big file are the cheapest for object count, but a CDN may fetch more from the origin than the
+ range asked for.
+ - Many small files cache best, but cost one operation each.
+ - 60 s chunks sit between the two.
+- **Tiers** (the placement engine, [media-fabric §6](media-fabric.md#6-placement-one-engine-every-class-down-to-the-segment-bucket)):
+
+ | tier | holds | cost |
+ |---|---|---|
+ | local NVMe | live, new and hot video | |
+ | Cloudflare R2 | what is watched | $0.015/GB-month, $0.36 per million reads, no egress fee |
+ | Backblaze B2 | everything durable | about $6.95/TB-month; egress free up to 3× the stored volume, and free through Cloudflare's Bandwidth Alliance |
+
+ For comparison, S3 egress is about $0.09/GB. A recording's parts tier independently, chunk by chunk. A long stream
+ whose first hour is never watched keeps that hour on B2 only.
+- **Derivatives are caches.**
+ - Lower renditions are made on demand (F4).
+ - The download MP4 of a whole recording is a lazy remux of its joined playlist, kept like any derivative and
+ dropped when cold.
+ - When the parts' shapes differ, the download is one MP4 per shape period, offered together in the Download menu,
+ because one MP4 track cannot change its picture size.
+- **The live DVR copy** (2–3× the recording's size while live, Media#162) is deleted at finalize, once the timeline is
+ durable.
+
+## 4. Clips: one system over recordings
+
+A clip is a window over a recording: `{ recording, start_ms, end_ms }`. Every way in becomes the same call:
+
+| way in | what it sends |
+|---|---|
+| Live player, live | "the last N seconds", resolved to the open recording's live edge |
+| Live player, rewinding; Live studio | stream time → recording time (one conversion, §2) |
+| openvibe.video | recording time from the player |
+| auto-clips | recording time from the moment |
+| any app (SDK) | `media.clips.create({ recording, start, end })` |
+
+- **Instant first.** The clip is virtual at once: a manifest over the recording's segments, segment-accurate. This is
+ what Mux calls an instant clip and what MediaPackage's harvest does from a live window. It works while the stream is
+ still live, because the open recording's timeline grows, and it can cross a part boundary: the window simply covers
+ rows of two parts.
+- **Exact when it matters.** A clip that is downloaded, shared outside, or watched past a threshold is materialized:
+ only its two edge segments are re-encoded, frame-accurate ([materialized-clips.md](materialized-clips.md)).
+- **Named by AI when nobody named it.** The form says so ("Leave empty: AI names it").
+ - **Placeholders.** Live's placeholder titles are a closed set, plus "Clip: <the stream's or the video's title>".
+ - **Provisional titles.** An auto-clip's title is marked `title_provisional` and is replaced the same way.
+ - **When the analysis fails, the name comes from the recording's transcript.** The transcript lines inside the window,
+ from the full pass or the live captions, plus the stream's title, go to the same `live.stream.titles` template. A
+ clip with no speech and no overview is named "<stream title> at <time>". No clip keeps a placeholder for more than
+ one sweep after its window has a transcript.
+- **One maker in the UI.** The shared player gets the clip maker: start and end from the current time, the AI-title
+ note, and keyboard marks. Live's player, Live's studio and openvibe.video all use it, so it looks and behaves the same.
+- **Clips from this stream** are the recording's clips. Their places are drawn on the recording's scrubber.
+
+## 5. Uploads and other apps
+
+- **Big uploads** use Media's existing multipart sessions (`media_uploads`), which are resumable part by part:
+ openvibe.video's upload page moves to them. The finished file is cut into a CMAF timeline like a recording part.
+ Several files of one event (a camera that splits at 4 GB) are one recording, by group key.
+- **Any app can record.** A Space, a Bot robot camera or an OpenRestream session sends parts with its own group key
+ and gets the same playback, clips, tiers and AI.
+
+## 6. API, events and SDK
+
+Media (`/api/v1/:app`, tenant token; capability `media.vod.write` for writes, `media.vod.read` for reads):
+
+| route | what |
+|---|---|
+| `POST /vods` (`recording_key`) | a part, joined to the open recording with that key (Live sends `stream:<id>`) |
+| `GET /recordings/:id` | the recording: its VOD row, `duration_ms`, `parts[]` (vod_id, start_ms, duration_ms, gap_before_ms, stream_offset_ms), state |
+| `GET /recordings/by-key/:key` | the same by group key |
+| `GET /vods/:id/recording` | a part's recording and its place in it (where to redirect, `?t=`) |
+| `POST /recordings/:id/close` | no more parts (Live: the stream ended) |
+| `POST /recordings/:id/clips` | `{ start_ms, end_ms, title?, materialize? }`: a clip over the recording (§4) |
+| public `/o/<recording object>/master.m3u8` | the joined playlist; `/v/<recording vod>` plays it |
+
+Events on OpenVibe.Events: `media.recording.updated` (a part joined, the recording closed, its duration changed).
+
+SDK (`openvibe-sdk/media`): `recordings.get(id | { key })`, `recordings.close(id)`, `recordings.clip(id, { start, end })`,
+`recordings.toRecordingTime(parts, streamMs)` and `toStreamTime(…)` (the one conversion, shared), and `vods.create({
+recordingKey })`. Contracts: the routes above in `media.json`, and the event in `events.json`.
+
+## 7. Build order
+
+1. **Media: recordings.**
+ - Migration `0015_recordings`; `timeline.joinRows`; the recording's VOD row and object.
+ - Joined HLS playback; clips over a recording, including across a join.
+ - The routes and the event.
+ - Existing multi-part streams joined by a backfill script (a dry run first).
+2. **Live.**
+ - Recorder parts carry `recording_key`, and the recording is closed at stream end.
+ - The channel's videos list recordings.
+ - The VOD route answers a recording: its transcript, AI state and chat replay over the parts in recording time.
+ - Clips are created over recordings.
+ - AI titles: provisional titles and the transcript-window fallback.
+3. **MediaHub.**
+ - A recording's watch page: one player, one transcript, chat replay, chapters and clips on the whole scrubber.
+ - Parts redirect to it; downloads offer the recording.
+4. **Shared player: the clip maker** (openvibe-shared), used by Live and MediaHub.
+5. **SDK and Contracts.**
+6. **Rollover and uploads.**
+ - Live's recorder rolls parts at the limits.
+ - openvibe.video uploads use multipart sessions.
+
+Research behind the choices (2026-10-27):
+- Mux's [reconnect windows](https://www.mux.com/docs/guides/handle-live-stream-disconnects) (one asset across
+ reconnects, 60 s default, up to 1800 s) and [instant clips](https://www.mux.com/docs/guides/create-instant-clips)
+ (trimmed manifests, segment-accurate, no re-encode);
+- AWS MediaPackage [live-to-VOD harvest jobs](https://docs.aws.amazon.com/mediapackage/latest/ug/ltov.html) (clips from
+ the live window, segment-accurate);
+- R2, B2 and S3 prices ([R2 vs S3 vs B2](https://mecanik.dev/en/posts/cloudflare-r2-pricing-explained-real-costs-vs-s3-and-backblaze/),
+ [B2 pricing](https://www.backblaze.com/cloud-storage/pricing));
+- byte-range against segment-file trade-offs for HLS
+ ([single-file HLS](https://command-tab.com/2026/08/14/single-file-http-live-streaming/));
+- `EXT-X-DISCONTINUITY` + `EXT-X-MAP` for joining parts
+ ([the discontinuity tag](https://www.forasoft.com/learn/video-streaming/glossary/terms-streaming/discontinuity-tag)).
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+-- phase: expand
+-- Recordings (docs/recordings.md): one playable video over a stream's parts. A stream recorded in several sessions
+-- (a broadcaster who dropped and came back, a recorder restart) is several VODs; a recording joins them, so people see
+-- one video that seeks across them. The recording is a native media object (kind 'asset', metadata.recording) whose
+-- timeline is its parts' rows joined on every read (objects/timeline.js joinRows): no bytes are copied, and no VOD row
+-- is added, so counts of videos and hours, tiering and health scans see each stored part exactly once.
+-- Additive only: an older release never reads these tables, and lists every part as before.
+CREATE TABLE media_recordings (
+ object_id text COLLATE "C" PRIMARY KEY REFERENCES media_objects(id),
+ app_id text COLLATE "C" NOT NULL,
+ group_key text COLLATE "C" NOT NULL, -- the app's own key for what makes one recording ("stream:2795" for Live)
+ state text COLLATE "C" NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')),
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ UNIQUE (app_id, group_key)
+);
+
+CREATE TABLE media_recording_parts (
+ recording_object_id text COLLATE "C" NOT NULL REFERENCES media_recordings(object_id) ON DELETE CASCADE,
+ seq integer NOT NULL CHECK (seq >= 1),
+ vod_id bigint NOT NULL,
+ gap_before_ms bigint NOT NULL DEFAULT 0, -- wall time between the previous part's end and this one's start (not played)
+ stream_offset_ms bigint, -- where the part starts in the app's own clock (Live: stream time), when sent
+ PRIMARY KEY (recording_object_id, seq),
+ UNIQUE (vod_id)
+);
+
+-- A clip over a recording (POST /clips with recording_time): its window is in recording time and its clip_of edge goes
+-- to the recording's object; vod_id stays the recording's first part, the video people know.
+ALTER TABLE clips ADD COLUMN recording_object_id text COLLATE "C";
@@ -277,9 +277,11 @@ function sinceParam(value) {
@@ -277,9 +277,11 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
}
}
-function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true, since = null } = {}) {
+function _vodConds(appId, { user_id = null, stream_id = null, managed_stream_id = null, include_private = false, includeRecording = true, since = null, includeParts = false } = {}) {
const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const conds = ['app_id = ?', 'COALESCE(clips_only, 0) = 0'];
const params = [appId];
const params = [appId];
+ // A recording's later parts (docs/recordings.md): its first part stands for the whole video in a list.
+ if (!includeParts) conds.push('NOT EXISTS (SELECT 1 FROM media_recording_parts rp WHERE rp.vod_id = vods.id AND rp.seq > 1)');
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
if (!includeRecording) conds.push('COALESCE(is_recording, 0) = 0');
if (!include_private) conds.push('is_public = 1');
if (!include_private) conds.push('is_public = 1');
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
if (user_id != null) { conds.push('user_id = ?'); params.push(user_id); }
@@ -445,15 +447,15 @@ function vodStatus(vod) {
@@ -445,15 +447,15 @@ function vodStatus(vod) {
// 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.
-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 }) {
+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 }) {
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');
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)
- 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)
+ 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']
+ vis === 'public' ? 1 : 0, vis, auto_generated ? 1 : 0, status || 'ready', recording_object_id || null]
));
));
}
}
@@ -235,6 +235,7 @@ const ready = (async () => {
@@ -235,6 +235,7 @@ const ready = (async () => {
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/views', require('./views/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/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
@@ -45,11 +45,18 @@ async function clipWindow(obj) {
@@ -45,11 +45,18 @@ async function clipWindow(obj) {
*/
*/
async function timelineOf(obj) {
async function timelineOf(obj) {
const timeline = require('./timeline');
const timeline = require('./timeline');
+ const recordings = require('../vod/recordings');
+ // A recording (docs/recordings.md): its parts' rows joined, never its own; `recording` tells the callers not to
+ // queue a cut of it (each part is cut on its own).
+ if (recordings.isRecording(obj)) return { rows: await recordings.rowsOf(obj), window: null, recording: true };
const own = await timeline.list(obj.id, timeline.SOURCE);
const own = await timeline.list(obj.id, timeline.SOURCE);
if (own.some((r) => Number(r.seq) > 0)) return { rows: own, window: null };
if (own.some((r) => Number(r.seq) > 0)) return { rows: own, window: null };
const window = await clipWindow(obj);
const window = await clipWindow(obj);
if (!window) return { rows: [], window: null };
if (!window) return { rows: [], window: null };
- return { rows: timeline.clipRows(await timeline.list(window.sourceId, timeline.SOURCE), window.startMs, window.endMs), window };
+ // A clip over a recording is a window in recording time over the joined rows (it may cross a join).
+ const src = await model.getObject(window.sourceId);
+ const srcRows = recordings.isRecording(src) ? await recordings.rowsOf(src) : await timeline.list(window.sourceId, timeline.SOURCE);
+ return { rows: timeline.clipRows(srcRows, window.startMs, window.endMs), window };
}
}
/** Whether an object is served openly: not private and not in a developer-project sandbox tenant (GET /o/:id's rule). */
/** Whether an object is served openly: not private and not in a developer-project sandbox tenant (GET /o/:id's rule). */
@@ -127,13 +134,13 @@ async function discoveryForObject(obj) {
@@ -127,13 +134,13 @@ async function discoveryForObject(obj) {
try {
try {
const tl = await timelineOf(obj);
const tl = await timelineOf(obj);
if (!tl.rows.some((r) => Number(r.seq) > 0)) {
if (!tl.rows.some((r) => Number(r.seq) > 0)) {
- if (!tl.window) await queueCmafOnce(obj.app_id, obj.id);
+ if (!tl.window && !tl.recording) await queueCmafOnce(obj.app_id, obj.id);
return null;
return null;
}
}
// The source timeline exists: queue any missing rendition (F4) so the master playlist can list it next time.
// The source timeline exists: queue any missing rendition (F4) so the master playlist can list it next time.
// A clip that plays without a file of its own (a virtual or materialized one) is never queued one — the job
// A clip that plays without a file of its own (a virtual or materialized one) is never queued one — the job
// would fail with media_unavailable — so the lazy path leaves it alone, as queueRendition itself does.
// would fail with media_unavailable — so the lazy path leaves it alone, as queueRendition itself does.
- if (!tl.window && !require('./timeline-queue').isVirtualClip(obj)) await queueRenditionsOnce(obj);
+ if (!tl.window && !tl.recording && !require('./timeline-queue').isVirtualClip(obj)) await queueRenditionsOnce(obj);
return playlistFor(obj, await isOpen(obj));
return playlistFor(obj, await isOpen(obj));
} catch (err) {
} catch (err) {
console.warn(`[HLS] Discovery failed for ${obj.id}:`, err.message);
console.warn(`[HLS] Discovery failed for ${obj.id}:`, err.message);
@@ -225,4 +232,4 @@ async function discoveryMany(rows) {
@@ -225,4 +232,4 @@ async function discoveryMany(rows) {
return out;
return out;
}
}
-module.exports = { timelineOf, renditionsFor, queueRenditionsOnce, discoveryForObject, discovery, discoveryMany };
+module.exports = { timelineOf, renditionsFor, queueCmafOnce, queueRenditionsOnce, discoveryForObject, discovery, discoveryMany };
@@ -384,7 +384,10 @@ async function syncClip(idOrRow) {
@@ -384,7 +384,10 @@ async function syncClip(idOrRow) {
const p = clipProjection(row);
const p = clipProjection(row);
const r = await project(p);
const r = await project(p);
await linkRow('clips', 'id', row.id, r.id);
await linkRow('clips', 'id', row.id, r.id);
- if (row.vod_id) {
+ if (row.recording_object_id) {
+ // A clip over a recording (docs/recordings.md): its window is in recording time, over the recording's object.
+ await setRelationship(r.id, 'clip_of', row.recording_object_id, { start_time: row.start_time, end_time: row.end_time, recording: true });
+ } else if (row.vod_id) {
const vod = await db.get('SELECT object_id FROM vods WHERE id = ?', [row.vod_id]);
const vod = await db.get('SELECT object_id FROM vods WHERE id = ?', [row.vod_id]);
if (vod && vod.object_id) await setRelationship(r.id, 'clip_of', vod.object_id, { start_time: row.start_time, end_time: row.end_time });
if (vod && vod.object_id) await setRelationship(r.id, 'clip_of', vod.object_id, { start_time: row.start_time, end_time: row.end_time });
}
}
@@ -808,7 +808,8 @@ const HLS_TYPE = 'application/vnd.apple.mpegurl';
@@ -808,7 +808,8 @@ const HLS_TYPE = 'application/vnd.apple.mpegurl';
// A rendition's seq 0 init plus the two edge inits of a materialized clip's source rendition (negative seq,
// A rendition's seq 0 init plus the two edge inits of a materialized clip's source rendition (negative seq,
// docs/materialized-clips.md F3.6) and the init of each shape period after the first (init-<n>.mp4, jobs/shapes.js);
// docs/materialized-clips.md F3.6) and the init of each shape period after the first (init-<n>.mp4, jobs/shapes.js);
// every init row (seq <= 0) is served as video/mp4, a media segment as iso.segment.
// every init row (seq <= 0) is served as video/mp4, a media segment as iso.segment.
-const SEGMENT_NAME = /^(init\.mp4|init-head\.mp4|init-tail\.mp4|init-\d{1,4}\.mp4|\d{6,9}\.m4s)$/;
+// A recording's joined rows (docs/recordings.md) name part k's segments and inits `p<k>-<name>`.
+const SEGMENT_NAME = /^(p\d{1,4}-)?(init\.mp4|init-head\.mp4|init-tail\.mp4|init-\d{1,4}\.mp4|\d{6,9}\.m4s)$/;
const RENDITION_NAME = /^[a-z0-9_-]{1,32}$/;
const RENDITION_NAME = /^[a-z0-9_-]{1,32}$/;
const SLICE_FETCH_MS = 15000; // a ranged read of one packed segment from B2/R2
const SLICE_FETCH_MS = 15000; // a ranged read of one packed segment from B2/R2
// clipWindow/timelineOf (a clip's window over its source, and the rows its playlists are written from) live in
// clipWindow/timelineOf (a clip's window over its source, and the rows its playlists are written from) live in
@@ -950,7 +951,10 @@ publicRouter.get('/:id/source/:name', hlsRoute(async (req, res, h) => {
@@ -950,7 +951,10 @@ publicRouter.get('/:id/source/:name', hlsRoute(async (req, res, h) => {
let row = SEGMENT_NAME.test(name) ? await timeline.byName(h.obj.id, timeline.SOURCE, name) : null;
let row = SEGMENT_NAME.test(name) ? await timeline.byName(h.obj.id, timeline.SOURCE, name) : null;
// A virtual clip serves only the source segments inside its window (timelineOf): any other name is a 404, whatever
// A virtual clip serves only the source segments inside its window (timelineOf): any other name is a 404, whatever
// the signature, so a clip's token never reaches the rest of its source.
// the signature, so a clip's token never reaches the rest of its source.
- if (!row && SEGMENT_NAME.test(name) && h.obj.kind === 'clip') row = (await hls.timelineOf(h.obj)).rows.find((r) => r.name === name) || null;
+ // A recording serves its parts' segments the same way, through its joined rows (and a clip over it, its window).
+ if (!row && SEGMENT_NAME.test(name) && (h.obj.kind === 'clip' || require('../vod/recordings').isRecording(h.obj))) {
+ row = (await hls.timelineOf(h.obj)).rows.find((r) => r.name === name) || null;
+ }
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row) return res.status(404).json({ error: 'Not found' });
return sendSegmentRow(req, res, h, row, timeline.SOURCE);
return sendSegmentRow(req, res, h, row, timeline.SOURCE);
}));
}));
@@ -111,7 +111,10 @@ function renditionNames() { return require('../jobs/rendition').renditionNames()
@@ -111,7 +111,10 @@ function renditionNames() { return require('../jobs/rendition').renditionNames()
* without file_path), so neither one can ever be cut a rendition.
* without file_path), so neither one can ever be cut a rendition.
*/
*/
function isVirtualClip(obj) {
function isVirtualClip(obj) {
- return !!obj && obj.kind === 'clip' && require('./model').parseJson(obj.metadata, {}).virtual === true;
+ if (!obj) return false;
+ const md = require('./model').parseJson(obj.metadata, {});
+ // A recording (docs/recordings.md) is virtual the same way: its parts are cut, renditioned and moved, never it.
+ return md.virtual === true && (obj.kind === 'clip' || md.recording === true);
}
}
/**
/**
@@ -50,6 +50,10 @@ const TAIL_INIT = 'init-tail.mp4';
@@ -50,6 +50,10 @@ const TAIL_INIT = 'init-tail.mp4';
// materialized clip's -1/-2 edges. Its media rows name it in init_name.
// materialized clip's -1/-2 edges. Its media rows name it in init_name.
const SHAPE_INIT = /^init-(\d{1,4})\.mp4$/;
const SHAPE_INIT = /^init-(\d{1,4})\.mp4$/;
function shapeInitName(n) { return `init-${n}.mp4`; }
function shapeInitName(n) { return `init-${n}.mp4`; }
+// A recording's joined timeline (docs/recordings.md, joinRows): part k's names are prefixed `p<k>-`, its init
+// `p<k>-init.mp4` (k >= 2; part 1's is the rendition's seq 0) and its shape periods' `p<k>-init-<n>.mp4`. Never stored:
+// the rows are built from the parts' own rows on every read.
+const PART_INIT = /^p\d{1,4}-init(-\d{1,4})?\.mp4$/;
/** The seq of a shape period's init row, or null for any other name. */
/** The seq of a shape period's init row, or null for any other name. */
function shapeInitSeq(name) {
function shapeInitSeq(name) {
const m = SHAPE_INIT.exec(String(name || ''));
const m = SHAPE_INIT.exec(String(name || ''));
@@ -421,7 +425,7 @@ function masterPlaylist(renditions, { query = '' } = {}) {
@@ -421,7 +425,7 @@ function masterPlaylist(renditions, { query = '' } = {}) {
*/
*/
function clipRows(rows, startMs, endMs) {
function clipRows(rows, startMs, endMs) {
const edge = (name) => name === HEAD_INIT || name === TAIL_INIT;
const edge = (name) => name === HEAD_INIT || name === TAIL_INIT;
- if (rows.some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null) || edge(r.init_name))) return [];
+ if (rows.some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null && !PART_INIT.test(r.name)) || edge(r.init_name))) return [];
const init = rows.find((r) => Number(r.seq) === 0);
const init = rows.find((r) => Number(r.seq) === 0);
const segs = rows
const segs = rows
.filter((r) => Number(r.seq) > 0 && Number(r.end_ms) > startMs && Number(r.start_ms) < endMs)
.filter((r) => Number(r.seq) > 0 && Number(r.end_ms) > startMs && Number(r.start_ms) < endMs)
@@ -433,8 +437,49 @@ function clipRows(rows, startMs, endMs) {
@@ -433,8 +437,49 @@ function clipRows(rows, startMs, endMs) {
return segs.length && init ? [{ ...init, seq: 0, start_ms: 0, end_ms: 0 }, ...shapeInits, ...segs] : segs;
return segs.length && init ? [{ ...init, seq: 0, start_ms: 0, end_ms: 0 }, ...shapeInits, ...segs] : segs;
}
}
+// ── Recordings (docs/recordings.md) ─────────────────────────
+/**
+ * A recording's timeline: its parts' source rows end to end, each part shifted by the length of the parts before it
+ * (the time between parts is not played). Every row keeps its bytes (key, local_path, durable_provider, packed chunk and
+ * byte range, sha256), so nothing is copied; what changes is its name and its init:
+ * - part 1's seq 0 init stays the rendition's seq 0 init (`p1-init.mp4`), so a recording plays from its first frame;
+ * - part k >= 2's seq 0 init becomes a named init `p<k>-init.mp4` (seq -(10000 k)), and its media rows name it;
+ * - a part's shape-period init `init-<n>.mp4` becomes `p<k>-init-<n>.mp4` (seq -(10000 k + n)), and its rows name it;
+ * - media rows are named `p<k>-<name>` and renumbered 1..N.
+ * mediaPlaylist() then writes #EXT-X-DISCONTINUITY + #EXT-X-MAP at each join, exactly as at a change of shape.
+ * `parts` is [rows of part 1, rows of part 2, …]. A part with no media row, or one with a materialized clip's edge inits
+ * (parts are recordings, never clips), makes the whole join empty: a recording plays whole or not at all.
+ */
+function joinRows(parts) {
+ const out = [];
+ let offset = 0;
+ let seq = 0;
+ for (const [i, rows] of parts.entries()) {
+ const k = i + 1;
+ const media = (rows || []).filter((r) => Number(r.seq) > 0);
+ const init = (rows || []).find((r) => Number(r.seq) === 0);
+ if (!media.length || !init) return [];
+ if ((rows || []).some((r) => (Number(r.seq) < 0 && shapeInitSeq(r.name) == null) || r.init_name === HEAD_INIT || r.init_name === TAIL_INIT)) return [];
+ const base = `p${k}-init.mp4`;
+ const renamedInit = (name) => (!name || name === INIT_NAME ? (k === 1 ? null : base) : `p${k}-${name}`);
+ out.push({ ...init, seq: k === 1 ? 0 : -(10000 * k), name: base, start_ms: 0, end_ms: 0, init_name: null });
+ for (const r of rows.filter((x) => Number(x.seq) < 0)) {
+ out.push({ ...r, seq: -(10000 * k + Number(SHAPE_INIT.exec(r.name)[1])), name: `p${k}-${r.name}`, start_ms: 0, end_ms: 0, init_name: null });
+ }
+ const start = Math.min(...media.map((r) => Number(r.start_ms)));
+ let end = start;
+ for (const r of media.sort((a, b) => Number(a.seq) - Number(b.seq))) {
+ seq += 1;
+ out.push({ ...r, seq, name: `p${k}-${r.name}`, start_ms: Number(r.start_ms) - start + offset, end_ms: Number(r.end_ms) - start + offset, init_name: renamedInit(r.init_name) });
+ end = Math.max(end, Number(r.end_ms));
+ }
+ offset += end - start;
+ }
+ return out;
+}
+
module.exports = {
module.exports = {
- SOURCE, INIT_NAME, HEAD_INIT, TAIL_INIT, FIELDS, localRoot, localPathFor, keyFor, segmentName, shapeInitName, shapeInitSeq,
+ SOURCE, INIT_NAME, HEAD_INIT, TAIL_INIT, FIELDS, PART_INIT, localRoot, localPathFor, keyFor, segmentName, shapeInitName, shapeInitSeq,
list, segments, byName, has, segmentAt, replace, TimelineChanged, isNamed, namedElsewhere, deleteBytes, removeObject, sweepPendingRemovals, markPacked, durableProvider, locationsOf,
list, segments, byName, has, segmentAt, replace, TimelineChanged, isNamed, namedElsewhere, deleteBytes, removeObject, sweepPendingRemovals, markPacked, durableProvider, locationsOf,
- mediaPlaylist, masterPlaylist, clipRows,
+ mediaPlaylist, masterPlaylist, clipRows, joinRows,
};
};
@@ -88,7 +88,8 @@ function thumbCard(name, mtimeMs) {
@@ -88,7 +88,8 @@ function thumbCard(name, mtimeMs) {
}
}
// ── Data per tab ─────────────────────────────────────────────
// ── Data per tab ─────────────────────────────────────────────
-const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0";
+// A recording's later parts (docs/recordings.md) are not videos of their own here: its first part stands for it.
+const V_WHERE = "is_public = 1 AND COALESCE(is_recording,0) = 0 AND COALESCE(clips_only,0) = 0 AND NOT EXISTS (SELECT 1 FROM media_recording_parts rp WHERE rp.vod_id = vods.id AND rp.seq > 1)";
const C_WHERE = "COALESCE(is_public,1) = 1";
const C_WHERE = "COALESCE(is_public,1) = 1";
// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.
// Developer-project tenants' files (ADR-014) are theirs to share; the gallery lists first-party files only.
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
const F_WHERE = "app_id NOT IN (SELECT app_id FROM apps WHERE project_id IS NOT NULL)";
@@ -66,6 +66,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -66,6 +66,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
id: clip.id,
id: clip.id,
app_id: clip.app_id,
app_id: clip.app_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.
+ ...(clip.recording_object_id && { recording_id: clip.recording_object_id, time_base: 'recording' }),
stream_id: clip.stream_id,
stream_id: clip.stream_id,
channel_user_id: clip.channel_user_id || null,
channel_user_id: clip.channel_user_id || null,
user_id: clip.user_id,
user_id: clip.user_id,
@@ -168,9 +170,9 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -168,9 +170,9 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
try {
try {
const body = req.body || {};
const body = req.body || {};
if (req.file) return await _createUploadedClip(req, res);
if (req.file) return await _createUploadedClip(req, res);
- const vodId = parseInt(body.vod_id, 10);
+ let vodId = parseInt(body.vod_id, 10);
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
if (!Number.isFinite(vodId)) return res.status(400).json({ error: 'vod_id is required' });
- const vod = await db.getVodById(vodId, req.appId);
+ let vod = await db.getVodById(vodId, req.appId);
if (!vod) return res.status(404).json({ error: 'VOD not found' });
if (!vod) return res.status(404).json({ error: 'VOD not found' });
// An acting user cannot cut someone else's private VOD, and is not told it exists.
// An acting user cannot cut someone else's private VOD, and is not told it exists.
if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
if (req.authType === 'user' && _isPrivate(vod) && !(vod.user_id != null && String(vod.user_id) === String(req.userId))) {
@@ -189,6 +191,19 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -189,6 +191,19 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
if (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });
if (duration < 1) return res.status(400).json({ error: 'Clip must be at least 1 second' });
if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
if (duration > maxClipDuration) return res.status(400).json({ error: `Clips are limited to ${maxClipDuration} seconds` });
+ // recording_time: the times are in the recording vod_id is part of (docs/recordings.md), the video people saw.
+ if (flag(body.recording_time) === 1) {
+ const made = await _createRecordingClip(req, res, vod, startTime, endTime, body);
+ if (made && made.part) {
+ // The joined timeline is not ready: the part the window sits in, in that part's own time.
+ vod = await db.getVodById(made.part.vod_id, req.appId);
+ vodId = vod.id;
+ startTime -= made.part.start_ms / 1000;
+ endTime -= made.part.start_ms / 1000;
+ } else if (made !== false) return made;
+ // false: not part of a recording, so the times are this VOD's own, as without the flag.
+ }
+
// Duplicate detection — reuse a just-made clip for the same window.
// Duplicate detection — reuse a just-made clip for the same window.
const duplicate = await db.findDuplicateClip({
const duplicate = await db.findDuplicateClip({
appId: req.appId, vodId, streamId: vod.stream_id || null,
appId: req.appId, vodId, streamId: vod.stream_id || null,
@@ -274,6 +289,53 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -274,6 +289,53 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
}
}
});
});
+/**
+ * A clip over a recording (docs/recordings.md): a virtual window in recording time over the recording's object, which
+ * may cross a join between parts. vod_id is the recording's first part (the video's public face); the row carries the
+ * recording's object, so its clip_of edge and playlists use the joined timeline. Before every part has a timeline, a
+ * window that sits inside one part is that part's ordinary clip, in its own time ({ part }: the caller moves its window
+ * there). Answers false when vod is in no recording (the caller goes on with its own times).
+ */
+async function _createRecordingClip(req, res, vod, startTime, endTime, body) {
+ const recordings = require('./recordings');
+ const found = await recordings.forVod(req.appId, vod.id);
+ if (!found) return false;
+ const rec = found.recording;
+ const total = rec.duration_ms / 1000;
+ if (startTime > total + 5) return res.status(400).json({ error: 'Start time exceeds video duration' });
+ if (endTime > total) endTime = total;
+ if (endTime - startTime < 1) return res.status(400).json({ error: 'Clip window is outside the video' });
+ const userId = body.user_id != null ? body.user_id : (req.userId ?? null);
+ const visibility = VALID_VIS.has(body.visibility) ? body.visibility : 'public';
+ const byApp = req.authType === 'app';
+ const first = await db.getVodById(rec.vod_id, req.appId) || vod;
+ const recObj = await require('../objects/model').getObject(rec.id);
+ const timeline = require('../objects/timeline');
+ const rows = config.hls.enabled ? timeline.clipRows(await recordings.rowsOf(recObj), Math.round(startTime * 1000), Math.round(endTime * 1000)) : [];
+ if (!rows.some((r) => Number(r.seq) > 0)) {
+ // The joined timeline is not ready (a part not cut yet): the part the window sits in, in its own time.
+ const part = rec.parts.find((p) => startTime * 1000 >= p.start_ms && endTime * 1000 <= p.start_ms + p.duration_ms);
+ if (!part) return res.status(409).json({ error: 'This recording is still being prepared; a clip across its parts works in a few minutes.', code: 'media.recording.not_ready' });
+ return { part };
+ }
+ const duplicate = await db.findDuplicateClip({ appId: req.appId, vodId: first.id, streamId: first.stream_id || null, startTime, endTime });
+ if (duplicate && duplicate.status !== 'failed') return res.status(200).json({ id: duplicate.id, status: duplicate.status, deduplicated: true });
+ const last = rows[rows.length - 1];
+ const result = await db.createClip({
+ app_id: req.appId, vod_id: first.id, recording_object_id: rec.id,
+ stream_id: first.stream_id || (body.stream_id != null ? parseInt(body.stream_id, 10) || null : null),
+ user_id: userId, channel_user_id: body.channel_user_id != null ? body.channel_user_id : (first.user_id ?? null),
+ title: sanitizeClipTitle(body.title, first.title ? `Clip: ${recordings.baseTitle(first.title)}`.slice(0, 200) : 'Untitled Clip'),
+ description: typeof body.description === 'string' ? body.description.replace(/<[^>]*>/g, '').trim().slice(0, 1000) : '',
+ auto_generated: byApp && flag(body.auto_generated) === 1,
+ file_path: '', start_time: startTime, end_time: endTime, duration_seconds: endTime - startTime,
+ is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing',
+ });
+ const clipId = result.lastInsertRowid;
+ await require('./clip-jobs').makeVirtual(clipId, { objectId: rec.id, endTime: startTime + Number(last.end_ms) / 1000 });
+ return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, req.appId), { readiness: true }) });
+}
+
/**
/**
* Direct clip upload: the blob is already cut client-side — remux it for
* Direct clip upload: the blob is already cut client-side — remux it for
* seeking (rebases cluster timestamps hours from zero on long streams),
* seeking (rebases cluster timestamps hours from zero on long streams),
new file mode 100644
new file mode 100644
@@ -0,0 +1,281 @@
@@ -0,0 +1,281 @@
+/**
+ * OpenVibe.Media — recordings (docs/recordings.md): one playable video over a stream's parts.
+ *
+ * A recording is a native media object (kind 'asset', metadata { recording: true, virtual: true }) with a row in
+ * media_recordings and its parts, in order, in media_recording_parts. It stores no bytes and has no VOD row: its HLS
+ * timeline is its parts' source rows joined on every read (objects/timeline.js joinRows), through the same routes and
+ * rules as any object (/o/<id>/master.m3u8), and a clip may sit on it (a window in recording time, across a join too).
+ * The first part is the video's public face: its VOD row stays, lists hide the parts after it (db._vodConds), and its
+ * answer carries `recording` so an app plays and counts the whole.
+ *
+ * setParts(appId, key, parts, { title, state }) the recording for an app's key, with these parts (idempotent);
+ * fewer than two parts: none (a part alone is its own video)
+ * forVod(appId, vodId) the recording a VOD is part of, with its place in it, or null
+ * get(appId, objectId) / byKey(appId, key) a recording with its parts
+ * rowsOf(obj) the joined source rows of a recording object ([] when a part has
+ * no timeline yet: a recording plays whole or not at all)
+ * summaries(vodIds) the `recording` field of many VODs in one query (list answers)
+ * syncPart(vodId, { visibility, title }) a part's visibility is every part's and the recording's; the first
+ * part's title is the recording's
+ * router GET/PUT /api/v1/:app/recordings/:key, POST …/:key/close,
+ * GET /api/v1/:app/recordings/object/:id
+ */
+'use strict';
+
+const express = require('express');
+const db = require('../db/database');
+const model = require('../objects/model');
+const { tenantAuth } = require('../auth');
+
+const KEY = /^[A-Za-z0-9:_.-]{1,120}$/;
+const MAX_PARTS = 200;
+
+const isRecording = (obj) => !!obj && obj.kind === 'asset' && model.parseJson(obj.metadata, {}).recording === true;
+
+async function partsOf(objectId) {
+ return await db.all(`SELECT p.seq, p.vod_id, p.gap_before_ms, p.stream_offset_ms, v.object_id, v.duration_seconds, v.title, v.visibility,
+ v.thumbnail_url, v.created_at
+ FROM media_recording_parts p LEFT JOIN vods v ON v.id = p.vod_id
+ WHERE p.recording_object_id = ? ORDER BY p.seq`, [objectId]);
+}
+
+/** Each part's length in ms: its timeline's (exact), else its row's duration. */
+async function partLengths(parts) {
+ const ids = parts.map((p) => p.object_id).filter(Boolean);
+ const spans = new Map();
+ if (ids.length) {
+ for (const r of await db.all(`SELECT object_id, MIN(start_ms) AS a, MAX(end_ms) AS b FROM media_timeline
+ WHERE rendition = 'source' AND seq > 0 AND object_id = ANY(?) GROUP BY object_id`, [ids])) {
+ spans.set(r.object_id, Number(r.b) - Number(r.a));
+ }
+ }
+ return parts.map((p) => (spans.has(p.object_id) ? spans.get(p.object_id) : Math.round(Number(p.duration_seconds || 0) * 1000)));
+}
+
+/** A recording's public answer: its object id, state, length and parts with their place (recording time, ms). */
+async function present(rec) {
+ if (!rec) return null;
+ const parts = await partsOf(rec.object_id);
+ const lengths = await partLengths(parts);
+ let at = 0;
+ const out = parts.map((p, i) => {
+ const part = { seq: p.seq, vod_id: Number(p.vod_id), start_ms: at, duration_ms: lengths[i], gap_before_ms: Number(p.gap_before_ms) || 0,
+ ...(p.stream_offset_ms != null && { stream_offset_ms: Number(p.stream_offset_ms) }) };
+ at += lengths[i];
+ return part;
+ });
+ const obj = await model.getObject(rec.object_id);
+ const hls = await require('../objects/hls').discoveryForObject(obj).catch(() => null);
+ return {
+ id: rec.object_id, key: rec.group_key, state: rec.state, duration_ms: at, duration_seconds: Math.round(at / 1000),
+ vod_id: out.length ? out[0].vod_id : null, parts: out, ...(hls || {}),
+ created_at: rec.created_at, updated_at: rec.updated_at,
+ };
+}
+
+async function byKey(appId, key) {
+ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND group_key = ?', [appId, String(key)]);
+ return rec ? await present(rec) : null;
+}
+
+async function get(appId, objectId) {
+ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND object_id = ?', [appId, String(objectId)]);
+ return rec ? await present(rec) : null;
+}
+
+async function forVod(appId, vodId) {
+ const p = await db.get(`SELECT r.* FROM media_recording_parts p JOIN media_recordings r ON r.object_id = p.recording_object_id
+ WHERE p.vod_id = ? AND r.app_id = ?`, [Number(vodId), appId]);
+ if (!p) return null;
+ const rec = await present(p);
+ const part = rec.parts.find((x) => x.vod_id === Number(vodId)) || null;
+ return { recording: rec, part };
+}
+
+/**
+ * The `recording` field of VOD answers, for many VODs in two queries: { recording_id, seq, parts, start_ms, duration_ms,
+ * first_vod_id }. duration_ms is the whole recording's (its parts' row durations; the detail answer is exact).
+ */
+async function summaries(vodIds) {
+ const ids = [...new Set((vodIds || []).map(Number).filter(Number.isFinite))];
+ const out = new Map();
+ if (!ids.length) return out;
+ const mine = await db.all('SELECT recording_object_id, vod_id FROM media_recording_parts WHERE vod_id = ANY(?)', [ids]);
+ if (!mine.length) return out;
+ const recs = [...new Set(mine.map((r) => r.recording_object_id))];
+ const all = await db.all(`SELECT p.recording_object_id, p.seq, p.vod_id, v.duration_seconds FROM media_recording_parts p
+ LEFT JOIN vods v ON v.id = p.vod_id WHERE p.recording_object_id = ANY(?) ORDER BY p.recording_object_id, p.seq`, [recs]);
+ const byRec = new Map();
+ for (const r of all) { if (!byRec.has(r.recording_object_id)) byRec.set(r.recording_object_id, []); byRec.get(r.recording_object_id).push(r); }
+ for (const m of mine) {
+ const parts = byRec.get(m.recording_object_id) || [];
+ let at = 0, start = 0, seq = 0;
+ for (const p of parts) {
+ if (Number(p.vod_id) === Number(m.vod_id)) { start = at; seq = p.seq; }
+ at += Math.round(Number(p.duration_seconds || 0) * 1000);
+ }
+ out.set(Number(m.vod_id), { recording_id: m.recording_object_id, seq, parts: parts.length, start_ms: start, duration_ms: at,
+ first_vod_id: parts.length ? Number(parts[0].vod_id) : null });
+ }
+ return out;
+}
+
+// The joined rows of a recording, kept a short while: every segment request of a player reads them. setParts clears
+// its recording's entry; a part re-cut or packed shows within ROWS_TTL_MS.
+const ROWS_TTL_MS = 20000;
+const ROWS_MAX = 200;
+const rowsCache = new Map(); // object id → { at, rows }
+
+/** The joined source rows of a recording object, or [] (not a recording, a part missing, a part with no timeline). */
+async function rowsOf(obj) {
+ if (!isRecording(obj)) return [];
+ const hit = rowsCache.get(obj.id);
+ if (hit && Date.now() - hit.at < ROWS_TTL_MS) return hit.rows;
+ const rows = await joinedRows(obj);
+ if (rows.length) {
+ if (rowsCache.size >= ROWS_MAX) rowsCache.delete(rowsCache.keys().next().value);
+ rowsCache.set(obj.id, { at: Date.now(), rows });
+ }
+ return rows;
+}
+
+async function joinedRows(obj) {
+ const timeline = require('../objects/timeline');
+ const parts = await partsOf(obj.id);
+ if (parts.length < 2 || parts.some((p) => !p.object_id)) return [];
+ const rows = [];
+ for (const p of parts) rows.push(await timeline.list(p.object_id, timeline.SOURCE));
+ if (rows.some((r) => !r.some((x) => Number(x.seq) > 0))) {
+ // A part not cut yet: queue it (idempotent) and answer nothing until every part plays.
+ const hls = require('../objects/hls');
+ for (const [i, r] of rows.entries()) if (!r.some((x) => Number(x.seq) > 0)) await hls.queueCmafOnce(obj.app_id, parts[i].object_id);
+ return [];
+ }
+ return timeline.joinRows(rows);
+}
+
+/** The title a recording shows: the first part's, without a " (Part 1)" the app may have added. */
+const baseTitle = (t) => String(t || 'Recording').replace(/\s*\(part \d+\)\s*$/i, '').slice(0, 200) || 'Recording';
+
+/**
+ * Make the recording for (appId, key) hold exactly `parts` ([{ vod_id, gap_before_ms?, stream_offset_ms? }], in order).
+ * Every VOD must be this app's, finished or recording, and in no other recording. Two or more parts: the recording is
+ * created or updated; fewer: an existing one is removed (its object deleted) and nothing is answered. Idempotent.
+ */
+async function setParts(appId, key, parts, { title = null, state = null } = {}) {
+ if (!KEY.test(String(key || ''))) throw Object.assign(new Error('the key is 1-120 letters, digits and : _ . -'), { status: 400, code: 'media.recording.bad_key' });
+ const list = (parts || []).map((p) => (typeof p === 'object' && p ? p : { vod_id: p }))
+ .map((p) => ({ vod_id: Number(p.vod_id), gap_before_ms: Math.max(0, Math.round(Number(p.gap_before_ms) || 0)),
+ stream_offset_ms: p.stream_offset_ms == null || !Number.isFinite(Number(p.stream_offset_ms)) ? null : Math.round(Number(p.stream_offset_ms)) }));
+ if (list.length > MAX_PARTS) throw Object.assign(new Error(`at most ${MAX_PARTS} parts`), { status: 400, code: 'media.recording.too_many_parts' });
+ if (list.some((p) => !Number.isFinite(p.vod_id)) || new Set(list.map((p) => p.vod_id)).size !== list.length) {
+ throw Object.assign(new Error('parts are distinct VOD ids'), { status: 400, code: 'media.recording.bad_parts' });
+ }
+ const vods = list.length ? await db.all('SELECT id, app_id, object_id, title, visibility, user_id, clips_only FROM vods WHERE id = ANY(?)', [list.map((p) => p.vod_id)]) : [];
+ const byId = new Map(vods.map((v) => [Number(v.id), v]));
+ for (const p of list) {
+ const v = byId.get(p.vod_id);
+ if (!v || v.app_id !== appId || v.clips_only) throw Object.assign(new Error(`VOD ${p.vod_id} is not one of this app's videos`), { status: 404, code: 'media.recording.vod_not_found' });
+ }
+ const raw = db.getDb();
+ const objectId = await raw.tx(async () => {
+ const rec = await db.get('SELECT * FROM media_recordings WHERE app_id = ? AND group_key = ? FOR UPDATE', [appId, String(key)]);
+ const taken = list.length ? await db.all('SELECT vod_id, recording_object_id FROM media_recording_parts WHERE vod_id = ANY(?)', [list.map((p) => p.vod_id)]) : [];
+ const other = taken.find((t) => !rec || t.recording_object_id !== rec.object_id);
+ if (other) throw Object.assign(new Error(`VOD ${other.vod_id} is part of another recording`), { status: 409, code: 'media.recording.part_taken' });
+ if (list.length < 2) {
+ if (rec) {
+ await db.run('DELETE FROM media_recordings WHERE object_id = ?', [rec.object_id]);
+ await model.updateObject(rec.object_id, { lifecycle_status: 'deleted', deleted_at: new Date().toISOString().replace('T', ' ').slice(0, 19) });
+ }
+ return null;
+ }
+ const first = byId.get(list[0].vod_id);
+ const metadata = { recording: true, virtual: true, group_key: String(key), title: title ? String(title).slice(0, 200) : baseTitle(first.title) };
+ let id = rec && rec.object_id;
+ if (!id) {
+ id = await model.createObject({ app_id: appId, kind: 'asset', visibility: first.visibility || 'public', lifecycle_status: 'ready',
+ owner_app: appId, owner_user_id: first.user_id ?? null, mime_type: 'application/vnd.apple.mpegurl', metadata });
+ await db.run('INSERT INTO media_recordings (object_id, app_id, group_key, state) VALUES (?, ?, ?, ?)', [id, appId, String(key), state === 'closed' ? 'closed' : 'open']);
+ } else {
+ const obj = await model.getObject(id);
+ await model.updateObject(id, { metadata: { ...model.parseJson(obj.metadata, {}), ...metadata }, visibility: first.visibility || obj.visibility });
+ await db.run("UPDATE media_recordings SET state = COALESCE(?, state), updated_at = ov_now() WHERE object_id = ?", [state === 'closed' || state === 'open' ? state : null, id]);
+ }
+ await db.run('DELETE FROM media_recording_parts WHERE recording_object_id = ?', [id]);
+ for (const [i, p] of list.entries()) {
+ await db.run('INSERT INTO media_recording_parts (recording_object_id, seq, vod_id, gap_before_ms, stream_offset_ms) VALUES (?, ?, ?, ?, ?)',
+ [id, i + 1, p.vod_id, p.gap_before_ms, p.stream_offset_ms]);
+ }
+ return id;
+ });
+ for (const id of [...rowsCache.keys()]) rowsCache.delete(id); // a recording's parts changed: read them again
+ return objectId ? await get(appId, objectId) : null;
+}
+
+async function close(appId, key) {
+ await db.run("UPDATE media_recordings SET state = 'closed', updated_at = ov_now() WHERE app_id = ? AND group_key = ?", [appId, String(key)]);
+ return await byKey(appId, key);
+}
+
+/**
+ * A part changed: its visibility is the whole recording's (every part and the recording object follow; the app sets it
+ * on one), and the first part's title is the recording's.
+ */
+async function syncPart(vodId, { visibility, title } = {}) {
+ const p = await db.get('SELECT recording_object_id, seq FROM media_recording_parts WHERE vod_id = ?', [Number(vodId)]);
+ if (!p) return;
+ if (visibility !== undefined) {
+ const vis = ['public', 'unlisted', 'private'].includes(visibility) ? visibility : 'public';
+ const others = await db.all('SELECT vod_id FROM media_recording_parts WHERE recording_object_id = ? AND vod_id <> ?', [p.recording_object_id, Number(vodId)]);
+ for (const o of others) await db.setVodVisibility(o.vod_id, vis); // the row and its object together
+ await model.updateObject(p.recording_object_id, { visibility: vis });
+ }
+ if (title !== undefined && Number(p.seq) === 1) {
+ const obj = await model.getObject(p.recording_object_id);
+ await model.updateObject(p.recording_object_id, { metadata: { ...model.parseJson(obj.metadata, {}), title: baseTitle(title) } });
+ }
+}
+
+// ── Routes (/api/v1/:app/recordings) ──
+const router = express.Router({ mergeParams: true });
+const fail = (res, err) => {
+ if (err && err.status) return res.status(err.status).json({ error: err.message, code: err.code });
+ console.error('[Recordings]', err && err.message);
+ return res.status(500).json({ error: 'Failed' });
+};
+
+router.get('/object/:id', tenantAuth({ verb: 'read' }), async (req, res) => {
+ try {
+ const rec = await get(req.appId, req.params.id);
+ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });
+ } catch (err) { return fail(res, err); }
+});
+
+router.get('/:key', tenantAuth({ verb: 'read' }), async (req, res) => {
+ try {
+ const rec = await byKey(req.appId, req.params.key);
+ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });
+ } catch (err) { return fail(res, err); }
+});
+
+// Body: { parts: [vod_id | { vod_id, gap_before_ms?, stream_offset_ms? }], title?, state? }. Answers the recording, or
+// { recording: null } when fewer than two parts were given (a part alone is its own video).
+router.put('/:key', tenantAuth({ verb: 'write' }), async (req, res) => {
+ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app groups its videos' });
+ try {
+ const b = req.body || {};
+ const rec = await setParts(req.appId, req.params.key, Array.isArray(b.parts) ? b.parts : [], { title: b.title || null, state: b.state || null });
+ return res.json(rec || { recording: null });
+ } catch (err) { return fail(res, err); }
+});
+
+router.post('/:key/close', tenantAuth({ verb: 'write' }), async (req, res) => {
+ if (req.authType !== 'app') return res.status(403).json({ error: 'only the owning app closes a recording' });
+ try {
+ const rec = await close(req.appId, req.params.key);
+ return rec ? res.json(rec) : res.status(404).json({ error: 'Recording not found', code: 'media.recording.not_found' });
+ } catch (err) { return fail(res, err); }
+});
+
+module.exports = { isRecording, setParts, close, get, byKey, forVod, summaries, rowsOf, syncPart, present, baseTitle, router };
@@ -289,11 +289,15 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
@@ -289,11 +289,15 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
order: req.query.order || req.query.sort, // `sort` = inherited alias
order: req.query.order || req.query.sort, // `sort` = inherited alias
// Created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC): "top this week".
// Created at or after (ISO 8601 or 'YYYY-MM-DD HH:MM:SS', UTC): "top this week".
since: req.query.since || null,
since: req.query.since || null,
+ // A recording's parts after its first (docs/recordings.md) are listed only when asked for.
+ includeParts: ['1', 'true'].includes(String(req.query.include_parts || '')),
};
};
const vods = await db.listVods(req.appId, filters);
const vods = await db.listVods(req.appId, filters);
const total = await db.countVods(req.appId, filters);
const total = await db.countVods(req.appId, filters);
const hls = await require('../objects/hls').discoveryMany(vods);
const hls = await require('../objects/hls').discoveryMany(vods);
- res.json({ vods: (await Promise.all(vods.map(async v => await vodPublic(v, { readiness: true, hls: hls.get(String(v.object_id)) || null })))), total, limit, offset, hasMore: offset + vods.length < total });
+ const recs = await require('./recordings').summaries(vods.map((v) => v.id));
+ res.json({ vods: (await Promise.all(vods.map(async v => ({ ...await vodPublic(v, { readiness: true, hls: hls.get(String(v.object_id)) || null }),
+ ...(recs.has(Number(v.id)) && { recording: recs.get(Number(v.id)) }) })))), total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
} catch (err) {
console.error('[VOD] List error:', err.message);
console.error('[VOD] List error:', err.message);
res.status(500).json({ error: 'Failed to list VODs' });
res.status(500).json({ error: 'Failed to list VODs' });
@@ -349,7 +353,10 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
@@ -349,7 +353,10 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
// Bare object per CONTRACTS.md: { id, title, status, duration, … }
// Detail responses carry the transcript too (lists stay light).
// Detail responses carry the transcript too (lists stay light).
- res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null });
+ // A part of a recording (docs/recordings.md) names it: the whole video's id, length, parts and playlist.
+ const rec = await require('./recordings').forVod(req.appId, vod.id);
+ res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null,
+ ...(rec && { recording: { ...rec.recording, this_part: rec.part } }) });
} catch (err) {
} catch (err) {
console.error('[VOD] Get error:', err.message);
console.error('[VOD] Get error:', err.message);
res.status(500).json({ error: 'Failed to get VOD' });
res.status(500).json({ error: 'Failed to get VOD' });
@@ -374,6 +381,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -374,6 +381,9 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
}
}
if (visibility !== undefined) await db.setVodVisibility(vod.id, visibility);
if (visibility !== undefined) await db.setVodVisibility(vod.id, visibility);
});
});
+ // A part of a recording (docs/recordings.md): who may see it is the whole video's, and the first part's title is
+ // the recording's.
+ if (visibility !== undefined || title !== undefined) await require('./recordings').syncPart(vod.id, { visibility, title });
// Made public (or already public): its watch page and the sitemap are indexable now.
// Made public (or already public): its watch page and the sitemap are indexable now.
if (visibility !== undefined) await require('../indexnow-notify').pingWatch('vod', await db.getVodById(vod.id));
if (visibility !== undefined) await require('../indexnow-notify').pingWatch('vod', await db.getVodById(vod.id));
res.json({ vod: await vodPublic(await db.getVodById(vod.id, req.appId), { readiness: true }) });
res.json({ vod: await vodPublic(await db.getVodById(vod.id, req.appId), { readiness: true }) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+// Recordings (docs/recordings.md; owner, 2026-10-27: "instead of 'The stream was recorded in 2 parts: Part 1 · Part 2'
+// it should automatically just have them as a single vod … that seeks just fine"):
+// - timeline.joinRows puts the parts' rows end to end: names p<k>-…, times shifted, part k's init (and its shape
+// periods') named, bytes untouched; the playlist puts a discontinuity and the next part's init at the join; a clip
+// window may cross the join.
+// - PUT /api/v1/:app/recordings/:key makes a recording of an app's VODs (a native object, no VOD row); its playlist and
+// segments play both parts' bytes; lists show only its first part, which names the recording; visibility and the
+// title follow the first part; a clip in recording time across the join is virtual over the recording.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const express = require('express');
+
+(async () => {
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-recordings-'));
+ 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'), THUMBNAILS_PATH: dir('thumbnails'),
+ PASTES_PATH: dir('pastes'), OBJECTS_PATH: dir('objects'), MEDIA_PUBLIC_URL: 'https://media.test',
+ MEDIA_HLS_ENABLED: '1', MEDIA_INVARIANT_SCAN_HOURS: '0',
+ });
+ const db = require('../server/db/database');
+ const model = require('../server/objects/model');
+ const timeline = require('../server/objects/timeline');
+ const tools = require('../server/vod/media-tools');
+ const thumbService = require('../server/thumbnails/thumbnail-service');
+ const vodStorage = require('../server/vod/vod-storage');
+ await vodStorage.tierConfig.init(vodStorage.DEFAULTS);
+ await require('../server/objects/tier-policy').init();
+ const { finalizeVod } = require('../server/vod/finalize');
+ await db.upsertApp({ app_id: 'live', api_key: 'live-key-recordings' });
+ thumbService.generateVodThumbnail = async () => null;
+ tools.remuxForSeekingDetailed = async () => ({ ok: true, seconds: 30, error: null });
+ tools.probeDuration = async () => ({ ok: true, seconds: 30, format: {}, streams: [], error: null });
+ tools.remuxForSeeking = async () => true;
+ tools.probeVodInfo = async () => ({ duration: 30 });
+
+ // ── joinRows ──
+ const row = (seq, start, end, extra = {}) => ({ seq, name: timeline.segmentName(seq), start_ms: start, end_ms: end, keyframe_ms: seq > 0 ? start : null,
+ key: `x/source/${timeline.segmentName(seq)}`, local_path: null, durable_provider: null, packed_object_id: null, byte_offset: null,
+ byte_length: 1000, sha256: String(Math.abs(seq) % 10).repeat(64), durability: 'local', init_name: null, ...extra });
+ const a = [row(0, 0, 0), row(1, 0, 4000), row(2, 4000, 8000), row(3, 8000, 10500)];
+ // Part 2 starts at its own 2000 ms and turns at 6000 ms (a shape period with init-2.mp4).
+ const b = [row(0, 0, 0), { ...row(-102, 0, 0), name: 'init-2.mp4' }, row(1, 2000, 6000), row(2, 6000, 9000, { init_name: 'init-2.mp4' })];
+ const joined = timeline.joinRows([a, b]);
+ assert.deepStrictEqual(joined.map((r) => [r.seq, r.name, r.start_ms, r.end_ms, r.init_name]), [
+ [0, 'p1-init.mp4', 0, 0, null],
+ [1, 'p1-000001.m4s', 0, 4000, null], [2, 'p1-000002.m4s', 4000, 8000, null], [3, 'p1-000003.m4s', 8000, 10500, null],
+ [-20000, 'p2-init.mp4', 0, 0, null], [-20002, 'p2-init-2.mp4', 0, 0, null],
+ [4, 'p2-000001.m4s', 10500, 14500, 'p2-init.mp4'], [5, 'p2-000002.m4s', 14500, 17500, 'p2-init-2.mp4'],
+ ], 'end to end, renamed, part 2 decoding with its own inits');
+ assert.strictEqual(joined[6].key, b[2].key, 'bytes stay where they are');
+ const mp = timeline.mediaPlaylist(joined);
+ assert.ok(mp.includes('#EXT-X-MAP:URI="p1-init.mp4"\n#EXTINF:4.000,\np1-000001.m4s'), mp);
+ assert.ok(mp.includes('p1-000003.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"\n#EXTINF:4.000,\np2-000001.m4s'), 'a clean decoder reset at the join');
+ assert.ok(mp.includes('p2-000001.m4s\n#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init-2.mp4"'), 'and at part 2\'s turn');
+ assert.deepStrictEqual(timeline.joinRows([a, [row(0, 0, 0)]]), [], 'a part with nothing to play: no recording yet');
+ const across = timeline.clipRows(joined, 9000, 13000);
+ assert.deepStrictEqual(across.filter((r) => r.seq > 0).map((r) => [r.name, r.start_ms, r.end_ms, r.init_name]),
+ [['p1-000003.m4s', 0, 1500, null], ['p2-000001.m4s', 1500, 4000, 'p2-init.mp4']], 'a clip window across the join');
+ assert.ok(across.some((r) => r.name === 'p2-init.mp4'), 'carrying part 2\'s init');
+ assert.ok(timeline.mediaPlaylist(across).includes('#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"'));
+ console.log('✅ joinRows: end to end, renamed inits, discontinuity at the join, a clip across it');
+
+ const app = express();
+ app.use(express.json());
+ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
+ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
+ app.use('/api/v1/:app/recordings', require('../server/vod/recordings').router);
+ app.use('/o', require('../server/objects/routes').publicRouter);
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const api = `http://127.0.0.1:${server.address().port}`;
+ const auth = { authorization: 'Bearer live-key-recordings' };
+ const call = async (method, p, body) => {
+ const headers = { ...auth };
+ if (body && !(body instanceof FormData)) headers['content-type'] = 'application/json';
+ const r = await fetch(api + p, { method, headers, body: body && !(body instanceof FormData) ? JSON.stringify(body) : body, redirect: 'manual' });
+ return { status: r.status, body: await r.json().catch(() => null) };
+ };
+ const get = async (url) => {
+ const u = new URL(url, api);
+ const r = await fetch(api + u.pathname + u.search, { redirect: 'manual' });
+ return { status: r.status, text: Buffer.from(await r.arrayBuffer()) };
+ };
+
+ // Two finished VODs of stream 2795 with timelines on this node's disk.
+ const made = [];
+ for (const [title, rows] of [['BIRD DRAMA (Part 1)', a], ['BIRD DRAMA (Part 2)', b]]) {
+ const r = await call('POST', '/api/v1/live/vods', { title, user_id: 5, stream_id: 2795 });
+ assert.strictEqual(r.status, 201);
+ const f = new FormData();
+ f.append('chunk', new Blob([Buffer.alloc(40000, 1)], { type: 'video/webm' }), 'a.webm');
+ await call('POST', `/api/v1/live/vods/${r.body.id}/chunks`, f);
+ const objectId = (await finalizeVod(r.body.id)).object_id;
+ const obj = await model.getObject(objectId);
+ const bytes = {};
+ await timeline.replace(objectId, 'source', rows.map((s) => {
+ const p = timeline.localPathFor(obj, 'source', s.name, 'v1');
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ bytes[s.name] = Buffer.from(`${title}:${s.name}`);
+ fs.writeFileSync(p, bytes[s.name]);
+ return { ...s, local_path: p, byte_length: bytes[s.name].length };
+ }));
+ made.push({ id: r.body.id, objectId, bytes });
+ }
+ const [p1, p2] = made;
+
+ // ── the recording ──
+ let r = await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id, { vod_id: p2.id, gap_before_ms: 4000, stream_offset_ms: 709000 }] });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ const rec = r.body;
+ assert.match(rec.id, /^med_/);
+ assert.deepStrictEqual([rec.key, rec.state, rec.duration_ms, rec.vod_id], ['stream:2795', 'open', 17500, p1.id]);
+ assert.deepStrictEqual(rec.parts.map((x) => [x.seq, x.vod_id, x.start_ms, x.duration_ms, x.gap_before_ms]), [[1, p1.id, 0, 10500, 0], [2, p2.id, 10500, 7000, 4000]]);
+ assert.strictEqual(rec.parts[1].stream_offset_ms, 709000);
+ assert.strictEqual(rec.hls_url, `https://media.test/o/${rec.id}/master.m3u8`);
+ assert.strictEqual((await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id, p2.id] })).body.id, rec.id, 'idempotent: the same recording');
+ assert.strictEqual((await call('PUT', '/api/v1/live/recordings/stream:9', { parts: [p2.id, p1.id] })).status, 409, 'a part belongs to one recording');
+ assert.strictEqual((await call('GET', '/api/v1/live/recordings/stream:2795')).body.id, rec.id);
+ assert.strictEqual((await call('GET', `/api/v1/live/recordings/object/${rec.id}`)).body.key, 'stream:2795');
+ assert.ok(!(await db.get('SELECT 1 FROM vods WHERE object_id = ?', [rec.id])), 'no VOD row of its own');
+
+ // Playback: both parts' bytes through the recording's playlists.
+ const master = await get(`/o/${rec.id}/master.m3u8`);
+ assert.strictEqual(master.status, 200, master.text.toString());
+ const media = (await get(`/o/${rec.id}/source/index.m3u8`)).text.toString();
+ assert.ok(media.includes('p1-000001.m4s') && media.includes('#EXT-X-DISCONTINUITY\n#EXT-X-MAP:URI="p2-init.mp4"') && media.includes('p2-000002.m4s'), media);
+ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p2-000001.m4s`)).text, p2.bytes['000001.m4s'], 'part 2\'s segment, as stored');
+ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p1-init.mp4`)).text, p1.bytes['init.mp4']);
+ assert.deepStrictEqual((await get(`/o/${rec.id}/source/p2-init-2.mp4`)).text, p2.bytes['init-2.mp4']);
+ assert.strictEqual((await get(`/o/${rec.id}/source/p3-000001.m4s`)).status, 404);
+ console.log('✅ PUT /recordings: one object over both parts, its playlist and segments play them');
+
+ // Lists: the first part stands for the video and names the recording; its later part only when asked.
+ r = await call('GET', '/api/v1/live/vods?stream_id=2795');
+ assert.deepStrictEqual(r.body.vods.map((v) => v.id), [p1.id], 'the later part is not a video of its own');
+ assert.deepStrictEqual([r.body.vods[0].recording.recording_id, r.body.vods[0].recording.parts], [rec.id, 2]);
+ assert.strictEqual((await call('GET', '/api/v1/live/vods?stream_id=2795&include_parts=1')).body.vods.length, 2);
+ r = await call('GET', `/api/v1/live/vods/${p2.id}`);
+ assert.deepStrictEqual([r.body.recording.id, r.body.recording.this_part.start_ms, r.body.recording.vod_id], [rec.id, 10500, p1.id], 'a part says where it is in the whole');
+
+ // Visibility and the title follow the first part.
+ assert.strictEqual((await call('PUT', `/api/v1/live/vods/${p1.id}`, { visibility: 'private', title: 'BIRD DRAMA (Part 1)' })).status, 200);
+ assert.strictEqual((await db.getVodById(p2.id)).visibility, 'private', 'every part');
+ assert.strictEqual((await model.getObject(rec.id)).visibility, 'private', 'and the recording');
+ assert.strictEqual((await get(`/o/${rec.id}/master.m3u8`)).status, 404, 'a private recording needs its signature');
+ assert.strictEqual(model.parseJson((await model.getObject(rec.id)).metadata, {}).title, 'BIRD DRAMA', 'the title without "(Part 1)"');
+ await call('PUT', `/api/v1/live/vods/${p1.id}`, { visibility: 'public' });
+ assert.strictEqual((await db.getVodById(p2.id)).visibility, 'public');
+
+ // A clip in recording time across the join: virtual over the recording, on the first part.
+ r = await call('POST', '/api/v1/live/clips', { vod_id: p2.id, start_s: 9, end_s: 13, recording_time: true, title: '' });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.deepStrictEqual([r.body.vod_id, r.body.recording_id, r.body.time_base, r.body.start_time, r.body.end_time], [p1.id, rec.id, 'recording', 9, 13]);
+ const clipObj = (await db.getClipById(r.body.id)).object_id;
+ const clipMedia = (await get(`/o/${clipObj}/source/index.m3u8`)).text.toString();
+ assert.ok(clipMedia.includes('p1-000003.m4s') && clipMedia.includes('p2-000001.m4s') && clipMedia.includes('#EXT-X-DISCONTINUITY'), clipMedia);
+ assert.deepStrictEqual((await get(`/o/${clipObj}/source/p2-000001.m4s`)).text, p2.bytes['000001.m4s'], 'the clip plays part 2\'s bytes');
+ assert.strictEqual((await get(`/o/${clipObj}/source/p2-000002.m4s`)).status, 404, 'and nothing outside its window');
+ console.log('✅ lists show the whole video once; visibility and title follow; a clip across the join');
+
+ // Fewer than two parts: no recording.
+ assert.deepStrictEqual((await call('PUT', '/api/v1/live/recordings/stream:2795', { parts: [p1.id] })).body, { recording: null });
+ assert.strictEqual((await call('GET', '/api/v1/live/recordings/stream:2795')).status, 404);
+ assert.strictEqual((await call('GET', '/api/v1/live/vods?stream_id=2795')).body.vods.length, 2, 'both parts list again');
+
+ server.close();
+ console.log('recordings: all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });