openvibers/openvibe.live Commits
docs/vods-and-clips.md: what used to read the frozen tables and what answers now, the Media and Community API additions that would make the moved lookups exact (batch by stream ids, created_after, owner-subject and metadata filters for pastes, a public paste count), and C-73 step 3, the procedure to drop vods, clips, pastes, paste_likes and paste_comments 30 days after this deploy: ship the code that stops creating them, back up (online .backup plus a .dump of the five tables, checksummed), reconcile read-only against media.db and community.db (every missing count 0, nothing else references them), drop children first with foreign keys off in one transaction, verify through a restart, rollback from the dump. docs/architecture.md points there
Changes
@@@ -11,7 +11,9 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv- **FFmpeg** — recording hand-off, restreams, thumbnails, audio capture for transcription- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it-- (`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen, read-only.++ (`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen: nothing reads or++ writes them ([vods-and-clips.md](vods-and-clips.md#live-reads-nothing-from-the-frozen-tables)), and they are dropped by++ [this procedure](vods-and-clips.md#dropping-the-frozen-tables).- **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads(`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the local `comments` table is frozen, read-only.@@@ -2,7 +2,9 @@> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).--> The local `vods`/`clips` tables are frozen and read-only. Implementation details below may be out of date.++> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing++> writes or reads them ([below](#live-reads-nothing-from-the-frozen-tables)), and they are dropped by++> [this procedure](#dropping-the-frozen-tables). Implementation details below may be out of date.## Recording System@@@ -90,3 +92,195 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (imageGo-live used to default every stream to `irl`, so every AI prompt (AI viewers, streamer overviews, VOD overviews, the Arena, sound detection) assumed everyone is an IRL streamer. Now the category selector defaults to **Auto** (stored as `NULL`), and the stream-memory rollup — the same summarise call that already runs — returns `{ overview, category, tags }` with `category` from the fixed taxonomy (`outdoors, travel, building, music, gaming, robot, desktop, irl, other`). It is stored on `streams.ai_category` / `streams.ai_tags` and the channel inherits the latest read (`channels.ai_category`).Everything reads the effective value: listing queries return `COALESCE(ai_category, category) AS category` (the self-selected value is still there as `chosen_category`), `stream_category` on VODs/clips, the AI viewers' session block ("judged from the stream itself"), streamer overviews, sound detection and the Arena all prefer `ai_category`. A streamer can still pick a category manually; the AI read wins once it exists.++++## Live reads nothing from the frozen tables++++Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media++split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,++same ids) and OpenVibe.Community (pastes, by `legacy_media_id` or slug) hold the old rows too. Since++register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read++of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its++allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this++page.++++What used to read them, and what answers now (most of it in `server/media-proxy/lookups.js`):++++| Page or job | Now asks | Hidden items |++|---|---|---|++| Recently ended streams (`/api/streams/recent`): VOD id, thumbnail, duration | Media, per stream (cached 5 min) | never: public VODs only |++| Recently online cards: slot thumbnail | Media `GET /vods/latest-thumbs` (the route already did) | never |++| Channel page badges: pastes, clips taken | Community (pastes), Media (clips, `hide_self`) | owner and staff only |++| Channel offline screen: top VOD and clip per week, month, all time | Media, newest 100 plus most-viewed | never |++| Workspace session history: each session's VOD | Media by slot (`managed_stream_id`) | owner only (private and recording included) |++| Setup hub: "Publish a paste" | Community, as the person | the person's own |++| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own |++| AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never |++| Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |++| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` when it owns them | counts only |++| Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only |++| Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff |++| Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |++| AI moments and auto-clip jobs: VOD pool, VOD source, where viewers clipped | Media (no local fallback: Media down means no run) | internal signal only |++| Stream analytics: clips per stream | Media, written into `stream_analytics.clips_created` right after the stream ends | internal |++| Arena portraits: VOD thumbnails | Media, public VODs | never |++++Removed from `server/db/database.js`: 45 exported helpers that had no caller (VOD health, public VOD and clip++lists, the old paste API, paste comments and likes, filtered channel lists), the 11 whose callers now ask Media++or Community, and the boot-time migrations that still rewrote rows of the frozen `vods`/`clips` tables (short++overviews, transcript status) along with the indexes that only served the removed listings. The streamer++overview job now only considers streamers with stream memories; the frozen `vods` table was its other signal,++and its rows moved to Media at the split.++++### API additions that would help (not blocking)++++- **Media: `GET /vods?stream_ids=1,2,3`** (a batch filter). `/api/streams/recent` asks once per stream now, up++ to 100 calls on a cold cache.++- **Media: `created_after` on `GET /vods` and `GET /clips`.** The offline screen's week and month windows are++ cut from the newest 100 items; a streamer with more than 100 in a month loses that month's oldest.++- **Media: `order=peak_viewers` for real.** It is an alias of `views` in Media. Live has `streams.peak_viewers`++ but cannot sort Media's list by it.++- **Community: list by owner subject** (`GET /api/pastes?owner=usr_…`). Live lists a person's pastes by++ username, which relies on Community's projection cache knowing that username.++- **Community: a metadata filter** (`GET /api/pastes?metadata.kind=avatar`). Avatar history lists up to 200 of++ the person's screenshots and keeps the avatar-tagged ones.++- **Community: a public paste count.** The hero reads Community's staff-only `/api/pastes/admin/stats` with++ Live's `community.paste.moderate` grant. Media's `/stats` and `/stats/series/pastes` still count Media's++ frozen paste copies; the hero's paste chart does too.++++## Dropping the frozen tables++++Register item C-73 step 3. **Start no earlier than 30 days after the deploy that contains C-73 step 2**++(the commit that made `test/frozen-tables.test.js` refuse reads), and only if every deploy since then passed++that test. Nothing in the procedure needs Live to stop. It drops five tables from Live's database:++`vods`, `clips`, `pastes`, `paste_likes`, `paste_comments`. Their indexes go with them. Live's `comments` table++is not part of this (OpenVibe.Community's comment import reads it).++++Paths on the production host (check `DB_PATH` in `/etc/openvibe/live.env` first):++++```bash++LIVE_DB=/opt/openvibe.live/data/live.db # -> /opt/openvibe.live/shared/data/live.db++MEDIA_DB=/opt/openvibe.media/data/media.db++COMMUNITY_DB=/var/lib/openvibe-community/community.db++STAMP=$(date -u +%Y%m%d)++BACKUP_DIR=/opt/backups++```++++### 1. Ship the code that stops creating the tables++++One Live commit, deployed before anything is dropped. Without it, the next boot recreates the five tables empty.++++- `server/db/schema.sql`: remove `CREATE TABLE vods`, `CREATE TABLE clips` and the indexes++ `idx_vods_user_id`, `idx_clips_stream_id`, `idx_clips_user_id`.++- `server/db/database.js` `initDb()`: remove every block that creates or alters these tables. That is the `vods`++ column adds (thumbnail_url through is_recording, storage_provider/storage_key, ai_overview/ai_transcript/++ ai_analyzed_at, visibility), the `clips` column adds (storage_provider/storage_key, the AI columns,++ clip_notified/clip_notify_at, visibility, auto_generated), the two visibility backfills, and the `pastes`,++ `paste_likes` and `paste_comments` blocks (tables, indexes, the copies/likes/is_nsfw columns). The++ `idx_vods_*`/`idx_clips_*` listing and transcript indexes already left the boot code in step 2; the ones++ production still has go with their tables.++- `server/db/migrations.js`: leave `002_pastes_ai_columns` in place. Its ledger row exists in production;++ with the table gone, `up` returns `DEFER` and changes nothing.++- `test/frozen-tables.test.js`: empty `ALLOWED_WRITES` (the backfills are gone), and add a check that++ `initDb()` on a new database creates none of the five tables.++++Deploying this changes no data: the existing tables stay where they are, and nothing reads, writes, creates or++alters them.++++### 2. Back up++++```bash++sudo mkdir -p "$BACKUP_DIR"++# The whole database, online (safe while Live runs):++sudo sqlite3 "$LIVE_DB" ".timeout 10000" ".backup '$BACKUP_DIR/live-pre-c73-drop-$STAMP.db'"++# The five tables alone, as SQL that recreates them:++sudo sqlite3 -readonly "$LIVE_DB" ".dump vods clips pastes paste_likes paste_comments" \++ | gzip -9 | sudo tee "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" > /dev/null++sudo sqlite3 -readonly "$BACKUP_DIR/live-pre-c73-drop-$STAMP.db" "PRAGMA integrity_check" # ok++sudo sha256sum "$BACKUP_DIR"/live-pre-c73-drop-$STAMP.db "$BACKUP_DIR"/live-frozen-tables-$STAMP.sql.gz \++ | sudo tee "$BACKUP_DIR/live-c73-$STAMP.sha256"++```++++Keep both files for at least a year, and copy them off the host with the usual backups. They are the only++copy of any row that step 3 finds missing upstream.++++### 3. Reconcile (read-only)++++```bash++sudo sqlite3 -readonly "$LIVE_DB" <<SQL++.headers on++SELECT 'vods' AS t, COUNT(*) AS n FROM vods UNION ALL SELECT 'clips', COUNT(*) FROM clips++UNION ALL SELECT 'pastes', COUNT(*) FROM pastes UNION ALL SELECT 'paste_likes', COUNT(*) FROM paste_likes++UNION ALL SELECT 'paste_comments', COUNT(*) FROM paste_comments;++ATTACH '$MEDIA_DB' AS m;++ATTACH '$COMMUNITY_DB' AS c;++SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v++ WHERE NOT EXISTS (SELECT 1 FROM m.vods x WHERE x.id = v.id AND x.app_id = 'live');++SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k++ WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');++SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p++ WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.legacy_media_id = p.id OR x.slug = p.slug);++SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l++ WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);++SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc++ WHERE NOT EXISTS (SELECT 1 FROM m.paste_comments x WHERE x.id = pc.id);++-- Anything else in Live's schema that still points at the five tables (foreign keys, views, triggers):++SELECT m2.name AS referencing_table, f."table" AS frozen FROM sqlite_master m2, pragma_foreign_key_list(m2.name) f++ WHERE m2.type = 'table' AND f."table" IN ('vods','clips','pastes','paste_likes','paste_comments')++ AND m2.name NOT IN ('vods','clips','pastes','paste_likes','paste_comments');++SELECT type, name FROM sqlite_master WHERE type IN ('view','trigger')++ AND (sql LIKE '%vods%' OR sql LIKE '%clips%' OR sql LIKE '%pastes%' OR sql LIKE '%paste_likes%' OR sql LIKE '%paste_comments%');++SQL++```++++The media split dropped these tables from `live.db` and `schema.sql` recreated them empty, so the first query++most likely returns 0 for all five. Whatever it returns, **every "missing" count must be 0, and the last two++queries must return no rows.** Otherwise stop: export the missing rows to Media or Community first (and log++them in the register), or fix what still references the tables, then run step 3 again.++++### 4. Drop++++Children first, foreign keys off, in one transaction. Live keeps running: it has no statement on these tables.++++```bash++sudo sqlite3 "$LIVE_DB" <<'SQL'++.timeout 10000++PRAGMA foreign_keys = OFF;++BEGIN IMMEDIATE;++DROP TABLE IF EXISTS paste_comments;++DROP TABLE IF EXISTS paste_likes;++DROP TABLE IF EXISTS clips;++DROP TABLE IF EXISTS pastes;++DROP TABLE IF EXISTS vods;++COMMIT;++PRAGMA foreign_key_check;++SQL++```++++If the tables held many rows, reclaim the space later with `VACUUM` at a quiet hour. It locks the database++while it runs.++++### 5. Verify++++```bash++sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # nothing++sudo systemctl restart openvibe-live # socket activation: no 502s; boot ~6 s++curl -fsS http://127.0.0.1:3000/api/ready++sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # still nothing++sudo journalctl -u openvibe-live --since '-10 min' | grep -iE 'no such table|SQLITE_ERROR' || echo clean++for p in /api/streams/recent /api/streams/recently-online /api/streams/recent-vods /api/home/hero; do++ curl -fsS -o /dev/null -w "%{http_code} $p\n" "http://127.0.0.1:3000$p"; done++```++++Then open a channel page (Videos, Clips, Pastes and AI tabs), the home page, and the dashboard's session history.++++### Rollback++++Nothing in Live reads the tables, so a rollback is only ever needed to get rows back. Recreate the five tables++from the dump (it holds their `CREATE TABLE`, the rows and the indexes):++++```bash++gunzip -c "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" | sudo sqlite3 "$LIVE_DB"++```++++`live-pre-c73-drop-$STAMP.db` is the second copy. Afterwards, record the date, the counts from step 3 and the++backup paths under C-73 in OpenVibe.Host's `docs/compatibility-register.md`.
@@ -11,7 +11,9 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
@@ -11,7 +11,9 @@ OpenVibe.Live is a self-hosted live streaming platform, one of the OpenVibe serv
- **FFmpeg** — recording hand-off, restreams, thumbnails, audio capture for transcription
- **FFmpeg** — recording hand-off, restreams, thumbnails, audio capture for transcription
- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)
- **OpenVibe.Network** — SSO/OAuth2 (RS256 JWTs), OpenCoins wallet, shared browser modules (`openvibe-shared`, a pinned OpenVibe.Shared release served at `/shared/*` from `node_modules`)
- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it
- **OpenVibe.Media** — VODs, clips, pastes, thumbnails and files. Live proxies to it
- (`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen, read-only.
+ (`server/media-client.js`, `server/media-proxy/`); the local `vods`/`clips`/`pastes` tables are frozen: nothing reads or
+ writes them ([vods-and-clips.md](vods-and-clips.md#live-reads-nothing-from-the-frozen-tables)), and they are dropped by
+ [this procedure](vods-and-clips.md#dropping-the-frozen-tables).
- **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads
- **OpenVibe.Community** — pastes (`server/pastes-client.js`) and VOD/clip comments: `/api/comments` is an adapter over Community comment threads
(`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the local `comments` table is frozen, read-only.
(`server/comments-client.js`, [vods-and-clips.md](vods-and-clips.md#comments-are-openvibecommunity-threads)); the local `comments` table is frozen, read-only.
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> Written before the media split. Storage, cutting, thumbnails and files now live in OpenVibe.Media;
> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).
> Live proxies to it (`server/media-proxy/`) and starts/stops recordings (`server/streaming/recorder.js`).
-> The local `vods`/`clips` tables are frozen and read-only. Implementation details below may be out of date.
+> Pastes live in OpenVibe.Community. Live's old local `vods`/`clips`/`pastes` tables are frozen: nothing
+> writes or reads them ([below](#live-reads-nothing-from-the-frozen-tables)), and they are dropped by
+> [this procedure](#dropping-the-frozen-tables). Implementation details below may be out of date.
## Recording System
## Recording System
@@ -90,3 +92,195 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image
@@ -90,3 +92,195 @@ Two jobs turn stream moments into content: `server/ai/ai-moments-job.js` (image
Go-live used to default every stream to `irl`, so every AI prompt (AI viewers, streamer overviews, VOD overviews, the Arena, sound detection) assumed everyone is an IRL streamer. Now the category selector defaults to **Auto** (stored as `NULL`), and the stream-memory rollup — the same summarise call that already runs — returns `{ overview, category, tags }` with `category` from the fixed taxonomy (`outdoors, travel, building, music, gaming, robot, desktop, irl, other`). It is stored on `streams.ai_category` / `streams.ai_tags` and the channel inherits the latest read (`channels.ai_category`).
Go-live used to default every stream to `irl`, so every AI prompt (AI viewers, streamer overviews, VOD overviews, the Arena, sound detection) assumed everyone is an IRL streamer. Now the category selector defaults to **Auto** (stored as `NULL`), and the stream-memory rollup — the same summarise call that already runs — returns `{ overview, category, tags }` with `category` from the fixed taxonomy (`outdoors, travel, building, music, gaming, robot, desktop, irl, other`). It is stored on `streams.ai_category` / `streams.ai_tags` and the channel inherits the latest read (`channels.ai_category`).
Everything reads the effective value: listing queries return `COALESCE(ai_category, category) AS category` (the self-selected value is still there as `chosen_category`), `stream_category` on VODs/clips, the AI viewers' session block ("judged from the stream itself"), streamer overviews, sound detection and the Arena all prefer `ai_category`. A streamer can still pick a category manually; the AI read wins once it exists.
Everything reads the effective value: listing queries return `COALESCE(ai_category, category) AS category` (the self-selected value is still there as `chosen_category`), `stream_category` on VODs/clips, the AI viewers' session block ("judged from the stream itself"), streamer overviews, sound detection and the Arena all prefer `ai_category`. A streamer can still pick a category manually; the AI read wins once it exists.
+
+## Live reads nothing from the frozen tables
+
+Live's local `vods`, `clips`, `pastes`, `paste_likes` and `paste_comments` tables stopped at the Media
+split: nothing recorded, clipped or pasted since then was ever in them, and OpenVibe.Media (VODs and clips,
+same ids) and OpenVibe.Community (pastes, by `legacy_media_id` or slug) hold the old rows too. Since
+register item C-73 step 2, no Live code reads them. `test/frozen-tables.test.js` fails on any write or read
+of these tables in `server/` or `scripts/` (SQL text, split over lines too, and `table: '…'` registries). Its
+allow-list of kept reads is empty. A read that has to stay goes into that list with a reason, and into this
+page.
+
+What used to read them, and what answers now (most of it in `server/media-proxy/lookups.js`):
+
+| Page or job | Now asks | Hidden items |
+|---|---|---|
+| Recently ended streams (`/api/streams/recent`): VOD id, thumbnail, duration | Media, per stream (cached 5 min) | never: public VODs only |
+| Recently online cards: slot thumbnail | Media `GET /vods/latest-thumbs` (the route already did) | never |
+| Channel page badges: pastes, clips taken | Community (pastes), Media (clips, `hide_self`) | owner and staff only |
+| Channel offline screen: top VOD and clip per week, month, all time | Media, newest 100 plus most-viewed | never |
+| Workspace session history: each session's VOD | Media by slot (`managed_stream_id`) | owner only (private and recording included) |
+| Setup hub: "Publish a paste" | Community, as the person | the person's own |
+| Settings: avatar history | Community, as the person (screenshots tagged `metadata.kind = 'avatar'`) | the person's own |
+| AI timeline (channel AI tab): each session's VOD link | Media by user, public only; not cached while Media is down | never |
+| Transcript view: the stream's VOD | Media (the old local fallback is gone) | never |
+| Home hero, kiosk, SEO snapshot: VOD, clip, hour and paste counts | Media `/stats`; pastes from Community `/api/pastes/admin/stats` when it owns them | counts only |
+| Home "over time" charts for VODs, clips, hours | Media `/stats/series/*` (the route already did) | counts only |
+| Admin dashboard: VOD totals | Media list totals (null when Media is down) | staff |
+| Admin AI explorer and the streamer overview job: VODs, clips, pastes | Media and Community | staff: any visibility (the overview job reads public VODs) |
+| AI moments and auto-clip jobs: VOD pool, VOD source, where viewers clipped | Media (no local fallback: Media down means no run) | internal signal only |
+| Stream analytics: clips per stream | Media, written into `stream_analytics.clips_created` right after the stream ends | internal |
+| Arena portraits: VOD thumbnails | Media, public VODs | never |
+
+Removed from `server/db/database.js`: 45 exported helpers that had no caller (VOD health, public VOD and clip
+lists, the old paste API, paste comments and likes, filtered channel lists), the 11 whose callers now ask Media
+or Community, and the boot-time migrations that still rewrote rows of the frozen `vods`/`clips` tables (short
+overviews, transcript status) along with the indexes that only served the removed listings. The streamer
+overview job now only considers streamers with stream memories; the frozen `vods` table was its other signal,
+and its rows moved to Media at the split.
+
+### API additions that would help (not blocking)
+
+- **Media: `GET /vods?stream_ids=1,2,3`** (a batch filter). `/api/streams/recent` asks once per stream now, up
+ to 100 calls on a cold cache.
+- **Media: `created_after` on `GET /vods` and `GET /clips`.** The offline screen's week and month windows are
+ cut from the newest 100 items; a streamer with more than 100 in a month loses that month's oldest.
+- **Media: `order=peak_viewers` for real.** It is an alias of `views` in Media. Live has `streams.peak_viewers`
+ but cannot sort Media's list by it.
+- **Community: list by owner subject** (`GET /api/pastes?owner=usr_…`). Live lists a person's pastes by
+ username, which relies on Community's projection cache knowing that username.
+- **Community: a metadata filter** (`GET /api/pastes?metadata.kind=avatar`). Avatar history lists up to 200 of
+ the person's screenshots and keeps the avatar-tagged ones.
+- **Community: a public paste count.** The hero reads Community's staff-only `/api/pastes/admin/stats` with
+ Live's `community.paste.moderate` grant. Media's `/stats` and `/stats/series/pastes` still count Media's
+ frozen paste copies; the hero's paste chart does too.
+
+## Dropping the frozen tables
+
+Register item C-73 step 3. **Start no earlier than 30 days after the deploy that contains C-73 step 2**
+(the commit that made `test/frozen-tables.test.js` refuse reads), and only if every deploy since then passed
+that test. Nothing in the procedure needs Live to stop. It drops five tables from Live's database:
+`vods`, `clips`, `pastes`, `paste_likes`, `paste_comments`. Their indexes go with them. Live's `comments` table
+is not part of this (OpenVibe.Community's comment import reads it).
+
+Paths on the production host (check `DB_PATH` in `/etc/openvibe/live.env` first):
+
+```bash
+LIVE_DB=/opt/openvibe.live/data/live.db # -> /opt/openvibe.live/shared/data/live.db
+MEDIA_DB=/opt/openvibe.media/data/media.db
+COMMUNITY_DB=/var/lib/openvibe-community/community.db
+STAMP=$(date -u +%Y%m%d)
+BACKUP_DIR=/opt/backups
+```
+
+### 1. Ship the code that stops creating the tables
+
+One Live commit, deployed before anything is dropped. Without it, the next boot recreates the five tables empty.
+
+- `server/db/schema.sql`: remove `CREATE TABLE vods`, `CREATE TABLE clips` and the indexes
+ `idx_vods_user_id`, `idx_clips_stream_id`, `idx_clips_user_id`.
+- `server/db/database.js` `initDb()`: remove every block that creates or alters these tables. That is the `vods`
+ column adds (thumbnail_url through is_recording, storage_provider/storage_key, ai_overview/ai_transcript/
+ ai_analyzed_at, visibility), the `clips` column adds (storage_provider/storage_key, the AI columns,
+ clip_notified/clip_notify_at, visibility, auto_generated), the two visibility backfills, and the `pastes`,
+ `paste_likes` and `paste_comments` blocks (tables, indexes, the copies/likes/is_nsfw columns). The
+ `idx_vods_*`/`idx_clips_*` listing and transcript indexes already left the boot code in step 2; the ones
+ production still has go with their tables.
+- `server/db/migrations.js`: leave `002_pastes_ai_columns` in place. Its ledger row exists in production;
+ with the table gone, `up` returns `DEFER` and changes nothing.
+- `test/frozen-tables.test.js`: empty `ALLOWED_WRITES` (the backfills are gone), and add a check that
+ `initDb()` on a new database creates none of the five tables.
+
+Deploying this changes no data: the existing tables stay where they are, and nothing reads, writes, creates or
+alters them.
+
+### 2. Back up
+
+```bash
+sudo mkdir -p "$BACKUP_DIR"
+# The whole database, online (safe while Live runs):
+sudo sqlite3 "$LIVE_DB" ".timeout 10000" ".backup '$BACKUP_DIR/live-pre-c73-drop-$STAMP.db'"
+# The five tables alone, as SQL that recreates them:
+sudo sqlite3 -readonly "$LIVE_DB" ".dump vods clips pastes paste_likes paste_comments" \
+ | gzip -9 | sudo tee "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" > /dev/null
+sudo sqlite3 -readonly "$BACKUP_DIR/live-pre-c73-drop-$STAMP.db" "PRAGMA integrity_check" # ok
+sudo sha256sum "$BACKUP_DIR"/live-pre-c73-drop-$STAMP.db "$BACKUP_DIR"/live-frozen-tables-$STAMP.sql.gz \
+ | sudo tee "$BACKUP_DIR/live-c73-$STAMP.sha256"
+```
+
+Keep both files for at least a year, and copy them off the host with the usual backups. They are the only
+copy of any row that step 3 finds missing upstream.
+
+### 3. Reconcile (read-only)
+
+```bash
+sudo sqlite3 -readonly "$LIVE_DB" <<SQL
+.headers on
+SELECT 'vods' AS t, COUNT(*) AS n FROM vods UNION ALL SELECT 'clips', COUNT(*) FROM clips
+UNION ALL SELECT 'pastes', COUNT(*) FROM pastes UNION ALL SELECT 'paste_likes', COUNT(*) FROM paste_likes
+UNION ALL SELECT 'paste_comments', COUNT(*) FROM paste_comments;
+ATTACH '$MEDIA_DB' AS m;
+ATTACH '$COMMUNITY_DB' AS c;
+SELECT 'vods missing in Media' AS t, COUNT(*) AS n FROM main.vods v
+ WHERE NOT EXISTS (SELECT 1 FROM m.vods x WHERE x.id = v.id AND x.app_id = 'live');
+SELECT 'clips missing in Media' AS t, COUNT(*) AS n FROM main.clips k
+ WHERE NOT EXISTS (SELECT 1 FROM m.clips x WHERE x.id = k.id AND x.app_id = 'live');
+SELECT 'pastes missing in Community' AS t, COUNT(*) AS n FROM main.pastes p
+ WHERE NOT EXISTS (SELECT 1 FROM c.pastes x WHERE x.legacy_media_id = p.id OR x.slug = p.slug);
+SELECT 'paste_likes missing in Media' AS t, COUNT(*) AS n FROM main.paste_likes l
+ WHERE NOT EXISTS (SELECT 1 FROM m.paste_likes x WHERE x.paste_id = l.paste_id AND x.user_id = l.user_id);
+SELECT 'paste_comments missing in Media' AS t, COUNT(*) AS n FROM main.paste_comments pc
+ WHERE NOT EXISTS (SELECT 1 FROM m.paste_comments x WHERE x.id = pc.id);
+-- Anything else in Live's schema that still points at the five tables (foreign keys, views, triggers):
+SELECT m2.name AS referencing_table, f."table" AS frozen FROM sqlite_master m2, pragma_foreign_key_list(m2.name) f
+ WHERE m2.type = 'table' AND f."table" IN ('vods','clips','pastes','paste_likes','paste_comments')
+ AND m2.name NOT IN ('vods','clips','pastes','paste_likes','paste_comments');
+SELECT type, name FROM sqlite_master WHERE type IN ('view','trigger')
+ AND (sql LIKE '%vods%' OR sql LIKE '%clips%' OR sql LIKE '%pastes%' OR sql LIKE '%paste_likes%' OR sql LIKE '%paste_comments%');
+SQL
+```
+
+The media split dropped these tables from `live.db` and `schema.sql` recreated them empty, so the first query
+most likely returns 0 for all five. Whatever it returns, **every "missing" count must be 0, and the last two
+queries must return no rows.** Otherwise stop: export the missing rows to Media or Community first (and log
+them in the register), or fix what still references the tables, then run step 3 again.
+
+### 4. Drop
+
+Children first, foreign keys off, in one transaction. Live keeps running: it has no statement on these tables.
+
+```bash
+sudo sqlite3 "$LIVE_DB" <<'SQL'
+.timeout 10000
+PRAGMA foreign_keys = OFF;
+BEGIN IMMEDIATE;
+DROP TABLE IF EXISTS paste_comments;
+DROP TABLE IF EXISTS paste_likes;
+DROP TABLE IF EXISTS clips;
+DROP TABLE IF EXISTS pastes;
+DROP TABLE IF EXISTS vods;
+COMMIT;
+PRAGMA foreign_key_check;
+SQL
+```
+
+If the tables held many rows, reclaim the space later with `VACUUM` at a quiet hour. It locks the database
+while it runs.
+
+### 5. Verify
+
+```bash
+sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # nothing
+sudo systemctl restart openvibe-live # socket activation: no 502s; boot ~6 s
+curl -fsS http://127.0.0.1:3000/api/ready
+sudo sqlite3 -readonly "$LIVE_DB" "SELECT name FROM sqlite_master WHERE name IN ('vods','clips','pastes','paste_likes','paste_comments')" # still nothing
+sudo journalctl -u openvibe-live --since '-10 min' | grep -iE 'no such table|SQLITE_ERROR' || echo clean
+for p in /api/streams/recent /api/streams/recently-online /api/streams/recent-vods /api/home/hero; do
+ curl -fsS -o /dev/null -w "%{http_code} $p\n" "http://127.0.0.1:3000$p"; done
+```
+
+Then open a channel page (Videos, Clips, Pastes and AI tabs), the home page, and the dashboard's session history.
+
+### Rollback
+
+Nothing in Live reads the tables, so a rollback is only ever needed to get rows back. Recreate the five tables
+from the dump (it holds their `CREATE TABLE`, the rows and the indexes):
+
+```bash
+gunzip -c "$BACKUP_DIR/live-frozen-tables-$STAMP.sql.gz" | sudo sqlite3 "$LIVE_DB"
+```
+
+`live-pre-c73-drop-$STAMP.db` is the second copy. Afterwards, record the date, the counts from step 3 and the
+backup paths under C-73 in OpenVibe.Host's `docs/compatibility-register.md`.