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)

OP OpenVibers committed 17 days ago · 23708f667d · 14 files, +871 −21

Changes

ddiff --git a/docs/recordings.md b/docs/recordings.md
nnew file mode 100644
iindex 0000000..25e1fd0
---- /dev/null
++++ b/docs/recordings.md
@@@ -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)).
ddiff --git a/migrations/0015_recordings.sql b/migrations/0015_recordings.sql
nnew file mode 100644
iindex 0000000..4b54400
---- /dev/null
++++ b/migrations/0015_recordings.sql
@@@ -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";
ddiff --git a/server/db/database.js b/server/db/database.js
iindex c0943ef..b649672 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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]
));
}
ddiff --git a/server/index.js b/server/index.js
iindex f95af58..605822b 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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'));
ddiff --git a/server/objects/hls.js b/server/objects/hls.js
iindex 16a4919..87464ee 100644
---- a/server/objects/hls.js
++++ b/server/objects/hls.js
@@@ -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 };
ddiff --git a/server/objects/model.js b/server/objects/model.js
iindex ad0eaa5..8579119 100644
---- a/server/objects/model.js
++++ b/server/objects/model.js
@@@ -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 });
}
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 03721b0..922ca7d 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -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);
}));
ddiff --git a/server/objects/timeline-queue.js b/server/objects/timeline-queue.js
iindex 680de57..5627c6d 100644
---- a/server/objects/timeline-queue.js
++++ b/server/objects/timeline-queue.js
@@@ -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);
}
/**
ddiff --git a/server/objects/timeline.js b/server/objects/timeline.js
iindex 6a79208..df1d048 100644
---- a/server/objects/timeline.js
++++ b/server/objects/timeline.js
@@@ -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,
};
ddiff --git a/server/public/browse.js b/server/public/browse.js
iindex af79901..74cb68c 100644
---- a/server/public/browse.js
++++ b/server/public/browse.js
@@@ -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)";
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 1409cc7..df5babe 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -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.sing
try {
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.sing
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` });
++ // 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),
ddiff --git a/server/vod/recordings.js b/server/vod/recordings.js
nnew file mode 100644
iindex 0000000..39a1379
---- /dev/null
++++ b/server/vod/recordings.js
@@@ -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 };
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex dc9fa18..71ee89e 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -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 }) });
ddiff --git a/test/recordings.test.js b/test/recordings.test.js
nnew file mode 100644
iindex 0000000..a66fb40
---- /dev/null
++++ b/test/recordings.test.js
@@@ -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); });