openvibers/openvibe.mediahub Commits
MediaHub: OpenVibe.Video v1, public watch pages for Live's recordings and clips at openvibe.video (#28)
Changes
@@@ -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@@@ -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@@@ -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 asigned-in person can upload, organise, share and delete their own files. The bytes live in OpenVibe.Media; MediaHubkeeps 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 itssubscriptions 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 hMediaHub 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 theEvery name is in [.env.example](.env.example): the Network client (`OV_OAUTH_CLIENT_ID=media-hub`, secret, internalURL), 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)@@@ -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"}@@@ -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;++ }}nnew file mode 100644@@@ -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; }++}nnew file mode 100644@@@ -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'); }++ });++ });++})();@@@ -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 });@@@ -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: {nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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, '&')++ .replace(/</g, '<')++ .replace(/>/g, '>')++ .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 };@@@ -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/openvibconst 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');nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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 & bye <tag> 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 & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> 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); });
@@ -55,3 +55,11 @@ MEDIAHUB_MEDIA_APP=media-hub
@@ -55,3 +55,11 @@ MEDIAHUB_MEDIA_APP=media-hub
# optional: defaults to true in production
# optional: defaults to true in production
# COOKIE_SECURE=true
# 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
@@ -7,8 +7,11 @@ What changed in OpenVibe.MediaHub, newest first. Each site also publishes its pa
@@ -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
- 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
`media.object.upload` and `media.object.read` on namespace `media-hub`). `MEDIAHUB_MEDIA_APP_KEY` is gone
(compatibility register C-71).
(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
## 0.1.0 — 2026-10-08
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
> Your files, shared on your terms.
> 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.
**Port:** 4990 · **Service id:** `media-hub` · **Env prefix:** `MEDIAHUB` · **License:** AGPL-3.0.
## Purpose
## Purpose
@@ -12,8 +12,9 @@ Video are built, so no request for either host reaches the app.
@@ -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
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
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
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
## Owns
@@ -39,8 +40,8 @@ creates none):
@@ -39,8 +40,8 @@ creates none):
`identity.subject.resolve`.
`identity.subject.resolve`.
- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
- Event delivery, and the account export/deletion topics, are **OpenVibe.Events**': MediaHub only creates its
subscriptions and consumes the two deliveries.
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
## Depends on
@@ -51,6 +52,8 @@ creates none):
@@ -51,6 +52,8 @@ creates none):
(and `MEDIAHUB_MEDIA_TIMEOUT_MS`). Media calls use the service's Network service token. Grant
(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]`
`[media-hub, media.object.upload, openvibe.media]` and `[media-hub, media.object.read, openvibe.media]`
on namespace `media-hub`.
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
- **OpenVibe.Events** — `MEDIAHUB_EVENTS_URL` (the two subscriptions are created at boot) and
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
`MEDIAHUB_EVENTS_SECRET` (the v2 signature on `POST /internal/events`).
- **PostgreSQL** and **Valkey** — `DATABASE_URL` / `DATABASE_DIRECT_URL`, `VALKEY_URL` / `VALKEY_PREFIX`, read
- **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
@@ -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).
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
## Pages
| Path | Who | What |
| 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 |
| `/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 |
| `/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 |
| `/staff` | staff | the reports queue: restore or remove |
@@ -141,6 +170,7 @@ takes `network.account.export_requested` and `network.account.deleted`, and the
@@ -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
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`),
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`,
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`).
`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
@@ -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),
`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)
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)
## Security (threat notes)
@@ -3,16 +3,16 @@
@@ -3,16 +3,16 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W20",
"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",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4990",
"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": [
"packages": [
"openvibe-contracts v0.143.0",
"openvibe-contracts v0.143.0",
"openvibe-sdk v0.44.0",
"openvibe-sdk v0.44.0",
"openvibe-shared v4.0.2"
"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": [
"works": [
"Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies); pages server-rendered, complete without JavaScript",
"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",
"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 @@
@@ -20,12 +20,14 @@
"Share links: 1 hour to 7 days, revocable, counted, sign-in required, optional named accounts (Network subjects only)",
"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)",
"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",
"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",
"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"
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
],
],
"notYet": [
"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",
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
"Previews and thumbnails of files"
],
],
@@ -43,6 +45,6 @@
@@ -43,6 +45,6 @@
"contracts": "openvibe-contracts v0.143.0",
"contracts": "openvibe-contracts v0.143.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.0.2",
"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"
}
}
@@ -153,12 +153,12 @@ server {
@@ -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 {
server {
listen 80;
listen 80;
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 /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 302 https://openvibe.network/; }
location / { return 302 https://openvibe.network/; }
@@ -186,24 +186,71 @@ server {
@@ -186,24 +186,71 @@ server {
return 302 https://openvibe.network/;
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 {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
http2 on;
- server_name openvibe.video;
+ server_name www.openvibe.video;
+
ssl_certificate /etc/letsencrypt/live/openvibe.video/fullchain.pem;
ssl_certificate /etc/letsencrypt/live/openvibe.video/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_protocols TLSv1.2 TLSv1.3;
- return 302 https://openvibe.network/;
+
+ return 301 https://openvibe.video$request_uri;
}
}
server {
server {
listen 443 ssl;
listen 443 ssl;
listen [::]:443 ssl;
listen [::]:443 ssl;
http2 on;
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 /etc/letsencrypt/live/openvibe.video/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_certificate_key /etc/letsencrypt/live/openvibe.video/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
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;
+ }
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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'); }
+ });
+ });
+})();
@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
+const { createVideoRoutes } = require('./video/pages');
const { createServiceReadiness } = require('./observability');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { assetVersion, send } = require('./render/layout');
const { assetVersion, send } = require('./render/layout');
@@ -61,7 +62,7 @@ async function createApp(opts = {}) {
@@ -61,7 +62,7 @@ async function createApp(opts = {}) {
const drive = createService({ s, media, identity, config, log });
const drive = createService({ s, media, identity, config, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const uploads = createUploads({ s, media, config, service: drive, log });
const account = createAccount({ db: s.db, config, media, 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -85,6 +86,8 @@ async function createApp(opts = {}) {
@@ -85,6 +86,8 @@ async function createApp(opts = {}) {
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
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'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
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'],
connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameSrc: ["'self'", 'https://openvibe.network'],
frameAncestors: ["'none'"],
frameAncestors: ["'none'"],
@@ -100,6 +103,15 @@ async function createApp(opts = {}) {
@@ -100,6 +103,15 @@ async function createApp(opts = {}) {
}));
}));
app.use(cookieParser());
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 ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-media-hub', version: VERSION }));
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
@@ -53,6 +53,16 @@ function load(env = process.env) {
@@ -53,6 +53,16 @@ function load(env = process.env) {
timeoutMs: int(env.MEDIAHUB_MEDIA_TIMEOUT_MS, 30_000),
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
// 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.
// tenant), so the per-person numbers are enforced in MediaHub and nowhere else.
quotas: {
quotas: {
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,495 @@
@@ -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, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .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 };
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -10,15 +11,30 @@ const conf = fs.readFileSync(path.join(__dirname, '..', ...'deploy/nginx/openvib
@@ -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 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));
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) {
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.
// Each host uses its own certificate, on both its apex and its www block.
for (const host of ['openvibe.pics', 'openvibe.video']) {
for (const host of ['openvibe.pics', 'openvibe.video']) {
for (const b of named(host).filter((x) => /listen 443 ssl;/.test(x))) {
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']) {
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,280 @@
@@ -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 & bye <tag> 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 & setup\n\n00:00:30.000 --> 00:00:45.000\nThe <main> 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); });