openvibers/openvibe.live Commits

The Rundown replaces the Arena: what streamers talked about, written up from their speech (topics, each streamer's take in their own words with a link to the moment, the desk's quip, every stream as an episode with a recap and chapters, crossovers when they name each other); no beefs, ratings, Lolcow Index or Clout Rankings; game audio is never a quote, quotes are verbatim, invented accusations are dropped; /arena redirects (owner, 2026-10-19) (#252)

OP OpenVibers committed 25 days ago · eccfa66654 · 32 files, +1698 −132

Changes

ddiff --git a/AGENTS.md b/AGENTS.md
iindex 037b7ebe..393a8929 100644
---- a/AGENTS.md
++++ b/AGENTS.md
@@@ -81,7 +81,7 @@ Every file below is also served on the site at `/docs/<name>` (rendered by `serv
- [docs/broadcasting.md](docs/broadcasting.md) — Streaming protocols (WebRTC/RTMP/JSMPEG/WHIP)
- [docs/go-live-in-your-browser.md](docs/go-live-in-your-browser.md) — Going live from a browser with no OBS and no follower minimum (the headline feature: keep it on the home page, in /llms.txt and in search metadata)
- [docs/whip.md](docs/whip.md) — WHIP ingest API reference (auth forms, CORS, browser-only publishing, error codes)
--- [docs/arena.md](docs/arena.md) — Arena tab (streamer vs streamer): ratings, AI personas, gated portrait generation, battles, votes
++- [docs/rundown.md](docs/rundown.md) — The Rundown (`/rundown`, was the Arena): what streamers talked about, written up from their speech — topics, takes, episodes, crossovers
- [docs/chat-system.md](docs/chat-system.md) — Chat features and moderation
- [docs/api-tokens.md](docs/api-tokens.md) — Bot/integration token system
- [docs/vods-and-clips.md](docs/vods-and-clips.md) — VOD/clip pipeline (pre-split; storage/cutting now in OpenVibe.Media)
ddiff --git a/README.md b/README.md
iindex 7a32128e..8ca7eb29 100644
---- a/README.md
++++ b/README.md
@@@ -69,7 +69,7 @@ The media subsystem lives in **OpenVibe.Media**:
- Channels and streams: go-live, stream keys, managed streams, heartbeats, channel pages (`/@username`), offline screens, panels and goals.
- The watch experience: the SPA in `public/`, the chat surface and its effects (TTS, sounds, emotes, overlays), anonymous chat identities.
- Streamer tools: the dashboard, restreaming (via OpenRestream for managed ingest), song requests (watch party), AI viewers (the context Live builds; the runs are OpenVibe.AI's).
--- Channel points, Vibes tipping flows (OpenVibe.Billing keeps the ledger since 2026-10-17), moderation of Live's own surfaces, the Arena (ARENA ALERT: the drama desk on streamer activity, the Lolcow Index and Clout Rankings, beefs from the mic; docs/arena.md) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).
++- Channel points, Vibes tipping flows (OpenVibe.Billing keeps the ledger since 2026-10-17), moderation of Live's own surfaces, The Rundown (what streamers talked about, written up from their speech: topics, takes in their own words and every stream as an episode; docs/rundown.md, replacing the Arena on 2026-10-19) and after-show recaps. Live transcripts come from NVIDIA Parakeet TDT on the server (whisper for other languages).
- Live-local state: PostgreSQL `ov_live` (users' Live profiles, streams, channel state, AI state for Media-hosted recordings in `vod_ai_state` / `clip_ai_state`, request analytics).
## Does not own
ddiff --git a/docs/README.md b/docs/README.md
iindex ea1810e7..e3b186e8 100644
---- a/docs/README.md
++++ b/docs/README.md
@@@ -10,7 +10,7 @@
- [Vibe Coding](vibe-coding.md) — Copilot companion publisher protocol, slot settings, and viewer feed
- [VOD & Clips](vods-and-clips.md) — Recordings, DVR seeking, clip creation
- [API Tokens](api-tokens.md) — Bot/integration authentication
--- [Arena](arena.md) — Streamer-vs-streamer tab: ratings from analytics, AI personas/portraits, daily battles, crowd votes
++- [The Rundown](rundown.md) — What streamers talked about, written up: topics, takes in their own words, episodes (replaced the Arena, 2026-10-19)
## Streamer Tools
- [Dashboard](dashboard.md) — Streamer dashboard features: emotes, rewards, chat logs, media management
ddiff --git a/docs/arena.md b/docs/arena.md
iindex d5c158f1..d2f3f59f 100644
---- a/docs/arena.md
++++ b/docs/arena.md
@@@ -1,3 +1,5 @@
++> **The Arena is gone (2026-10-19).** It became [The Rundown](rundown.md): what streamers talked about, written up — no beefs, ratings, Lolcow Index or Clout Rankings. Its jobs are off and `/arena` redirects to `/rundown`; this page describes the old code until it is removed.
++
# Arena — ARENA ALERT (v4, 2026-10-16)
The **Arena** tab (`/arena`) is the site's drama desk and competitive board, in the owner's words "drama alert type competitive shit … based on streamer activity". It replaced the mic-only Battle Cam home (v3, 2026-09-10): the mic still feeds it (beefs, the judged lines), and now so does everything else streamers measurably do.
ddiff --git a/docs/rundown.md b/docs/rundown.md
nnew file mode 100644
iindex 00000000..4c93406a
---- /dev/null
++++ b/docs/rundown.md
@@@ -0,0 +1,102 @@
++# The Rundown (2026-10-19)
++
++**The Rundown** (`/rundown`) is what streamers talked about, written up: the topics, each streamer's take in their own words with a link to the moment, and every stream as an episode with a recap and chapters. It replaced the Arena (the competitive drama desk: viewer-crash stories, the Lolcow Index, Clout Rankings, mic ratings and beefs). In the owner's words: "gathers topics and stuff the streamers talk about and is less about competing with eachother and more about talking shit and fleshing out stories and creating content for people to look through".
++
++- [The desk](#the-desk)
++- [What is kept, and what never is](#what-is-kept-and-what-never-is)
++- [Pages](#pages)
++- [API](#api)
++- [Data](#data)
++- [Chat](#chat)
++- [Settings and limits](#settings-and-limits)
++
++## The desk
++
++`server/rundown/desk.js` runs every 5 minutes. It reads streams' speech-to-text (`stream_timeline_events`, kind `speech`, from Parakeet; whisper for other languages) a stretch at a time:
++
++1. **A stretch.** The unread speech of one stream, game announcer lines left out, at most ~2,400 words (about 15 minutes of talk). A live stream is read once the stretch holds 700 words or spans 10 minutes with 150 words; an ended stream is read to the end.
++2. **Segments.** OpenVibe.AI's `live.rundown.segment` gets the stretch with the stream's title and category, the 50 most recent topic names (so the same subject keeps one topic) and what the stream covered so far. It returns up to 4 segments, each with:
++ - a **topic**, concrete ("Supermarket fridges hacked in France", never "news"), and its kind (news, games, tech, life, music, sports, money, internet, chat, other);
++ - a **headline**, **what** they talked about and **their take**;
++ - the desk's **quip**, a dry late-night-host line about the take or the situation;
++ - a 1–5 **score**;
++ - up to two **quotes**, copied word for word, with an English translation when they spoke another language.
++3. **Topics.** A segment joins the topic with the same slug, else a recent topic whose key words mostly match, else a new topic. A topic with new takes (2 or more in all) gets its page rewritten by `live.rundown.topic`: a headline, plus a summary of who said what and where they agree or clash.
++4. **Episodes.** When an ended stream is read to the end, `live.rundown.episode` writes it up from its segments: title, logline, a recap in 2–3 short paragraphs and chapters (VOD timestamps). A stream with no segments gets no episode.
++5. **Crossovers.** A segment records the streamers it named. Only a whole username or display name said as it is spelled counts (`server/arena/names.js`, `wholeOnly`), never a sound-alike or a nickname. Naming someone is talking, not a fight: there are no beefs, scores or winners.
++
++The first deploy reads back the last 7 days, newest streams first. At most 4 AI calls a tick and 40 an hour, only while AI is on and within budget. All calls go to the `live.creative` route.
++
++## What is kept, and what never is
++
++Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "quote of the day" was League of Legends' announcer ("You have slain an enemy"), credited to a streamer. The desk's rules (`server/rundown/filters.js` and the prompts in OpenVibe.AI):
++
++- **Game lines** (League, Counter-Strike and Valorant announcer and system lines) never reach the AI and are never a quote. The prompt tells the AI that announcers, characters, lyrics, ads, video narration and articles read aloud are never the streamer; a reaction to them is the take.
++- **A quote** is kept only when it is in the transcript the AI read, word for word.
++- **No invented accusations.** A headline, account or take that uses an accusing word (threat, attack, scam, hacked…) the transcript never used drops the whole segment; in a quip, only the quip goes. Episodes and topic pages follow the same rule.
++- **No looks, ever.** Every text passes `db.noLooks()`, and the prompts forbid jokes about looks, body, clothing, age, race, religion, sexuality, gender or disability, sexual content about anyone, threats and self-harm jokes. Swearing is fine.
++- **Names and pronouns.** Names are written exactly as given, and people are called by their name or "they".
++- **Opting out.** A streamer can keep themselves out ("Keep me out of The Rundown" on their page, `PUT /api/rundown/opt-out`; the Arena's opt-outs carry over). The desk stops reading their streams, and what was written about them is set aside (status `out`). Coming back restores it, never anything staff took down.
++- **Staff** take an item down with `POST /api/rundown/hide {type, id}` (segment, episode or topic).
++
++## Pages
++
++- `/rundown`, the front page:
++ - the masthead (date, counts) and the "On air now" strip, showing who is live and what they are talking about;
++ - **The latest:** episodes and the best takes (score 3 and up, or naming someone), newest first;
++ - **Trending topics** this week (most streamers, then most takes);
++ - **Who's been talking:** each streamer with their top topics, and how the desk works.
++- `/rundown/topics` lists every topic, by most recent or most talked about, with a filter by kind.
++- `/rundown/t/<slug>` is one topic: the desk's summary, every take (newest first), and the topics that came up in the same streams.
++- `/rundown/e/<streamId>` is one episode: logline, recap, chapters, what they talked about, and more from the streamer. A live stream shows what the desk has heard so far.
++- `/rundown/<username>` is a streamer's page: what they talk about (topics with counts), best lines, episodes and crossovers.
++- **Every card:**
++ - reactions (🔥 😂 💀 🧢 💯), one per reader;
++ - ▶ to the moment in the VOD;
++ - 🔊 to hear a quote in the streamer's chat voice (`/api/rundown/voice/<user>?t=`, server/arena/voice.js).
++- **Search:** the front page and topic index are indexed. Topic, episode and streamer pages are AI-written text about real people, so they are `noindex,follow` (server/seo/pages.js).
++- **Old links:** `/arena` and its pages redirect (301) to `/rundown`, and a fighter's `/arena/<username>` goes to `/rundown/<username>`.
++
++## API
++
++`/api/rundown` (server/rundown/routes.js):
++
++| Method | Path | Notes |
++|---|---|---|
++| GET | `/` | `on_air`, `latest` (+ `next`), `trending`, `voices`, `counts` |
++| GET | `/latest?before=` | the next page of the latest |
++| GET | `/topics?sort=recent\|most&kind=` | every topic |
++| GET | `/t/:slug` | `topic`, `takes`, `related` |
++| GET | `/e/:streamId` | `episode`, `stream`, `segments`, `more` |
++| GET | `/s/:username` | `user`, `episodes`, `topics`, `lines`, `crossovers` (or `opted_out`) |
++| POST | `/react {type, id, reaction}` | segment or episode; fire, lol, dead, cap, facts; the same again takes it back |
++| POST | `/hide {type, id}` | staff |
++| GET, PUT | `/opt-out {out}` | signed in |
++| GET | `/voice/:user?t=` | a line in the streamer's chat voice |
++
++A person in any answer is `{ id, username, display_name, avatar_url, profile_color }`.
++
++## Data
++
++Migration `0010_rundown.sql`:
++
++- `rundown_topics`: slug, title, kind, the desk's headline and summary, counts, first and last seen.
++- `rundown_segments`: stream, streamer, topic, seconds, headline, what, take, quip, score, verified quotes, named streamers (`mentions`), VOD, said at.
++- `rundown_episodes`: one per stream, holding the title, logline, recap, chapters, VOD and minutes.
++- `rundown_progress`: how far the desk has read each stream.
++- `rundown_reactions`: readers' reactions.
++
++The Arena's tables are no longer written; their code goes in a later change.
++
++## Chat
++
++When an episode is written within 6 hours of its stream ending, it is announced in global chat as **THE RUNDOWN**, at most 3 an hour. Chat still forwards the Arena's commands: `!arena [user]` answers with the streamer's latest episode and topics plus a link, and `!hype` and `!beef` say beefs are gone and point to The Rundown.
++
++## Settings and limits
++
++| Key | Default | Effect |
++|---|---|---|
++| `rundown_enabled` | `true` | `false` stops the desk (pages keep what was written) |
++| `ai_timeline_enabled` | `false` | the speech-to-text the desk reads: must be on |
++
++Tests: `test/rundown.test.js` (filters, the desk on a stubbed AI, topics, episodes, opt-outs, the API and the redirects).
ddiff --git a/migrations/0010_rundown.sql b/migrations/0010_rundown.sql
nnew file mode 100644
iindex 00000000..e3b6261d
---- /dev/null
++++ b/migrations/0010_rundown.sql
@@@ -0,0 +1,85 @@
++-- phase: expand
++-- The Rundown (2026-10-19, replaces the Arena's drama desk; the owner: "gathers topics and stuff the streamers talk
++-- about and is less about competing with eachother and more about talking shit and fleshing out stories and creating
++-- content for people to look through"). server/rundown/desk.js reads streams' speech-to-text (stream_timeline_events,
++-- kind 'speech') a stretch at a time and has OpenVibe.AI pull out what each streamer talked about.
++--
++-- A segment is one stretch of one stream about one thing: its topic, a headline, what they said, their take, the desk's
++-- quip and up to two of their own lines (verbatim, checked against the transcript), with the streamers they named by
++-- their whole name. A topic gathers every segment about the same subject, across streams and streamers, with a summary
++-- the desk rewrites as takes arrive. An episode is one finished stream written up (title, logline, recap, chapters).
++-- rundown_progress is how far the desk has read each stream; rundown_reactions are readers' reactions. status: live, hidden
++-- (staff took it down) or out (its streamer keeps themselves out; coming back makes it live again, never a hidden one).
++CREATE TABLE IF NOT EXISTS rundown_topics (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ slug text COLLATE "C" NOT NULL UNIQUE,
++ title text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL DEFAULT 'other',
++ headline text COLLATE "C",
++ summary text COLLATE "C",
++ summary_takes integer NOT NULL DEFAULT 0,
++ summary_at text COLLATE "C",
++ takes integer NOT NULL DEFAULT 0,
++ streamers integer NOT NULL DEFAULT 0,
++ first_seen_at text COLLATE "C" NOT NULL,
++ last_seen_at text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'hidden'))
++);
++CREATE INDEX IF NOT EXISTS idx_rundown_topics_recent ON rundown_topics (status, last_seen_at DESC);
++
++CREATE TABLE IF NOT EXISTS rundown_segments (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ stream_id bigint NOT NULL REFERENCES streams(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ topic_id bigint REFERENCES rundown_topics(id) ON DELETE SET NULL,
++ start_sec double precision NOT NULL,
++ end_sec double precision NOT NULL,
++ title text COLLATE "C" NOT NULL,
++ what text COLLATE "C" NOT NULL,
++ take text COLLATE "C",
++ quip text COLLATE "C",
++ score integer NOT NULL DEFAULT 1 CHECK (score BETWEEN 1 AND 5),
++ quotes_json text COLLATE "C" NOT NULL DEFAULT '[]',
++ mentions bigint[] NOT NULL DEFAULT '{}',
++ vod_id bigint,
++ said_at text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'hidden', 'out')),
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_recent ON rundown_segments (status, said_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_topic ON rundown_segments (topic_id, said_at DESC);
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_user ON rundown_segments (user_id, said_at DESC);
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_stream ON rundown_segments (stream_id, start_sec);
++CREATE INDEX IF NOT EXISTS idx_rundown_segments_mentions ON rundown_segments USING gin (mentions);
++
++CREATE TABLE IF NOT EXISTS rundown_episodes (
++ stream_id bigint PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ title text COLLATE "C" NOT NULL,
++ logline text COLLATE "C",
++ recap text COLLATE "C",
++ chapters_json text COLLATE "C" NOT NULL DEFAULT '[]',
++ vod_id bigint,
++ minutes integer,
++ aired_at text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'live' CHECK (status IN ('live', 'hidden', 'out')),
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS idx_rundown_episodes_recent ON rundown_episodes (status, aired_at DESC);
++CREATE INDEX IF NOT EXISTS idx_rundown_episodes_user ON rundown_episodes (user_id, aired_at DESC);
++
++CREATE TABLE IF NOT EXISTS rundown_progress (
++ stream_id bigint PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
++ read_to_sec double precision NOT NULL DEFAULT -1,
++ done integer NOT NULL DEFAULT 0,
++ updated_at text COLLATE "C" DEFAULT ov_now()
++);
++
++CREATE TABLE IF NOT EXISTS rundown_reactions (
++ item_type text COLLATE "C" NOT NULL CHECK (item_type IN ('segment', 'episode')),
++ item_id bigint NOT NULL,
++ voter text COLLATE "C" NOT NULL,
++ reaction text COLLATE "C" NOT NULL,
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ PRIMARY KEY (item_type, item_id, voter)
++);
ddiff --git a/public/css/features/rundown.css b/public/css/features/rundown.css
nnew file mode 100644
iindex 00000000..36414d29
---- /dev/null
++++ b/public/css/features/rundown.css
@@@ -0,0 +1,168 @@
++/* The Rundown (public/js/rundown.js): what streamers talked about, written up. An editorial page over the site theme:
++ a masthead with a date line, cards for takes and episodes, topic chips coloured by kind (--rd-k). */
++.rundown-container { padding-bottom: 40px; }
++
++/* Masthead */
++.rd-mast { margin: 18px 0 16px; padding: 0 0 12px; border-bottom: 3px double var(--border-light, var(--border)); }
++.rd-mast-line { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 6px 0; border-bottom: 1px solid var(--border);
++ font-size: 0.78rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-secondary); }
++.rd-title { margin: 10px 0 4px; font-size: clamp(2.4rem, 8vw, 4.4rem); line-height: 0.95; font-weight: 900; letter-spacing: -0.04em; text-transform: uppercase; }
++.rd-tagline { margin: 0 0 12px; max-width: 64ch; color: var(--text-secondary); font-size: 1.02rem; }
++.rd-tabs { display: flex; gap: 6px; }
++.rd-tab { padding: 7px 14px; border-radius: 999px; font-weight: 700; color: var(--text-secondary); text-decoration: none; border: 1px solid transparent; }
++.rd-tab:hover { color: var(--text-primary); border-color: var(--border); }
++.rd-tab.is-on { color: var(--text-primary); background: var(--bg-tertiary); border-color: var(--border); }
++
++/* Headings, layout */
++.rd-h1 { margin: 6px 0 8px; font-size: clamp(1.7rem, 5vw, 2.6rem); line-height: 1.08; font-weight: 900; letter-spacing: -0.02em; }
++.rd-h2 { display: flex; align-items: baseline; gap: 8px; margin: 26px 0 12px; font-size: 0.85rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-secondary); }
++.rd-h2 small { font-weight: 600; letter-spacing: 0; text-transform: none; color: var(--text-muted); }
++.rd-main > .rd-h2:first-child { margin-top: 4px; }
++.rd-h3 { margin: 0 0 4px; font-size: 1.18rem; line-height: 1.25; font-weight: 800; letter-spacing: -0.01em; }
++.rd-h3 a { color: inherit; text-decoration: none; }
++.rd-h3 a:hover { text-decoration: underline; }
++.rd-grid { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 24px; align-items: start; }
++.rd-side { display: flex; flex-direction: column; gap: 14px; position: sticky; top: 70px; }
++.rd-box { padding: 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.rd-box .rd-h2 { margin-top: 0; }
++.rd-quiet { margin: 0; color: var(--text-secondary); font-size: 0.92rem; }
++.rd-empty { padding: 34px 20px; text-align: center; border: 2px dashed var(--border); border-radius: 16px; color: var(--text-secondary); }
++.rd-empty i { font-size: 1.8rem; margin-bottom: 8px; opacity: 0.7; }
++.rd-empty p { margin: 6px auto 14px; max-width: 46ch; }
++.rd-loadmore { display: block; margin: 16px auto 0; }
++.rd-more-link { display: inline-block; margin-top: 10px; font-weight: 700; font-size: 0.9rem; }
++.rd-link { background: none; border: 0; padding: 0; color: var(--accent-light, var(--accent)); font: inherit; font-weight: 600; cursor: pointer; text-decoration: underline; }
++.rd-crumbs { margin: 16px 0 0; font-size: 0.88rem; color: var(--text-secondary); }
++.rd-crumbs a { color: inherit; }
++.rd-dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; background: var(--live-red, #ef4444); box-shadow: 0 0 0 4px color-mix(in srgb, var(--live-red, #ef4444) 25%, transparent); }
++
++/* On air now */
++.rd-onair { margin: 0 0 6px; }
++.rd-onair .rd-h2 { margin-top: 0; }
++.rd-onair-list { display: flex; gap: 10px; margin: 0; padding: 0 0 4px; list-style: none; overflow-x: auto; scrollbar-width: thin; }
++.rd-onair-item { display: flex; align-items: center; gap: 10px; min-width: 220px; max-width: 320px; padding: 8px 14px 8px 8px; border-radius: 14px; background: var(--bg-card);
++ border: 1px solid var(--border); color: inherit; text-decoration: none; }
++.rd-onair-item:hover { border-color: var(--live-red, #ef4444); }
++.rd-onair-item img, .rd-onair-i { flex: none; width: 32px; height: 32px; border-radius: 50%; object-fit: cover; display: grid; place-items: center; background: var(--bg-tertiary); font-weight: 800; }
++.rd-onair-t { display: flex; flex-direction: column; min-width: 0; }
++.rd-onair-t small { color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
++
++/* Avatars and names */
++.rd-av { flex: none; display: grid; place-items: center; width: var(--rd-av, 36px); height: var(--rd-av, 36px); border-radius: 50%; overflow: hidden; text-decoration: none;
++ background: color-mix(in srgb, var(--rd-c, #64748b) 35%, var(--bg-tertiary)); color: var(--text-primary); font-weight: 800; }
++.rd-av img { width: 100%; height: 100%; object-fit: cover; }
++.rd-user { font-weight: 700; color: var(--text-primary); text-decoration: none; }
++.rd-user:hover { text-decoration: underline; }
++.rd-by { margin: 0 0 6px; font-size: 0.9rem; color: var(--text-secondary); }
++
++/* Chips */
++.rd-chips { display: flex; flex-wrap: wrap; gap: 6px; }
++.rd-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 10px; border-radius: 999px; font-size: 0.82rem; font-weight: 700; line-height: 1.3;
++ color: var(--text-primary); text-decoration: none; background: color-mix(in srgb, var(--rd-k, #94a3b8) 16%, transparent); border: 1px solid color-mix(in srgb, var(--rd-k, #94a3b8) 45%, transparent);
++ overflow-wrap: anywhere; }
++.rd-chip::before { content: ''; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--rd-k, #94a3b8); }
++.rd-chip:hover, .rd-chip.is-on { background: color-mix(in srgb, var(--rd-k, #94a3b8) 30%, transparent); }
++.rd-chip b { font-weight: 800; color: var(--text-secondary); }
++.rd-chips-sm .rd-chip { font-size: 0.76rem; padding: 2px 8px; }
++
++/* Cards */
++.rd-card { margin: 0 0 14px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.rd-card-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 0 10px; }
++.rd-card-top time { flex: none; font-size: 0.8rem; color: var(--text-muted); }
++.rd-take-body { display: flex; gap: 12px; }
++.rd-take-text { flex: 1; min-width: 0; }
++.rd-what { margin: 0 0 8px; color: var(--text-primary); line-height: 1.55; }
++.rd-their { margin: 0 0 8px; line-height: 1.5; }
++.rd-their span { margin-right: 6px; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-secondary); }
++.rd-quote { margin: 10px 0; padding: 10px 14px; border-left: 3px solid var(--accent); border-radius: 0 12px 12px 0; background: var(--bg-tertiary); }
++.rd-quote p { margin: 0; font-size: 1.04rem; font-style: italic; line-height: 1.5; }
++.rd-quote .rd-quote-en, .rd-quote-en { margin: 4px 0 0; font-style: normal; font-size: 0.9rem; color: var(--text-secondary); }
++.rd-quote footer { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; font-size: 0.86rem; color: var(--text-secondary); }
++.rd-quip { margin: 8px 0 0; color: var(--text-secondary); font-style: italic; line-height: 1.5; }
++.rd-desk { display: inline-block; margin-right: 6px; padding: 1px 7px; border-radius: 6px; background: var(--text-primary); color: var(--bg-card);
++ font-size: 0.68rem; font-weight: 900; font-style: normal; letter-spacing: 0.08em; text-transform: uppercase; vertical-align: 1px; }
++.rd-cross { margin: 8px 0 0; font-size: 0.88rem; color: var(--text-secondary); }
++.rd-card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
++.rd-kicker { font-size: 0.74rem; font-weight: 900; letter-spacing: 0.12em; text-transform: uppercase; color: var(--accent-light, var(--accent)); }
++.rd-kicker-k { color: var(--rd-k, var(--accent)); }
++.rd-logline { margin: 0 0 8px; font-size: 1.02rem; line-height: 1.5; }
++.rd-read { font-weight: 800; text-decoration: none; }
++.rd-episode { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); }
++
++/* Chapters */
++.rd-chapters { margin: 6px 0 10px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; font-size: 0.92rem; }
++.rd-chapters li { display: flex; gap: 10px; align-items: baseline; }
++.rd-stamp { flex: none; min-width: 3.4em; font-variant-numeric: tabular-nums; font-weight: 700; color: var(--text-secondary); }
++.rd-chapters a { text-decoration: none; }
++.rd-chapters-big { font-size: 1rem; gap: 8px; }
++
++/* Buttons and reactions */
++.rd-watch { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; font-size: 0.88rem; text-decoration: none; }
++.rd-watch .rd-stamp { min-width: 0; font-weight: 600; }
++.rd-speak { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border-radius: 50%; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-secondary); cursor: pointer; }
++.rd-speak:hover, .rd-speak.is-speaking { color: var(--accent-light, var(--accent)); border-color: var(--accent); }
++.rd-react { display: flex; flex-wrap: wrap; gap: 6px; }
++.rd-react-btn { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--bg-tertiary);
++ color: var(--text-primary); font: inherit; font-size: 0.9rem; cursor: pointer; }
++.rd-react-btn b { font-size: 0.8rem; }
++.rd-react-btn:hover { border-color: var(--border-light, var(--accent)); }
++.rd-react-btn.is-mine { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 22%, var(--bg-tertiary)); }
++
++/* Side boxes */
++.rd-trend { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; }
++.rd-trend li { display: flex; gap: 10px; align-items: flex-start; }
++.rd-trend li > div { min-width: 0; }
++.rd-trend-n { flex: none; width: 1.4em; font-size: 1.2rem; font-weight: 900; color: var(--text-muted); line-height: 1.4; }
++.rd-trend-meta { display: flex; align-items: center; gap: 8px; margin: 6px 0 0; font-size: 0.82rem; color: var(--text-secondary); }
++.rd-stack { display: inline-flex; }
++.rd-stack i { display: grid; place-items: center; width: 22px; height: 22px; margin-left: -6px; border-radius: 50%; overflow: hidden; border: 2px solid var(--bg-card);
++ background: color-mix(in srgb, var(--rd-c, #64748b) 35%, var(--bg-tertiary)); font-size: 0.7rem; font-style: normal; font-weight: 800; }
++.rd-stack i:first-child { margin-left: 0; }
++.rd-stack img { width: 100%; height: 100%; object-fit: cover; }
++.rd-voices { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; }
++.rd-voices li { display: flex; gap: 10px; align-items: flex-start; }
++.rd-voices li > div { min-width: 0; }
++.rd-voice-name { margin: 0 0 4px; }
++.rd-voice-name small { margin-left: 4px; color: var(--text-muted); font-size: 0.78rem; }
++.rd-about p { margin: 0 0 8px; color: var(--text-secondary); font-size: 0.9rem; line-height: 1.5; }
++
++/* Topic, episode and streamer pages */
++.rd-page { max-width: 820px; }
++.rd-page-head { margin: 8px 0 14px; }
++.rd-deck { margin: 0 0 8px; font-size: 1.15rem; line-height: 1.45; color: var(--text-primary); }
++.rd-meta { margin: 0; color: var(--text-secondary); font-size: 0.9rem; }
++.rd-actions { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0 0; }
++.rd-summary { margin: 0 0 8px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); }
++.rd-summary p { margin: 8px 0 0; line-height: 1.6; }
++.rd-recap p { margin: 0 0 12px; font-size: 1.05rem; line-height: 1.7; }
++.rd-ep-who { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
++.rd-ep-who .rd-by { margin: 2px 0 0; }
++.rd-ep-react { border-top: 0; padding-top: 0; }
++.rd-streamer-head { display: flex; align-items: center; gap: 16px; }
++.rd-streamer-head .rd-h1 { margin: 0 0 4px; }
++.rd-lines { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 12px; }
++.rd-line { margin: 0; padding: 14px; border-radius: 16px; background: var(--bg-card); border: 1px solid var(--border); display: flex; flex-direction: column; gap: 8px; }
++.rd-line blockquote { margin: 0; font-size: 1.05rem; font-style: italic; line-height: 1.45; }
++.rd-line figcaption { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: auto; }
++
++/* Topics index */
++.rd-filters { display: flex; flex-direction: column; gap: 10px; margin: 0 0 16px; }
++.rd-seg { display: inline-flex; align-self: flex-start; padding: 3px; border-radius: 999px; background: var(--bg-tertiary); border: 1px solid var(--border); }
++.rd-seg-btn { padding: 6px 14px; border-radius: 999px; font-weight: 700; font-size: 0.88rem; color: var(--text-secondary); text-decoration: none; }
++.rd-seg-btn.is-on { background: var(--bg-card); color: var(--text-primary); }
++.rd-topic-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 1fr)); gap: 14px; }
++.rd-topic-grid .rd-card { margin: 0; display: flex; flex-direction: column; gap: 8px; }
++.rd-topic-card .rd-chip { align-self: flex-start; }
++.rd-topic-card .rd-what { margin: 0; font-size: 0.94rem; color: var(--text-secondary); }
++
++@media (max-width: 960px) {
++ .rd-grid { grid-template-columns: minmax(0, 1fr); }
++ .rd-side { position: static; }
++}
++@media (max-width: 560px) {
++ .rd-card { padding: 12px 13px; }
++ .rd-take-who { display: none; }
++ .rd-h3 { font-size: 1.08rem; }
++ .rd-onair-item { min-width: 200px; }
++ .rd-streamer-head { align-items: flex-start; }
++}
ddiff --git a/public/features.json b/public/features.json
iindex 32daf970..0f861722 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -245,16 +245,7 @@
"regenerateDashStreamKey"
]
},
-- "arena": {
-- "css": [
-- "/css/features/arena.css",
-- "/css/features/arena-alert.css"
-- ],
-- "js": [
-- "/js/arena.js",
-- "/js/arena-alert.js"
-- ]
-- },
++ "rundown": {"css": ["/css/features/rundown.css"], "js": ["/js/rundown.js"]},
"recap": {
"css": [
"/css/recap.css"
@@@ -376,12 +367,7 @@
"recap"
]
},
-- {
-- "path": "^/arena",
-- "features": [
-- "arena"
-- ]
-- },
++ {"path": "^/rundown", "features": ["rundown"]}, {"path": "^/arena", "features": ["rundown"]},
{
"path": "^/documentation",
"features": [
ddiff --git a/public/index.html b/public/index.html
iindex dfaf51b0..a9d7764b 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -762,9 +762,9 @@
</div>
</section>
-- <section id="page-arena" class="page">
-- <div class="content-container arena-container">
-- <div id="arena-root">
++ <section id="page-rundown" class="page">
++ <div class="content-container rundown-container">
++ <div id="rundown-root">
<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
</div>
</div>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex fde552d8..89eca3f6 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -52,7 +52,7 @@ function isStaffUser(user = currentUser) {
}
// Reserved paths (not usernames)
--const RESERVED = new Set(['content', 'moments', 'search', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
++const RESERVED = new Set(['content', 'moments', 'search', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'rundown', 'recap']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
@@@ -581,7 +581,7 @@ function _ssoHint() {
// What the signed-in account watches/opens here shows up in its network-wide History and the
// "Recently used" rows of the shared navbar on every other site. Recorded after the route
// rendered and its title settled; never for guests, and only for the pages worth remembering.
--const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, arena: 'page' };
++const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, rundown: 'page' };
let _historyTimer = null;
function _recordHistory(pageId) {
const type = _HISTORY_PAGES[pageId];
@@@ -879,7 +879,7 @@ function dashNav(event) {
* which go through popstate → routeFromURL() and never touch navigate() — left everything
* running: the chat WebSocket still bound to the previous channel, the coin heartbeat still
* crediting watch time on a page with nothing playing, the canvas socket and its timer, the
-- * global AI poll, the arena and AI-viewer loops, and the broadcast desk's five polls. Every Back
++ * global AI poll, the AI-viewer loops, and the broadcast desk's five polls. Every Back
* press added another set. Now both paths go through here.
*/
function teardownRoute(nextPath) {
@@@ -924,7 +924,6 @@ function teardownRoute(nextPath) {
if (typeof _goalCycleTimer !== 'undefined' && _goalCycleTimer) { clearInterval(_goalCycleTimer); _goalCycleTimer = null; }
// A Back press with the clip creator open left its key handler, drag handlers and preview loop.
try { const cm = document.getElementById('vp-clip-modal'); if (cm && cm.style.display && cm.style.display !== 'none') stop('closeClipCreator'); } catch { /* */ }
-- stop('_aStopTimers');
stop('stopAiViewerActivity');
stop('stopPlayerStatsPoll');
// The broadcast desk's polls only matter while you are on it. Leaving them running elsewhere
@@@ -1126,8 +1125,10 @@ function routeFromURL() {
showPage('recap');
whenRouteReady('recap', () => { if (typeof loadRecapPage === 'function') loadRecapPage(segments[1]); });
} else if (segments[0] === 'arena') {
-- showPage('arena');
-- whenRouteReady('arena', () => { if (typeof loadArenaPage === 'function') loadArenaPage(segments); });
++ location.replace(location.pathname); // the server sends it on to The Rundown
++ } else if (segments[0] === 'rundown') {
++ showPage('rundown');
++ whenRouteReady('rundown', () => { if (typeof loadRundownPage === 'function') loadRundownPage(segments); });
} else if (segments[0] === 'updates') {
showPage('updates');
loadUpdatesPage();
@@@ -1295,7 +1296,7 @@ function showPage(page) {
document.body.style.overflow = '';
// Highlight nav link
-- const pageToNav = { home: 'home', content: 'content', moments: 'moments', search: 'search', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', 'paste-viewer': 'content', arena: 'arena' };
++ const pageToNav = { home: 'home', content: 'content', moments: 'moments', search: 'search', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', 'paste-viewer': 'content', rundown: 'rundown' };
const navPage = pageToNav[page];
if (navPage) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
ddiff --git a/public/js/footer-live.js b/public/js/footer-live.js
iindex f99ae940..3d549b3c 100644
---- a/public/js/footer-live.js
++++ b/public/js/footer-live.js
@@@ -11,7 +11,7 @@
*/
(function () {
'use strict';
-- const COMPACT_PAGES = ['channel', 'vod-player', 'clip-player', 'broadcast', 'chat', 'arena', 'recap'];
++ const COMPACT_PAGES = ['channel', 'vod-player', 'clip-player', 'broadcast', 'chat', 'rundown', 'recap'];
const LINKS = [
{
heading: 'Watch',
@@@ -19,7 +19,7 @@
{ name: 'Live streams', url: '/', onclick: "return handleLinkClick(event, '/')" },
{ name: 'VODs, clips & pastes', url: '/content', onclick: "return handleLinkClick(event, '/content')" },
{ name: 'AI Moments', url: '/moments', onclick: "return handleLinkClick(event, '/moments')" },
-- { name: 'The Arena', url: '/arena', onclick: "return handleLinkClick(event, '/arena')" },
++ { name: 'The Rundown', url: '/rundown', onclick: "return handleLinkClick(event, '/rundown')" },
{ name: 'Global chat', url: '/chat', onclick: "return handleLinkClick(event, '/chat')" },
],
},
ddiff --git a/public/js/guides-live.js b/public/js/guides-live.js
iindex b3f1a93e..9e50189f 100644
---- a/public/js/guides-live.js
++++ b/public/js/guides-live.js
@@@ -371,7 +371,7 @@
steps: [
tourStep('stream', 'fa-tower-broadcast', 'One stream, everywhere', `${ui.lead('Go live from your browser, OBS or a robot, and mirror the same stream to Twitch, YouTube, Kick, RobotStreamer or any RTMP server at once. Chat from every platform lands in one place.')}<ul class="ovg-tips"><li>Sub-second WebRTC latency for viewers here.</li><li>Every stream is recorded; clips and AI moments are cut for you.</li></ul>`),
tourStep('chat', 'fa-comments', 'Chat is the show', `${ui.lead('7TV, BTTV, FFZ and custom emotes; sound commands like <b>!boom</b> that play on stream; server-rendered TTS with collectible voices; auto-translation both ways for non-English streamers.')}<ul class="ovg-tips"><li>Type <b>.</b> before a message to keep it out of TTS.</li><li>Viewers earn OpenCoins for watching and chatting, plus channel points on each channel.</li></ul>`),
-- tourStep('arena', 'fa-microphone-lines', 'The Arena', `${ui.lead('Everything streamers say on mic is judged by AI: the best trash talk lands in the Arena, callouts open beefs, and levels rise with the mouth. Chat can <b>!hype</b> a fight but never write one.')}`),
++ tourStep('rundown', 'fa-newspaper', 'The Rundown', `${ui.lead('What streamers talk about, written up: topics, their takes in their own words, and every stream as an episode.')}`),
tourStep('after', 'fa-clipboard-list', 'After every stream', `${ui.lead('An after-show report with a grade, the viewer curve, the loudest chatters, mic moments and clips — plus a daily AI-picked Star of OpenVibe and a fresh daily secret to crack.')}`),
tourStep('you', 'fa-user-astronaut', 'Your turn', `${ui.lead(me() ? 'You have an account — the streamer setup walks you through everything in a few minutes.' : 'One free account works across the whole OpenVibe network. Sign in and the setup walks you through your first stream.')}`),
],
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex 003e0559..82c75ba9 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -25,7 +25,7 @@
{ id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
{ id: 'search', page: 'search', label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
-- { id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
++ { id: 'rundown', page: 'rundown', label: 'Rundown', href: '/rundown', icon: 'fa-newspaper' },
{ id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',
onClick: (e) => call('goLiveNav', e),
children: user ? [{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'fa-gauge-high', onClick: (e) => call('dashNav', e) }] : [] },
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
nnew file mode 100644
iindex 00000000..a3dc9140
---- /dev/null
++++ b/public/js/rundown.js
@@@ -0,0 +1,307 @@
++/**
++ * OpenVibe.Live — The Rundown: what streamers talked about, written up (server/rundown/, docs/rundown.md).
++ *
++ * Routes (loadRundownPage(segments) from app.js):
++ * /rundown the front page: on air now, the latest episodes and takes, trending topics, who's been talking
++ * /rundown/topics every topic
++ * /rundown/t/<slug> one topic and every take on it
++ * /rundown/e/<streamId> one stream written up as an episode
++ * /rundown/<username> a streamer's episodes, topics, lines and crossovers
++ *
++ * Everything shown comes from the desk, which reads streams' speech-to-text: a take is what a streamer said about a
++ * topic, a quote is their own words (checked against the transcript), the desk's quip is the desk's. All text goes
++ * through _rdEsc; every link through handleLinkClick.
++ */
++'use strict';
++
++const RD_KIND = {
++ news: ['News', '#f59e0b'], games: ['Games', '#a78bfa'], tech: ['Tech', '#22d3ee'], life: ['Life', '#34d399'], music: ['Music', '#f472b6'],
++ sports: ['Sports', '#fb923c'], money: ['Money', '#a3e635'], internet: ['Internet', '#60a5fa'], chat: ['Chat', '#94a3b8'], other: ['Other', '#94a3b8'],
++};
++const RD_REACT = [['fire', '🔥', 'Fire'], ['lol', '😂', 'Funny'], ['dead', '💀', 'I\'m dead'], ['cap', '🧢', 'Cap'], ['facts', '💯', 'Facts']];
++let _rdAudio = null;
++let _rdNext = null;
++
++// ── Helpers ──────────────────────────────────────────────────
++function _rdEsc(s) { return typeof esc === 'function' ? esc(String(s ?? '')) : String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
++function _rdA(href, inner, cls = '', label = '') { return `<a class="${cls}" href="${_rdEsc(href)}"${label ? ` aria-label="${_rdEsc(label)}"` : ''} onclick="return handleLinkClick(event, '${_rdEsc(href)}')">${inner}</a>`; }
++function _rdTime(d) { return new Date(String(d || '').includes('T') ? d : String(d || '').replace(' ', 'T') + 'Z'); }
++function _rdAgo(d) {
++ if (!d) return '';
++ const m = Math.round((Date.now() - _rdTime(d).getTime()) / 60000);
++ if (!Number.isFinite(m)) return '';
++ if (m < 1) return 'just now'; if (m < 60) return `${m}m ago`;
++ const h = Math.round(m / 60); if (h < 48) return `${h}h ago`;
++ return `${Math.round(h / 24)}d ago`;
++}
++function _rdDay(d) { try { return _rdTime(d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); } catch { return ''; } }
++function _rdStamp(sec) { sec = Math.max(0, Math.floor(sec || 0)); const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60; return (h ? `${h}:${String(m).padStart(2, '0')}` : `${m}`) + `:${String(s).padStart(2, '0')}`; }
++function _rdLength(min) { min = Number(min) || 0; return min >= 60 ? `${Math.floor(min / 60)}h ${String(min % 60).padStart(2, '0')}m` : `${min}m`; }
++function _rdName(u) { return u ? (u.display_name || u.username) : ''; }
++function _rdUserHref(u) { return `/rundown/${encodeURIComponent(u.username)}`; }
++function _rdChannelHref(u) { return typeof channelPath === 'function' ? channelPath(u.username) : `/@${encodeURIComponent(u.username)}`; }
++function _rdAv(u, size = 36) {
++ if (!u) return '';
++ const inner = u.avatar_url ? `<img src="${_rdEsc(u.avatar_url)}" alt="" loading="lazy" width="${size}" height="${size}">` : `<span>${_rdEsc(_rdName(u).trim().charAt(0).toUpperCase() || '?')}</span>`;
++ return _rdA(_rdUserHref(u), inner, 'rd-av', _rdName(u)).replace('class="rd-av"', `class="rd-av" style="--rd-av:${size}px;--rd-c:${_rdEsc(u.profile_color || '#64748b')}"`);
++}
++function _rdUser(u) { return u ? _rdA(_rdUserHref(u), _rdEsc(_rdName(u)), 'rd-user') : ''; }
++function _rdTopicChip(t, extra = '') {
++ if (!t) return '';
++ const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
++ return _rdA(`/rundown/t/${encodeURIComponent(t.slug)}`, `${_rdEsc(t.title)}${extra}`, 'rd-chip', `${label}: ${t.title}`).replace('class="rd-chip"', `class="rd-chip" style="--rd-k:${color}"`);
++}
++function _rdWatch(vodId, sec, label = 'Watch') { return vodId ? _rdA(`/vod/${vodId}?t=${Math.max(0, Math.floor(sec || 0))}`, `<i class="fa-solid fa-play" aria-hidden="true"></i> ${_rdEsc(label)}${sec ? ` <span class="rd-stamp">${_rdStamp(sec)}</span>` : ''}`, 'rd-watch') : ''; }
++function _rdParas(text) { return String(text || '').split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean).map((p) => `<p>${_rdEsc(p)}</p>`).join(''); }
++function _rdMe() { return (typeof currentUser !== 'undefined' && currentUser) ? currentUser : null; }
++function _rdEmpty(icon, text, action = '') { return `<div class="rd-empty"><i class="fa-solid ${icon}" aria-hidden="true"></i><p>${text}</p>${action}</div>`; }
++
++function _rdSpeakBtn(text, user) { return user ? `<button type="button" class="rd-speak" data-rd-speak="${_rdEsc(text)}" data-rd-voice="${_rdEsc(user.username)}" aria-label="Hear it in ${_rdEsc(_rdName(user))}'s chat voice"><i class="fa-solid fa-volume-high" aria-hidden="true"></i></button>` : ''; }
++function _rdSpeak(btn) {
++ if (_rdAudio) { _rdAudio.pause(); _rdAudio = null; document.querySelectorAll('.rd-speak.is-speaking').forEach((b) => b.classList.remove('is-speaking')); }
++ if (btn.classList.contains('was-speaking')) { btn.classList.remove('was-speaking'); return; }
++ const a = new Audio(`/api/rundown/voice/${encodeURIComponent(btn.dataset.rdVoice)}?t=${encodeURIComponent(btn.dataset.rdSpeak)}`);
++ _rdAudio = a;
++ btn.classList.add('is-speaking');
++ const done = () => { btn.classList.remove('is-speaking'); if (_rdAudio === a) _rdAudio = null; };
++ a.onended = done; a.onerror = done;
++ a.play().catch(done);
++}
++
++function _rdReactions(type, id, reactions = {}, mine = null) {
++ return `<div class="rd-react" role="group" aria-label="React">${RD_REACT.map(([k, icon, label]) => {
++ const n = Number(reactions[k] || 0);
++ return `<button type="button" class="rd-react-btn${mine === k ? ' is-mine' : ''}" data-rd-react="${type}:${id}:${k}" aria-pressed="${mine === k}" aria-label="${_rdEsc(label)}${n ? `, ${n}` : ''}"><span aria-hidden="true">${icon}</span>${n ? `<b>${n}</b>` : ''}</button>`;
++ }).join('')}</div>`;
++}
++
++// ── Cards ────────────────────────────────────────────────────
++function _rdQuote(q, user, vodId) {
++ return `<blockquote class="rd-quote"><p>“${_rdEsc(q.text)}”</p>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}
++ <footer>— ${_rdUser(user)} ${_rdSpeakBtn(q.text, user)} ${_rdWatch(vodId, q.sec, 'Hear it')}</footer></blockquote>`;
++}
++function _rdTake(s, { topic = true, user = true } = {}) {
++ const mentions = (s.mentions || []).map(_rdUser).join(', ');
++ return `<article class="rd-card rd-take" id="rd-take-${s.id}">
++ <div class="rd-card-top">${topic ? _rdTopicChip(s.topic) : ''}<time datetime="${_rdEsc(s.said_at)}">${_rdEsc(_rdAgo(s.said_at))}</time></div>
++ <div class="rd-take-body">
++ ${user ? `<div class="rd-take-who">${_rdAv(s.user, 40)}</div>` : ''}
++ <div class="rd-take-text">
++ <h3 class="rd-h3">${_rdEsc(s.title)}</h3>
++ ${user ? `<p class="rd-by">${_rdUser(s.user)}</p>` : ''}
++ <p class="rd-what">${_rdEsc(s.what)}</p>
++ ${s.take ? `<p class="rd-their"><span>Their take</span> ${_rdEsc(s.take)}</p>` : ''}
++ ${(s.quotes || []).map((q) => _rdQuote(q, s.user, s.vod_id)).join('')}
++ ${s.quip ? `<p class="rd-quip"><span class="rd-desk">The desk</span> ${_rdEsc(s.quip)}</p>` : ''}
++ ${mentions ? `<p class="rd-cross"><i class="fa-solid fa-arrows-left-right" aria-hidden="true"></i> Brought up ${mentions}</p>` : ''}
++ </div>
++ </div>
++ <div class="rd-card-foot">${_rdReactions('segment', s.id, s.reactions, s.my_reaction)}${_rdWatch(s.vod_id, s.sec)}</div>
++ </article>`;
++}
++function _rdEpisodeCard(e, { user = true } = {}) {
++ const href = `/rundown/e/${e.stream_id}`;
++ const chapters = (e.chapters || []).slice(0, 4);
++ return `<article class="rd-card rd-episode">
++ <div class="rd-card-top"><span class="rd-kicker">Episode</span><time datetime="${_rdEsc(e.aired_at)}">${_rdEsc(_rdDay(e.aired_at))}${e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</time></div>
++ <div class="rd-take-body">
++ ${user ? `<div class="rd-take-who">${_rdAv(e.user, 48)}</div>` : ''}
++ <div class="rd-take-text">
++ <h3 class="rd-h3 rd-ep-title">${_rdA(href, _rdEsc(e.title))}</h3>
++ ${user ? `<p class="rd-by">${_rdUser(e.user)}</p>` : ''}
++ ${e.logline ? `<p class="rd-logline">${_rdEsc(e.logline)}</p>` : ''}
++ ${chapters.length ? `<ol class="rd-chapters">${chapters.map((c) => `<li>${e.vod_id ? _rdA(`/vod/${e.vod_id}?t=${c.sec}`, `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${_rdEsc(c.title)}</li>`).join('')}</ol>` : ''}
++ ${(e.topics || []).length ? `<div class="rd-chips">${e.topics.slice(0, 5).map((t) => _rdTopicChip(t)).join('')}</div>` : ''}
++ </div>
++ </div>
++ <div class="rd-card-foot">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}${_rdA(href, 'Read the episode <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-read')}</div>
++ </article>`;
++}
++function _rdItem(x) { return x.type === 'episode' ? _rdEpisodeCard(x) : _rdTake(x); }
++
++// ── The front page ───────────────────────────────────────────
++function _rdMast(counts, active = 'latest') {
++ const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
++ const tally = counts ? [[counts.topics, 'topic'], [counts.takes, 'take'], [counts.episodes, 'episode']].filter(([n]) => n).map(([n, w]) => `${n} ${w}${n === 1 ? '' : 's'}`).join(' · ') : '';
++ const tab = (id, href, label) => _rdA(href, label, `rd-tab${active === id ? ' is-on' : ''}`).replace('<a ', `<a${active === id ? ' aria-current="page"' : ''} `);
++ return `<header class="rd-mast">
++ <div class="rd-mast-line"><span>${_rdEsc(today)}</span>${tally ? `<span>${_rdEsc(tally)}</span>` : ''}</div>
++ <h1 class="rd-title">The Rundown</h1>
++ <p class="rd-tagline">What streamers talked about, written up: the topics, their takes in their own words, and every stream as an episode.</p>
++ <nav class="rd-tabs" aria-label="The Rundown">${tab('latest', '/rundown', 'Latest')}${tab('topics', '/rundown/topics', 'Topics')}</nav>
++ </header>`;
++}
++function _rdOnAir(list) {
++ if (!list.length) return '';
++ return `<section class="rd-onair" aria-labelledby="rd-onair-h"><h2 id="rd-onair-h" class="rd-h2"><span class="rd-dot" aria-hidden="true"></span> On air now</h2>
++ <ul class="rd-onair-list">${list.map((x) => `<li>${_rdA(_rdChannelHref(x.user), `${x.user.avatar_url ? `<img src="${_rdEsc(x.user.avatar_url)}" alt="" width="32" height="32" loading="lazy">` : `<span class="rd-onair-i">${_rdEsc(_rdName(x.user).charAt(0).toUpperCase())}</span>`}<span class="rd-onair-t"><b>${_rdEsc(_rdName(x.user))}</b><small>${x.talking_about ? `talking about ${_rdEsc(x.talking_about)}` : _rdEsc(x.stream_title || 'live now')}</small></span>`, 'rd-onair-item')}</li>`).join('')}</ul></section>`;
++}
++function _rdTrending(list) {
++ return `<section class="rd-box" aria-labelledby="rd-trend-h"><h2 id="rd-trend-h" class="rd-h2">Trending topics <small>this week</small></h2>
++ ${list.length ? `<ol class="rd-trend">${list.map((t, i) => `<li><span class="rd-trend-n" aria-hidden="true">${i + 1}</span><div>${_rdTopicChip(t)}
++ <p class="rd-trend-meta"><span class="rd-stack" aria-hidden="true">${(t.people || []).map((p) => `<i style="--rd-c:${_rdEsc(p.profile_color || '#64748b')}">${p.avatar_url ? `<img src="${_rdEsc(p.avatar_url)}" alt="" width="20" height="20" loading="lazy">` : _rdEsc(_rdName(p).charAt(0).toUpperCase())}</i>`).join('')}</span>
++ ${t.streamers} streamer${t.streamers === 1 ? '' : 's'} · ${t.takes} take${t.takes === 1 ? '' : 's'}</p></div></li>`).join('')}</ol>` : '<p class="rd-quiet">No topics yet this week.</p>'}
++ ${_rdA('/rundown/topics', 'Every topic <i class="fa-solid fa-arrow-right" aria-hidden="true"></i>', 'rd-more-link')}
++ </section>`;
++}
++function _rdVoices(list) {
++ if (!list.length) return '';
++ return `<section class="rd-box" aria-labelledby="rd-voices-h"><h2 id="rd-voices-h" class="rd-h2">Who's been talking</h2>
++ <ul class="rd-voices">${list.map((v) => `<li>${_rdAv(v.user, 36)}<div><p class="rd-voice-name">${_rdUser(v.user)} <small>${_rdEsc(_rdAgo(v.last_seen))}</small></p>
++ ${(v.top_topics || []).length ? `<div class="rd-chips rd-chips-sm">${v.top_topics.map((t) => _rdTopicChip(t)).join('')}</div>` : ''}</div></li>`).join('')}</ul></section>`;
++}
++function _rdAbout() {
++ const me = _rdMe();
++ return `<section class="rd-box rd-about" aria-labelledby="rd-about-h"><h2 id="rd-about-h" class="rd-h2">How the desk works</h2>
++ <p>The desk reads what streamers say on stream (speech to text) and writes up what they talked about: the topic, their take, a line or two in their own words, and a quip of its own. Game sounds, videos and songs are never quoted as theirs. When a stream ends, it becomes an episode.</p>
++ ${me ? `<p>${_rdA(`/rundown/${encodeURIComponent(me.username)}`, 'Your page')} · <button type="button" class="rd-link" data-rd-optout="1">Keep me out of The Rundown</button></p>` : ''}
++ </section>`;
++}
++async function _rdHome(root) {
++ const d = await api('/rundown');
++ _rdNext = d.next;
++ const feed = d.latest || [];
++ root.innerHTML = `${_rdMast(d.counts, 'latest')}${_rdOnAir(d.on_air || [])}
++ <div class="rd-grid">
++ <section class="rd-main" aria-labelledby="rd-latest-h"><h2 id="rd-latest-h" class="rd-h2">The latest</h2>
++ <div id="rd-feed">${feed.length ? feed.map(_rdItem).join('') : _rdEmpty('fa-newspaper', 'The desk is reading this week\'s streams. The first takes and episodes show up here within minutes.')}</div>
++ ${_rdNext ? '<button type="button" class="btn btn-secondary rd-loadmore" data-rd-more="1">More</button>' : ''}
++ </section>
++ <aside class="rd-side">${_rdTrending(d.trending || [])}${_rdVoices(d.voices || [])}${_rdAbout()}</aside>
++ </div>`;
++ document.title = 'The Rundown · OpenVibe.Live';
++}
++async function _rdMore(btn) {
++ if (!_rdNext) return;
++ btn.disabled = true;
++ try {
++ const d = await api(`/rundown/latest?before=${encodeURIComponent(_rdNext)}`);
++ _rdNext = d.next;
++ document.getElementById('rd-feed')?.insertAdjacentHTML('beforeend', (d.items || []).map(_rdItem).join(''));
++ if (!_rdNext) btn.remove(); else btn.disabled = false;
++ } catch { btn.disabled = false; }
++}
++
++// ── Topics ───────────────────────────────────────────────────
++async function _rdTopics(root, query) {
++ const sort = query.get('sort') === 'most' ? 'most' : 'recent';
++ const kind = RD_KIND[query.get('kind')] ? query.get('kind') : '';
++ const d = await api(`/rundown/topics?sort=${sort}${kind ? `&kind=${kind}` : ''}`);
++ const href = (o) => { const p = new URLSearchParams({ ...(sort === 'most' ? { sort } : {}), ...(kind ? { kind } : {}), ...o }); for (const [k, v] of [...p]) if (!v) p.delete(k); const s = p.toString(); return `/rundown/topics${s ? `?${s}` : ''}`; };
++ root.innerHTML = `${_rdMast(null, 'topics')}
++ <div class="rd-filters">
++ <div class="rd-seg" role="group" aria-label="Sort">${_rdA(href({ sort: '' }), 'Most recent', `rd-seg-btn${sort === 'recent' ? ' is-on' : ''}`)}${_rdA(href({ sort: 'most' }), 'Most talked about', `rd-seg-btn${sort === 'most' ? ' is-on' : ''}`)}</div>
++ <div class="rd-chips" role="group" aria-label="Kind">${_rdA(href({ kind: '' }), 'All', `rd-chip${kind ? '' : ' is-on'}`)}${Object.entries(RD_KIND).map(([k, [label, color]]) => _rdA(href({ kind: k }), label, `rd-chip${kind === k ? ' is-on' : ''}`).replace('class="rd-chip', `style="--rd-k:${color}" class="rd-chip`)).join('')}</div>
++ </div>
++ ${d.topics.length ? `<div class="rd-topic-grid">${d.topics.map((t) => `<article class="rd-card rd-topic-card">
++ ${_rdTopicChip(t)}
++ ${t.headline ? `<h3 class="rd-h3">${_rdA(`/rundown/t/${encodeURIComponent(t.slug)}`, _rdEsc(t.headline))}</h3>` : ''}
++ ${t.summary ? `<p class="rd-what">${_rdEsc(t.summary)}</p>` : ''}
++ <p class="rd-trend-meta"><span class="rd-stack" aria-hidden="true">${(t.people || []).map((p) => `<i style="--rd-c:${_rdEsc(p.profile_color || '#64748b')}">${p.avatar_url ? `<img src="${_rdEsc(p.avatar_url)}" alt="" width="20" height="20" loading="lazy">` : _rdEsc(_rdName(p).charAt(0).toUpperCase())}</i>`).join('')}</span>
++ ${t.streamers} streamer${t.streamers === 1 ? '' : 's'} · ${t.takes} take${t.takes === 1 ? '' : 's'} · ${_rdEsc(_rdAgo(t.last_seen_at))}</p>
++ </article>`).join('')}</div>` : _rdEmpty('fa-comments', kind ? 'No topics of that kind yet.' : 'No topics yet: the desk is reading this week\'s streams.')}`;
++ document.title = 'Topics · The Rundown · OpenVibe.Live';
++}
++async function _rdTopic(root, slug) {
++ const d = await api(`/rundown/t/${encodeURIComponent(slug)}`);
++ const t = d.topic;
++ const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
++ root.innerHTML = `<article class="rd-page" style="--rd-k:${color}">
++ <p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')} / ${_rdA('/rundown/topics', 'Topics')}</p>
++ <header class="rd-page-head"><span class="rd-kicker rd-kicker-k">${_rdEsc(label)}</span><h1 class="rd-h1">${_rdEsc(t.title)}</h1>
++ ${t.headline ? `<p class="rd-deck">${_rdEsc(t.headline)}</p>` : ''}
++ <p class="rd-meta">${t.streamers} streamer${t.streamers === 1 ? '' : 's'} · ${t.takes} take${t.takes === 1 ? '' : 's'} · first came up ${_rdEsc(_rdDay(t.first_seen_at))}${t.last_seen_at !== t.first_seen_at ? `, last ${_rdEsc(_rdAgo(t.last_seen_at))}` : ''}</p></header>
++ ${t.summary ? `<div class="rd-summary"><span class="rd-desk">The desk</span>${_rdParas(t.summary)}</div>` : ''}
++ <h2 class="rd-h2">Every take</h2>
++ ${(d.takes || []).map((s) => _rdTake(s, { topic: false })).join('') || _rdEmpty('fa-comment', 'No takes on this topic right now.')}
++ ${(d.related || []).length ? `<h2 class="rd-h2">Came up alongside</h2><div class="rd-chips">${d.related.map((r) => _rdTopicChip(r)).join('')}</div>` : ''}
++ </article>`;
++ document.title = `${t.title} · The Rundown · OpenVibe.Live`;
++}
++
++// ── An episode ───────────────────────────────────────────────
++async function _rdEpisode(root, id) {
++ const d = await api(`/rundown/e/${encodeURIComponent(id)}`);
++ const e = d.episode, s = d.stream, u = s.user;
++ const title = e ? e.title : (s.is_live ? `${_rdName(u)} is live` : `${_rdName(u)}'s stream`);
++ root.innerHTML = `<article class="rd-page">
++ <p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')} / ${u ? _rdA(_rdUserHref(u), _rdEsc(_rdName(u))) : ''}</p>
++ <header class="rd-page-head rd-ep-head">
++ <div class="rd-ep-who">${_rdAv(u, 56)}<div><span class="rd-kicker">${s.is_live ? '<span class="rd-dot" aria-hidden="true"></span> On air now' : 'Episode'}</span><p class="rd-by">${_rdUser(u)} · ${_rdEsc(_rdDay(s.started_at))}${e && e.minutes ? ` · ${_rdLength(e.minutes)}` : ''}</p></div></div>
++ <h1 class="rd-h1">${_rdEsc(title)}</h1>
++ ${e && e.logline ? `<p class="rd-deck">${_rdEsc(e.logline)}</p>` : ''}
++ <p class="rd-actions">${e ? _rdWatch(e.vod_id, 0, 'Watch the stream') : ''}${s.is_live && u ? _rdA(_rdChannelHref(u), '<i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Watch live', 'rd-watch') : ''}</p>
++ </header>
++ ${e && e.recap ? `<div class="rd-recap">${_rdParas(e.recap)}</div>` : (s.is_live ? '<p class="rd-quiet">The episode is written when the stream ends. Here is what the desk has heard so far.</p>' : '')}
++ ${e && e.chapters.length ? `<section aria-labelledby="rd-ch-h"><h2 id="rd-ch-h" class="rd-h2">Chapters</h2><ol class="rd-chapters rd-chapters-big">${e.chapters.map((c) => `<li>${e.vod_id ? _rdA(`/vod/${e.vod_id}?t=${c.sec}`, `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`) : `<span class="rd-stamp">${_rdStamp(c.sec)}</span>`} ${c.segment_id ? `<a href="#rd-take-${c.segment_id}">${_rdEsc(c.title)}</a>` : _rdEsc(c.title)}</li>`).join('')}</ol></section>` : ''}
++ ${e ? `<div class="rd-card-foot rd-ep-react">${_rdReactions('episode', e.stream_id, e.reactions, e.my_reaction)}</div>` : ''}
++ <h2 class="rd-h2">What they talked about</h2>
++ ${(d.segments || []).map((x) => _rdTake(x, { user: false })).join('') || _rdEmpty('fa-microphone-lines', 'Nothing written up from this stream.')}
++ ${(d.more || []).length ? `<h2 class="rd-h2">More from ${_rdEsc(_rdName(u))}</h2>${d.more.map((x) => _rdEpisodeCard(x, { user: false })).join('')}` : ''}
++ </article>`;
++ document.title = `${title} · The Rundown · OpenVibe.Live`;
++}
++
++// ── A streamer ───────────────────────────────────────────────
++async function _rdStreamer(root, username) {
++ const d = await api(`/rundown/s/${encodeURIComponent(username)}`);
++ const u = d.user;
++ const mine = _rdMe() && _rdMe().username && _rdMe().username.toLowerCase() === u.username.toLowerCase();
++ const head = `<p class="rd-crumbs">${_rdA('/rundown', 'The Rundown')}</p>
++ <header class="rd-page-head rd-streamer-head">${_rdAv(u, 72)}<div><h1 class="rd-h1">${_rdEsc(_rdName(u))}</h1>
++ <p class="rd-meta">${d.opted_out ? 'Kept out of The Rundown' : `${d.episodes.length} episode${d.episodes.length === 1 ? '' : 's'} · ${d.topics.length} topic${d.topics.length === 1 ? '' : 's'}`} · ${_rdA(_rdChannelHref(u), 'Channel')}</p>
++ ${mine ? `<p><button type="button" class="rd-link" data-rd-optout="${d.opted_out ? '0' : '1'}">${d.opted_out ? 'Come back to The Rundown' : 'Keep me out of The Rundown'}</button></p>` : ''}</div></header>`;
++ if (d.opted_out) { root.innerHTML = `<article class="rd-page">${head}${_rdEmpty('fa-eye-slash', `${_rdEsc(_rdName(u))} keeps themselves out of The Rundown.`)}</article>`; return; }
++ root.innerHTML = `<article class="rd-page">${head}
++ ${d.topics.length ? `<section aria-labelledby="rd-st-t"><h2 id="rd-st-t" class="rd-h2">What they talk about</h2><div class="rd-chips">${d.topics.map((t) => _rdTopicChip(t, t.takes > 1 ? ` <b>${t.takes}</b>` : '')).join('')}</div></section>` : ''}
++ ${d.lines.length ? `<section aria-labelledby="rd-st-l"><h2 id="rd-st-l" class="rd-h2">Best lines</h2><div class="rd-lines">${d.lines.flatMap((s) => (s.quotes || []).slice(0, 1).map((q) => `<figure class="rd-line"><blockquote>“${_rdEsc(q.text)}”</blockquote>${q.en ? `<p class="rd-quote-en">${_rdEsc(q.en)}</p>` : ''}<figcaption>${_rdTopicChip(s.topic)} ${_rdSpeakBtn(q.text, u)} ${_rdWatch(s.vod_id, q.sec, 'Hear it')}</figcaption></figure>`)).join('')}</div></section>` : ''}
++ <section aria-labelledby="rd-st-e"><h2 id="rd-st-e" class="rd-h2">Episodes</h2>${d.episodes.map((e) => _rdEpisodeCard(e, { user: false })).join('') || _rdEmpty('fa-film', 'No episodes yet. Every stream with talk in it becomes one.')}</section>
++ ${d.crossovers.length ? `<section aria-labelledby="rd-st-c"><h2 id="rd-st-c" class="rd-h2">Crossovers</h2>${d.crossovers.map((s) => _rdTake(s)).join('')}</section>` : ''}
++ </article>`;
++ document.title = `${_rdName(u)} · The Rundown · OpenVibe.Live`;
++}
++
++// ── Router and events ────────────────────────────────────────
++async function loadRundownPage(segments = []) {
++ const root = document.getElementById('rundown-root');
++ if (!root) return;
++ const [, first, second] = segments;
++ try {
++ if (first === 'topics') return await _rdTopics(root, new URLSearchParams(location.search));
++ if (first === 't' && second) return await _rdTopic(root, decodeURIComponent(second));
++ if (first === 'e' && second) return await _rdEpisode(root, second);
++ if (first) return await _rdStreamer(root, decodeURIComponent(first));
++ return await _rdHome(root);
++ } catch (err) {
++ const missing = err && err.status === 404;
++ root.innerHTML = _rdEmpty(missing ? 'fa-magnifying-glass' : 'fa-plug-circle-xmark', _rdEsc(missing ? 'Nothing here. It may have been taken down, or it never existed.' : (err && err.message) || 'The Rundown could not load.'), _rdA('/rundown', 'Back to The Rundown', 'btn btn-primary'));
++ }
++}
++
++document.addEventListener('click', async (e) => {
++ const root = document.getElementById('rundown-root');
++ if (!root || !root.contains(e.target)) return;
++ const speak = e.target.closest('[data-rd-speak]');
++ if (speak) { e.preventDefault(); if (speak.classList.contains('is-speaking')) speak.classList.add('was-speaking'); _rdSpeak(speak); return; }
++ const more = e.target.closest('[data-rd-more]');
++ if (more) { e.preventDefault(); _rdMore(more); return; }
++ const react = e.target.closest('[data-rd-react]');
++ if (react) {
++ e.preventDefault();
++ const [type, id, reaction] = react.dataset.rdReact.split(':');
++ try {
++ const r = await api('/rundown/react', { method: 'POST', body: { type, id: Number(id), reaction } });
++ const group = react.closest('.rd-react');
++ if (group) group.outerHTML = _rdReactions(type, id, r.reactions, r.mine);
++ } catch (err) { if (typeof toast === 'function') toast(err.message || 'Could not react', 'error'); }
++ return;
++ }
++ const opt = e.target.closest('[data-rd-optout]');
++ if (opt) {
++ e.preventDefault();
++ const out = opt.dataset.rdOptout === '1';
++ if (out && !confirm('Keep yourself out of The Rundown? Everything written about your streams is hidden and the desk stops reading them. You can come back any time.')) return;
++ try { await api('/rundown/opt-out', { method: 'PUT', body: { out } }); if (typeof toast === 'function') toast(out ? 'You are out of The Rundown.' : 'Welcome back to The Rundown.', 'success'); loadRundownPage(location.pathname.split('/').filter(Boolean)); }
++ catch (err) { if (typeof toast === 'function') toast(err.message || 'That did not work', 'error'); }
++ }
++});
ddiff --git a/scripts/perf/check-budgets.js b/scripts/perf/check-budgets.js
iindex 7c1e04a9..2ca7827a 100644
---- a/scripts/perf/check-budgets.js
++++ b/scripts/perf/check-budgets.js
@@@ -29,7 +29,7 @@ const BUDGETS = {
};
// Code that must never be part of the home page's first load.
const FORBIDDEN_ON_HOME = ['/js/broadcast.js', '/js/broadcast-workspace.js', '/js/dashboard.js', '/js/call.js', '/js/voice-channels.js',
-- '/js/stream-player.js', '/js/app-channel.js', '/js/arena.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
++ '/js/stream-player.js', '/js/app-channel.js', '/js/rundown.js', '/js/pastes.js', '/css/features/broadcast.css', '/css/features/channel.css'];
// perf-budget's measure() fetches from a running server; this script stays offline, so it reads
// what the server would render for "/" and hands check() the same measurement shape.
ddiff --git a/scripts/perf/critical-css.js b/scripts/perf/critical-css.js
iindex 61a6026e..495f2bf7 100644
---- a/scripts/perf/critical-css.js
++++ b/scripts/perf/critical-css.js
@@@ -19,7 +19,7 @@ const opt = (name, def) => { const i = args.indexOf(name); return i === -1 ? def
const BASE = String(opt('--base', 'https://openvibe.live')).replace(/\/+$/, '');
const SETTLE = Number(opt('--settle', 9000));
// The public routes people land on from search, links and the home page (signed out).
--const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/arena', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];
++const ROUTES = ['/', '/content', '/vods', '/clips', '/moments', '/rundown', '/chat', '/search?q=game', '/@iZoR_One', '/vod/5717', '/clip/441', '/pastes', '/docs'];
const WIDTHS = [[412, 900, true], [1366, 900, false]];
async function coverage(url, [width, height, mobile]) {
ddiff --git a/server/arena/arena-chat.js b/server/arena/arena-chat.js
iindex 58ea5393..044e1682 100644
---- a/server/arena/arena-chat.js
++++ b/server/arena/arena-chat.js
@@@ -1,13 +1,12 @@
/**
-- * OpenVibe.Live — Arena chat commands (viewers watch along from any stream chat)
++ * OpenVibe.Live — The Rundown's chat commands (the Arena's three, which Chat still forwards to
++ * /internal/chat-effects/arena-command; the Arena became The Rundown on 2026-10-19).
*
-- * !hype hype the streamer in their newest open beef (one per person per side)
-- * !beef what beefs this streamer has open + clocks
-- * !arena [user] fighter card summary + link
++ * !arena [user] what the streamer (or user) has been talking about on The Rundown + a link
++ * !hype, !beef beefs are gone: a pointer to The Rundown
*
-- * Nothing in chat starts, feeds or decides anything — the Arena is pure mic. Called from
-- * ChatServer.handleBangCommand; returns true when the command was handled. Replies go to the
-- * sender as a system line; milestones are announced to the room.
++ * Called from ChatServer.handleBangCommand; returns true when the command was handled. Replies go to
++ * the sender as a system line.
*/
'use strict';
@@@ -29,62 +28,28 @@ function limited(map, key, ms) {
return false;
}
function base() { try { const c = require('../config'); return String(c.baseUrl || '').replace(/\/$/, ''); } catch { return ''; } }
--function clock(b) {
-- if (!b.on_clock || b.clock_seconds_left == null) return '';
-- const s = b.clock_seconds_left, who = (b.on_clock === 'a' ? b.a : b.b).fighter_name;
-- const t = s >= 3600 ? `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m` : `${Math.floor(s / 60)}m ${s % 60}s`;
-- return ` · ${who} on the clock (${t} to answer)`;
--}
--function beefLine(b) { return `${b.a.fighter_name} ${b.share_a}% — ${100 - b.share_a}% ${b.b.fighter_name}${clock(b)} → ${base()}/arena/beef/${b.id}`; }
async function handle(chat, ws, client, cmd, parts) {
if (!COMMANDS.includes(cmd)) return false;
const reply = (message) => chat.sendTo(ws, { type: 'system', message });
-- const room = (message) => client.streamId && chat.broadcastToStream(client.streamId, { type: 'system', message });
-- let arena, mic, beef;
-- try { arena = require('./arena-service'); mic = require('./mic'); beef = require('./beef'); } catch { reply('The Arena is closed right now.'); return true; }
-- if (!await arena.arenaEnabled()) { reply('The Arena is closed right now.'); return true; }
-- const key = voterKey(client);
-- if (limited(_last, key, RATE_MS)) { reply('Easy — one Arena command every few seconds.'); return true; }
-- const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
-- const streamer = stream ? await db.getUserById(stream.user_id) : null;
--
-- // A caller that answers once (Chat's /internal/chat-effects/arena-command) waits for the replies via chat.track.
++ if (limited(_last, voterKey(client), RATE_MS)) { reply('Easy — one command every few seconds.'); return true; }
const work = (async () => {
try {
-- if (cmd === '!hype') {
-- if (!streamer) return reply("!hype works inside a streamer's chat.");
-- const open = await beef.openBeefsFor(streamer.id);
-- if (!open.length) return reply(`${streamer.display_name || streamer.username} has no beef open. Another fighter only has to get called out on mic… ${base()}/arena`);
-- const b = open[0];
-- const side = b.a_user_id === streamer.id ? 'a' : 'b';
-- const r = await beef.hype(b.id, side, key);
-- const v = await beef.get(b.id);
-- if (!r.added) return reply(`You already hyped this beef. ${beefLine(v)}`);
-- reply(`🔥 Hyped ${streamer.display_name || streamer.username} in their beef. ${beefLine(v)}`);
-- const n = side === 'a' ? v.a.crowd : v.b.crowd;
-- if (n === 1 || n % 5 === 0) room(`🔥 Chat is hyping ${streamer.display_name || streamer.username}'s beef with ${(side === 'a' ? v.b : v.a).fighter_name} — ${n} so far. Type !hype to add yours. ${base()}/arena/beef/${v.id}`);
-- return;
-- }
-- if (cmd === '!beef') {
-- if (!streamer) return reply(`Open beefs → ${base()}/arena`);
-- const open = await Promise.all((await beef.openBeefsFor(streamer.id)).map(b => beef.get(b.id)));
-- if (!open.length) {
-- const lvl = await mic.levelView(streamer.id);
-- return reply(`${streamer.display_name || streamer.username} has no beef open right now (Trash Level ${lvl.level}). Another streamer only has to say their name on mic… ${base()}/arena`);
-- }
-- return reply(open.map(b => `🥊 ${b.headline || `${b.a.fighter_name} vs ${b.b.fighter_name}`}: ${beefLine(b)}`).join(' · '));
-- }
-- if (cmd === '!arena') {
-- const target = parts[1] ? await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')]) : streamer;
-- if (!target) return reply('Usage: !arena <username>');
-- const card = await arena.getFighter(target.id, { generate: false });
-- if (!card || card.not_on_roster) return reply(`${target.display_name || target.username} isn't on the Arena roster yet — it takes mic time on a transcribed stream.`);
-- const r = card.ratings;
-- return reply(`🥊 ${card.persona.fighter_name} — ${card.persona.title} · #${card.rank} of ${card.roster_size} · PWR ${r.power}${r.talk_bonus ? ` (+${r.talk_bonus} mouth)` : ''} · Trash Level ${card.level.level} · beefs ${card.record.wins}W–${card.record.losses}L · “${card.persona.taunt}” → ${base()}/arena/${encodeURIComponent(target.username)}`);
-- }
++ if (cmd !== '!arena') return reply(`Beefs are gone. The Rundown writes up what streamers talk about instead → ${base()}/rundown`);
++ const stream = client.streamId ? await db.getStreamById(client.streamId) : null;
++ const target = parts[1] ? await db.get('SELECT id, username, display_name FROM users WHERE lower(username) = lower(?)', [String(parts[1]).replace(/^@/, '')])
++ : (stream ? await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]) : null);
++ if (!target) return reply(parts[1] ? `Nobody called ${String(parts[1]).slice(0, 30)} here.` : `What streamers talked about, written up → ${base()}/rundown`);
++ const read = require('../rundown/read');
++ const page = await read.streamer(target.username);
++ const name = target.display_name || target.username;
++ if (!page || page.opted_out) return reply(`${name} keeps themselves out of The Rundown.`);
++ const ep = page.episodes[0];
++ const topics = page.topics.slice(0, 3).map((t) => t.title);
++ if (!ep && !topics.length) return reply(`Nothing on The Rundown about ${name} yet: it takes talk on a transcribed stream → ${base()}/rundown`);
++ return reply(`📰 ${name}${ep ? ` — latest episode: “${ep.title}”` : ''}${topics.length ? ` · talks about ${topics.join(', ')}` : ''} → ${base()}/rundown/${encodeURIComponent(target.username)}`);
} catch (err) {
-- reply(`Arena: ${err.message}`);
++ reply(`The Rundown: ${err.message}`);
}
})();
if (typeof chat.track === 'function') chat.track(work);
ddiff --git a/server/index.js b/server/index.js
iindex c1a8e7c8..f8590db6 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -770,7 +770,8 @@ app.use('/api/vods', searchMedia.afterChange('vod'), vodRoutes);
app.use('/api/clips', searchMedia.afterChange('clip'), clipRoutes);
app.use('/api/chat-ai', require('./ai/chat-ai-routes'));
app.use('/api/easter-egg', require('./ai/easter-egg-routes'));
--app.use('/api/arena', require('./arena/routes')); // streamer vs streamer (docs/arena.md)
++app.use('/api/arena', require('./arena/routes')); // the old Arena's reads, until its code is removed (docs/rundown.md)
++app.use('/api/rundown', require('./rundown/routes')); // what streamers talked about, written up (docs/rundown.md)
app.use('/api/recap', require('./recap/routes')); // after-show reports (docs/recap.md)
app.use('/api/social', require('./social/routes').router); // channel social links and their previews (server/social/)
app.get('/embed/x-timeline', require('./social/routes').xTimelinePage);
@@@ -1009,6 +1010,9 @@ app.get('/media/:username', (req, res) => {
// DM notifications sent before 2026-09-23 linked to /dm/<conversation id>, a page Live never had;
// the messenger opens the thread from /?dm=<id> now.
app.get(/^\/dm\/(\d{1,12})\/?$/, (req, res) => res.redirect(302, `/?dm=${req.params[0]}`));
++// The Arena became The Rundown (2026-10-19): a fighter's page is the streamer's Rundown page; every other Arena page
++// (drama stories, beefs, the ears) is gone, so it lands on the Rundown.
++app.get(/^\/arena(?:\/.*)?$/, (req, res) => res.redirect(301, require('./rundown/routes').arenaRedirect(req.path)));
// ── Legal Pages ───────────────────────────────────────────────
app.get('/dmca', (req, res) => {
@@@ -1418,7 +1422,8 @@ async function start() {
try { require('./ai/ai-moments-job').start(); } catch (e) { console.warn('[AI] moments job not started:', e.message); }
try { require('./ai/auto-clip-job').start(); } catch (e) { console.warn('[AI] auto-clip job not started:', e.message); }
try { require('./ai/easter-egg-job').start(); } catch (e) { console.warn('[AI] easter-egg job not started:', e.message); }
-- try { require('./arena/arena-job').start(); } catch (e) { console.warn('[Arena] job not started:', e.message); }
++ // The Rundown's desk replaced the Arena's jobs (the drama desk, the beef listener and judges, 2026-10-19).
++ try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
try { require('./recap/recap').start(); } catch (e) { console.warn('[Recap] job not started:', e.message); }
// Continuous audio → stream_timeline_events. Gated behind ai_timeline_enabled
// (default off), so starting it is a no-op until switched on in admin.
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
nnew file mode 100644
iindex 00000000..00d1d683
---- /dev/null
++++ b/server/rundown/desk.js
@@@ -0,0 +1,318 @@
++'use strict';
++/**
++ * The Rundown's desk (docs/rundown.md): what streamers talk about, written up.
++ *
++ * Every TICK_MS it reads streams' speech-to-text (stream_timeline_events, kind 'speech') a stretch at a time and asks
++ * OpenVibe.AI (`live.rundown.segment`) what the streamer talked about in it. Each answer becomes segments
++ * (rundown_segments): a topic, a headline, what they said, their take, the desk's quip and up to two of their own lines.
++ * A finished stream becomes an episode (`live.rundown.episode`: title, logline, recap, chapters); a topic that gathers
++ * takes gets its page rewritten (`live.rundown.topic`).
++ *
++ * A live stream is read once its unread talk holds READ_WORDS words or spans READ_SPAN_SEC seconds; an ended one,
++ * whatever is left (TAIL_WORDS or more), then its episode. Streams that ended within BACKFILL_DAYS are read too, newest
++ * first, so the page has the last week the day it ships.
++ * At most CALLS_PER_TICK AI calls a tick and CALLS_PER_HOUR an hour, only while AI is on and within budget.
++ *
++ * What is kept (server/rundown/filters.js): a game's announcer lines never reach the AI; a quote survives only when it
++ * is in the transcript word for word; a headline, account or take that uses an accusing word the transcript never used
++ * drops the segment (a quip, only the quip); every text passes db.noLooks(). Streamers in arena_opt_outs (the Arena's
++ * opt-out, kept for the Rundown) are never read. A streamer named in a segment is named by their whole username or
++ * display name in the segment's own lines (server/arena/names.js, wholeOnly), never a sound-alike.
++ */
++const db = require('../db/database');
++const filters = require('./filters');
++
++const TICK_MS = 5 * 60 * 1000;
++const READ_WORDS = 700;
++const READ_SPAN_SEC = 10 * 60;
++const MIN_WORDS = 150;
++const TAIL_WORDS = 120;
++const MAX_WORDS = 2400;
++const BACKFILL_DAYS = 7;
++const CALLS_PER_TICK = 4;
++const CALLS_PER_HOUR = 40;
++const KNOWN_TOPICS = 50;
++const ANNOUNCE_PER_HOUR = 3;
++const ANNOUNCE_WITHIN_H = 6;
++
++let _calls = [];
++let _announced = [];
++let _busy = false;
++let _job = null;
++
++const words = (t) => String(t || '').split(/\s+/).filter(Boolean).length;
++const parseJson = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
++const toMs = (t) => { const ms = Date.parse(String(t || '').replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(String(t || '')) ? '' : 'Z')); return Number.isFinite(ms) ? ms : null; };
++const sqlTime = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
++const daysAgo = (n) => sqlTime(Date.now() - n * 86400000);
++
++async function enabled() {
++ try { const v = await db.getSetting('rundown_enabled'); return v === null || v === undefined || v === true || v === 'true' || v === 1 || v === '1'; } catch { return true; }
++}
++async function aiOn() {
++ try { const llm = require('../ai/llm'); return await llm.isEnabled() && await llm.withinBudget(); } catch { return false; }
++}
++function budgetLeft() {
++ const now = Date.now();
++ _calls = _calls.filter((t) => now - t < 3600_000);
++ return _calls.length < CALLS_PER_HOUR;
++}
++async function ai(workflow, input, userId) {
++ _calls.push(Date.now());
++ return await require('../ai/ai-service').structured(workflow, input, { meter: { kind: workflow.replace(/^live\./, '').replace(/\./g, '_'), role: 'chat', ownerUserId: userId, source: 'rundown' } });
++}
++const clean = (t, n) => {
++ const s = String(t == null ? '' : t).replace(/\s+/g, ' ').trim();
++ const out = s ? (db.noLooks ? db.noLooks(s) : s) : '';
++ return String(out || '').trim().slice(0, n) || null;
++};
++
++// ── Who can be named ─────────────────────────────────────────
++let _people = { at: 0, entries: [] };
++/**
++ * Name entries for every streamer who streamed in the last 60 days. Only a whole name counts (server/arena/names.js),
++ * and never a name that is an everyday word or shorter than 5 letters: "you rubes" names nobody.
++ */
++async function nameEntries() {
++ if (Date.now() - _people.at < 10 * 60 * 1000) return _people.entries;
++ const rows = await db.all('SELECT u.id, u.username, u.display_name FROM users u WHERE u.id IN (SELECT DISTINCT user_id FROM streams WHERE started_at >= ?)', [daysAgo(60)]);
++ const names = require('../arena/names');
++ const roster = { order: rows.map((r) => r.id), byId: Object.fromEntries(rows.map((r) => [r.id, { user: r }])) };
++ const entries = names.rosterEntries(roster).filter((e) => e.whole && String(e.name).replace(/[^a-z0-9]/gi, '').length >= 5 && !names.COMMON.has(String(e.name).toLowerCase()));
++ _people = { at: Date.now(), entries };
++ return _people.entries;
++}
++async function mentionsIn(text, speakerId) {
++ const found = require('../arena/names').findMentions(text, await nameEntries(), { excludeUserId: speakerId, wholeOnly: true });
++ return [...new Set(found.map((m) => Number(m.userId)).filter(Boolean))];
++}
++
++// ── Topics ───────────────────────────────────────────────────
++async function knownTopics() {
++ return (await db.all("SELECT title FROM rundown_topics WHERE status = 'live' AND last_seen_at >= ? ORDER BY last_seen_at DESC LIMIT ?", [daysAgo(30), KNOWN_TOPICS])).map((r) => r.title);
++}
++/** The topic for a name: the same slug, else a recent topic with most of the same key words, else a new one. */
++async function topicFor(title, kind, at) {
++ const slug = filters.slugify(title);
++ if (!slug) return null;
++ let row = await db.get('SELECT id, title FROM rundown_topics WHERE slug = ?', [slug]);
++ if (!row) {
++ const recent = await db.all('SELECT id, title FROM rundown_topics WHERE last_seen_at >= ? ORDER BY last_seen_at DESC LIMIT 300', [daysAgo(60)]);
++ row = recent.find((t) => filters.sameTopic(t.title, title)) || null;
++ }
++ if (!row) {
++ row = await db.get(`INSERT INTO rundown_topics (slug, title, kind, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT (slug) DO UPDATE SET last_seen_at = rundown_topics.last_seen_at RETURNING id, title`, [slug, String(title).slice(0, 120), kind || 'other', at, at]);
++ }
++ return row;
++}
++async function recountTopic(topicId) {
++ await db.run(`UPDATE rundown_topics SET
++ takes = (SELECT COUNT(*) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
++ streamers = (SELECT COUNT(DISTINCT user_id) FROM rundown_segments WHERE topic_id = ? AND status = 'live'),
++ last_seen_at = COALESCE((SELECT MAX(said_at) FROM rundown_segments WHERE topic_id = ? AND status = 'live'), last_seen_at)
++ WHERE id = ?`, [topicId, topicId, topicId, topicId]);
++}
++
++// ── Reading a stream ─────────────────────────────────────────
++async function optedOut(userId) { try { return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [userId]); } catch { return false; } }
++
++/** Streams with unread speech or a missing episode: live ones first, then the newest ended ones. */
++async function candidates() {
++ return await db.all(`SELECT s.id, s.user_id, s.title, s.category, s.is_live, s.started_at, s.ended_at, s.peak_viewers, s.duration_seconds,
++ COALESCE(p.read_to_sec, -1) AS read_to_sec
++ FROM streams s LEFT JOIN rundown_progress p ON p.stream_id = s.id
++ WHERE COALESCE(p.done, 0) = 0 AND (s.is_live = 1 OR s.ended_at >= ? OR s.started_at >= ?)
++ AND EXISTS (SELECT 1 FROM stream_timeline_events e WHERE e.stream_id = s.id AND e.kind = 'speech')
++ ORDER BY s.is_live DESC, COALESCE(s.ended_at, s.started_at) DESC
++ LIMIT 60`, [daysAgo(BACKFILL_DAYS), daysAgo(BACKFILL_DAYS)]);
++}
++async function setProgress(streamId, readTo, done = 0) {
++ await db.run(`INSERT INTO rundown_progress (stream_id, read_to_sec, done, updated_at) VALUES (?, ?, ?, ov_now())
++ ON CONFLICT (stream_id) DO UPDATE SET read_to_sec = GREATEST(rundown_progress.read_to_sec, excluded.read_to_sec), done = excluded.done, updated_at = ov_now()`, [streamId, readTo, done]);
++}
++
++/** The next stretch: speech lines after read_to_sec, game lines out, at most MAX_WORDS. */
++async function nextStretch(stream) {
++ const rows = await db.all(`SELECT start_sec, text, vod_id FROM stream_timeline_events
++ WHERE stream_id = ? AND kind = 'speech' AND start_sec > ? ORDER BY start_sec ASC LIMIT 800`, [stream.id, stream.read_to_sec]);
++ const lines = [];
++ let n = 0, lastSec = Number(stream.read_to_sec);
++ for (const r of rows) {
++ const text = String(r.text || '').replace(/\s+/g, ' ').trim();
++ const w = words(text);
++ if (text && !filters.gameLine(text)) {
++ if (n + w > MAX_WORDS && lines.length) break;
++ lines.push({ t: Math.round(Number(r.start_sec) * 10) / 10, text: text.slice(0, 1000), vod_id: r.vod_id });
++ n += w;
++ }
++ lastSec = Number(r.start_sec); // read up to here (a game line or an empty one is read too)
++ }
++ return { lines, words: n, lastSec, unread: rows.length };
++}
++
++/** Is this stretch ready to read now? A live stream waits for enough talk; an ended one is read to the end. */
++function ready(stream, stretch) {
++ if (!stretch.lines.length) return false;
++ if (!Number(stream.is_live)) return stretch.words >= TAIL_WORDS;
++ const span = stretch.lines[stretch.lines.length - 1].t - stretch.lines[0].t;
++ return stretch.words >= MAX_WORDS * 0.9 || stretch.words >= READ_WORDS || (span >= READ_SPAN_SEC && stretch.words >= MIN_WORDS);
++}
++
++/** Store the AI's segments for one stretch. Returns the number kept. */
++async function keepSegments(stream, stretch, segments, user) {
++ const source = stretch.lines.map((l) => l.text).join('\n');
++ const startedMs = toMs(stream.started_at) || Date.now();
++ let kept = 0;
++ for (const s of segments || []) {
++ const title = clean(s.title, 90), what = clean(s.what, 500), take = clean(s.take, 300);
++ let quip = clean(s.quip, 220);
++ if (!title || !what || !s.topic) continue;
++ // An accusation the transcript never made is the desk's invention: the segment goes (its quip alone, the quip).
++ if (filters.addsAccusation(`${title} ${what} ${take || ''}`, source)) continue;
++ if (quip && filters.addsAccusation(quip, source)) quip = null;
++ const span = stretch.lines.filter((l) => l.t >= Number(s.start) - 1 && l.t <= Number(s.end) + 1);
++ const spanText = (span.length ? span : stretch.lines).map((l) => l.text).join('\n');
++ const quotes = (s.quotes || []).map((q) => {
++ const text = filters.verifiedQuote(q.text, source);
++ return text ? { text, sec: Number(q.t) || Number(s.start) || 0, en: q.en ? clean(q.en, 400) : null } : null;
++ }).filter(Boolean).slice(0, 2);
++ const at = sqlTime(startedMs + (Number(s.start) || 0) * 1000);
++ const topic = await topicFor(clean(s.topic, 120), s.kind, at);
++ const vod = (span.find((l) => l.vod_id) || stretch.lines.find((l) => l.vod_id) || {}).vod_id || null;
++ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
++ [stream.id, user.id, topic ? topic.id : null, Number(s.start) || 0, Number(s.end) || Number(s.start) || 0, title, what, take, quip,
++ Math.min(5, Math.max(1, Number(s.score) || 1)), JSON.stringify(quotes), await mentionsIn(spanText, user.id), vod, at]);
++ if (topic) await recountTopic(topic.id);
++ kept++;
++ }
++ return kept;
++}
++
++async function readStretch(stream, user) {
++ const stretch = await nextStretch(stream);
++ if (!stretch.unread) return { called: false };
++ if (!ready(stream, stretch)) {
++ // An ended stream's last few words, or nothing but game lines: nothing to read; move past them.
++ if (!Number(stream.is_live) || (!stretch.lines.length && stretch.unread)) await setProgress(stream.id, stretch.lastSec);
++ return { called: false };
++ }
++ const earlier = (await db.all('SELECT title FROM rundown_segments WHERE stream_id = ? ORDER BY start_sec DESC LIMIT 6', [stream.id])).map((r) => r.title).reverse().join('; ');
++ const out = await ai('live.rundown.segment', {
++ streamer: user.display_name || user.username, title: String(stream.title || '').slice(0, 200), category: String(stream.category || '').slice(0, 80),
++ topics: await knownTopics(), earlier: earlier.slice(0, 800), lines: stretch.lines.map(({ t, text }) => ({ t, text })),
++ }, user.id);
++ if (!out || !Array.isArray(out.segments)) return { called: true, failed: true };
++ const kept = await keepSegments(stream, stretch, out.segments, user);
++ await setProgress(stream.id, stretch.lastSec);
++ return { called: true, kept };
++}
++
++// ── Episodes ─────────────────────────────────────────────────
++async function writeEpisode(stream, user) {
++ const segs = await db.all("SELECT id, start_sec, title, what, take, quotes_json, vod_id, topic_id FROM rundown_segments WHERE stream_id = ? AND status = 'live' ORDER BY start_sec ASC LIMIT 40", [stream.id]);
++ if (!segs.length) { await setProgress(stream.id, stream.read_to_sec, 1); return { called: false }; }
++ const topics = new Map((await db.all('SELECT id, title FROM rundown_topics WHERE id = ANY(?)', [[...new Set(segs.map((s) => s.topic_id).filter(Boolean))]])).map((t) => [t.id, t.title]));
++ let chat = null;
++ const minutes = Math.max(0, Math.round((Number(stream.duration_seconds) || ((toMs(stream.ended_at) || Date.now()) - (toMs(stream.started_at) || Date.now())) / 1000) / 60));
++ const out = await ai('live.rundown.episode', {
++ streamer: user.display_name || user.username, title: String(stream.title || '').slice(0, 200), category: String(stream.category || '').slice(0, 80), minutes,
++ peak_viewers: Number.isFinite(Number(stream.peak_viewers)) ? Number(stream.peak_viewers) : null, chat_messages: Number.isFinite(Number(chat)) ? Number(chat) : null,
++ segments: segs.map((s) => ({ t: Math.round(Number(s.start_sec)), topic: topics.get(s.topic_id) || undefined, title: s.title, what: s.what, take: s.take || null, quotes: parseJson(s.quotes_json, []).map((q) => q.en || q.text).slice(0, 2) })),
++ }, user.id);
++ if (!out || !out.title || !out.recap) return { called: true, failed: true };
++ const source = segs.map((s) => `${s.title} ${s.what} ${s.take || ''} ${parseJson(s.quotes_json, []).map((q) => q.text).join(' ')}`).join('\n');
++ const recap = filters.addsAccusation(out.recap, source) ? null : clean(out.recap.replace(/\n\s*\n/g, ' ¶ '), 1400);
++ const title = clean(out.title, 80);
++ if (!title || filters.addsAccusation(`${out.title} ${out.logline || ''}`, source)) { await setProgress(stream.id, stream.read_to_sec, 1); return { called: true, failed: true }; }
++ const chapters = (out.chapters || []).map((c) => segs[c.index] ? { sec: Math.round(Number(segs[c.index].start_sec)), title: clean(c.title, 60), segment_id: segs[c.index].id } : null).filter((c) => c && c.title);
++ const vod = (segs.find((s) => s.vod_id) || {}).vod_id || null;
++ await db.run(`INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (stream_id) DO UPDATE SET title = excluded.title, logline = excluded.logline, recap = excluded.recap,
++ chapters_json = excluded.chapters_json, vod_id = excluded.vod_id, minutes = excluded.minutes`,
++ [stream.id, user.id, title, clean(out.logline, 220), recap ? recap.replace(/ ¶ /g, '\n\n') : null, JSON.stringify(chapters), vod, minutes, stream.started_at || sqlTime(Date.now())]);
++ await setProgress(stream.id, stream.read_to_sec, 1);
++ await announce(stream, user, title).catch(() => {});
++ return { called: true, episode: true };
++}
++
++/** A fresh episode (its stream ended within ANNOUNCE_WITHIN_H hours) goes to global chat, at most ANNOUNCE_PER_HOUR an hour. */
++async function announce(stream, user, title) {
++ const ended = toMs(stream.ended_at);
++ if (!ended || Date.now() - ended > ANNOUNCE_WITHIN_H * 3600_000) return false;
++ const now = Date.now();
++ _announced = _announced.filter((t) => now - t < 3600_000);
++ if (_announced.length >= ANNOUNCE_PER_HOUR) return false;
++ _announced.push(now);
++ let base = 'https://openvibe.live';
++ try { base = String(require('../config').baseUrl || base).replace(/\/$/, ''); } catch { /* */ }
++ const url = `${base}/rundown/e/${stream.id}`;
++ await require('../chat/chat-delivery').message({ username: 'THE RUNDOWN', user_id: null, message: `📰 ${user.display_name || user.username}: “${title}” → ${url}`.slice(0, 480), message_type: 'system', key: `rundown:episode:${stream.id}`, metadata: { rundown_episode: stream.id, url }, is_global: true });
++ return true;
++}
++
++// ── Topic pages ──────────────────────────────────────────────
++async function refreshTopic(topicId) {
++ const topic = await db.get('SELECT id, title FROM rundown_topics WHERE id = ?', [topicId]);
++ if (!topic) return { called: false };
++ const takes = await db.all(`SELECT s.what, s.take, s.quotes_json, s.said_at, u.username, u.display_name FROM rundown_segments s JOIN users u ON u.id = s.user_id
++ WHERE s.topic_id = ? AND s.status = 'live' ORDER BY s.said_at ASC LIMIT 30`, [topicId]);
++ if (!takes.length) return { called: false };
++ const out = await ai('live.rundown.topic', { topic: topic.title, takes: takes.map((t) => ({ streamer: t.display_name || t.username, when: String(t.said_at).slice(0, 10), what: t.what, take: t.take || null, quote: (parseJson(t.quotes_json, [])[0] || {}).text || null })) }, null);
++ const source = takes.map((t) => `${t.what} ${t.take || ''} ${parseJson(t.quotes_json, []).map((q) => q.text).join(' ')}`).join('\n');
++ if (!out || !out.summary || filters.addsAccusation(`${out.headline || ''} ${out.summary}`, source)) {
++ await db.run('UPDATE rundown_topics SET summary_takes = ?, summary_at = ov_now() WHERE id = ?', [takes.length, topicId]);
++ return { called: true, failed: true };
++ }
++ await db.run('UPDATE rundown_topics SET headline = ?, summary = ?, summary_takes = ?, summary_at = ov_now() WHERE id = ?', [clean(out.headline, 100), clean(out.summary, 700), takes.length, topicId]);
++ return { called: true };
++}
++/** Topics with new takes since their page was written: two or more takes, the busiest first. */
++async function staleTopics(limit) {
++ return await db.all(`SELECT id FROM rundown_topics WHERE status = 'live' AND takes >= 2 AND takes > summary_takes
++ AND (summary_at IS NULL OR summary_at <= ?) ORDER BY streamers DESC, takes DESC, last_seen_at DESC LIMIT ?`, [sqlTime(Date.now() - 20 * 60 * 1000), limit]);
++}
++
++// ── The tick ─────────────────────────────────────────────────
++async function tick() {
++ if (_busy || !await enabled() || !await aiOn()) return { skipped: true };
++ _busy = true;
++ const done = { segments: 0, episodes: 0, topics: 0, calls: 0 };
++ try {
++ let calls = 0;
++ for (const stream of await candidates()) {
++ if (calls >= CALLS_PER_TICK || !budgetLeft()) break;
++ const user = await db.get('SELECT id, username, display_name FROM users WHERE id = ?', [stream.user_id]);
++ if (!user || await optedOut(user.id)) { await setProgress(stream.id, stream.read_to_sec, 1); continue; }
++ const r = await readStretch(stream, user);
++ if (r.called) { calls++; done.segments += r.kept || 0; continue; }
++ // Read to the end and over: the episode.
++ if (!Number(stream.is_live)) {
++ const fresh = await db.get('SELECT read_to_sec FROM rundown_progress WHERE stream_id = ?', [stream.id]);
++ const left = await db.get("SELECT COUNT(*) AS n FROM stream_timeline_events WHERE stream_id = ? AND kind = 'speech' AND start_sec > ?", [stream.id, fresh ? fresh.read_to_sec : -1]);
++ if (!Number(left && left.n)) {
++ const e = await writeEpisode({ ...stream, read_to_sec: fresh ? fresh.read_to_sec : stream.read_to_sec }, user);
++ if (e.called) { calls++; if (e.episode) done.episodes++; }
++ }
++ }
++ }
++ for (const t of await staleTopics(Math.max(0, CALLS_PER_TICK - calls))) {
++ if (!budgetLeft()) break;
++ const r = await refreshTopic(t.id);
++ if (r.called) { calls++; if (!r.failed) done.topics++; }
++ }
++ done.calls = calls;
++ } finally { _busy = false; }
++ if (done.segments || done.episodes || done.topics) console.log(`[Rundown] ${done.segments} segment(s), ${done.episodes} episode(s), ${done.topics} topic page(s) from ${done.calls} AI call(s)`);
++ return done;
++}
++
++function start() {
++ if (_job) return;
++ _job = require('../utils/jobs').every('rundown-desk', TICK_MS, () => tick().catch((e) => console.warn('[Rundown] desk:', e.message)), { initialDelayMs: 60_000, jitterMs: 20_000 });
++ console.log(`[Rundown] desk started (every ${TICK_MS / 60000} min; at most ${CALLS_PER_TICK} AI calls a tick, ${CALLS_PER_HOUR} an hour)`);
++}
++function stop() { if (typeof _job === 'function') _job(); _job = null; }
++
++module.exports = { start, stop, tick, readStretch, writeEpisode, refreshTopic, keepSegments, topicFor, nextStretch, ready, optedOut, _test: { reset() { _calls = []; _announced = []; _people = { at: 0, entries: [] }; } } };
ddiff --git a/server/rundown/filters.js b/server/rundown/filters.js
nnew file mode 100644
iindex 00000000..0b7362bc
---- /dev/null
++++ b/server/rundown/filters.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * The Rundown's checks on what the desk keeps (server/rundown/desk.js).
++ *
++ * Speech-to-text hears a stream's whole audio track: a game's announcer and characters, videos, songs and articles read
++ * aloud sit beside the streamer's own voice. On 2026-10-18 the Arena's "quote of the day" was League of Legends'
++ * announcer ("You have slain an enemy"), credited to iZoR_One. So:
++ * gameLine(text) a line a game says, never a streamer's own words (also dropped before the AI reads them)
++ * verifiedQuote(q, text) a quote is kept only when it is in the transcript the AI read, word for word, and is no game line
++ * addsAccusation(t, src) an AI line may use an accusing word (threat, attack, scam…) only when the transcript did
++ * slugify / sameTopic topic identity
++ */
++
++// Announcer and system lines of the games streamed here (League of Legends, Counter-Strike, Valorant), as speech-to-text
++// writes them. Whole-phrase patterns only: a streamer saying "first blood" in a sentence about the game still is the
++// streamer, but these lines are exactly what the game says.
++const GAME_LINES = [
++ /^(?:an? )?(?:enemy|ally) (?:has been slain|double kill|triple kill|quadra kill|penta kill)\.?$/i,
++ /\byou have (?:slain an? enemy|been slain)\b/i,
++ /\b(?:an )?(?:enemy|ally) has been slain\b/i,
++ /^(?:first blood|double kill|triple kill|quadra kill|penta ?kill|killing spree|rampage|unstoppable|dominating|godlike|legendary|ace|shut ?down|victory|defeat)[.!]?$/i,
++ /\bwelcome to summoner'?s rift\b/i,
++ /\b(?:thirty seconds until minions spawn|minions have spawned)\b/i,
++ /\b(?:your|the enemy|our) team has (?:destroyed|taken|slain) (?:a |an |the )?(?:turret|inhibitor|dragon|baron|herald|nexus)\b/i,
++ /\b(?:an )?inhibitor (?:is respawning|has been destroyed)\b/i,
++ /\b(?:your|the enemy) (?:turret|inhibitor) has been destroyed\b/i,
++ /^(?:counter[- ]?)?terrorists win[.!]?$/i,
++ /\bthe bomb has been (?:planted|defused)\b/i,
++ /^spike (?:planted|defused)[.!]?$/i,
++];
++function gameLine(text) {
++ const t = String(text || '').trim();
++ return !!t && GAME_LINES.some((re) => re.test(t));
++}
++
++const norm = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
++
++/** The quote, trimmed, when it is in `source` (the lines the AI read) word for word and no game line; else null. */
++function verifiedQuote(quote, source) {
++ const q = String(quote || '').replace(/\s+/g, ' ').trim().replace(/^["“”'‘’]+|["“”'‘’]+$/g, '').trim();
++ const nq = norm(q);
++ if (nq.split(' ').filter(Boolean).length < 3 || gameLine(q)) return null;
++ return norm(source).includes(nq) ? q.slice(0, 400) : null;
++}
++
++// Words that accuse a real person of something (the list the Arena's desk used after it rewrote a beef built on a game's
++// voice lines as "DEATH THREATS", 2026-10-18).
++const ACCUSING = /\b(threat\w*|death|deadly|kill\w*|murder\w*|abus\w*|assault\w*|attack\w*|violen\w*|harass\w*|stalk\w*|racis\w*|nazi\w*|crim(?:e|es|inal\w*)|illegal\w*|weapon\w*|groom\w*|predator\w*|pedo\w*|doxx?\w*|rap(?:e|es|ed|ist\w*)|scam\w*|fraud\w*|cheat\w*|steal\w*|stole\w*|hack(?:ed|er|ers|ing)?)\b/gi;
++/** Does `text` use an accusing word whose root (its first four letters: hackers ~ hacked) `source` never used? */
++function addsAccusation(text, source) {
++ const root = (w) => w.toLowerCase().slice(0, 4);
++ const have = new Set((String(source || '').match(ACCUSING) || []).map(root));
++ return (String(text || '').match(ACCUSING) || []).some((w) => !have.has(root(w)));
++}
++
++function slugify(title) {
++ return norm(title).replace(/ /g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 80).replace(/-$/, '');
++}
++
++const STOP = new Set(['the', 'and', 'for', 'with', 'about', 'from', 'that', 'this', 'their', 'into', 'over', 'what', 'who', 'how', 'why', 'are', 'was', 'his', 'her', 'its', 'new', 'old', 'vs']);
++const keyWords = (t) => new Set(norm(t).split(' ').filter((w) => w.length >= 3 && !STOP.has(w)));
++/** Two topic names for one subject: most of their key words shared ("League ranked matchmaking" ~ "Ranked matchmaking in League"). */
++function sameTopic(a, b) {
++ const x = keyWords(a), y = keyWords(b);
++ if (!x.size || !y.size) return false;
++ let both = 0;
++ for (const w of x) if (y.has(w)) both++;
++ return both / (x.size + y.size - both) >= 0.6;
++}
++
++module.exports = { gameLine, verifiedQuote, addsAccusation, slugify, sameTopic, norm, GAME_LINES };
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
nnew file mode 100644
iindex 00000000..f92517ef
---- /dev/null
++++ b/server/rundown/read.js
@@@ -0,0 +1,221 @@
++'use strict';
++/**
++ * What The Rundown's pages read (server/rundown/routes.js, docs/rundown.md). Rows come out through allow-listed shapes:
++ * a person is { id, username, display_name, avatar_url, profile_color }, never a users row.
++ *
++ * home({ before, viewer }) on air now, the latest (episodes and the best segments), trending topics, who's been talking
++ * topics({ sort, kind }) every topic
++ * topic(slug) one topic and every take on it
++ * episode(streamId) one stream written up, with its segments and the streamer's other episodes
++ * streamer(username) their episodes, topics, lines and crossovers
++ * react(type, id, voter, r) one reaction per reader per item (a reader is their account, else a salted address hash)
++ */
++const db = require('../db/database');
++
++const REACTIONS = ['fire', 'lol', 'dead', 'cap', 'facts'];
++const FEED_MIN_SCORE = 3;
++const parseJson = (s, d) => { try { return JSON.parse(s); } catch { return d; } };
++const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString().slice(0, 19).replace('T', ' ');
++
++function person(u) {
++ return u ? { id: Number(u.id), username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null } : null;
++}
++async function people(ids) {
++ const list = [...new Set(ids.map(Number).filter(Boolean))];
++ if (!list.length) return new Map();
++ const rows = await db.all('SELECT id, username, display_name, avatar_url, profile_color FROM users WHERE id = ANY(?)', [list]);
++ return new Map(rows.map((r) => [Number(r.id), person(r)]));
++}
++async function topicsById(ids) {
++ const list = [...new Set(ids.map(Number).filter(Boolean))];
++ if (!list.length) return new Map();
++ const rows = await db.all("SELECT id, slug, title, kind FROM rundown_topics WHERE id = ANY(?) AND status = 'live'", [list]);
++ return new Map(rows.map((r) => [Number(r.id), { slug: r.slug, title: r.title, kind: r.kind }]));
++}
++async function reactionsFor(type, ids, voter) {
++ const list = [...new Set(ids.map(Number).filter(Boolean))];
++ const counts = new Map(), mine = new Map();
++ if (!list.length) return { counts, mine };
++ for (const r of await db.all('SELECT item_id, reaction, COUNT(*) AS n FROM rundown_reactions WHERE item_type = ? AND item_id = ANY(?) GROUP BY item_id, reaction', [type, list])) {
++ const c = counts.get(Number(r.item_id)) || {};
++ c[r.reaction] = Number(r.n);
++ counts.set(Number(r.item_id), c);
++ }
++ if (voter) for (const r of await db.all('SELECT item_id, reaction FROM rundown_reactions WHERE item_type = ? AND item_id = ANY(?) AND voter = ?', [type, list, voter])) mine.set(Number(r.item_id), r.reaction);
++ return { counts, mine };
++}
++
++/** Segments as the pages show them, with their people, topics and reactions resolved in three queries. */
++async function segmentsOut(rows, voter) {
++ const ppl = await people(rows.flatMap((r) => [r.user_id, ...(r.mentions || [])]));
++ const tps = await topicsById(rows.map((r) => r.topic_id));
++ const rx = await reactionsFor('segment', rows.map((r) => r.id), voter);
++ return rows.map((r) => ({
++ type: 'segment', id: Number(r.id), title: r.title, what: r.what, take: r.take || null, quip: r.quip || null, score: Number(r.score),
++ quotes: parseJson(r.quotes_json, []).map((q) => ({ text: q.text, en: q.en || null, sec: Math.max(0, Math.floor(Number(q.sec) || 0)) })),
++ topic: tps.get(Number(r.topic_id)) || null, user: ppl.get(Number(r.user_id)) || null,
++ mentions: (r.mentions || []).map((id) => ppl.get(Number(id))).filter(Boolean),
++ stream_id: Number(r.stream_id), vod_id: r.vod_id ? Number(r.vod_id) : null, sec: Math.max(0, Math.floor(Number(r.start_sec) || 0)),
++ said_at: r.said_at, reactions: rx.counts.get(Number(r.id)) || {}, my_reaction: rx.mine.get(Number(r.id)) || null,
++ }));
++}
++async function episodesOut(rows, voter) {
++ const ppl = await people(rows.map((r) => r.user_id));
++ const rx = await reactionsFor('episode', rows.map((r) => r.stream_id), voter);
++ const ids = rows.map((r) => Number(r.stream_id));
++ const topicRows = ids.length ? await db.all(`SELECT DISTINCT ON (s.stream_id, t.id) s.stream_id, t.slug, t.title FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
++ WHERE s.stream_id = ANY(?) AND s.status = 'live' AND t.status = 'live' ORDER BY s.stream_id, t.id`, [ids]) : [];
++ const byStream = new Map();
++ for (const t of topicRows) { const l = byStream.get(Number(t.stream_id)) || []; if (l.length < 6) l.push({ slug: t.slug, title: t.title }); byStream.set(Number(t.stream_id), l); }
++ return rows.map((r) => ({
++ type: 'episode', stream_id: Number(r.stream_id), title: r.title, logline: r.logline || null, recap: r.recap || null,
++ chapters: parseJson(r.chapters_json, []).map((c) => ({ sec: Math.max(0, Math.floor(Number(c.sec) || 0)), title: c.title, segment_id: c.segment_id ? Number(c.segment_id) : null })),
++ user: ppl.get(Number(r.user_id)) || null, vod_id: r.vod_id ? Number(r.vod_id) : null, minutes: r.minutes == null ? null : Number(r.minutes),
++ aired_at: r.aired_at, topics: byStream.get(Number(r.stream_id)) || [],
++ reactions: rx.counts.get(Number(r.stream_id)) || {}, my_reaction: rx.mine.get(Number(r.stream_id)) || null,
++ }));
++}
++
++const SEGMENT_COLS = 'id, stream_id, user_id, topic_id, start_sec, title, what, take, quip, score, quotes_json, mentions, vod_id, said_at';
++const EPISODE_COLS = 'stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at';
++
++async function onAir() {
++ const rows = await db.all(`SELECT s.id AS stream_id, s.user_id, s.title AS stream_title,
++ (SELECT g.title FROM rundown_segments g WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS talking_about,
++ (SELECT t.slug FROM rundown_segments g JOIN rundown_topics t ON t.id = g.topic_id WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS topic_slug,
++ (SELECT t.title FROM rundown_segments g JOIN rundown_topics t ON t.id = g.topic_id WHERE g.stream_id = s.id AND g.status = 'live' ORDER BY g.start_sec DESC LIMIT 1) AS topic_title
++ FROM streams s WHERE s.is_live = 1 AND s.user_id NOT IN (SELECT user_id FROM arena_opt_outs) ORDER BY s.viewer_count DESC NULLS LAST LIMIT 12`);
++ const ppl = await people(rows.map((r) => r.user_id));
++ return rows.map((r) => ({ stream_id: Number(r.stream_id), user: ppl.get(Number(r.user_id)) || null, stream_title: r.stream_title || null, talking_about: r.talking_about || null, topic: r.topic_slug ? { slug: r.topic_slug, title: r.topic_title } : null })).filter((r) => r.user);
++}
++
++async function trending(limit = 12, days = 7) {
++ const rows = await db.all(`SELECT t.id, t.slug, t.title, t.kind, t.headline, t.summary, t.last_seen_at,
++ COUNT(s.id) AS week_takes, COUNT(DISTINCT s.user_id) AS week_streamers, array_agg(DISTINCT s.user_id) AS users
++ FROM rundown_topics t JOIN rundown_segments s ON s.topic_id = t.id AND s.status = 'live' AND s.said_at >= ?
++ WHERE t.status = 'live' GROUP BY t.id ORDER BY COUNT(DISTINCT s.user_id) DESC, COUNT(s.id) DESC, MAX(s.said_at) DESC LIMIT ?`, [daysAgo(days), limit]);
++ const ppl = await people(rows.flatMap((r) => (r.users || []).slice(0, 4)));
++ return rows.map((r) => ({ slug: r.slug, title: r.title, kind: r.kind, headline: r.headline || null, summary: r.summary || null, last_seen_at: r.last_seen_at,
++ takes: Number(r.week_takes), streamers: Number(r.week_streamers), people: (r.users || []).slice(0, 4).map((id) => ppl.get(Number(id))).filter(Boolean) }));
++}
++
++async function voices(days = 7) {
++ const rows = await db.all(`SELECT s.user_id, COUNT(*) AS segments, COUNT(DISTINCT s.topic_id) AS topics, MAX(s.said_at) AS last_seen
++ FROM rundown_segments s WHERE s.status = 'live' AND s.said_at >= ? GROUP BY s.user_id ORDER BY MAX(s.said_at) DESC LIMIT 16`, [daysAgo(days)]);
++ const ppl = await people(rows.map((r) => r.user_id));
++ const tops = rows.length ? await db.all(`SELECT s.user_id, t.slug, t.title, COUNT(*) AS n FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
++ WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= ? AND s.user_id = ANY(?) GROUP BY s.user_id, t.slug, t.title ORDER BY COUNT(*) DESC`, [daysAgo(days), rows.map((r) => Number(r.user_id))]) : [];
++ const byUser = new Map();
++ for (const t of tops) { const l = byUser.get(Number(t.user_id)) || []; if (l.length < 3) l.push({ slug: t.slug, title: t.title }); byUser.set(Number(t.user_id), l); }
++ return rows.map((r) => ({ user: ppl.get(Number(r.user_id)) || null, segments: Number(r.segments), topics: Number(r.topics), last_seen: r.last_seen, top_topics: byUser.get(Number(r.user_id)) || [] })).filter((v) => v.user);
++}
++
++/** The latest: episodes and the best segments (score 3+, or naming someone), newest first, 30 a page. */
++async function latest({ before = null, limit = 30, viewer = null } = {}) {
++ const cut = before ? String(before).slice(0, 19) : '9999-12-31 00:00:00';
++ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND said_at < ? AND (score >= ? OR cardinality(mentions) > 0)
++ ORDER BY said_at DESC, id DESC LIMIT ?`, [cut, FEED_MIN_SCORE, limit]);
++ const eps = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE status = 'live' AND aired_at < ? ORDER BY aired_at DESC LIMIT ?`, [cut, Math.ceil(limit / 3)]);
++ const items = [...await segmentsOut(segs, viewer), ...await episodesOut(eps, viewer)]
++ .sort((a, b) => String(b.said_at || b.aired_at).localeCompare(String(a.said_at || a.aired_at))).slice(0, limit);
++ const last = items[items.length - 1];
++ return { items, next: items.length >= limit && last ? (last.said_at || last.aired_at) : null };
++}
++
++async function home({ before = null, viewer = null } = {}) {
++ const [air, feed, topics, talk] = await Promise.all([before ? [] : onAir(), latest({ before, viewer }), before ? [] : trending(), before ? [] : voices()]);
++ const counts = before ? null : await db.get(`SELECT (SELECT COUNT(*) FROM rundown_topics WHERE status = 'live') AS topics,
++ (SELECT COUNT(*) FROM rundown_episodes WHERE status = 'live') AS episodes, (SELECT COUNT(*) FROM rundown_segments WHERE status = 'live') AS takes`);
++ return { on_air: air, latest: feed.items, next: feed.next, trending: topics, voices: talk, counts: counts ? { topics: Number(counts.topics), episodes: Number(counts.episodes), takes: Number(counts.takes) } : null };
++}
++
++const KINDS = ['news', 'games', 'tech', 'life', 'music', 'sports', 'money', 'internet', 'chat', 'other'];
++async function topics({ sort = 'recent', kind = null, limit = 60, offset = 0 } = {}) {
++ const order = sort === 'most' ? 'streamers DESC, takes DESC, last_seen_at DESC' : 'last_seen_at DESC';
++ const k = KINDS.includes(kind) ? kind : null;
++ const rows = await db.all(`SELECT id, slug, title, kind, headline, summary, takes, streamers, first_seen_at, last_seen_at FROM rundown_topics
++ WHERE status = 'live' AND takes > 0 AND (?::text IS NULL OR kind = ?) ORDER BY ${order} LIMIT ? OFFSET ?`, [k, k, Math.min(100, limit), Math.max(0, offset)]);
++ const users = rows.length ? await db.all(`SELECT topic_id, array_agg(DISTINCT user_id) AS users FROM rundown_segments WHERE status = 'live' AND topic_id = ANY(?) GROUP BY topic_id`, [rows.map((r) => Number(r.id))]) : [];
++ const byTopic = new Map(users.map((u) => [Number(u.topic_id), (u.users || []).slice(0, 4)]));
++ const ppl = await people([...byTopic.values()].flat());
++ return { topics: rows.map((r) => ({ slug: r.slug, title: r.title, kind: r.kind, headline: r.headline || null, summary: r.summary || null, takes: Number(r.takes), streamers: Number(r.streamers),
++ first_seen_at: r.first_seen_at, last_seen_at: r.last_seen_at, people: (byTopic.get(Number(r.id)) || []).map((id) => ppl.get(Number(id))).filter(Boolean) })), kinds: KINDS };
++}
++
++async function topic(slug, { viewer = null } = {}) {
++ const t = await db.get("SELECT id, slug, title, kind, headline, summary, takes, streamers, first_seen_at, last_seen_at FROM rundown_topics WHERE slug = ? AND status = 'live'", [String(slug || '').slice(0, 80)]);
++ if (!t) return null;
++ const rows = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE topic_id = ? AND status = 'live' ORDER BY said_at DESC LIMIT 100`, [t.id]);
++ const related = await db.all(`SELECT DISTINCT t2.slug, t2.title, t2.last_seen_at FROM rundown_segments s JOIN rundown_segments s2 ON s2.stream_id = s.stream_id AND s2.topic_id <> s.topic_id
++ JOIN rundown_topics t2 ON t2.id = s2.topic_id AND t2.status = 'live' WHERE s.topic_id = ? ORDER BY t2.last_seen_at DESC LIMIT 8`, [t.id]);
++ return { topic: { slug: t.slug, title: t.title, kind: t.kind, headline: t.headline || null, summary: t.summary || null, takes: Number(t.takes), streamers: Number(t.streamers), first_seen_at: t.first_seen_at, last_seen_at: t.last_seen_at },
++ takes: await segmentsOut(rows, viewer), related: related.map((r) => ({ slug: r.slug, title: r.title })) };
++}
++
++async function episode(streamId, { viewer = null } = {}) {
++ const id = parseInt(streamId, 10);
++ if (!(id > 0)) return null;
++ const e = await db.get(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE stream_id = ? AND status = 'live'`, [id]);
++ const segs = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE stream_id = ? AND status = 'live' ORDER BY start_sec ASC LIMIT 60`, [id]);
++ if (!e && !segs.length) return null;
++ const stream = await db.get('SELECT id, user_id, title, is_live, started_at, ended_at FROM streams WHERE id = ?', [id]);
++ if (!stream) return null;
++ const others = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE user_id = ? AND status = 'live' AND stream_id <> ? ORDER BY aired_at DESC LIMIT 6`, [stream.user_id, id]);
++ const ppl = await people([stream.user_id]);
++ return { episode: e ? (await episodesOut([e], viewer))[0] : null, stream: { id: Number(stream.id), title: stream.title || null, is_live: !!Number(stream.is_live), started_at: stream.started_at, ended_at: stream.ended_at, user: ppl.get(Number(stream.user_id)) || null },
++ segments: await segmentsOut(segs, viewer), more: await episodesOut(others, viewer) };
++}
++
++async function streamer(username, { viewer = null } = {}) {
++ const u = await db.get('SELECT id, username, display_name, avatar_url, profile_color FROM users WHERE lower(username) = lower(?)', [String(username || '').replace(/^@/, '').slice(0, 64)]);
++ if (!u) return null;
++ const out = !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [u.id]);
++ if (out) return { user: person(u), opted_out: true, episodes: [], topics: [], lines: [], crossovers: [] };
++ const eps = await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes WHERE user_id = ? AND status = 'live' ORDER BY aired_at DESC LIMIT 30`, [u.id]);
++ const tops = await db.all(`SELECT t.slug, t.title, t.kind, COUNT(*) AS n, MAX(s.said_at) AS last FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id
++ WHERE s.user_id = ? AND s.status = 'live' AND t.status = 'live' GROUP BY t.slug, t.title, t.kind ORDER BY COUNT(*) DESC, MAX(s.said_at) DESC LIMIT 24`, [u.id]);
++ const lines = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE user_id = ? AND status = 'live' AND quotes_json <> '[]' ORDER BY score DESC, said_at DESC LIMIT 12`, [u.id]);
++ const cross = await db.all(`SELECT ${SEGMENT_COLS} FROM rundown_segments WHERE status = 'live' AND (? = ANY(mentions) OR (user_id = ? AND cardinality(mentions) > 0)) ORDER BY said_at DESC LIMIT 12`, [u.id, u.id]);
++ return { user: person(u), opted_out: false, episodes: await episodesOut(eps, viewer), topics: tops.map((t) => ({ slug: t.slug, title: t.title, kind: t.kind, takes: Number(t.n), last_seen_at: t.last })),
++ lines: await segmentsOut(lines, viewer), crossovers: await segmentsOut(cross, viewer) };
++}
++
++/** One reaction per reader per item; the same reaction again takes it back. → { reactions, mine } */
++async function react(type, id, voter, reaction) {
++ if (!['segment', 'episode'].includes(type) || !REACTIONS.includes(reaction) || !voter) throw Object.assign(new Error('Unknown reaction'), { status: 400 });
++ const itemId = parseInt(id, 10);
++ const exists = type === 'segment' ? await db.get("SELECT 1 FROM rundown_segments WHERE id = ? AND status = 'live'", [itemId]) : await db.get("SELECT 1 FROM rundown_episodes WHERE stream_id = ? AND status = 'live'", [itemId]);
++ if (!exists) throw Object.assign(new Error('Not found'), { status: 404 });
++ const cur = await db.get('SELECT reaction FROM rundown_reactions WHERE item_type = ? AND item_id = ? AND voter = ?', [type, itemId, voter]);
++ if (cur && cur.reaction === reaction) await db.run('DELETE FROM rundown_reactions WHERE item_type = ? AND item_id = ? AND voter = ?', [type, itemId, voter]);
++ else await db.run(`INSERT INTO rundown_reactions (item_type, item_id, voter, reaction) VALUES (?, ?, ?, ?)
++ ON CONFLICT (item_type, item_id, voter) DO UPDATE SET reaction = excluded.reaction, created_at = ov_now()`, [type, itemId, voter, reaction]);
++ const rx = await reactionsFor(type, [itemId], voter);
++ return { reactions: rx.counts.get(itemId) || {}, mine: rx.mine.get(itemId) || null };
++}
++
++/** Staff take an item down (status hidden); a hidden topic leaves every list. */
++async function hide(type, id) {
++ if (type === 'segment') await db.run("UPDATE rundown_segments SET status = 'hidden' WHERE id = ?", [parseInt(id, 10)]);
++ else if (type === 'episode') await db.run("UPDATE rundown_episodes SET status = 'hidden' WHERE stream_id = ?", [parseInt(id, 10)]);
++ else if (type === 'topic') await db.run("UPDATE rundown_topics SET status = 'hidden' WHERE slug = ?", [String(id)]);
++ else throw Object.assign(new Error('Unknown item'), { status: 400 });
++ return { hidden: true };
++}
++
++async function optedOut(userId) { return !!await db.get('SELECT 1 FROM arena_opt_outs WHERE user_id = ?', [userId]); }
++/** Keep yourself out (or come back): out hides everything about you and the desk stops reading your streams. */
++async function setOptOut(userId, out) {
++ if (out) {
++ await db.run('INSERT INTO arena_opt_outs (user_id) VALUES (?) ON CONFLICT DO NOTHING', [userId]);
++ await db.run("UPDATE rundown_segments SET status = 'out' WHERE user_id = ? AND status = 'live'", [userId]);
++ await db.run("UPDATE rundown_episodes SET status = 'out' WHERE user_id = ? AND status = 'live'", [userId]);
++ } else {
++ await db.run('DELETE FROM arena_opt_outs WHERE user_id = ?', [userId]);
++ await db.run("UPDATE rundown_segments SET status = 'live' WHERE user_id = ? AND status = 'out'", [userId]);
++ await db.run("UPDATE rundown_episodes SET status = 'live' WHERE user_id = ? AND status = 'out'", [userId]);
++ }
++ return { opted_out: !!out };
++}
++
++module.exports = { home, latest, topics, topic, episode, streamer, react, hide, optedOut, setOptOut, trending, voices, onAir, REACTIONS, KINDS, person };
ddiff --git a/server/rundown/routes.js b/server/rundown/routes.js
nnew file mode 100644
iindex 00000000..345c5a37
---- /dev/null
++++ b/server/rundown/routes.js
@@@ -0,0 +1,100 @@
++'use strict';
++/**
++ * /api/rundown — The Rundown (docs/rundown.md): what streamers talked about, written up by the desk (desk.js).
++ *
++ * GET / on air now, the latest (episodes + the best segments), trending topics, who's been talking
++ * GET /latest?before= the next page of the latest
++ * GET /topics?sort=&kind= every topic (sort recent | most)
++ * GET /t/:slug one topic and every take on it
++ * GET /e/:streamId one stream as an episode, with its segments
++ * GET /s/:username a streamer's episodes, topics, lines and crossovers
++ * POST /react {type, id, reaction} one per reader per item (fire, lol, dead, cap, facts); the same again takes it back
++ * POST /hide {type, id} staff: take a segment, episode or topic down
++ * GET|PUT /opt-out {out} signed in: keep yourself out of the Rundown (or come back)
++ * GET /voice/:user?t= a line in that streamer's chat voice (server/arena/voice.js)
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { requireAuth, optionalAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const db = require('../db/database');
++const read = require('./read');
++
++const router = express.Router();
++const fail = (res, err, msg) => {
++ if (err && err.status && err.status < 500) return res.status(err.status).json({ error: err.message });
++ console.error('[Rundown]', msg, err && err.message);
++ res.status(500).json({ error: msg });
++};
++async function voterKey(req) {
++ if (req.user && req.user.id) return `user:${req.user.id}`;
++ const ip = String(req.ip || req.socket?.remoteAddress || '');
++ let salt = '';
++ try { salt = String(await db.getSetting('arena_vote_salt') || ''); } catch { /* */ }
++ return `anon:${crypto.createHash('sha256').update(`${ip}|${salt || process.env.JWT_SECRET || 'rundown'}`).digest('hex').slice(0, 24)}`;
++}
++// Pages with reactions are per reader; the rest is the same for everyone for a minute.
++const fresh = (res) => res.set('Cache-Control', 'no-store');
++
++router.get('/', optionalAuth, async (req, res) => {
++ try { fresh(res); res.json(await read.home({ viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'The Rundown is unavailable'); }
++});
++router.get('/latest', optionalAuth, async (req, res) => {
++ try { fresh(res); res.json(await read.latest({ before: req.query.before ? String(req.query.before) : null, viewer: await voterKey(req) })); } catch (err) { fail(res, err, 'Failed to load the latest'); }
++});
++router.get('/topics', async (req, res) => {
++ try {
++ res.set('Cache-Control', 'public, max-age=60');
++ res.json(await read.topics({ sort: req.query.sort === 'most' ? 'most' : 'recent', kind: req.query.kind ? String(req.query.kind) : null, offset: parseInt(req.query.offset, 10) || 0 }));
++ } catch (err) { fail(res, err, 'Failed to load the topics'); }
++});
++router.get('/t/:slug', optionalAuth, async (req, res) => {
++ try { const t = await read.topic(req.params.slug, { viewer: await voterKey(req) }); if (!t) return res.status(404).json({ error: 'No such topic' }); fresh(res); res.json(t); }
++ catch (err) { fail(res, err, 'Failed to load the topic'); }
++});
++router.get('/e/:streamId', optionalAuth, async (req, res) => {
++ try { const e = await read.episode(req.params.streamId, { viewer: await voterKey(req) }); if (!e) return res.status(404).json({ error: 'No such episode' }); fresh(res); res.json(e); }
++ catch (err) { fail(res, err, 'Failed to load the episode'); }
++});
++router.get('/s/:username', optionalAuth, async (req, res) => {
++ try { const s = await read.streamer(req.params.username, { viewer: await voterKey(req) }); if (!s) return res.status(404).json({ error: 'No such streamer' }); fresh(res); res.json(s); }
++ catch (err) { fail(res, err, 'Failed to load the streamer'); }
++});
++router.post('/react', optionalAuth, async (req, res) => {
++ try { const b = req.body || {}; res.json(await read.react(String(b.type || ''), b.id, await voterKey(req), String(b.reaction || ''))); }
++ catch (err) { fail(res, err, 'Failed to react'); }
++});
++router.post('/hide', requireAuth, permissions.requireAdmin, async (req, res) => {
++ try { const b = req.body || {}; res.json(await read.hide(String(b.type || ''), b.id)); } catch (err) { fail(res, err, 'Failed to hide'); }
++});
++router.get('/opt-out', requireAuth, async (req, res) => {
++ try { res.json({ opted_out: await read.optedOut(req.user.id) }); } catch (err) { fail(res, err, 'Failed'); }
++});
++router.put('/opt-out', requireAuth, async (req, res) => {
++ try { res.json(await read.setOptOut(req.user.id, !!(req.body && req.body.out))); } catch (err) { fail(res, err, 'Failed'); }
++});
++router.get('/voice/:user', optionalAuth, async (req, res) => {
++ try {
++ const voice = require('../arena/voice');
++ if (!voice.allow(req.ip, !!req.user)) return res.status(429).json({ error: 'Slow down — too many voice requests' });
++ const text = voice.cleanText(req.query.t || '');
++ if (text.length < 2) return res.status(400).json({ error: 'Nothing to say' });
++ const user = await db.get('SELECT * FROM users WHERE lower(username) = lower(?)', [String(req.params.user || '').replace(/^@/, '')]);
++ if (!user) return res.status(404).json({ error: 'No such user' });
++ const out = await voice.speak({ user, text });
++ res.set({ 'Content-Type': out.mimeType, 'Cache-Control': 'public, max-age=604800, immutable', 'X-Cache': out.cached ? 'HIT' : 'MISS' });
++ require('stream').pipeline(require('fs').createReadStream(out.path), res, () => {});
++ } catch (err) { res.status(err.message && /budget|hoarse/.test(err.message) ? 429 : 500).json({ error: err.message || 'Voice failed' }); }
++});
++
++/** Where an old Arena link goes: a fighter's /arena/<username> is the streamer's page, everything else the front page. */
++function arenaRedirect(path) {
++ const segs = String(path || '').split('/').filter(Boolean);
++ let seg = '';
++ try { seg = segs[1] ? decodeURIComponent(segs[1]) : ''; } catch { seg = ''; }
++ const fighter = segs[0] === 'arena' && segs.length === 2 && /^[A-Za-z0-9_.-]{1,64}$/.test(seg) && !/^(drama|beef|live|topic|chatter)$/i.test(seg);
++ return fighter ? `/rundown/${encodeURIComponent(seg)}` : '/rundown';
++}
++
++module.exports = router;
++module.exports.arenaRedirect = arenaRedirect;
ddiff --git a/server/seo/discovery.js b/server/seo/discovery.js
iindex f21cdacd..ffc526dd 100644
---- a/server/seo/discovery.js
++++ b/server/seo/discovery.js
@@@ -36,7 +36,7 @@ async function buildSitemap() {
}
return names.get(id);
};
-- const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/arena', 'hourly', 0.6]];
++ const statics = [['/', 'daily', 1.0], ['/content', 'hourly', 0.9], ['/moments', 'hourly', 0.7], ['/vods', 'hourly', 0.8], ['/clips', 'hourly', 0.8], ['/pastes', 'hourly', 0.7], ['/chat', 'daily', 0.5], ['/rundown', 'hourly', 0.7], ['/rundown/topics', 'hourly', 0.6]];
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
try {
for (const f of fs.readdirSync(DOCS_DIR)) {
@@@ -137,7 +137,7 @@ function llmsTxt() {
{ title: 'Clip pages', url: 'https://openvibe.video/c/<id>', note: 'on OpenVibe.Video (Live\'s /clip/<id> names it as canonical)' },
{ title: 'Pastes', url: 'https://openvibe.community/p/<slug>', note: 'pastes live on OpenVibe.Community (Live\'s /p/<slug> redirects there)' },
{ title: 'Chat', url: `${b}/chat` },
-- { title: 'The Arena', url: `${b}/arena`, note: 'mic-judged streamer callouts' },
++ { title: 'The Rundown', url: `${b}/rundown`, note: 'what streamers talked about, written up: topics, takes and episodes' },
] },
{ title: 'What the AI made (AI Moments)', links: [
{ title: 'AI Moments', url: `${b}/moments`, note: `auto-clips cut when chat reacted, frames the AI picked from streams, and AI-written after-show recaps. JSON: ${b}/api/content/moments. AI Moments pages are noindex; the /moments collection is their indexable form. They are never listed in the sitemap. An AI clip says "AI clip · from <streamer>'s stream"; it is never presented as something the streamer or a viewer clipped. A streamer can turn AI Moments off for their channel; the AI then makes no new ones from their streams.` },
ddiff --git a/server/seo/pages.js b/server/seo/pages.js
iindex b799b025..90fd072a 100644
---- a/server/seo/pages.js
++++ b/server/seo/pages.js
@@@ -220,7 +220,7 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if ((m = p.match(/^\/p\/([A-Za-z0-9_-]+)$/))) return await _pasteMeta(m[1]);
if ((m = p.match(CHANNEL_PATH_RE))) return await _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return await _recapMeta(parseInt(m[1], 10));
-- if (p === '/arena') return await _arenaMeta();
++ if (p === '/rundown' || p.startsWith('/rundown/')) return await _rundownMeta(p);
// Search results (public/js/app-search.js) are never indexed; the page itself is findable.
if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
// The global chat room: its own title and canonical (the shell left it with neither; browser check, WS-Q task 3).
@@@ -358,31 +358,63 @@ const _day = (dt) => { const d = isoDate(dt); return d ? d.slice(0, 10) : null;
/** A person's clip's clipper, from Live's accounts (Media rows carry ids only). */
async function _clipperOf(c) { return c.display_name || c.username || _nameOf(await _channelOwner(c.user_id)); }
--// The Arena: a real content page (mic-judged trash talk), so give it real metadata.
--async function _arenaMeta() {
-- let fighters = [], moments = [];
-- try { fighters = (await require('../arena/arena-service').loadRoster() || {}).order || []; } catch { fighters = []; }
-- try { moments = await require('../arena/mic').feed({ limit: 10 }) || []; } catch { moments = []; }
-- const nameOf = async (id) => { try { return await require('../arena/mic').nameOf(id); } catch { return null; } };
-- const names = (await Promise.all(fighters.slice(0, 8).map(nameOf))).filter(Boolean);
-- const title = 'The Arena — Mic-Judged Trash Talk Between Streamers';
-- const desc = clean(`Every callout streamers make on mic, judged and ranked. ${names.length ? 'Fighters right now: ' + names.slice(0, 5).join(', ') + '. ' : ''}Chat can hype a beef but never write one.`, 200);
-- const items = moments.slice(0, 10).map((mo) => ({ name: clean(mo.text || 'Mic moment', 110) }));
-- const snapshot = _detailSnapshot({
-- title: 'The Arena', byline: names.length ? `Fighters: ${names.join(', ')}` : null,
-- desc, overview: moments.slice(0, 8).map(mo => mo.text).filter(Boolean).join(' · ') || null,
-- transcript: null, canonicalPath: '/arena', watchLabel: 'Open the Arena',
-- });
-- return {
-- title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/arena',
-- image: DEFAULT_OG_IMAGE, ogType: 'website', robots: 'index,follow',
-- jsonLd: [
-- seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/arena'), description: desc }),
-- ...(items.length ? [seo.jsonLd.itemList('Latest mic moments', items)] : []),
-- _breadcrumb([{ name: 'Home', url: '/' }, { name: 'The Arena', url: '/arena' }]),
-- ],
-- snapshot,
-- };
++// The Rundown (server/rundown/): what streamers talked about, written up by the desk. The front page and the topic index
++// are collection pages people browse, so they are indexed; a topic, an episode and a streamer's page are AI-written text
++// about real people, so they are noindex,follow, like the AI recap and the Wiki's AI pages until a person reviews them.
++async function _rundownMeta(p) {
++ const read = require('../rundown/read');
++ const crumbs = [{ name: 'Home', url: '/' }, { name: 'The Rundown', url: '/rundown' }];
++ const base = { image: DEFAULT_OG_IMAGE, ogType: 'website' };
++ let m;
++ if (p === '/rundown') {
++ let home = null;
++ try { home = await read.home({}); } catch { home = null; }
++ const topics = (home && home.trending || []).map((t) => t.title);
++ const eps = (home && home.latest || []).filter((x) => x.type === 'episode');
++ const title = 'The Rundown — What Streamers Talked About';
++ const desc = clean(`Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words.${topics.length ? ` Trending: ${topics.slice(0, 4).join(', ')}.` : ''}`, 200);
++ return { ...base, title: `${title} | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown', robots: 'index,follow',
++ jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: title, url: abs('/rundown'), description: desc }),
++ ...(eps.length ? [seo.jsonLd.itemList('Latest episodes', eps.slice(0, 10).map((e) => ({ name: clean(e.title, 110), url: abs(`/rundown/e/${e.stream_id}`) })))] : []),
++ _breadcrumb(crumbs)],
++ snapshot: _detailSnapshot({ title: 'The Rundown', byline: topics.length ? `Trending: ${topics.slice(0, 6).join(', ')}` : null, desc, overview: eps.slice(0, 6).map((e) => e.title).join(' · ') || null, transcript: null, canonicalPath: '/rundown', watchLabel: 'Open The Rundown' }) };
++ }
++ if (p === '/rundown/topics') {
++ let list = [];
++ try { list = (await read.topics({})).topics; } catch { list = []; }
++ const desc = clean(`Every topic streamers on OpenVibe.Live talked about, with each streamer's take.${list.length ? ` Lately: ${list.slice(0, 5).map((t) => t.title).join(', ')}.` : ''}`, 200);
++ return { ...base, title: `Topics — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: '/rundown/topics', robots: 'index,follow',
++ jsonLd: [seo.jsonLd.webPage({ type: 'CollectionPage', name: 'Topics', url: abs('/rundown/topics'), description: desc }), _breadcrumb([...crumbs, { name: 'Topics', url: '/rundown/topics' }])] };
++ }
++ if ((m = p.match(/^\/rundown\/t\/([a-z0-9-]{1,80})$/))) {
++ let d = null;
++ try { d = await read.topic(m[1]); } catch { d = null; }
++ if (!d) return null;
++ const t = d.topic;
++ const desc = clean(t.summary || t.headline || `${t.streamers} streamer${t.streamers === 1 ? '' : 's'} on ${t.title}.`, 200);
++ return { ...base, title: `${clean(t.title, 80)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/t/${t.slug}`, robots: 'noindex,follow',
++ jsonLd: [_breadcrumb([...crumbs, { name: 'Topics', url: '/rundown/topics' }, { name: t.title, url: `/rundown/t/${t.slug}` }])] };
++ }
++ if ((m = p.match(/^\/rundown\/e\/(\d{1,12})$/))) {
++ let d = null;
++ try { d = await read.episode(m[1]); } catch { d = null; }
++ if (!d) return null;
++ const who = d.stream.user ? (d.stream.user.display_name || d.stream.user.username) : 'A streamer';
++ const title = d.episode ? d.episode.title : `${who}'s stream`;
++ const desc = clean((d.episode && (d.episode.logline || d.episode.recap)) || `What ${who} talked about on stream.`, 200);
++ return { ...base, title: `${clean(title, 80)} — ${clean(who, 40)} | The Rundown`, description: desc, canonicalPath: `/rundown/e/${d.stream.id}`, robots: 'noindex,follow',
++ jsonLd: [_breadcrumb([...crumbs, { name: title, url: `/rundown/e/${d.stream.id}` }])] };
++ }
++ if ((m = p.match(/^\/rundown\/([A-Za-z0-9_.-]{1,64})$/))) {
++ let d = null;
++ try { d = await read.streamer(m[1]); } catch { d = null; }
++ if (!d) return null;
++ const who = d.user.display_name || d.user.username;
++ const desc = clean(d.opted_out ? `${who} keeps themselves out of The Rundown.` : `What ${who} talks about on stream: ${d.topics.slice(0, 5).map((t) => t.title).join(', ') || 'episodes and takes'}.`, 200);
++ return { ...base, title: `${clean(who, 40)} — The Rundown | ${SITE_NAME}`, description: desc, canonicalPath: `/rundown/${encodeURIComponent(d.user.username)}`, robots: 'noindex,follow',
++ jsonLd: [_breadcrumb([...crumbs, { name: who, url: `/rundown/${encodeURIComponent(d.user.username)}` }])] };
++ }
++ return null;
}
// After-show report: /recap/:streamId
@@@ -991,7 +1023,7 @@ function _cacheSet(key, html, ttl = CACHE_TTL_MS) {
_cache.set(key, { html, at: Date.now(), ttl });
}
--const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|rundown(?:\/(?:topics|t\/[a-z0-9-]{1,80}|e\/\d{1,12}|[A-Za-z0-9_.-]{1,64}))?$|chat$|search$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_]{3,24}$)/;
/** ?page=N on a channel page (its video list); 1 everywhere else and for anything that is not a number. */
function _pageParam(p, query) {
ddiff --git a/server/web/assets.js b/server/web/assets.js
iindex de47670a..b04e4199 100644
---- a/server/web/assets.js
++++ b/server/web/assets.js
@@@ -300,7 +300,7 @@ function activePage(html, names, reg) {
* links the subset as the blocking stylesheet and the full style.css right after it, without blocking. Every other
* route (the dashboard, settings, the broadcast desk, admin…) keeps style.css blocking, exactly as before.
*/
--const FIRST_PAINT_ROUTES = /^\/(?:$|content|vods|clips|moments|arena|chat|pastes|search)(?:[/?#]|$)|^\/@[A-Za-z0-9_]+\/?$|^\/(?:vod|clip)\/\d+\/?$/;
++const FIRST_PAINT_ROUTES = /^\/(?:$|content|vods|clips|moments|rundown|chat|pastes|search)(?:[/?#]|$)|^\/@[A-Za-z0-9_]+\/?$|^\/(?:vod|clip)\/\d+\/?$/;
const STYLE_LINK = /<link rel="stylesheet" href="(\/css\/style\.css\?v=[A-Za-z0-9_.-]+)">/;
function firstPaintCss(html, urlPath) {
const p = String(urlPath || '/').split(/[?#]/)[0] || '/';
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex 7ed1621e..874205cb 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -17,7 +17,7 @@
* /search Live's channels, VODs and clips through OpenVibe.Search
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
-- * /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
++ * /dashboard/… /broadcast/… /chat/… /rundown/… any depth: these pages route sub-paths themselves (/arena/… redirects)
* /@user /@user/<slot> a user that exists; a renamed one's old name
* answers 301 → /@current (never a bare /name)
* /vod/:id /clip/:id a Media item this visitor may see — a private
@@@ -44,7 +44,7 @@ const NOT_FOUND = 404;
// Pages that exist only at their exact path.
const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes', 'search']);
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
--const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
++const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena', 'rundown']);
// The client's channel-name rule (CHANNEL_USERNAME_RE in public/js/app.js); anything else it
// never treats as a channel.
ddiff --git a/server/web/rum.js b/server/web/rum.js
iindex 5b8ec041..44cd1491 100644
---- a/server/web/rum.js
++++ b/server/web/rum.js
@@@ -31,7 +31,7 @@ function pageKind(path) {
if (/^\/@[^/]+\/?$/.test(p)) return 'channel';
if (/^\/vod\//.test(p)) return 'vod';
if (/^\/clip\//.test(p)) return 'clip';
-- if (/^\/(chat|broadcast|dashboard|moments|content|arena|search)(\/|$)/.test(p)) return p.split('/')[1];
++ if (/^\/(chat|broadcast|dashboard|moments|content|rundown|search)(\/|$)/.test(p)) return p.split('/')[1];
return 'other';
}
const deviceClass = (w) => (w > 0 && w < 600 ? 'phone' : w > 0 && w < 1024 ? 'tablet' : 'desktop');
ddiff --git a/test/arena-mic.test.js b/test/arena-mic.test.js
iindex 1f3e5ecf..97a3c52b 100644
---- a/test/arena-mic.test.js
++++ b/test/arena-mic.test.js
@@@ -202,11 +202,14 @@ console.log('✅ roster + ratings are pure mic');
assert.strictEqual(await run({ user: { id: viewer, username: 'viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!topic anything'), false, '!topic no longer exists');
assert.strictEqual(await run({ anonId: 'b1', streamId: liveId }, '!bounty nova'), false, '!bounty no longer exists');
assert.strictEqual(await run({ anonId: 'b2', streamId: liveId }, '!board'), false, '!board no longer exists');
++ // The Arena became The Rundown (2026-10-19): !hype and !beef point there, !arena answers with the streamer's Rundown.
await run({ user: { id: viewer, username: 'viewer', display_name: 'Viewer' }, streamId: liveId, ip: '1.1.1.1' }, '!hype');
-- assert.ok(sent.pop().includes('Hyped'), 'hype goes to the streamer\'s open beef');
++ assert.match(sent.pop(), /Beefs are gone.*\/rundown$/);
await run({ anonId: 'zz', streamId: liveId }, '!beef');
-- assert.ok(sent.pop().includes('on the clock'));
-- console.log('✅ chat: !hype / !beef only');
++ assert.match(sent.pop(), /Beefs are gone/);
++ await run({ anonId: 'zy', streamId: liveId }, '!arena');
++ assert.match(sent.pop(), /rundown/i);
++ console.log('✅ chat: !hype / !beef point to The Rundown');
// ── Backfill: past speech on ended streams gets judged into the feed (heuristic judges, no AI) ──
const backfill = require('../server/arena/backfill');
ddiff --git a/test/browser/smoke.js b/test/browser/smoke.js
iindex 14a613c0..9f010da5 100644
---- a/test/browser/smoke.js
++++ b/test/browser/smoke.js
@@@ -29,7 +29,7 @@ const ROUTES = [
['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'], ['/search?q=minecraft', 'page-search'],
// The old list pages are the Content feed with a filter.
['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],
-- ['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
++ ['/chat', 'page-chat'], ['/rundown', 'page-rundown'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],
// Answered 404 by the server (server/web/page-status.js) with the same shell.
['/no-such-page', 'page-not-found'],
ddiff --git a/test/critical-css.test.js b/test/critical-css.test.js
iindex 5f359654..067a5f49 100644
---- a/test/critical-css.test.js
++++ b/test/critical-css.test.js
@@@ -58,7 +58,7 @@ ok(`the subset of the real style.css is ${(100 * core.bytes / core.from).toFixed
// ── The shell ──
const doc = assets.document('index.html').html;
const blockingLinks = (html) => [...html.matchAll(/<link rel="stylesheet" href="([^"]+)"(?![^>]*media="print")[^>]*>/g)].map((m) => m[1]).filter((h) => !/noscript/.test(h));
--for (const url of ['/', '/content', '/vods', '/clips', '/chat', '/search?q=x', '/@someone', '/vod/12', '/clip/7', '/arena']) {
++for (const url of ['/', '/content', '/vods', '/clips', '/chat', '/search?q=x', '/@someone', '/vod/12', '/clip/7', '/rundown']) {
const html = assets.renderRoute(doc, url);
const head = html.slice(0, html.indexOf('</head>'));
assert.ok(head.includes(`<link rel="stylesheet" href="/css/style.core.css?v=${core.hash}" data-ov-first-paint>`), `${url}: the subset blocks the first paint`);
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex 2261cac6..e2d126be 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -136,7 +136,7 @@ async function main() {
await check('every real route answers 200', () => expectAll([
'/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes', '/search',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
-- '/arena', '/arena/beef/4', '/arena/alice',
++ '/arena', '/arena/beef/4', '/arena/alice', '/rundown', '/rundown/topics', '/rundown/t/fridges', '/rundown/e/12', '/rundown/alice',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
'/vod/100', '/vod/103', '/clip/200', '/p/abc123',
`/recap/${streamA}`, `/stream/${streamA}`,
ddiff --git a/test/rundown.test.js b/test/rundown.test.js
nnew file mode 100644
iindex 00000000..e64f1b1f
---- /dev/null
++++ b/test/rundown.test.js
@@@ -0,0 +1,200 @@
++'use strict';
++/**
++ * The Rundown (server/rundown/, docs/rundown.md) on a temp DB with a stubbed OpenVibe.AI:
++ * filters game lines, verbatim quotes, invented accusations, topic identity
++ * the desk a stretch without game lines goes to the AI; segments keep only verified quotes, drop invented
++ * accusations, name streamers by whole name; topics join by name; an ended stream becomes an episode
++ * (announced once); a topic with takes from two streamers gets its page; a live stream waits for enough
++ * talk; an opted-out streamer is never read
++ * reads the front page, topics, a topic, an episode, a streamer, reactions, opt-out and staff hiding
++ * the API GET /api/rundown and /t/:slug, POST /react as a signed-out reader; old /arena links
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const db = require('../server/db/database');
++
++(async () => {
++await db.initDb();
++const filters = require('../server/rundown/filters');
++const desk = require('../server/rundown/desk');
++const read = require('../server/rundown/read');
++
++// ── Filters ──
++assert.ok(filters.gameLine('You have slain an enemy.'), 'the League announcer');
++assert.ok(filters.gameLine('Victory!') && filters.gameLine('An enemy has been slain') && filters.gameLine('Welcome to Summoner\'s Rift'));
++assert.ok(!filters.gameLine('I got first blood on that guy and chat lost it'), 'a sentence about the game is the streamer');
++const src = 'This is why having the internet inside of a refrigerator is a bad idea.\nThe hackers got in with the date as the password.';
++assert.strictEqual(filters.verifiedQuote('“having the internet inside of a refrigerator is a bad idea”', src), 'having the internet inside of a refrigerator is a bad idea');
++assert.strictEqual(filters.verifiedQuote('internet fridges are a terrible idea', src), null, 'a paraphrase is not a quote');
++assert.strictEqual(filters.verifiedQuote('bad idea', src), null, 'too short to be a line');
++assert.strictEqual(filters.verifiedQuote('You have slain an enemy', 'You have slain an enemy'), null, 'a game line is never a quote');
++assert.ok(filters.addsAccusation('goosely threatened the fridge company', src), 'an accusation the transcript never made');
++assert.ok(!filters.addsAccusation('hackers got into French fridges', src), 'the transcript said hackers');
++assert.ok(!filters.addsAccusation('French fridges got hacked', src), 'hacked: the same root as the transcript\'s hackers');
++assert.strictEqual(filters.slugify('Supermarket fridges hacked in France!'), 'supermarket-fridges-hacked-in-france');
++assert.ok(filters.sameTopic('League ranked matchmaking', 'Ranked matchmaking in League'));
++assert.ok(!filters.sameTopic('League ranked matchmaking', 'Moving apartments'));
++console.log('✅ filters: game lines, verbatim quotes, accusations, topics');
++
++// ── People and streams ──
++const mk = async (username, display) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: display || username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
++const goose = await mk('goosely', 'goosely'), izor = await mk('iZoR_One'), jax = await mk('Jax_eats_tacks'), shy = await mk('shyone');
++const stream = async (uid, { live = false, startedAgoMin = 180, endedAgoMin = 60, title = 'Tech news' } = {}) => {
++ const id = Number((await db.createStream({ user_id: uid, title, category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run(`UPDATE streams SET is_live = ?, started_at = datetime('now', ?), ended_at = ${live ? 'NULL' : "datetime('now', ?)"}, duration_seconds = ?, peak_viewers = 6 WHERE id = ?`,
++ live ? [1, `-${startedAgoMin} minutes`, (startedAgoMin) * 60, id] : [0, `-${startedAgoMin} minutes`, `-${endedAgoMin} minutes`, (startedAgoMin - endedAgoMin) * 60, id]);
++ return id;
++};
++const speak = async (streamId, uid, lines, from = 0) => db.addTimelineEvents(lines.map((text, i) => ({ stream_id: streamId, user_id: uid, vod_id: 900 + streamId, kind: 'speech', start_sec: from + i * 10, end_sec: from + i * 10 + 8, text, label: null, confidence: 0.9 })));
++const filler = (n, say) => Array.from({ length: n }, (_, i) => `${say} number ${i}, and that is all I have to say about it right now chat.`);
++
++await stream(jax, { startedAgoMin: 3000, endedAgoMin: 2900, title: 'Tacos' }); // a streamer (no transcript), so their name can be said
++const s1 = await stream(goose);
++await speak(s1, goose, [
++ 'Okay chat, the news today is about supermarket fridges in France getting hacked.',
++ 'You have slain an enemy.',
++ 'This is why having the internet inside of a refrigerator is a bad idea.',
++ 'The hackers got in because the password was literally the date.',
++ 'Jax_eats_tacks would never put his fridge on the internet, right?',
++ ...filler(20, 'Fridge thought'),
++]);
++const sShy = await stream(shy);
++await speak(sShy, shy, filler(30, 'Shy line'));
++await db.run('INSERT INTO arena_opt_outs (user_id) VALUES (?) ON CONFLICT DO NOTHING', [shy]);
++
++// ── A stubbed OpenVibe.AI, budget and chat ──
++const llm = require('../server/ai/llm');
++llm.isEnabled = async () => true;
++llm.withinBudget = async () => true;
++const calls = [];
++const answers = {
++ 'live.rundown.segment': (input) => ({ segments: input.streamer === 'goosely' ? [
++ { start: 0, end: 40, topic: 'Supermarket fridges hacked in France', kind: 'tech', title: 'French fridges get hacked', what: 'goosely read about supermarket fridges in France getting hacked.', take: 'Putting the internet in a fridge is a bad idea.',
++ quip: 'Somewhere a fridge is reading this.', score: 4, quotes: [{ t: 20, text: 'having the internet inside of a refrigerator is a bad idea', en: null }, { t: 20, text: 'fridges deserve rights', en: null }, { t: 10, text: 'You have slain an enemy', en: null }] },
++ { start: 40, end: 60, topic: 'Fridge security', kind: 'tech', title: 'goosely threatens fridge makers', what: 'An invented threat.', take: null, quip: 'x', score: 3, quotes: [] },
++ { start: 60, end: 240, topic: 'League ranked matchmaking', kind: 'games', title: 'Ranked is pain', what: 'goosely talked about ranked.', take: null, quip: 'goosely attacked the ladder.', score: 2, quotes: [] },
++ ] : [
++ { start: 0, end: 100, topic: 'Supermarket fridges hacked in France', kind: 'tech', title: 'iZoR_One weighs in on the fridge hack', what: 'iZoR_One also talked about the hacked fridges.', take: 'Fridges should stay dumb.', quip: 'A rare unity.', score: 3, quotes: [] },
++ ] }),
++ 'live.rundown.episode': (input) => ({ title: 'The Fridge Files', logline: 'A night of cold takes.', recap: 'goosely opened with French fridges.\n\nThen ranked.', chapters: [{ index: 0, title: 'Fridges' }, { index: 1, title: 'Ranked' }, { index: 9, title: 'Nope' }] }),
++ 'live.rundown.topic': () => ({ headline: 'Two streamers agree fridges should stay offline', summary: 'goosely and iZoR_One both think internet fridges are a bad idea.' }),
++};
++require('../server/ai/ai-service').structured = async (workflow, input) => { calls.push({ workflow, input }); return answers[workflow] ? answers[workflow](input) : null; };
++const announced = [];
++require('../server/chat/chat-delivery').message = async (line) => { announced.push(line); return true; };
++
++// ── The desk: segments ──
++desk._test.reset();
++await desk.tick();
++const segCalls = calls.filter((c) => c.workflow === 'live.rundown.segment');
++assert.strictEqual(segCalls.length, 1, 'one stretch read; the opted-out streamer never');
++assert.ok(!segCalls[0].input.lines.some((l) => /slain an enemy/.test(l.text)), 'the game line never reaches the AI');
++assert.strictEqual(segCalls[0].input.streamer, 'goosely');
++assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [sShy])).done, 1, 'an opted-out streamer is marked done, unread');
++const segs = await db.all('SELECT * FROM rundown_segments WHERE stream_id = ? ORDER BY start_sec', [s1]);
++assert.strictEqual(segs.length, 2, 'the invented threat dropped its segment');
++assert.deepStrictEqual(JSON.parse(segs[0].quotes_json).map((q) => q.text), ['having the internet inside of a refrigerator is a bad idea'], 'only the verbatim, non-game quote stays');
++assert.deepStrictEqual(segs[0].mentions.map(Number), [Number(jax)], 'Jax_eats_tacks was named by whole name');
++assert.strictEqual(segs[1].quip, null, 'a quip with an accusation the transcript never made is dropped');
++assert.strictEqual(Number(segs[0].vod_id), 900 + s1);
++const fridge = await db.get("SELECT * FROM rundown_topics WHERE slug = 'supermarket-fridges-hacked-in-france'");
++assert.ok(fridge && fridge.takes === 1 && fridge.streamers === 1 && fridge.kind === 'tech');
++console.log('✅ desk: game lines out, verified quotes, invented accusations dropped, whole-name mentions, topics');
++
++// ── The desk: the episode, announced once ──
++await desk.tick();
++const ep = await db.get('SELECT * FROM rundown_episodes WHERE stream_id = ?', [s1]);
++assert.ok(ep, 'the ended stream became an episode');
++assert.strictEqual(ep.title, 'The Fridge Files');
++assert.strictEqual(ep.recap, 'goosely opened with French fridges.\n\nThen ranked.');
++assert.deepStrictEqual(JSON.parse(ep.chapters_json).map((c) => [c.title, Number(c.segment_id)]), [['Fridges', Number(segs[0].id)], ['Ranked', Number(segs[1].id)]], 'chapters name real segments');
++assert.strictEqual(announced.length, 1);
++assert.match(announced[0].message, /^📰 goosely: “The Fridge Files” → .*\/rundown\/e\/\d+$/);
++assert.strictEqual((await db.get('SELECT done FROM rundown_progress WHERE stream_id = ?', [s1])).done, 1);
++const before = calls.length;
++await desk.tick();
++assert.strictEqual(calls.length, before, 'nothing left to read or write');
++console.log('✅ desk: an ended stream is an episode with real chapters, announced once');
++
++// ── A live stream waits for enough talk; a second streamer on the same topic gets the topic its page ──
++const s2 = await stream(izor, { live: true, startedAgoMin: 30, title: 'Ranked' });
++await speak(s2, izor, ['Short hello chat.']);
++await desk.tick();
++assert.strictEqual(calls.filter((c) => c.input && c.input.streamer === 'iZoR_One').length, 0, 'a live stream with a few words waits');
++await speak(s2, izor, filler(70, 'Those hacked fridges should stay dumb'), 100);
++await desk.tick();
++assert.strictEqual(calls.filter((c) => c.workflow === 'live.rundown.segment' && c.input.streamer === 'iZoR_One').length, 1, 'enough talk: read');
++const fridge2 = await db.get('SELECT * FROM rundown_topics WHERE id = ?', [fridge.id]);
++assert.deepStrictEqual([fridge2.takes, fridge2.streamers], [2, 2], 'the same topic name joined the topic');
++assert.strictEqual(fridge2.summary, 'goosely and iZoR_One both think internet fridges are a bad idea.', 'two takes: the topic page is written');
++assert.ok(!await db.get('SELECT 1 FROM rundown_episodes WHERE stream_id = ?', [s2]), 'no episode while live');
++console.log('✅ desk: live streams wait for talk; topics gather streamers and get their page');
++
++// ── Reads ──
++const home = await read.home({ viewer: 'anon:t' });
++assert.ok(home.latest.some((x) => x.type === 'episode' && x.title === 'The Fridge Files'), 'the episode is in the latest');
++assert.ok(home.latest.some((x) => x.type === 'segment' && x.title === 'French fridges get hacked'), 'a score-4 take is in the latest');
++assert.ok(!home.latest.some((x) => x.title === 'Ranked is pain'), 'a score-2 take without a name is not on the front page');
++assert.strictEqual(home.trending[0].slug, 'supermarket-fridges-hacked-in-france');
++assert.strictEqual(home.trending[0].streamers, 2);
++assert.deepStrictEqual(Object.keys(home.latest[0].user).sort(), ['avatar_url', 'display_name', 'id', 'profile_color', 'username'], 'a person is five fields, never a users row');
++assert.ok(home.on_air.some((x) => x.user.username === 'iZoR_One' && x.talking_about), 'on air, with what they are talking about');
++assert.ok(home.voices.some((v) => v.user.username === 'goosely' && v.top_topics.length));
++const t = await read.topic('supermarket-fridges-hacked-in-france');
++assert.strictEqual(t.takes.length, 2);
++assert.ok(t.related.some((r) => r.slug === 'league-ranked-matchmaking'), 'came up alongside');
++const e = await read.episode(s1);
++assert.strictEqual(e.episode.title, 'The Fridge Files');
++assert.strictEqual(e.segments.length, 2);
++const g = await read.streamer('GOOSELY');
++assert.strictEqual(g.episodes.length, 1);
++assert.ok(g.lines.length >= 1 && g.crossovers.length === 1, 'their best line and the crossover naming Jax');
++const j = await read.streamer('jax_eats_tacks');
++assert.strictEqual(j.crossovers.length, 1, 'Jax sees being brought up');
++let r = await read.react('segment', segs[0].id, 'anon:a', 'fire');
++assert.deepStrictEqual([r.reactions.fire, r.mine], [1, 'fire']);
++r = await read.react('segment', segs[0].id, 'anon:a', 'fire');
++assert.deepStrictEqual([r.reactions.fire, r.mine], [undefined, null], 'the same reaction again takes it back');
++await assert.rejects(read.react('segment', segs[0].id, 'anon:a', 'clown'), /Unknown reaction/);
++console.log('✅ reads: front page, topic, episode, streamer, reactions');
++
++// ── Opt-out and staff ──
++await read.hide('segment', segs[1].id);
++await read.setOptOut(goose, true);
++assert.strictEqual((await read.streamer('goosely')).opted_out, true);
++assert.ok(!(await read.home({})).latest.some((x) => x.user && x.user.username === 'goosely'), 'out: nothing about them on the front page');
++await read.setOptOut(goose, false);
++assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[0].id])).status, 'live', 'back: their take is back');
++assert.strictEqual((await db.get('SELECT status FROM rundown_segments WHERE id = ?', [segs[1].id])).status, 'hidden', 'what staff took down stays down');
++console.log('✅ opt-out and staff hiding');
++
++// ── The API and old links ──
++const routes = require('../server/rundown/routes');
++assert.strictEqual(routes.arenaRedirect('/arena'), '/rundown');
++assert.strictEqual(routes.arenaRedirect('/arena/goosely'), '/rundown/goosely');
++assert.strictEqual(routes.arenaRedirect('/arena/drama/12'), '/rundown');
++assert.strictEqual(routes.arenaRedirect('/arena/beef'), '/rundown');
++assert.strictEqual(routes.arenaRedirect('/arena/live/goosely'), '/rundown');
++const app = express();
++app.use(express.json());
++app.use('/api/rundown', routes);
++const server = http.createServer(app);
++await new Promise((res) => server.listen(0, '127.0.0.1', res));
++const base = `http://127.0.0.1:${server.address().port}/api/rundown`;
++try {
++ const front = await (await fetch(`${base}/`)).json();
++ assert.ok(Array.isArray(front.latest) && front.trending.length >= 1 && front.counts.episodes === 1);
++ const topic = await fetch(`${base}/t/supermarket-fridges-hacked-in-france`);
++ assert.strictEqual(topic.status, 200);
++ assert.strictEqual((await fetch(`${base}/t/no-such-topic`)).status, 404);
++ const react = await fetch(`${base}/react`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'episode', id: s1, reaction: 'lol' }) });
++ assert.deepStrictEqual((await react.json()).reactions, { lol: 1 });
++ assert.strictEqual((await fetch(`${base}/react`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'episode', id: 999999, reaction: 'lol' }) })).status, 404);
++ assert.strictEqual((await fetch(`${base}/opt-out`)).status, 401, 'opting out needs an account');
++} finally { server.close(); }
++console.log('✅ API: front page, topic, reactions as a signed-out reader; old /arena links');
++
++console.log('rundown: ok');
++process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });