openvibers/openvibe.mediahub Commits

MediaHub: OpenVibe.Video v1, public watch pages for Live's recordings and clips at openvibe.video (#28)

OP OpenVibers committed 12 Oct 2026 · 7c5a42d6b6 · 15 files, +1326 −37

First slice of OpenVibe.Video (plan T10): **public, read-only watch pages for OpenVibe.Live's stream recordings and clips at openvibe.video**, served by MediaHub (the manifest's `publicOrigin` for Video) when the request's host is openvibe.video. There are no uploads and no new storage: the files stay on OpenVibe.Media, and the pages read Live's public JSON API server-side, signed out. Live already applies visibility: a private item is a 404, and an unlisted one is reachable but noindex and never listed.

- **Pages:**
  - `/`: the latest recordings and clips.
  - `/v/:id`: the player, a byline, AI overview, transcript, chapters, the stream's clips and the Community thread.
  - `/c/:id`: the player, attribution and a link into the full recording at the clip's start.
  - `/@:username`: the channel's recordings and clips.
  - Discovery and legal pages naming openvibe.video.
- **Captions and chapters:** `/v/:id/captions.vtt` renders the AI transcript as WebVTT. `/v/:id/chapters.vtt` turns the AI moments into chapters: at most twelve, a twelfth of the stream apart, titled by what was seen (a 2 h 24 min stream had 66 moments). Cue text is escaped, and newlines and `-->` cannot break a cue.
- **Safety:** on the video host the drive's pages, API and `/auth` never answer. Only https URLs from Live reach an `href` or `src`. The CSP gains `media-src` for openvibe.media.
- **Design pass:**
  - **Home:** a header band, and cards with the duration on the thumbnail plus an AI-clip tag.
  - **Watch page:** two columns, with the player, byline actions, overview and transcript on the left and chapters and clips beside them. The playing chapter is marked as playback moves, and a chapter click seeks and plays.
  - **Phones:** no horizontal scroll at 390 px on any page.
- **nginx** (`deploy/nginx/openvibe.download.conf`): openvibe.video proxies to the app instead of the parked 302; openvibe.pics stays parked.
- Not built yet: chat replay and comments on the page, playlists, uploads, and owner tools; those stay on Live, and each page links "Manage on OpenVibe.Live".

Tests: the full suite (24 files), including the new `video-pages` (a stub Live) and `video-chapters`.

Changes

ddiff --git a/.env.example b/.env.example
iindex e6992a9..7106442 100644
---- a/.env.example
++++ b/.env.example
@@@ -55,3 +55,11 @@ MEDIAHUB_MEDIA_APP=media-hub
# optional: defaults to true in production
# COOKIE_SECURE=true
++
++# ── OpenVibe.Video: public watch pages for OpenVibe.Live's recordings and clips ──
++# Served by Host header on openvibe.video; Live is called server-side, signed out. liveUrl is the
++# host-internal address (production sets it to the loopback address); links people click use
++# livePublicUrl.
++MEDIAHUB_VIDEO_URL=https://openvibe.video
++MEDIAHUB_LIVE_URL=https://openvibe.live
++MEDIAHUB_LIVE_PUBLIC_URL=https://openvibe.live
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex bd71a9e..2edc630 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -7,8 +7,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
- MediaHub reaches OpenVibe.Media with its Network service token (audience `openvibe.media`, grants
`media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone
(compatibility register C-71).
--- openvibe.pics and openvibe.video are parked: their vhosts answer 302 to https://openvibe.network/ and proxy
-- nothing. The app no longer has "coming" pages or host-based brand handling.
++- OpenVibe.Video step A: openvibe.video now serves public, read-only watch pages for OpenVibe.Live's stream
++ recordings and clips (`/v/:id`, `/c/:id`, `/@:username`, WebVTT captions and chapters), reading Live's public
++ JSON API server-side, signed out. No uploads and no new storage.
++- openvibe.pics is still parked: its vhost answers 302 to https://openvibe.network/ and proxies nothing. The app
++ no longer has "coming" pages or host-based brand handling.
## 0.1.0 — 2026-10-08
ddiff --git a/README.md b/README.md
iindex 9b0d349..f91f831 100644
---- a/README.md
++++ b/README.md
@@@ -2,9 +2,9 @@
> Your files, shared on your terms.
--**Status:** alpha. **openvibe.download** (a private drive, live in v1) is the one domain this service serves.
--**openvibe.pics** and **openvibe.video** are parked: they redirect to `https://openvibe.network/` until Pics and
--Video are built, so no request for either host reaches the app.
++**Status:** alpha. One process serves **openvibe.download** (a private drive, live in v1) and
++**openvibe.video** (public watch pages for OpenVibe.Live's stream recordings and clips, step A), chosen by Host
++header. **openvibe.pics** is still parked: it redirects to `https://openvibe.network/` until Pics is built.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
## Purpose
@@@ -12,8 +12,9 @@ Video are built, so no request for either host reaches the app.
OpenVibe.MediaHub is the network's file service: it serves **openvibe.download** as a private drive where a
signed-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHub
keeps the metadata, the per-person limits, the share links and the report queue. It also answers OpenVibe.Network's
--account export and deletion events (ADR-033). openvibe.pics and openvibe.video are parked in nginx and redirect to
--openvibe.network, so v1 is the drive and nothing else.
++account export and deletion events (ADR-033). The same process serves **openvibe.video**, the public watch pages for
++OpenVibe.Live's stream recordings and clips (OpenVibe.Video v1, step A). openvibe.pics is parked in nginx and redirects
++to openvibe.network.
## Owns
@@@ -39,8 +40,8 @@ creates none):
`identity.subject.resolve`.
- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
subscriptions and consumes the two deliveries.
--- The **openvibe.pics** and **openvibe.video** products (image hosting, albums, video watch pages) are not built
-- here yet: those domains are parked in nginx. Previews and thumbnails are not built either.
++- The **openvibe.pics** product (image hosting and albums) is not built here yet: that domain is parked in nginx.
++ Previews and thumbnails are not built either.
## Depends on
@@@ -51,6 +52,8 @@ creates none):
(and `MEDIAHUB_MEDIA_TIMEOUT_MS`). Media calls use the service's Network service token. Grant
`[media-hub, media.object.upload, openvibe.media]` and `[media-hub, media.object.read, openvibe.media]`
on namespace `media-hub`.
++- **OpenVibe.Live** — where the recordings and clips come from. openvibe.video reads Live's public JSON API
++ server-side, signed out (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`).
- **OpenVibe.Events** — `MEDIAHUB_EVENTS_URL` (the two subscriptions are created at boot) and
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
- **PostgreSQL** and **Valkey** — `DATABASE_URL` / `DATABASE_DIRECT_URL`, `VALKEY_URL` / `VALKEY_PREFIX`, read
@@@ -105,11 +108,37 @@ calls Media server-side only, naming the person with `X-OV-Subject`, and never h
MediaHub owns the drive: `mh_files`, `mh_folders`, `mh_shares`, `mh_reports`, `mh_usage` (migrations/0002_mediahub.sql).
++## OpenVibe.Video (openvibe.video)
++
++OpenVibe.Video v1 is public and read-only: it serves watch pages for the recordings and clips people already made
++public on OpenVibe.Live. There are no uploads and no new storage — the media files stay on OpenVibe.Media
++(`playback_url`), and the pages read Live's public JSON API server-side, signed out (Live already applies
++visibility: a private item answers 404, an unlisted one is reachable by id but noindex and never listed).
++
++| Path | What |
++|---|---|
++| `/` | what OpenVibe.Video is, then the latest recordings and clips |
++| `/v/:id` | a VOD watch page: player, AI chapters and captions, its clips, the Community thread link |
++| `/c/:id` | a clip watch page: player, attribution, the full-recording link, the Community thread link |
++| `/@:username` | a channel's recordings (and clips), newest first, 24 per page |
++| `/v/:id/captions.vtt` | the AI transcript as WebVTT captions |
++| `/v/:id/chapters.vtt` | the AI moments as WebVTT chapters: at most twelve, a twelfth of the stream apart, titled by what was seen |
++| `/sitemap.xml`, `/robots.txt`, `/llms.txt` | crawl artifacts for the video host |
++
++Configuration: `MEDIAHUB_VIDEO_URL` (default `https://openvibe.video`; its hostname is the video host),
++`MEDIAHUB_LIVE_URL` (default `https://openvibe.live`; production points it at the loopback address) and
++`MEDIAHUB_LIVE_PUBLIC_URL` (default `https://openvibe.live`; links people click). The video host is chosen by
++`Host` header in `server/app.js`, so the drive's pages, API and `/auth` never answer there. Only https URLs from
++Live (player source, thumbnails, the thread link) reach a page; anything else is dropped.
++
++Not built yet: chat replay, comments on the page (only the OpenVibe.Community thread link is offered), playlists,
++and uploads.
++
## Pages
| Path | Who | What |
|---|---|---|
--| `/` | anyone | openvibe.download's home: what it is, the safety rules, sign in. (openvibe.pics and openvibe.video are parked in nginx, not served here) |
++| `/` | anyone | openvibe.download's home: what it is, the safety rules, sign in. (openvibe.video is served by this same app; openvibe.pics is parked in nginx) |
| `/files`, `/files/:id` | the owner | your drive: folders, files, the usage bar, upload, share, delete |
| `/s/:slug` | a signed-in person | a share page: the name, the size, who shared it (their username), the expiry, Download, Report |
| `/staff` | staff | the reports queue: restore or remove |
@@@ -141,6 +170,7 @@ takes `network.account.export_requested` and `network.account.deleted`, and the
Every name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internal
URL), PostgreSQL and Valkey (written by the host's data role), Media (`MEDIAHUB_MEDIA_URL`, `MEDIAHUB_MEDIA_APP`),
++Live (`MEDIAHUB_LIVE_URL`, `MEDIAHUB_LIVE_PUBLIC_URL`) and Video (`MEDIAHUB_VIDEO_URL`),
Events, and the limits (`MEDIAHUB_QUOTA_BYTES`, `MEDIAHUB_MAX_FILE_BYTES`,
`MEDIAHUB_UPLOADS_PER_DAY`, `MEDIAHUB_MAX_SHARES`, `MEDIAHUB_SINGLE_UPLOAD_BYTES`, `MEDIAHUB_PART_BYTES`).
@@@ -185,9 +215,9 @@ database, with in-process stand-ins for OpenVibe.Network and OpenVibe.Media. The
`sudo ovhost deploy media-hub` on the host (unit [deploy/systemd/openvibe-media-hub.service](deploy/systemd/openvibe-media-hub.service),
env `/etc/openvibe/media-hub.env`). The vhost [deploy/nginx/openvibe.download.conf](deploy/nginx/openvibe.download.conf)
--serves openvibe.download and parks openvibe.pics and openvibe.video with a 302 to `https://openvibe.network/` (each
--name with its own certificate); it is installed with `ov-vhost-install`. Uploads stream through nginx unbuffered;
--downloads are not buffered either.
++serves openvibe.download and openvibe.video (both proxy to the app on 127.0.0.1:4990) and parks openvibe.pics with a
++302 to `https://openvibe.network/` (each name with its own certificate); it is installed with `ov-vhost-install`.
++Uploads stream through nginx unbuffered; downloads are not buffered either.
## Security (threat notes)
ddiff --git a/STATUS.json b/STATUS.json
iindex a505017..54ad0f0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,16 +3,16 @@
"stage": "alpha",
"code": true,
"wave": "W20",
-- "plan": "plan T10/T19: media-hub serves openvibe.download as a private drive in v1; openvibe.pics and openvibe.video are parked (302 to openvibe.network) until scanning and abuse intake exist",
++ "plan": "plan T10/T19: media-hub serves openvibe.download as a private drive in v1; openvibe.video serves public read-only watch pages for OpenVibe.Live recordings and clips (Video v1 step A); openvibe.pics is parked (302 to openvibe.network) until scanning and abuse intake exist",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4990",
-- "domain": "openvibe.download (openvibe.pics and openvibe.video are parked: they redirect to openvibe.network)",
++ "domain": "openvibe.download and openvibe.video (openvibe.pics is parked: it redirects to openvibe.network)",
"packages": [
"openvibe-contracts v0.143.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2"
],
-- "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.pics and openvibe.video are parked and redirect to openvibe.network.",
++ "summary": "OpenVibe.MediaHub: openvibe.download as a private drive. Account-only uploads stream into OpenVibe.Media (a plain form up to 256 MB, resumable parts beyond). Files are private. Share links expire in 1 hour to 7 days, need a signed-in downloader and can be limited to named accounts (matched by subject). Reports suspend a share for staff. Downloads are always sandboxed attachments. Per-person quotas, and account export and deletion (ADR-033). openvibe.video serves public watch pages for Live's recordings and clips (no uploads); openvibe.pics is parked and redirects to openvibe.network.",
"works": [
"Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",
"Uploads: a plain form streamed to disk with its sha256 (up to 256 MB), and resumable chunked uploads (8 MB parts) through Media multipart",
@@@ -20,12 +20,14 @@
"Share links: 1 hour to 7 days, revocable, counted, sign-in required, optional named accounts (Network subjects only)",
"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
"Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",
-- "Parked domains: openvibe.pics and openvibe.video redirect to openvibe.network in nginx; no request reaches the app",
++ "OpenVibe.Video step A: public watch pages on openvibe.video (/v/:id, /c/:id, /@:username) reading Live's public API, with WebVTT captions/chapters and no uploads",
++ "Parked domain: openvibe.pics redirects to openvibe.network in nginx; no request reaches the app",
"Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
],
"notYet": [
-- "Uploads on openvibe.pics (images, albums, embeds) and openvibe.video (watch pages, captions, playlists): both domains are parked until scanning (hash lists, malware) and abuse intake shared with Media holds exist",
++ "Uploads on openvibe.pics (images, albums, embeds): parked until scanning (hash lists, malware) and abuse intake shared with Media holds exist",
++ "OpenVibe.Video chat replay, comments on the page, playlists and uploads",
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
],
@@@ -43,6 +45,6 @@
"contracts": "openvibe-contracts v0.143.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.2",
-- "deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08.",
-- "updated": "2026-10-11"
++ "deployment": "openvibe-ovh: unit openvibe-media-hub.service on 127.0.0.1:4990 (git checkout /opt/openvibe.download, env /etc/openvibe/media-hub.env, database ov_media_hub), deployed with `sudo ovhost deploy media-hub`; public at https://openvibe.download since 2026-10-08 and https://openvibe.video since 2026-10-12.",
++ "updated": "2026-10-12"
}
ddiff --git a/deploy/nginx/openvibe.download.conf b/deploy/nginx/openvibe.download.conf
iindex d05c44a..801ac1e 100644
---- a/deploy/nginx/openvibe.download.conf
++++ b/deploy/nginx/openvibe.download.conf
@@@ -153,12 +153,12 @@ server {
}
}
--# ── openvibe.pics and openvibe.video: parked. They redirect to openvibe.network until Pics and Video are built. ──
--# No app is behind these hosts: every request is answered here with a 302, and nothing is proxied.
++# ── openvibe.pics: parked. It redirects to openvibe.network until Pics is built. ──
++# No app is behind this host: every request is answered here with a 302, and nothing is proxied.
server {
listen 80;
listen [::]:80;
-- server_name openvibe.pics www.openvibe.pics openvibe.video www.openvibe.video;
++ server_name openvibe.pics www.openvibe.pics;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 302 https://openvibe.network/; }
@@@ -186,24 +186,71 @@ server {
return 302 https://openvibe.network/;
}
++# ── openvibe.video: OpenVibe.Video, the same app as openvibe.download, chosen by Host header. ──
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.video www.openvibe.video;
++
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.video$request_uri; }
++}
++
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
-- server_name openvibe.video;
++ server_name www.openvibe.video;
++
ssl_certificate /etc/letsencrypt/live/openvibe.video/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
-- return 302 https://openvibe.network/;
++
++ return 301 https://openvibe.video$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
-- server_name www.openvibe.video;
++ server_name openvibe.video;
++
++ include snippets/security-txt.conf; # /.well-known/security.txt (host file; SECURITY.md in every repo)
ssl_certificate /etc/letsencrypt/live/openvibe.video/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
-- return 302 https://openvibe.network/;
++
++ access_log /var/log/nginx/openvibe.video.access.log;
++ error_log /var/log/nginx/openvibe.video.error.log;
++
++ client_max_body_size 128k;
++
++ add_header X-Content-Type-Options nosniff always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
++
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++
++ # The watch pages, their VTT tracks and the static assets are plain GETs; nothing is buffered to disk.
++ location ~* ^/internal(/|$) { return 404; }
++ location ~* ^/metrics(/|$) { return 404; }
++
++ location / {
++ proxy_pass http://127.0.0.1:4990;
++ }
++
++ location ~ /\.(?!well-known) {
++ deny all;
++ }
}
ddiff --git a/public/css/video.css b/public/css/video.css
nnew file mode 100644
iindex 0000000..7a1060b
---- /dev/null
++++ b/public/css/video.css
@@@ -0,0 +1,108 @@
++/* OpenVibe.Video — watch pages (server/video/pages.js, ovv-*). Loaded after app.css (the drive's base: body type,
++ links, .page); colours come from the network theme variables with the default theme as fallback. */
++
++.ovv-page {
++ --ovv-card: var(--bg-card, var(--bg-secondary, #101828));
++ --ovv-line: var(--border, #1f2d47);
++ --ovv-line-strong: var(--border-light, #2c3d5c);
++ --ovv-dim: var(--text-secondary, #96a7c2);
++ --ovv-faint: var(--text-muted, #7386a3);
++ --ovv-accent: var(--accent, #3b82f6);
++ max-width: 1280px;
++ padding: 24px 24px 72px;
++}
++.ovv-page h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
++.ovv-fine { margin: .5rem 0 0; font-size: .8rem; color: var(--ovv-faint); }
++
++/* Buttons */
++.ovv-btn { display: inline-flex; align-items: center; gap: 8px; min-height: 38px; padding: 7px 14px; border-radius: 10px; border: 1px solid var(--ovv-line-strong); background: var(--bg-tertiary, #16223a); color: var(--text-primary, #e6edf7) !important; font-size: .9rem; font-weight: 700; text-decoration: none; white-space: nowrap; }
++.ovv-btn:hover, .ovv-btn:focus-visible { border-color: var(--ovv-accent); background: var(--bg-hover, #1c2a44); }
++.ovv-btn--primary { background: var(--accent-strong, #2563eb); border-color: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff) !important; }
++.ovv-btn--primary:hover, .ovv-btn--primary:focus-visible { background: var(--accent-dark, #1d4ed8); border-color: var(--accent-light, #60a5fa); }
++
++/* Home header */
++.ovv-hero { padding: 12px 0 8px; }
++.ovv-eyebrow { display: inline-flex; align-items: center; gap: 8px; margin: 0; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--ovv-line-strong); color: var(--accent-light, var(--ovv-accent)); font-size: .78rem; font-weight: 700; letter-spacing: .3px; }
++.ovv-hero h1 { margin: .6rem 0 .5rem; font-size: clamp(1.9rem, 4.4vw, 2.9rem); line-height: 1.1; letter-spacing: -.02em; }
++.ovv-lede { margin: 0; max-width: 62ch; font-size: 1.05rem; color: var(--ovv-dim); }
++.ovv-hero-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 1.1rem 0 0; }
++
++/* Sections and card grids */
++.ovv-section { margin-top: 2.2rem; }
++.ovv-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 22px 16px; }
++.ovv-card { display: flex; flex-direction: column; gap: .55rem; min-width: 0; }
++.ovv-card-media { position: relative; display: block; aspect-ratio: 16 / 9; border-radius: 12px; overflow: hidden; background: var(--ovv-card); border: 1px solid var(--ovv-line); }
++.ovv-card-media img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s ease; }
++.ovv-card:hover .ovv-card-media img { transform: scale(1.03); }
++.ovv-card-placeholder { display: grid; place-items: center; width: 100%; height: 100%; background: radial-gradient(circle at 30% 20%, color-mix(in srgb, var(--ovv-accent) 22%, transparent), transparent 60%), var(--ovv-card); color: var(--ovv-faint); font-size: 1.6rem; }
++.ovv-dur, .ovv-tag { position: absolute; bottom: 8px; padding: 1px 7px; border-radius: 6px; background: rgba(5, 8, 15, .82); color: #fff; font-size: .75rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.5; }
++.ovv-dur { right: 8px; }
++.ovv-tag { left: 8px; background: color-mix(in srgb, var(--ovv-accent) 85%, #000); }
++.ovv-tag--inline { position: static; display: inline-block; margin-right: 4px; vertical-align: 1px; }
++.ovv-card-body { min-width: 0; }
++.ovv-card-title { margin: 0; font-size: .98rem; line-height: 1.35; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
++.ovv-card-title a { color: inherit; text-decoration: none; }
++.ovv-card-title a:hover { text-decoration: underline; }
++.ovv-card-meta { margin: .25rem 0 0; font-size: .84rem; color: var(--ovv-dim); overflow-wrap: anywhere; }
++.ovv-card-meta a { color: inherit; text-decoration: none; font-weight: 600; }
++.ovv-card-meta a:hover { text-decoration: underline; }
++
++/* Watch page: the player and the stream on the left, chapters and clips beside it */
++.ovv-watch { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 28px; align-items: start; }
++.ovv-watch--clip { grid-template-columns: minmax(0, 960px); justify-content: center; }
++.ovv-main, .ovv-side { min-width: 0; }
++.ovv-side { display: grid; gap: 18px; }
++.ovv-player-wrap { border-radius: 14px; overflow: hidden; background: #000; border: 1px solid var(--ovv-line); box-shadow: 0 18px 40px rgba(0, 0, 0, .35); }
++.ovv-player { display: block; width: 100%; aspect-ratio: 16 / 9; background: #000; }
++.ovv-title { margin: 1rem 0 .6rem; font-size: clamp(1.3rem, 2.4vw, 1.75rem); line-height: 1.25; }
++.ovv-byline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px 14px; padding-bottom: 16px; border-bottom: 1px solid var(--ovv-line); }
++.ovv-avatar { display: grid; place-items: center; flex: none; width: 42px; height: 42px; border-radius: 50%; background: linear-gradient(135deg, var(--accent-light, #60a5fa), var(--accent-strong, #2563eb)); color: #fff !important; font-weight: 800; text-decoration: none; }
++.ovv-avatar--big { width: 64px; height: 64px; font-size: 1.6rem; }
++.ovv-byline-text { display: grid; gap: 2px; min-width: 0; flex: 1 1 200px; }
++.ovv-channel-name { font-weight: 700; color: var(--text-primary, #e6edf7) !important; text-decoration: none; overflow-wrap: anywhere; }
++a.ovv-channel-name:hover { text-decoration: underline; }
++.ovv-meta { font-size: .88rem; color: var(--ovv-dim); }
++.ovv-byline-actions { display: flex; flex-wrap: wrap; gap: 8px; }
++.ovv-recording { display: flex; gap: 8px; align-items: baseline; margin: 16px 0 0; padding: 10px 14px; border-radius: 10px; border: 1px solid color-mix(in srgb, var(--live-red, #ef4444) 50%, transparent); background: color-mix(in srgb, var(--live-red, #ef4444) 10%, transparent); }
++.ovv-recording i { color: var(--live-red, #ef4444); font-size: .6rem; }
++.ovv-panel { margin-top: 18px; padding: 16px 18px; border: 1px solid var(--ovv-line); border-radius: 14px; background: var(--ovv-card); }
++.ovv-side .ovv-panel { margin-top: 0; }
++.ovv-panel > p { margin: 0; line-height: 1.6; }
++.ovv-panel > p.ovv-fine { margin-top: .6rem; }
++
++/* Chapters */
++.ovv-chapters ol { list-style: none; margin: 0 -8px; padding: 0; display: grid; gap: 2px; max-height: 520px; overflow-y: auto; }
++.ovv-chapters a { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: baseline; padding: 8px; border-radius: 8px; color: inherit; text-decoration: none; font-size: .9rem; line-height: 1.4; }
++.ovv-chapters a:hover, .ovv-chapters a:focus-visible { background: var(--bg-hover, #1c2a44); }
++.ovv-chapters a.is-current { background: color-mix(in srgb, var(--ovv-accent) 14%, transparent); }
++.ovv-time { font-variant-numeric: tabular-nums; font-size: .8rem; font-weight: 700; color: var(--accent-light, var(--ovv-accent)); }
++
++/* Transcript */
++.ovv-transcript summary { cursor: pointer; font-weight: 700; }
++.ovv-transcript[open] summary { margin-bottom: .6rem; }
++.ovv-transcript-body { max-height: 26rem; overflow-y: auto; display: grid; gap: 2px; }
++.ovv-transcript-body p { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; margin: 0; padding: 4px 0; line-height: 1.5; }
++.ovv-seek { all: unset; cursor: pointer; font-variant-numeric: tabular-nums; font-size: .8rem; font-weight: 700; color: var(--accent-light, var(--ovv-accent)); padding-top: 2px; }
++.ovv-seek:hover, .ovv-seek:focus-visible { text-decoration: underline; }
++.ovv-grid--side { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 14px 12px; }
++.ovv-grid--side .ovv-card-title { font-size: .88rem; }
++.ovv-manage { margin: 0; font-size: .88rem; color: var(--ovv-dim); }
++
++/* Channel */
++.ovv-channel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 8px 0 4px; }
++.ovv-channel-head > div { flex: 1 1 240px; min-width: 0; }
++.ovv-channel-head h1 { margin: 0 0 .2rem; }
++.ovv-channel-head .ovv-meta { margin: 0; }
++.ovv-pagination { display: flex; gap: 10px; margin-top: 1.2rem; }
++
++@media (max-width: 1000px) {
++ .ovv-watch { grid-template-columns: minmax(0, 1fr); }
++ .ovv-chapters ol { max-height: none; }
++}
++@media (max-width: 600px) {
++ .ovv-page { padding: 12px 16px 56px; }
++ .ovv-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 12px; }
++ .ovv-card-title { font-size: .9rem; }
++ .ovv-player-wrap { border-radius: 10px; margin: 0 -4px; }
++ .ovv-btn { white-space: normal; }
++}
ddiff --git a/public/js/video.js b/public/js/video.js
nnew file mode 100644
iindex 0000000..fbb75c2
---- /dev/null
++++ b/public/js/video.js
@@@ -0,0 +1,64 @@
++/* OpenVibe.Video — seek helpers for the watch pages (no inline handlers). */
++(function () {
++ 'use strict';
++
++ function startSeconds() {
++ const m = /[?&]t=(\d+(?:\.\d+)?)/.exec(window.location.search);
++ if (!m) return null;
++ const n = Number(m[1]);
++ return Number.isFinite(n) && n >= 0 ? n : null;
++ }
++
++ function seek(video, seconds) {
++ if (!video || !Number.isFinite(seconds) || seconds < 0) return;
++ const apply = function () { video.currentTime = seconds; };
++ if (video.readyState >= 1) apply();
++ else video.addEventListener('loadedmetadata', apply, { once: true });
++ }
++
++ function setT(seconds) {
++ const u = new URL(window.location.href);
++ u.searchParams.set('t', String(seconds));
++ window.history.replaceState(null, '', u.pathname + '?' + u.searchParams.toString());
++ }
++
++ document.addEventListener('DOMContentLoaded', function () {
++ const video = document.querySelector('video[data-ovv-video]');
++ if (!video) return;
++
++ const t = startSeconds();
++ if (t != null) seek(video, t);
++
++ document.querySelectorAll('[data-ovv-seek]').forEach(function (el) {
++ el.addEventListener('click', function () {
++ seek(video, Number(el.getAttribute('data-ovv-seek')));
++ });
++ });
++
++ const chapters = Array.prototype.slice.call(document.querySelectorAll('[data-ovv-chapter]'));
++ chapters.forEach(function (el) {
++ el.addEventListener('click', function (e) {
++ e.preventDefault();
++ const n = Number(el.getAttribute('data-ovv-chapter'));
++ seek(video, n);
++ setT(n);
++ const p = video.play();
++ if (p && p.catch) p.catch(function () { /* autoplay refused: the seek still happened */ });
++ });
++ });
++
++ // The chapter being played is marked as playback moves (aria-current for screen readers too).
++ let current = null;
++ video.addEventListener('timeupdate', function () {
++ let at = null;
++ for (let i = 0; i < chapters.length; i++) {
++ if (Number(chapters[i].getAttribute('data-ovv-chapter')) <= video.currentTime + 0.25) at = chapters[i];
++ else break;
++ }
++ if (at === current) return;
++ if (current) { current.classList.remove('is-current'); current.removeAttribute('aria-current'); }
++ current = at;
++ if (current) { current.classList.add('is-current'); current.setAttribute('aria-current', 'true'); }
++ });
++ });
++})();
ddiff --git a/server/app.js b/server/app.js
iindex 4a4036b..86cc6e8 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
++const { createVideoRoutes } = require('./video/pages');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
@@@ -61,7 +62,7 @@ async function createApp(opts = {}) {
const drive = createService({ s, media, identity, config, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const account = createAccount({ db: s.db, config, media, log });
-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, log };
++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, fetchImpl, log };
const app = express();
app.disable('x-powered-by');
@@@ -85,6 +86,8 @@ async function createApp(opts = {}) {
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
++ // The video player loads its media file from OpenVibe.Media (https://openvibe.media/v|/c).
++ mediaSrc: ["'self'", 'https://openvibe.media'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
@@@ -100,6 +103,15 @@ async function createApp(opts = {}) {
}));
app.use(cookieParser());
++ // ── Host routing: openvibe.video is OpenVibe.Video; every other host (openvibe.download) stays
++ // the drive. On the video host the drive's pages, API and /auth routes never answer.
++ const videoHost = (() => { try { return new URL(config.video.url).hostname; } catch { return null; } })();
++ const videoRoutes = createVideoRoutes(ctx);
++ app.use((req, res, next) => {
++ if (videoHost && req.hostname === videoHost) return videoRoutes(req, res, next);
++ return next();
++ });
++
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
release.mount(app, { registry: metrics.registry });
ddiff --git a/server/config.js b/server/config.js
iindex c9422c3..134320a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -53,6 +53,16 @@ function load(env = process.env) {
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
},
++ // OpenVibe.Video: public, read-only watch pages for OpenVibe.Live's stream recordings and clips. The
++ // app serves openvibe.video by Host header and reads Live's public JSON API server-side, signed out.
++ // `url` is the public origin (its hostname is the video host); `liveUrl` is where the app calls Live
++ // (production sets it to the loopback address) and `livePublicUrl` is what links people click.
++ video: {
++ url: trim(env.MEDIAHUB_VIDEO_URL || 'https://openvibe.video'),
++ liveUrl: trim(env.MEDIAHUB_LIVE_URL || 'https://openvibe.live'),
++ livePublicUrl: trim(env.MEDIAHUB_LIVE_PUBLIC_URL || 'https://openvibe.live'),
++ },
++
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
quotas: {
ddiff --git a/server/video/layout.js b/server/video/layout.js
nnew file mode 100644
iindex 0000000..128c0e9
---- /dev/null
++++ b/server/video/layout.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * The OpenVibe.Video page shell. Same composition as the drive's layout (openvibe-shared/shell,
++ * the OpenVibe Frame navbar and footer, the shared app icon), but public and read-only: no sign-in
++ * bar, no drive navigation, and every canonical URL is on the video host. The brand here is
++ * OpenVibe.Video.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const appIcon = require('openvibe-shared/app-icon');
++const shell = require('openvibe-shared/shell');
++const cache = require('openvibe-shared/cache-policy');
++const { html, raw, esc, render } = require('../render/html');
++
++const NETWORK_URL = 'https://openvibe.network';
++const SITE_NAME = 'OpenVibe.Video';
++const TAGLINE = 'Stream recordings and clips from OpenVibe.Live';
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++
++const hashes = new Map();
++function assetVersion(rel) {
++ if (hashes.has(rel)) return hashes.get(rel);
++ let v = 'dev';
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ hashes.set(rel, v);
++ return v;
++}
++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
++
++/** o: title, description, body (html), config, path, index, noindex, crumbs [{label, href}], scripts [rel] */
++function renderPage(o) {
++ const title = o.title || SITE_NAME;
++ const pathNow = o.path || '/';
++ const canonical = `${String(o.baseUrl).replace(/\/+$/, '')}${pathNow.split('?')[0]}`;
++ const crumbs = (o.crumbs || []).length
++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
++ : '';
++ const head = `<meta name="referrer" content="strict-origin-when-cross-origin">
++${appIcon.headTags({ site: 'media-hub' })}
++<link rel="stylesheet" href="${asset('css/app.css')}">
++<link rel="stylesheet" href="${asset('css/video.css')}">
++<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">
++${(o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`).join('\n')}`;
++ const body = `<a class="skip" href="#main">Skip to content</a>
++<div id="navbar-mount"></div>
++<main id="main" class="page ovv-page">
++${render(crumbs)}
++${render(o.body)}
++</main>`;
++ return shell.page({
++ name: SITE_NAME,
++ lang: 'en',
++ title,
++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
++ siteName: SITE_NAME,
++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
++ canonical,
++ robots: o.noindex ? 'noindex' : (o.index ? 'index, follow' : 'noindex, nofollow'),
++ home: '/',
++ navLinks: [],
++ navbar: { service: 'media-hub', apiBase: NETWORK_URL, history: { type: 'page', title } },
++ footer: { service: 'media-hub', variant: 'compact', brandName: SITE_NAME },
++ head,
++ body,
++ });
++}
++
++/** Send a video page. Video pages are public; unlisted pages are noindex but still cached. */
++function send(res, status, o) {
++ if (!res.get('Cache-Control')) res.set('Cache-Control', o.cache || cache.htmlHeaders({ maxAge: 300 }));
++ res.set('Vary', 'Host');
++ res.status(status).type('html').send(renderPage(o));
++}
++
++module.exports = { renderPage, send, asset, assetVersion, SITE_NAME, TAGLINE, raw, html };
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
nnew file mode 100644
iindex 0000000..ccc420b
---- /dev/null
++++ b/server/video/live-client.js
@@@ -0,0 +1,115 @@
++'use strict';
++
++/**
++ * OpenVibe.Video's read-only client for OpenVibe.Live's public JSON API. It is signed out, never
++ * sends cookies or credentials, and only accepts JSON. A 404 is a missing/private item (null);
++ * anything else wrong — a timeout, a non-2xx, a body that is not JSON — becomes a typed error the
++ * pages render as a 502.
++ *
++ * The answers are cached in memory for a minute (details and lists alike), at most 500 entries,
++ * oldest dropped. Ids and usernames are validated before any request so a malformed route can never
++ * reach Live.
++ */
++const TIMEOUT_MS = 5000;
++const CACHE_TTL_MS = 60_000;
++const CACHE_MAX = 500;
++const USERNAME_RE = /^[A-Za-z0-9_]{1,32}$/;
++
++/** Positive safe integers (the kind Live uses for VOD and clip ids). */
++function isId(v) {
++ return /^[1-9]\d*$/.test(String(v)) && Number.isSafeInteger(Number(v)) && Number(v) > 0;
++}
++
++/** Live was down or answered nonsense: the pages turn this into a 502. */
++class LiveUnavailableError extends Error {
++ constructor(message) {
++ super(message);
++ this.name = 'LiveUnavailableError';
++ this.liveUnavailable = true;
++ }
++}
++
++function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const cache = new Map(); // url → { at, value }
++
++ function hit(key) {
++ const entry = cache.get(key);
++ if (!entry) return undefined;
++ if (Date.now() - entry.at < CACHE_TTL_MS) {
++ cache.delete(key);
++ cache.set(key, entry); // touch: this entry is now the most recent
++ return entry.value;
++ }
++ cache.delete(key);
++ return undefined;
++ }
++
++ function remember(key, value) {
++ cache.delete(key);
++ cache.set(key, { at: Date.now(), value });
++ if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
++ return value;
++ }
++
++ async function getJson(path) {
++ const url = `${config.video.liveUrl}${path}`;
++ const cached = hit(url);
++ if (cached !== undefined) return cached;
++
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'GET',
++ headers: { Accept: 'application/json' },
++ credentials: 'omit',
++ cache: 'no-store',
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ } catch (err) {
++ log.warn('[OpenVibe.Video] Live did not answer', err && err.name);
++ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ }
++
++ if (res.status === 404) return null;
++ if (!res.ok) throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++
++ let body;
++ try {
++ body = await res.json();
++ } catch {
++ throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
++ }
++ return remember(url, body);
++ }
++
++ return {
++ feed({ type, limit }) {
++ const qs = new URLSearchParams({ type, limit: String(limit) });
++ return getJson(`/api/content/feed?${qs.toString()}`);
++ },
++ vodsByUser(username, { limit, offset = 0 }) {
++ const qs = new URLSearchParams({ username, limit: String(limit), offset: String(offset) });
++ return getJson(`/api/vods?${qs.toString()}`);
++ },
++ clipsByUser(username, { limit }) {
++ const qs = new URLSearchParams({ username, limit: String(limit) });
++ return getJson(`/api/clips?${qs.toString()}`);
++ },
++ vod(id) {
++ return getJson(`/api/vods/${id}`);
++ },
++ vodMemories(id) {
++ return getJson(`/api/vods/${id}/memories`);
++ },
++ clip(id) {
++ return getJson(`/api/clips/${id}`);
++ },
++ comments(kind, id) {
++ return getJson(`/api/comments/${kind}/${id}`);
++ },
++ isId,
++ isUsername: (u) => USERNAME_RE.test(String(u || '')),
++ };
++}
++
++module.exports = { createLiveClient, LiveUnavailableError, isId, USERNAME_RE };
ddiff --git a/server/video/pages.js b/server/video/pages.js
nnew file mode 100644
iindex 0000000..b22ae00
---- /dev/null
++++ b/server/video/pages.js
@@@ -0,0 +1,495 @@
++'use strict';
++
++/**
++ * The pages of openvibe.video: public, read-only watch pages for OpenVibe.Live's stream recordings
++ * (VODs) and clips. Everything here is server-rendered and complete without JavaScript; the one
++ * script (public/js/video.js) only adds seeking and keeps the URL's `?t=` in step.
++ *
++ * / what OpenVibe.Video is, then the latest recordings and clips
++ * /v/:id a VOD watch page (player, AI chapters and captions, its clips)
++ * /c/:id a clip watch page
++ * /@:username a channel's recordings (and clips), newest first
++ * /v/:id/captions.vtt the AI transcript as WebVTT captions
++ * /v/:id/chapters.vtt the AI moments as WebVTT chapters
++ * /sitemap.xml /robots.txt /llms.txt for the video host
++ *
++ * An unlisted item (visibility 'unlisted') is reachable by id but noindex and never in a list or
++ * the sitemap. A private item is missing (Live already answers 404) and becomes a 404 here.
++ */
++const express = require('express');
++const path = require('path');
++const cache = require('openvibe-shared/cache-policy');
++const seo = require('openvibe-shared/seo');
++const ovServe = require('openvibe-shared/serve');
++const { asyncRouter } = require('../http/router');
++const { html } = require('../render/html');
++const { send, assetVersion, SITE_NAME } = require('./layout');
++const { createLiveClient } = require('./live-client');
++
++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const HOME_LIMIT = 10;
++const SITEMAP_LIMIT = 30;
++const CHANNEL_CLIPS_LIMIT = 12;
++const PER_PAGE = 24;
++
++// ── Small display helpers ───────────────────────────────────
++/**
++ * Only an https URL from Live reaches an href or src: the template escapes every value, but a
++ * javascript: or data: URL would still be live. Anything else becomes null (no thumbnail, no link).
++ */
++function httpsUrl(u) {
++ if (!u) return null;
++ try { const x = new URL(String(u)); return x.protocol === 'https:' ? x.href : null; } catch { return null; }
++}
++
++function durationLabel(s) {
++ const n = Math.max(0, Math.floor(Number(s) || 0));
++ const h = Math.floor(n / 3600);
++ const m = Math.floor((n % 3600) / 60);
++ const sec = n % 60;
++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : `${m}:${String(sec).padStart(2, '0')}`;
++}
++
++function dateLabel(iso) {
++ if (!iso) return '';
++ const d = new Date(iso);
++ if (Number.isNaN(d.getTime())) return String(iso);
++ return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
++}
++
++/** WebVTT timestamp: HH:MM:SS.mmm. */
++function vttTime(s) {
++ const totalMs = Math.max(0, Math.round((Number(s) || 0) * 1000));
++ const ms = totalMs % 1000;
++ const totalSec = Math.floor(totalMs / 1000);
++ const h = Math.floor(totalSec / 3600);
++ const m = Math.floor((totalSec % 3600) / 60);
++ const sec = totalSec % 60;
++ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(ms).padStart(3, '0')}`;
++}
++
++/** VTT cue text: drop a literal --> first, escape markup, then collapse newlines/whitespace. */
++function cueText(s) {
++ return String(s == null ? '' : s)
++ .replace(/-->/g, '')
++ .replace(/&/g, '&amp;')
++ .replace(/</g, '&lt;')
++ .replace(/>/g, '&gt;')
++ .replace(/\s+/g, ' ')
++ .trim();
++}
++
++function seg(s) {
++ if (!s || typeof s !== 'object') return null;
++ const start = Number(s.start);
++ const end = Number(s.end);
++ const text = String(s.text == null ? '' : s.text);
++ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
++ return { start, end, text };
++}
++
++/** Live stores ai_transcript_json as a JSON string; the public shape is an array. Take either. */
++function transcriptSegments(vod) {
++ const raw = vod && vod.ai_transcript_json;
++ if (Array.isArray(raw)) return raw.map(seg).filter(Boolean);
++ if (typeof raw === 'string') {
++ try {
++ const arr = JSON.parse(raw);
++ return Array.isArray(arr) ? arr.map(seg).filter(Boolean) : [];
++ } catch { return []; }
++ }
++ return [];
++}
++
++function moments(list) {
++ if (!Array.isArray(list)) return [];
++ return list.map((m) => {
++ if (!m || typeof m !== 'object') return null;
++ const offset = Number(m.offset_seconds);
++ return Number.isFinite(offset) ? { offset_seconds: offset, description: String(m.description == null ? '' : m.description) } : null;
++ }).filter(Boolean).sort((a, b) => a.offset_seconds - b.offset_seconds);
++}
++
++const MAX_CHAPTERS = 12;
++/** A moment's chapter title: what was seen (Live appends ' — heard: "…"', which the transcript already has). */
++function chapterTitle(description) {
++ const seen = String(description || '').split(/\s+[—–-]\s+heard:/)[0].trim().replace(/[.\s]+$/, '');
++ if (seen.length <= 90) return seen;
++ const cut = seen.slice(0, 90);
++ return `${cut.slice(0, Math.max(cut.lastIndexOf(' '), 50))}…`;
++}
++
++/** The AI moments as chapters: the first one, then one at least a twelfth of the stream later, at most twelve. */
++function chapters(mems, durationSeconds) {
++ const gap = Math.max(0, Number(durationSeconds) || 0) / MAX_CHAPTERS;
++ const out = [];
++ for (const m of mems) {
++ if (out.length >= MAX_CHAPTERS) break;
++ if (out.length && m.offset_seconds - out[out.length - 1].offset_seconds < gap) continue;
++ out.push({ offset_seconds: m.offset_seconds, description: chapterTitle(m.description) });
++ }
++ return out;
++}
++
++function renderCaptionsVtt(segs) {
++ const lines = ['WEBVTT', ''];
++ for (const s of segs) {
++ lines.push(`${vttTime(s.start)} --> ${vttTime(s.end)}`);
++ lines.push(cueText(s.text));
++ lines.push('');
++ }
++ return lines.join('\n');
++}
++
++function renderChaptersVtt(mems, durationSeconds) {
++ const lines = ['WEBVTT', ''];
++ for (let i = 0; i < mems.length; i++) {
++ const start = mems[i].offset_seconds;
++ const end = i + 1 < mems.length ? mems[i + 1].offset_seconds : Math.max(start, Number(durationSeconds) || 0);
++ lines.push(`${vttTime(start)} --> ${vttTime(end)}`);
++ lines.push(cueText(mems[i].description));
++ lines.push('');
++ }
++ return lines.join('\n');
++}
++
++function meta(parts) {
++ const present = parts.filter(Boolean);
++ if (!present.length) return '';
++ return html`<p class="ovv-card-meta">${present.map((p, i) => (i === 0 ? p : [html` · `, p]))}</p>`;
++}
++
++function card(o) {
++ const channel = o.channel ? html`<a href="${o.channelHref}">@${o.channel}</a>` : null;
++ return html`<article class="ovv-card">
++<a class="ovv-card-media" href="${o.href}" tabindex="-1" aria-hidden="true">${o.thumb ? html`<img src="${o.thumb}" alt="" loading="lazy">` : html`<span class="ovv-card-placeholder"><i class="fa-solid ${o.kind === 'clip' ? 'fa-scissors' : 'fa-video'}"></i></span>`}${o.duration ? html`<span class="ovv-dur">${o.duration}</span>` : ''}${o.badge ? html`<span class="ovv-tag">${o.badge}</span>` : ''}</a>
++<div class="ovv-card-body">
++<h3 class="ovv-card-title"><a href="${o.href}">${o.title}</a></h3>
++${meta([channel, o.date ? html`<time datetime="${o.iso}">${o.date}</time>` : null])}
++</div>
++</article>`;
++}
++
++function feedCard(item) {
++ const href = item.kind === 'clip' ? `/c/${item.id}` : `/v/${item.id}`;
++ return card({
++ href,
++ kind: item.kind,
++ badge: item.ai ? 'AI clip' : null,
++ thumb: httpsUrl(item.thumbnail_url),
++ title: item.title || (item.kind === 'clip' ? 'Clip' : 'Stream recording'),
++ channel: item.channel && item.channel.username ? item.channel.username : null,
++ channelHref: item.channel && item.channel.username ? `/@${item.channel.username}` : null,
++ duration: item.duration_seconds != null ? durationLabel(item.duration_seconds) : null,
++ date: dateLabel(item.created_at) || null,
++ iso: item.created_at || null,
++ });
++}
++
++function vodCard(v) {
++ return card({
++ href: `/v/${v.id}`,
++ thumb: httpsUrl(v.thumbnail_url),
++ title: v.title || 'Stream recording',
++ duration: v.duration_seconds != null ? durationLabel(v.duration_seconds) : null,
++ date: dateLabel(v.created_at) || null,
++ iso: v.created_at || null,
++ });
++}
++
++function clipCard(c) {
++ const dur = c.duration_seconds != null ? c.duration_seconds : c.duration;
++ return card({
++ href: `/c/${c.id}`,
++ kind: 'clip',
++ badge: c.auto_generated ? 'AI clip' : null,
++ thumb: httpsUrl(c.thumbnail_url),
++ title: c.title || 'Clip',
++ duration: dur != null ? durationLabel(dur) : null,
++ date: dateLabel(c.created_at) || null,
++ iso: c.created_at || null,
++ });
++}
++
++const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? html`<div class="ovv-grid">${cards}</div>` : html`<p class="muted">${empty}</p>`);
++
++function createVideoRoutes(ctx) {
++ const { config, log } = ctx;
++ const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
++ const r = asyncRouter();
++ const base = String(config.video.url).replace(/\/+$/, '');
++ const livePublic = String(config.video.livePublicUrl).replace(/\/+$/, '');
++ const videoHostname = (() => { try { return new URL(config.video.url).hostname; } catch { return 'openvibe.video'; } })();
++
++ const page = (req, res, o, status = 200) => send(res, status, { ...o, baseUrl: base, path: req.originalUrl });
++ const notFound = (req, res) => page(req, res, {
++ title: 'Not found',
++ cache: cache.htmlHeaders({ private: true }),
++ body: html`<h1>Not found</h1><p class="muted">No page here. <a href="/">OpenVibe.Video</a>.</p>`,
++ }, 404);
++ const notFoundVtt = (_req, res) => res.status(404).set('Content-Type', 'text/plain; charset=utf-8').send('Not found');
++ const sendVtt = (res, body) => res.set('Content-Type', 'text/vtt; charset=utf-8').set('Cache-Control', cache.htmlHeaders({ maxAge: 300 })).send(body);
++
++ // The shell's navbar/footer scripts come from the site's own /shared copy, and the one
++ // product stylesheet/script live in public/.
++ r.use('/shared', ovServe.handler());
++ r.use(express.static(PUBLIC_DIR, {
++ index: false, redirect: false,
++ setHeaders(res, filePath) {
++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
++ const v = res.req && res.req.query && res.req.query.v;
++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
++ },
++ }));
++
++ // ── Home ────────────────────────────────────────────────
++ r.get('/', async (req, res) => {
++ const [vods, clips] = await Promise.all([
++ live.feed({ type: 'vods', limit: HOME_LIMIT }),
++ live.feed({ type: 'clips', limit: HOME_LIMIT }),
++ ]);
++ const vodItems = (vods && Array.isArray(vods.items)) ? vods.items : [];
++ const clipItems = (clips && Array.isArray(clips.items)) ? clips.items : [];
++ return page(req, res, {
++ index: true,
++ description: 'Stream recordings and clips from OpenVibe.Live, with AI chapters and captions.',
++ body: html`<header class="ovv-hero"><p class="ovv-eyebrow"><i class="fa-solid fa-circle-play" aria-hidden="true"></i> OpenVibe.Video</p>
++<h1>Watch what OpenVibe streamed.</h1>
++<p class="ovv-lede">Stream recordings and clips from OpenVibe.Live, with AI chapters and captions. Uploads are not open here — watch what people made public on OpenVibe.Live.</p>
++<p class="ovv-hero-actions"><a class="ovv-btn ovv-btn--primary" href="${livePublic}/">Live now on OpenVibe.Live</a> <a class="ovv-btn" href="${livePublic}/docs/go-live-in-your-browser">Go live from your browser</a></p></header>
++<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Latest recordings</h2>${grid(vodItems.map(feedCard), 'No recordings yet.')}</section>
++<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Latest clips</h2>${grid(clipItems.map(feedCard), 'No clips yet.')}</section>`,
++ });
++ });
++
++ // ── WebVTT tracks ───────────────────────────────────────
++ r.get('/v/:id/captions.vtt', async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!live.isId(id)) return notFoundVtt(req, res);
++ const data = await live.vod(id);
++ if (!data || !data.vod) return notFoundVtt(req, res);
++ const segs = transcriptSegments(data.vod);
++ if (!segs.length) return notFoundVtt(req, res);
++ return sendVtt(res, renderCaptionsVtt(segs));
++ });
++
++ r.get('/v/:id/chapters.vtt', async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!live.isId(id)) return notFoundVtt(req, res);
++ const data = await live.vod(id);
++ if (!data || !data.vod) return notFoundVtt(req, res);
++ let mems = [];
++ try {
++ const m = await live.vodMemories(id);
++ mems = chapters(moments(m && m.memories), data.vod.duration_seconds);
++ } catch { mems = []; }
++ if (!mems.length) return notFoundVtt(req, res);
++ return sendVtt(res, renderChaptersVtt(mems, data.vod.duration_seconds));
++ });
++
++ // ── VOD watch page ──────────────────────────────────────
++ r.get('/v/:id', async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!live.isId(id)) return notFound(req, res);
++
++ const data = await live.vod(id);
++ if (!data || !data.vod) return notFound(req, res);
++ const vod = data.vod;
++ const clips = Array.isArray(data.clips) ? data.clips.filter((c) => c && c.visibility !== 'private') : [];
++
++ const segs = transcriptSegments(vod);
++ let mems = [];
++ try {
++ const m = await live.vodMemories(id);
++ mems = chapters(moments(m && m.memories), vod.duration_seconds);
++ } catch { mems = []; }
++ let threadUrl = null;
++ try {
++ const cm = await live.comments('vod', id);
++ threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
++ } catch { threadUrl = null; }
++
++ const unlisted = vod.visibility === 'unlisted';
++ const channelName = vod.display_name || vod.username || null;
++ const localChannel = vod.username ? `/@${vod.username}` : null;
++ const liveChannel = `${livePublic}/@${vod.username}`;
++ const manage = `${livePublic}/vod/${id}`;
++ const metaParts = [];
++ if (vod.created_at) metaParts.push(html`<time datetime="${vod.created_at}">${dateLabel(vod.created_at)}</time>`);
++ if (vod.duration_seconds != null) metaParts.push(durationLabel(vod.duration_seconds));
++ if (vod.stream_category) metaParts.push(vod.stream_category);
++ if (vod.view_count != null) metaParts.push(`${Number(vod.view_count) || 0} views`);
++
++ return page(req, res, {
++ index: !unlisted,
++ noindex: unlisted,
++ title: vod.title || 'Stream recording',
++ description: vod.description || vod.ai_overview || '',
++ scripts: ['js/video.js'],
++ body: html`<div class="ovv-watch">
++<div class="ovv-main">
++<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(vod.playback_url) || ''}" data-ovv-video>${segs.length ? html`<track kind="captions" label="Transcript (AI)" src="/v/${id}/captions.vtt" default>` : ''}${mems.length ? html`<track kind="chapters" src="/v/${id}/chapters.vtt">` : ''}</video></div>
++<h1 class="ovv-title">${vod.title || 'Stream recording'}</h1>
++<div class="ovv-byline">
++${channelName ? html`<a class="ovv-avatar" href="${localChannel || liveChannel}" aria-hidden="true" tabindex="-1">${String(channelName).slice(0, 1).toUpperCase()}</a>` : ''}
++<div class="ovv-byline-text">${channelName ? (localChannel ? html`<a class="ovv-channel-name" href="${localChannel}">@${channelName}</a>` : html`<span class="ovv-channel-name">@${channelName}</span>`) : ''}
++${metaParts.length ? html`<span class="ovv-meta">${metaParts.map((p, i) => (i === 0 ? p : [html` · `, p]))}</span>` : ''}</div>
++<div class="ovv-byline-actions">${channelName ? html`<a class="ovv-btn" href="${liveChannel}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++</div>
++${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-solid fa-circle" aria-hidden="true"></i> This recording is still in progress. <a href="${liveChannel}">Watch the live channel</a>.</p>` : ''}
++${vod.ai_overview ? html`<section class="ovv-panel" aria-labelledby="h-overview"><h2 id="h-overview">About this stream</h2><p>${vod.ai_overview}</p><p class="ovv-fine">Written by AI from the stream.</p></section>` : ''}
++${segs.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${segs.length} lines)</span></summary><div class="ovv-transcript-body">${segs.map((s) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${s.start}">${vttTime(s.start).slice(0, 8)}</button> <span>${s.text}</span></p>`)}</div></details>` : ''}
++</div>
++<aside class="ovv-side">
++${mems.length ? html`<section class="ovv-panel ovv-chapters" aria-labelledby="h-chapters"><h2 id="h-chapters">Chapters</h2><ol>${mems.map((m) => html`<li><a href="/v/${id}?t=${m.offset_seconds}" data-ovv-chapter="${m.offset_seconds}"><span class="ovv-time">${vttTime(m.offset_seconds).slice(0, 8)}</span><span>${m.description}</span></a></li>`)}</ol><p class="ovv-fine">From the AI moments of the stream.</p></section>` : ''}
++${clips.length ? html`<section class="ovv-panel" aria-labelledby="h-clips"><h2 id="h-clips">Clips from this stream</h2><div class="ovv-grid ovv-grid--side">${clips.map(clipCard)}</div></section>` : ''}
++<p class="ovv-manage"><a href="${manage}">Manage on OpenVibe.Live</a> <span class="ovv-fine">· owner tools (visibility, clips, delete) stay on Live for now</span></p>
++</aside>
++</div>`,
++ });
++ });
++
++ // ── Clip watch page ─────────────────────────────────────
++ r.get('/c/:id', async (req, res) => {
++ const id = String(req.params.id || '');
++ if (!live.isId(id)) return notFound(req, res);
++
++ const data = await live.clip(id);
++ if (!data || !data.clip) return notFound(req, res);
++ const clip = data.clip;
++
++ let threadUrl = null;
++ try {
++ const cm = await live.comments('clip', id);
++ threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
++ } catch { threadUrl = null; }
++
++ const unlisted = clip.visibility === 'unlisted';
++ const fullRecording = clip.vod_id ? `/v/${clip.vod_id}${clip.start_time != null ? `?t=${clip.start_time}` : ''}` : null;
++ const sourceName = clip.source_streamer_display_name || clip.source_streamer_username || 'the streamer';
++ const madeBy = clip.display_name || clip.username || 'an OpenVibe member';
++ const attribution = clip.auto_generated ? `AI clip from ${sourceName}'s stream` : `Made by ${madeBy}`;
++
++ return page(req, res, {
++ index: !unlisted,
++ noindex: unlisted,
++ title: clip.title || 'Clip',
++ description: clip.ai_overview || '',
++ scripts: ['js/video.js'],
++ body: html`<div class="ovv-watch ovv-watch--clip">
++<div class="ovv-main">
++<div class="ovv-player-wrap"><video class="ovv-player" controls preload="metadata" playsinline src="${httpsUrl(clip.playback_url) || ''}" data-ovv-video></video></div>
++<h1 class="ovv-title">${clip.title || 'Clip'}</h1>
++<div class="ovv-byline">
++<div class="ovv-byline-text"><span class="ovv-channel-name">${clip.auto_generated ? html`<span class="ovv-tag ovv-tag--inline">AI clip</span> ` : ''}${attribution}</span>
++${clip.created_at ? html`<span class="ovv-meta"><time datetime="${clip.created_at}">${dateLabel(clip.created_at)}</time>${clip.duration_seconds != null ? html` · ${durationLabel(clip.duration_seconds)}` : ''}</span>` : ''}</div>
++<div class="ovv-byline-actions">${fullRecording ? html`<a class="ovv-btn ovv-btn--primary" href="${fullRecording}"><i class="fa-solid fa-film" aria-hidden="true"></i> Watch in the full recording</a>` : ''}${threadUrl ? html`<a class="ovv-btn" href="${threadUrl}"><i class="fa-regular fa-comments" aria-hidden="true"></i> Discuss on OpenVibe.Community</a>` : ''}</div>
++</div>
++${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><p class="ovv-fine">Written by AI from the clip.</p></section>` : ''}
++<p class="ovv-manage"><a href="${livePublic}/clip/${id}">Manage on OpenVibe.Live</a></p>
++</div>
++</div>`,
++ });
++ });
++
++ // ── Channel page ────────────────────────────────────────
++ r.get('/@:username', async (req, res) => {
++ const username = String(req.params.username || '');
++ if (!live.isUsername(username)) return notFound(req, res);
++
++ const pageNum = Math.max(1, parseInt(req.query.page, 10) || 1);
++ const offset = (pageNum - 1) * PER_PAGE;
++
++ const out = await live.vodsByUser(username, { limit: PER_PAGE, offset });
++ const vods = Array.isArray(out && out.vods)
++ ? out.vods.filter((v) => v && v.visibility !== 'private' && v.visibility !== 'unlisted')
++ : [];
++ vods.sort((a, b) => (Date.parse(String(b.created_at || '')) || 0) - (Date.parse(String(a.created_at || '')) || 0));
++ const hasMore = !!(out && out.hasMore);
++
++ let clips = [];
++ try {
++ const c = await live.clipsByUser(username, { limit: CHANNEL_CLIPS_LIMIT });
++ clips = Array.isArray(c && c.clips)
++ ? c.clips.filter((x) => x && x.visibility !== 'private' && x.visibility !== 'unlisted')
++ : [];
++ } catch { clips = []; }
++
++ const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
++ const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
++
++ return page(req, res, {
++ index: true,
++ title: `@${username}`,
++ description: `Recordings and clips from @${username} on OpenVibe.Video.`,
++ body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
++<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
++${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}`,
++ });
++ });
++
++ // ── Discovery: robots.txt, sitemap.xml, llms.txt ────────
++ r.get('/sitemap.xml', async (req, res) => {
++ const [vods, clips] = await Promise.all([
++ live.feed({ type: 'vods', limit: SITEMAP_LIMIT }),
++ live.feed({ type: 'clips', limit: SITEMAP_LIMIT }),
++ ]);
++ const items = [...((vods && vods.items) || []), ...((clips && clips.items) || [])];
++ const urls = items.map((it) => ({ loc: `${base}${it.kind === 'clip' ? '/c/' : '/v/'}${it.id}` }));
++ return res.type('application/xml').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.sitemapXml(urls));
++ });
++
++ r.get('/robots.txt', (_req, res) => {
++ return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(`# openvibe.video: the public watch pages are for search and AI crawlers.\n${seo.robotsTxt({ sitemaps: [`${base}/sitemap.xml`] })}`);
++ });
++
++ r.get('/llms.txt', async (req, res) => {
++ const [vods, clips] = await Promise.all([
++ live.feed({ type: 'vods', limit: SITEMAP_LIMIT }),
++ live.feed({ type: 'clips', limit: SITEMAP_LIMIT }),
++ ]);
++ const vodItems = (vods && vods.items) || [];
++ const clipItems = (clips && clips.items) || [];
++ const links = (items, kind) => items.map((it) => ({ title: it.title || (kind === 'vod' ? 'Recording' : 'Clip'), url: `${base}/${kind === 'vod' ? 'v' : 'c'}/${it.id}` }));
++ return res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
++ name: SITE_NAME,
++ summary: 'Public watch pages for stream recordings and clips from OpenVibe.Live.',
++ details: 'AI chapters and captions are rendered on the watch pages. Uploads are not open.',
++ sections: [
++ { title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }] },
++ { title: 'Latest recordings', links: links(vodItems, 'vod') },
++ { title: 'Latest clips', links: links(clipItems, 'clip') },
++ ],
++ }));
++ });
++
++ // ── Legal pages name this host (openvibe.video), not the drive's host ──
++ const legal = require('openvibe-shared/legal');
++ r.get(legal.PATHS, legal.handler({ id: 'media-hub', service: 'media-hub', host: videoHostname, name: 'OpenVibe.Video', profile: 'ugc' }));
++
++ // ── Video host 404 and errors ───────────────────────────
++ r.use((req, res) => notFound(req, res));
++
++ // eslint-disable-next-line no-unused-vars
++ r.use((err, req, res, _next) => {
++ log.error('[OpenVibe.Video]', err && err.message ? err.message.slice(0, 300) : err);
++ if (res.headersSent) return undefined;
++ if (err && err.liveUnavailable) {
++ return page(req, res, {
++ title: 'OpenVibe.Live did not answer',
++ cache: cache.htmlHeaders({ private: true }),
++ body: html`<h1>OpenVibe.Live did not answer</h1><p class="muted">Try again in a minute. The recordings and clips are still on OpenVibe.Live.</p>`,
++ }, 502);
++ }
++ return page(req, res, {
++ title: 'Something went wrong',
++ cache: cache.htmlHeaders({ private: true }),
++ body: html`<h1>Something went wrong on our side</h1><p class="muted">Try again in a moment.</p>`,
++ }, 500);
++ });
++
++ return r;
++}
++
++module.exports = { createVideoRoutes, chapters, chapterTitle };
ddiff --git a/test/nginx-parked-domains.test.js b/test/nginx-parked-domains.test.js
iindex e3a286b..ed1f018 100644
---- a/test/nginx-parked-domains.test.js
++++ b/test/nginx-parked-domains.test.js
@@@ -1,7 +1,8 @@
'use strict';
--// deploy/nginx/openvibe.download.conf: openvibe.pics and openvibe.video are parked. Their server blocks (HTTP and
--// HTTPS, apex and www) answer 302 to openvibe.network and proxy nothing — no request for either host reaches the
--// app, which is why the app has no "coming" pages for them. Each name keeps its own certificate.
++// deploy/nginx/openvibe.download.conf: openvibe.pics is still parked (302 to openvibe.network).
++// openvibe.video is now OpenVibe.Video, served by the same app on the same port, chosen by Host
++// header: its apex HTTPS block proxies to 127.0.0.1:4990 exactly like openvibe.download, and its
++// HTTP and www blocks redirect to the apex. Each host keeps its own certificate.
const assert = require('assert');
const fs = require('fs');
const path = require('path');
@@@ -10,15 +11,30 @@ const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvib
const blocks = [...conf.matchAll(/(?:^|\n)server \{([\s\S]*?)\n\}/g)].map((m) => m[1]);
const named = (host) => blocks.filter((b) => new RegExp(`server_name [^;]*\\b${host.replace(/\./g, '\\.')}\\b`).test(b));
--for (const host of ['openvibe.pics', 'openvibe.video']) {
-- const parked = named(host);
-- assert.ok(parked.length >= 3, `${host}: the HTTP block and the apex and www HTTPS blocks are there`);
++// openvibe.pics: parked. Its HTTP block and apex/www HTTPS blocks all answer 302 to the network.
++{
++ const parked = named('openvibe.pics');
++ assert.ok(parked.length >= 3, 'openvibe.pics: the HTTP block and the apex and www HTTPS blocks are there');
for (const b of parked) {
-- assert.match(b, /return 302 https:\/\/openvibe\.network\/;/, `${host}: redirects to openvibe.network`);
-- assert.doesNotMatch(b, /proxy_pass/, `${host}: proxies nothing`);
++ assert.match(b, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.pics: redirects to openvibe.network');
++ assert.doesNotMatch(b, /proxy_pass/, 'openvibe.pics: proxies nothing');
}
}
++// openvibe.video: no longer parked. Its apex HTTPS block proxies to the app; www and HTTP redirect to apex.
++{
++ const video = named('openvibe.video');
++ assert.ok(video.length >= 3, 'openvibe.video: the HTTP block and the apex and www HTTPS blocks are there');
++ const apex = video.find((b) => /listen 443 ssl;/.test(b) && /server_name openvibe\.video;/.test(b));
++ const www = video.find((b) => /listen 443 ssl;/.test(b) && /server_name www\.openvibe\.video;/.test(b));
++ const http = video.find((b) => /listen 80;/.test(b));
++ assert.ok(apex && www && http, 'openvibe.video: apex, www and HTTP blocks are there');
++ assert.match(apex, /proxy_pass http:\/\/127\.0\.0\.1:4990;/, 'openvibe.video apex proxies to the app');
++ assert.match(www, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video www redirects to apex');
++ assert.match(http, /return 301 https:\/\/openvibe\.video\$request_uri;/, 'openvibe.video HTTP redirects to apex');
++ assert.doesNotMatch(apex, /return 302 https:\/\/openvibe\.network\/;/, 'openvibe.video apex is no longer parked');
++}
++
// Each host uses its own certificate, on both its apex and its www block.
for (const host of ['openvibe.pics', 'openvibe.video']) {
for (const b of named(host).filter((x) => /listen 443 ssl;/.test(x))) {
@@@ -26,4 +42,4 @@ for (const host of ['openvibe.pics', 'openvibe.video']) {
}
}
--console.log('nginx parked domains: pics and video answer 302 openvibe.network, no proxy_pass, own certificates');
++console.log('nginx: pics parked (302 openvibe.network), video proxies to 127.0.0.1:4990, own certificates');
ddiff --git a/test/video-chapters.test.js b/test/video-chapters.test.js
nnew file mode 100644
iindex 0000000..ddd131f
---- /dev/null
++++ b/test/video-chapters.test.js
@@@ -0,0 +1,22 @@
++'use strict';
++// OpenVibe.Video chapters: a long stream's AI moments become at most twelve chapters, a twelfth of the stream
++// apart, titled by what was seen (Live appends ' — heard: "…"', which the transcript already carries).
++const assert = require('assert');
++const { chapters, chapterTitle } = require('../server/video/pages');
++
++// A 2 h 24 min stream with a moment every 2.5 minutes (66 of them, as on production VOD 5717).
++const mems = Array.from({ length: 66 }, (_, i) => ({ offset_seconds: 16 + i * 150, description: `Moment ${i} — heard: "words ${i}"` }));
++const out = chapters(mems, 8638);
++assert.ok(out.length <= 12, `at most twelve, got ${out.length}`);
++assert.strictEqual(out[0].offset_seconds, 16, 'the first moment opens the first chapter');
++for (let i = 1; i < out.length; i++) assert.ok(out[i].offset_seconds - out[i - 1].offset_seconds >= 8638 / 12, 'a twelfth of the stream apart');
++assert.strictEqual(out[0].description, 'Moment 0', 'the heard part is dropped');
++
++// A short clip-length recording keeps close moments (the gap is a twelfth of 45 s).
++assert.deepStrictEqual(chapters([{ offset_seconds: 10, description: 'Intro' }, { offset_seconds: 30, description: 'Main' }], 45).map((c) => c.offset_seconds), [10, 30]);
++
++// Titles: a trailing period goes; a long one is cut at a word with an ellipsis.
++assert.strictEqual(chapterTitle('A streamer waves.'), 'A streamer waves');
++const long = chapterTitle('word '.repeat(40));
++assert.ok(long.length <= 91 && long.endsWith('…'), long);
++console.log('✅ video chapters: at most twelve, evenly spaced, titled by what was seen');
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
nnew file mode 100644
iindex 0000000..3e334c2
---- /dev/null
++++ b/test/video-pages.test.js
@@@ -0,0 +1,280 @@
++'use strict';
++/**
++ * OpenVibe.Video step A: public watch pages served on Host: openvibe.video, reading OpenVibe.Live's
++ * public JSON API through a stub. Covers the home grids, both watch pages (player, tracks, chapters,
++ * transcript), the channel page, the two VTT files (exact bytes, escaping included), the sitemap
++ * leaving the unlisted VOD out, the video host 404ing the drive's routes, Live being down → 502, and
++ * openvibe.download's home being unchanged.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++function startLive() {
++ const vodFeedItem = {
++ key: 'vod:101', kind: 'vod', id: 101, title: 'A public recording',
++ created_at: '2026-10-12T10:00:00.000Z', views: 100, duration_seconds: 45,
++ thumbnail_url: 'https://openvibe.media/t/101.jpg', excerpt: 'A public stream', ai: false,
++ channel: { username: 'streamer', display_name: 'Streamer', avatar_url: null, profile_color: null },
++ };
++ const clipFeedItem = {
++ key: 'clip:201', kind: 'clip', id: 201, title: 'A clip',
++ created_at: '2026-10-12T11:00:00.000Z', views: 20, duration_seconds: 10,
++ thumbnail_url: null, excerpt: 'A clip overview', ai: false,
++ channel: { username: 'clipper', display_name: 'Clipper', avatar_url: null, profile_color: null },
++ };
++ const publicVod = {
++ id: 101, title: 'A public recording', description: 'A public stream', duration_seconds: 45,
++ created_at: '2026-10-12T10:00:00.000Z', thumbnail_url: 'https://openvibe.media/t/101.jpg',
++ playback_url: 'https://openvibe.media/v/101', visibility: 'public',
++ username: 'streamer', display_name: 'Streamer', avatar_url: null, profile_color: null,
++ ai_overview: 'A public overview.',
++ ai_transcript_json: [
++ { start: 0, end: 2, text: 'Hi & bye <tag> --> here' },
++ { start: 2, end: 4, text: 'Second\nline --> done' },
++ ],
++ transcript_status: 'done', stream_category: 'Just Chatting', stream_started_at: '2026-10-12T09:30:00.000Z',
++ stream_peak_viewers: 12, comment_count: 3, view_count: 100, is_recording: false,
++ };
++ const unlistedVod = {
++ id: 102, title: 'An unlisted recording', description: '', duration_seconds: 20,
++ created_at: '2026-10-11T10:00:00.000Z', thumbnail_url: null,
++ playback_url: 'https://openvibe.media/v/102', visibility: 'unlisted',
++ username: 'streamer', display_name: 'Streamer', view_count: 5, is_recording: false,
++ ai_overview: '', ai_transcript_json: null,
++ };
++ const publicClip = {
++ id: 201, vod_id: 101, title: 'A clip', start_time: 5, end_time: 15, duration_seconds: 10,
++ playback_url: 'https://openvibe.media/c/201', thumbnail_url: null, visibility: 'public',
++ auto_generated: false, username: 'clipper', display_name: 'Clipper',
++ source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer',
++ ai_overview: 'A clip overview', comment_count: 2,
++ };
++ const vodListItem = {
++ id: 101, title: 'A public recording', description: 'A public stream', duration_seconds: 45,
++ created_at: '2026-10-12T10:00:00.000Z', thumbnail_url: 'https://openvibe.media/t/101.jpg',
++ playback_url: 'https://openvibe.media/v/101', visibility: 'public',
++ username: 'streamer', display_name: 'Streamer', view_count: 100,
++ };
++ const clipListItem = {
++ id: 201, title: 'A clip', duration_seconds: 10, created_at: '2026-10-12T11:00:00.000Z',
++ thumbnail_url: null, playback_url: 'https://openvibe.media/c/201', visibility: 'public',
++ username: 'clipper', display_name: 'Clipper',
++ source_streamer_username: 'streamer', source_streamer_display_name: 'Streamer', view_count: 20,
++ };
++
++ const state = { down: false };
++ const server = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://x');
++ const json = (status, body) => {
++ const text = JSON.stringify(body);
++ res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(text) });
++ res.end(text);
++ };
++ if (state.down) return json(503, { error: 'Live is down' });
++
++ const p = url.pathname;
++ if (p === '/api/content/feed') {
++ const type = url.searchParams.get('type');
++ if (type === 'vods') return json(200, { items: [vodFeedItem], next: null });
++ if (type === 'clips') return json(200, { items: [clipFeedItem], next: null });
++ return json(200, { items: [], next: null });
++ }
++ if (p === '/api/vods') {
++ const username = url.searchParams.get('username');
++ if (username === 'streamer') return json(200, { vods: [vodListItem], total: 1, hasMore: false });
++ return json(200, { vods: [], total: 0, hasMore: false });
++ }
++ if (p === '/api/clips') {
++ const username = url.searchParams.get('username');
++ if (username === 'streamer') return json(200, { clips: [clipListItem], total: 1, hasMore: false });
++ return json(200, { clips: [], total: 0, hasMore: false });
++ }
++ const memories = /^\/api\/vods\/(\d+)\/memories$/.exec(p);
++ if (memories) {
++ if (memories[1] === '103') return json(404, { error: 'not found' });
++ if (memories[1] === '101') return json(200, { memories: [{ offset_seconds: 10, description: 'Intro & setup' }, { offset_seconds: 30, description: 'The <main> part' }] });
++ return json(200, { memories: [] });
++ }
++ const vodDetail = /^\/api\/vods\/(\d+)$/.exec(p);
++ if (vodDetail) {
++ if (vodDetail[1] === '103') return json(404, { error: 'not found' });
++ if (vodDetail[1] === '101') return json(200, { vod: publicVod, clips: [clipListItem] });
++ if (vodDetail[1] === '102') return json(200, { vod: unlistedVod, clips: [] });
++ return json(404, { error: 'not found' });
++ }
++ const clipDetail = /^\/api\/clips\/(\d+)$/.exec(p);
++ if (clipDetail) {
++ if (clipDetail[1] === '201') return json(200, { clip: publicClip });
++ return json(404, { error: 'not found' });
++ }
++ const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
++ if (comments) {
++ return json(200, { comments: [], total: 0, thread: { id: `thr_${comments[1]}_${comments[2]}`, url: `https://openvibe.community/t/${comments[1]}-${comments[2]}` } });
++ }
++ return json(404, { error: 'not found' });
++ });
++
++ return new Promise((resolve) => {
++ server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ setDown: (v) => { state.down = v; },
++ close: () => new Promise((r) => server.close(r)),
++ }));
++ });
++}
++
++function request(base, path, host) {
++ return new Promise((resolve, reject) => {
++ const u = new URL(base + path);
++ const req = http.request({
++ hostname: u.hostname,
++ port: u.port,
++ path: u.pathname + u.search,
++ method: 'GET',
++ headers: { Host: host },
++ }, (res) => {
++ const chunks = [];
++ res.on('data', (c) => chunks.push(c));
++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
++ });
++ req.on('error', reject);
++ req.end();
++ });
++}
++
++(async () => {
++ const live = await startLive();
++ const t = await boot({
++ env: {
++ MEDIAHUB_VIDEO_URL: 'https://openvibe.video',
++ MEDIAHUB_LIVE_URL: live.url,
++ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live',
++ },
++ });
++ const getVideo = (p) => request(t.base, p, 'openvibe.video');
++ const getDownload = (p) => request(t.base, p, 'openvibe.download');
++
++ await check('Live being down renders a 502 page', async () => {
++ live.setDown(true);
++ const r = await getVideo('/v/101');
++ assert.strictEqual(r.status, 502);
++ assert.ok(r.text.includes('did not answer'), r.text.slice(0, 300));
++ live.setDown(false);
++ });
++
++ await check('the video home shows both latest grids and links the watch pages', async () => {
++ const r = await getVideo('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('OpenVibe.Video'));
++ assert.ok(r.text.includes('Latest recordings'));
++ assert.ok(r.text.includes('Latest clips'));
++ assert.ok(r.text.includes('/v/101'));
++ assert.ok(r.text.includes('/c/201'));
++ assert.ok(!r.text.includes('/v/102'), 'the unlisted VOD is not in a list');
++ });
++
++ await check('the VOD watch page has a player, both tracks, chapters and a transcript', async () => {
++ const r = await getVideo('/v/101');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('<video'), 'a video element');
++ assert.ok(r.text.includes('src="https://openvibe.media/v/101"'), 'the playback url');
++ assert.ok(r.text.includes('preload="metadata"'));
++ assert.ok(r.text.includes('playsinline'));
++ assert.ok(r.text.includes('data-ovv-video'));
++ assert.ok(r.text.includes('kind="captions"') && r.text.includes('src="/v/101/captions.vtt"'), 'the transcript track');
++ assert.ok(r.text.includes('kind="chapters"') && r.text.includes('src="/v/101/chapters.vtt"'), 'the chapters track');
++ assert.ok(r.text.includes('Chapters'));
++ assert.ok(r.text.includes('Transcript'));
++ assert.ok(r.text.includes('A public overview'));
++ assert.ok(r.text.includes('/@streamer'));
++ assert.ok(r.text.includes('Manage on OpenVibe.Live'));
++ assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
++ assert.ok(r.text.includes('data-ovv-chapter'));
++ assert.ok(r.text.includes('data-ovv-seek'));
++ });
++
++ await check('the clip watch page attributes its maker and links the full recording', async () => {
++ const r = await getVideo('/c/201');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('src="https://openvibe.media/c/201"'), 'the clip playback url');
++ assert.ok(r.text.includes('Made by Clipper'), 'attributed to the person who made it');
++ assert.ok(r.text.includes('Watch in the full recording'));
++ assert.ok(r.text.includes('/v/101?t=5'), 'the full recording link carries the clip start');
++ assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
++ assert.ok(r.text.includes('Manage on OpenVibe.Live'));
++ });
++
++ await check('an unlisted VOD is reachable but noindex', async () => {
++ const r = await getVideo('/v/102');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('An unlisted recording'));
++ assert.ok(r.text.includes('<meta name="robots" content="noindex">'));
++ });
++
++ await check('a private VOD is a 404', async () => {
++ assert.strictEqual((await getVideo('/v/103')).status, 404);
++ });
++
++ await check('the channel page lists recordings and clips', async () => {
++ const r = await getVideo('/@streamer');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('@streamer'));
++ assert.ok(r.text.includes('Recordings'));
++ assert.ok(r.text.includes('/v/101'));
++ assert.ok(r.text.includes('Clips'));
++ assert.ok(r.text.includes('/c/201'));
++ });
++
++ await check('captions.vtt is exact, escaping and collapsing cue text', async () => {
++ const r = await getVideo('/v/101/captions.vtt');
++ assert.strictEqual(r.status, 200);
++ assert.ok(String(r.headers['content-type']).includes('text/vtt'));
++ assert.strictEqual(r.text, 'WEBVTT\n\n00:00:00.000 --> 00:00:02.000\nHi &amp; bye &lt;tag&gt; here\n\n00:00:02.000 --> 00:00:04.000\nSecond line done\n');
++ });
++
++ await check('chapters.vtt runs each moment to the next or the end', async () => {
++ const r = await getVideo('/v/101/chapters.vtt');
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.text, 'WEBVTT\n\n00:00:10.000 --> 00:00:30.000\nIntro &amp; setup\n\n00:00:30.000 --> 00:00:45.000\nThe &lt;main&gt; part\n');
++ });
++
++ await check('the sitemap lists public items and leaves the unlisted VOD out', async () => {
++ const r = await getVideo('/sitemap.xml');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('https://openvibe.video/v/101'));
++ assert.ok(r.text.includes('https://openvibe.video/c/201'));
++ assert.ok(!r.text.includes('/v/102'));
++ });
++
++ await check('robots.txt and llms.txt answer on the video host', async () => {
++ const robots = await getVideo('/robots.txt');
++ assert.strictEqual(robots.status, 200);
++ assert.ok(robots.text.includes('https://openvibe.video/sitemap.xml'));
++ const llms = await getVideo('/llms.txt');
++ assert.strictEqual(llms.status, 200);
++ assert.ok(llms.text.includes('# OpenVibe.Video'));
++ });
++
++ await check('legal pages on the video host name openvibe.video', async () => {
++ const terms = await getVideo('/terms');
++ assert.strictEqual(terms.status, 200);
++ assert.ok(terms.text.includes('openvibe.video'));
++ assert.ok(terms.text.includes('OpenVibe.Video'));
++ });
++
++ await check('the drive routes 404 on the video host', async () => {
++ assert.strictEqual((await getVideo('/files')).status, 404);
++ assert.strictEqual((await getVideo('/api/v1/files')).status, 404);
++ });
++
++ await check("the drive's home on openvibe.download is unchanged", async () => {
++ const r = await getDownload('/');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('private drive'));
++ });
++
++ await t.close();
++ await live.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });