Files
dogfather-universe/server/workspace-reaktion.js
T
DogFatherGit 711a5470ab Die Regie wird eine Regie -- und das Video lief bei niemandem
Filipe: "perfektionier das auch mit den videoos. pefektionier auch das
aussehen und das layout von der regie. ich will dass du das viel
hochwertiger und profissioneller machst."

DAS VIDEO LIEF BEI NIEMANDEM -- AUCH NICHT BEIM HOST

Gemessen ueber ein neues Merkmal am Rahmen: `onStateChange` ist nie
ausgeloest worden, bei keinem der drei Browser. Zwei Ursachen, die
sich gegenseitig verdeckt haben:

  Ein Player mit Ton darf ohne Handlung des Menschen nicht losgehen.
  Ohne `mute: 1` greift `playVideo()` nicht -- und ein Zuschauer hat
  keine Bedienung (mit Absicht), haette also NIE eine Moeglichkeit
  gehabt, es zu starten. Eine Stunde Standbild.

  `onReady` tat `if (host) takt(); else folgen();`. Der Host hat damit
  nur GEMELDET, wo er steht, und nie selbst begonnen. Er meldete
  "laeuft nicht", und alle anderen folgten ihm brav ins Stehen.

Die Messung bricht ab jetzt ab, wenn der Player nicht bei beiden
laeuft. Ein gruener Haken ueber einem Standbild ist wertlos.

DIE WARTESCHLANGE

Vorher gab es genau EIN Videofeld. Wer zwei Sachen hintereinander
schauen wollte, tippte mitten in der Sendung eine YouTube-Adresse ein
-- vor Publikum, mit laufender Kamera, ein Tippfehler von einem
schwarzen Rechteck entfernt. Jetzt wird vorher eingeraeumt und im Live
nur weitergeschaltet: anhaengen, schieben, "Jetzt", "Naechstes".

Die Titel kommen von YouTube selbst (oEmbed, kein Schluessel, kein
Kontingent) und werden EINMAL geholt und hingelegt. Klappt der Abruf
nicht, steht dort die Kennung -- kein erfundener Name. Die Grenze ist
ueber die Umgebung veraenderbar, damit die Pruefung den vollen Fall in
Sekunden erreicht statt dreissig Videos anzuhaengen.

DIE REGIE

Vorher fuenf Kaesten untereinander in einem Bereich, der hoechstens
die halbe Bildschirmhoehe hat -- man sah fuenf halbe Dinge. Die drei
grossen Knoepfe lagen ganz unten, hinter acht Feldern und vier
Reglern. Wer mitten in der Sendung "Beenden" drueckt, drueckt es, weil
etwas passiert ist; das darf nicht hinter einer Bewegung liegen.

  Eine Leiste, die IMMER steht: Lampe, Laufzeit, Zuschauer, wie viele
  davon Bild bekommen, Gaeste, gemessener Upload -- und rechts die
  drei Knoepfe.

  Register statt Stapel: Sendung, Warteschlange, Ton, Gaeste, Bild.

  Eine Videospur mit echter Bedienung: Pause, +/-10 s, Positionsband,
  Restzeit, "Naechstes". Vorher musste man ins YouTube-Bild fassen --
  und was dort passiert, passiert nur bei einem selbst.

  PEGEL an den Reglern, aus einer echten Messung (AnalyserNode). Sie
  beantworten die Frage, die man sich sonst erst nach der Sendung
  stellt: "War mein Mikro ueberhaupt an?" Ein Regler auf 100 sagt
  darueber nichts. Beim YouTube-Regler steht dabei, dass dort nichts
  zu messen ist -- Ton aus einem fremden Rahmen laesst sich nicht
  abgreifen, und ein erfundener Ausschlag waere schlimmer als keiner.

  Der Upload wird an der Verbindung gemessen (`getStats`), nicht aus
  "zwoelf mal 350 kbit/s" gerechnet.

  Tastaturkuerzel -- und die Legende steht darunter. Ein Kuerzel, das
  niemand kennt, ist keins.

VIER FEHLER, DIE NUR DIE MESSUNG GEZEIGT HAT

1. `.tafel` WAR SCHON VERGEBEN. Die Anmeldeseite hat diese Klasse und
   setzt sie `position: absolute`; reaktion.html laedt beide
   Stilvorlagen. Die Register-Tafel trug damit NICHTS zur Hoehe bei
   (114 px Raster, 294 px Inhalt) und malte quer ueber Register und
   Kuerzel -- 197 Pixel, um die die Seite ueberlief. Auf dem Bild sah
   es aus wie ein Anzeigefehler; es war ein Namensstreit. Dritter
   Fall dieser Art nach .knopf-still und .schalter, deshalb steht er
   ab jetzt in einer Pruefung: keine Klasse aus reaktion.css darf in
   gate/start/module/haus.css vorkommen.

2. `node:sqlite` kennt kein `.transaction()`. Von Hand geklammert.

3. Das Schild war auf dem Handy 10,24 px gross -- unter der
   Hausgrenze von 11,5 px. Gefunden von `pruef-handy`, das die
   GEZEICHNETE Groesse misst; die Stilvorlagen-Pruefung haette die
   Zeile in der Medienabfrage durchgelassen.

4. Die Messung klickte blind auf den Griff des Pults ("umschalten")
   und machte es damit ZU, seit es aufgeklappt startet. Sie stellt
   den Zustand jetzt her, statt ihn umzuschalten.

UND DIE HARTNAECKIGSTE: DAS BILD DES GASTES KAM BEIM HOST NICHT AN

Erst in einem von drei Laeufen, dann in drei von drei -- und zwar
SCHLIMMER, nachdem ich einen Wachhund dagegen gebaut hatte. Vier
Ursachen, hintereinander gemessen statt geraten:

  a) `kameraHolen()` wurde beim Host FUENFMAL betreten. Die Wache
     `if (meinStrom) return` wirkt erst, wenn die Kamera DA ist --
     solange die erste Anfrage laeuft, geht jede weitere als zweite
     Anfrage an dasselbe Geraet. Eine blieb liegen, und mit ihr der
     Anruf, der darauf wartete. Der Platz in `ruftGerade` blieb
     belegt, und damit kam nie wieder eine Leitung zustande. Jetzt
     bekommen alle dasselbe Versprechen zurueck.

  b) Der Wachhund riss Leitungen ab, die gerade verhandelt wurden --
     zwischen "Leitung angelegt" und "Angebot abgeschickt" liegen
     drei await.

  c) "Ruf mich an" brach dasselbe ab: Der Bittende weiss nicht, dass
     es schon laeuft, und fragt alle drei Sekunden weiter.

  d) Meine Reparatur von (b) und (c) war "signalingState !== stable
     heisst: in Arbeit" -- ohne Uhr. Damit war eine Leitung, deren
     Antwort nie ankommt, vor BEIDEN Aufraeumwegen sicher, dauerhaft.
     Der Zuschauer bekam daraufhin gar nichts mehr; ich hatte den
     Fehler nur von einer Seite auf die andere geschoben. "Wird
     verhandelt" ist ein Zustand MIT DAUER und steht jetzt an genau
     einer Stelle.

Dazu ein eigener Fehler beim Aufraeumen: Mit der Messspur ist
`ruftGerade` mit herausgefallen. Vier Laeufe ohne jedes Bild -- und
`node --check` sieht das nicht, eine fehlende Variable ist
syntaktisch tadellos.

GEMESSEN

  mess-reaktion   fuenf Laeufe hintereinander ohne Beanstandung;
                  beide Kameras 640 px bei Host UND Zuschauerin,
                  Video laeuft bei beiden, Upload 0,9 Mbit/s,
                  Warteschlange 2 Zeilen mit Vorschaubildern,
                  kein Ueberlauf (4 px statt 197)
  pruef-reaktion  132 Punkte, 0 Fehler (vorher 88)
  pruef-handy     180 Punkte, 0 Fehler
  dazu gruen      css-klassen 33, struktur 35, tippziele 11,
                  meldungen 8, kamera-richtlinie 10

SCHEMA: neue Tabelle `reaktion_liste`, neue Spalte
`reaktion.video_titel` (ALTER TABLE, keine Abschrift der Spalten).
2026-09-28 03:45:15 +02:00

919 lines
38 KiB
JavaScript

/* =====================================================================
DIE REACTION — die Wege (28.09.2026)
Der Ablauf aus Filipes Notiz, von links nach rechts:
Kachel sichtbar -> geschlossen -> Vorbereitung ->
Wartebereich/Chat -> Countdown -> LIVE ->
Reaction + Gaeste + Chat + PayPal -> Ende
Das sind drei Staende und nicht sieben. „Wartebereich" und
„Countdown" sind keine eigenen Zustaende, sondern das, was
„vorbereitung" auf dem Bildschirm TUT: Der Chat ist offen, der
Countdown laeuft, das Vorschaubild steht. Drei Staende, die sich
gegenseitig ausschliessen, sind pruefbar; sieben, von denen sich
vier ueberlappen, sind es nicht.
---------------------------------------------------------------------
WER WAS DARF
Host = DogFather. Er stellt ein, startet, beendet, holt und
entfernt Gaeste, schaltet stumm, bestimmt die Anordnung.
Moderation= DogFather, rechte und linke Hand, Modis. Sie koennen
Beitraege aus dem Live-Chat nehmen.
Gast = wen der Host holt. Jede Rolle des Hauses.
Zuschauer = jeder im Team-Dogi-Haus, auch die Community.
Die Reaction gibt es NUR im Team-Dogi-Haus. Auf der Agenturadresse
antwortet jeder Weg mit 404 -- nicht mit 403, denn ein „verboten"
verraet, dass es hier etwas gibt.
---------------------------------------------------------------------
DAS VIDEO LAEUFT NICHT UEBER DIESEN SERVER
Uebertragen wird nur der Spielstand: Kennung, laeuft/pausiert,
Sekunde. Jeder Browser laedt das Video selbst von YouTube. Die
Begruendung steht in reaktion-tabellen.js.
Aufrufen laesst sich alles ohne Sonderfall: Wer zusieht, meldet
sich alle 30 Sekunden mit `/dabei`. Wer sich zwei Minuten nicht
meldet, faellt heraus -- ein sauberes „Tschuess" gibt es im Netz
nicht, ein Handy geht einfach aus.
===================================================================== */
import express from "express";
import { db, sitzungLesen, protokolliere, echteIp } from "./workspace.js";
import { stromAn } from "./workspace-chat.js";
import { einstellung } from "./workspace.js";
import { mitZugangsdaten, wegeAusfaechern } from "./workspace-turn.js";
import {
SICHT_PLAETZE, GAESTE_MAX, TEXT_MAX, SCHREIB_BREMSE, STAENDE, LAYOUTS,
VERLAUF_TAGE, LISTE_MAX,
} from "./reaktion-tabellen.js";
export const reaktionRouter = express.Router();
/** Wer die Sendung fuehrt: DogFather und die rechte Hand.
*
* Filipe am 28.09.2026: „die rolle dogfather und vanvan sollen alles
* sehen und bereit machen auch vorher. nur die zwei sollen alles
* sehen, vorbereiten und einschakten koennen."
*
* Das ist eine AUFGABE und kein Rang -- wer die Sendung fuehrt,
* bedient die Technik. Beide duerfen dasselbe; es gibt hier keinen
* Ersten und keinen Zweiten. */
const HOST_ROLLEN = new Set(["admin", "hand"]);
/** Wer Beitraege aus dem Chat nehmen darf.
*
* BLEIBT GROESSER ALS HOST_ROLLEN, und das ist Absicht: Einen
* Beitrag wegzunehmen ist weder „alles sehen" noch „vorbereiten"
* noch „einschalten" -- es ist dasselbe, was die Modis im Treff
* ohnehin tun. Wer moderiert, bekommt deshalb aber AUCH NICHT die
* Liste derer, die zusehen; die beiden Dinge hingen vorher
* zusammen, ohne dass es einen Grund dafuer gab. */
const MODERIERT = new Set(["admin", "hand", "linke", "modi"]);
export const REAKTION_HOST_ROLLEN = HOST_ROLLEN;
const jetzt = () => new Date().toISOString();
/* ---------- Schranken --------------------------------------------------- */
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
/** Nicht das andere Haus.
*
* Gefragt wird „ist es die Agentur?" und nicht „ist es crew?" --
* auf einer Pruefadresse hat niemand ein Haus, und eine Schranke,
* die das mit dem fremden Haus verwechselt, macht die Seite dort
* still tot. Genau dieser Fehler ist am 27.09. beim Notizblock
* passiert und von pruef-handy gefunden worden. */
function nurTeamDogi(req, res, next) {
if (req.person?.haus === "agentur") {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
next();
}
function nurHost(req, res, next) {
if (!HOST_ROLLEN.has(req.person?.rolle)) {
return res.status(403).json({ fehler: "nur_host" });
}
next();
}
/* Schutz gegen Anfragen von fremden Seiten -- wortgleich zu den
uebrigen Modulen. */
function gleicheHerkunft(req, res, next) {
const herkunft = req.get("origin");
if (!herkunft) return next();
let erlaubt;
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
next();
}
/* ---------- Die Schreibbremse -------------------------------------------
Ein Live-Chat ist der eine Ort im Haus, an dem jemand in zehn
Sekunden dreissig Zeilen schreiben kann -- aus Begeisterung oder
aus Aerger. Die Bremse zaehlt je Person und Minute. */
const bremse = new Map();
function zuSchnell(personId) {
const minute = Math.floor(Date.now() / 60_000);
const s = bremse.get(personId);
if (!s || s.minute !== minute) { bremse.set(personId, { minute, n: 1 }); return false; }
s.n += 1;
return s.n > SCHREIB_BREMSE;
}
/* ---------- Hilfen ------------------------------------------------------ */
const sendung = () => db().prepare("SELECT * FROM reaktion WHERE id = 1").get();
/** Wie heisst das Video? — einmal bei YouTube nachfragen.
*
* UEBER oEmbed, NICHT UEBER DIE DATEN-API. oEmbed braucht keinen
* Schluessel, kein Konto und kein Kontingent; es ist genau dafuer
* da, dass eine fremde Seite einen Titel anzeigen darf.
*
* DREI AUSGAENGE, NICHT ZWEI. Geht der Abruf schief -- kein Netz,
* YouTube langsam, Video nicht oeffentlich --, kommt ein LEERER
* Titel zurueck, und die Oberflaeche zeigt dann die Kennung. Sie
* erfindet keinen Namen und behauptet auch nicht, es gaebe keinen.
* „Konnte nicht nachsehen" ist eine eigene Antwort.
*
* VIER SEKUNDEN UND DANN SCHLUSS. Das Anhaengen eines Videos darf
* nicht daran haengen, ob ein fremder Dienst gerade Zeit hat. Ohne
* Frist stuende Filipe vor einem Knopf, der sich dreht, und wuesste
* nicht, ob er noch einmal druecken soll. */
async function videoTitel(kennung) {
if (!kennung) return "";
try {
const adresse = "https://www.youtube.com/oembed?format=json&url="
+ encodeURIComponent("https://www.youtube.com/watch?v=" + kennung);
const a = await fetch(adresse, { signal: AbortSignal.timeout(4000) });
if (!a.ok) return "";
const j = await a.json();
return String(j?.title || "").trim().slice(0, 160);
} catch (fehler) {
console.warn("[reaktion] Titel von YouTube:", fehler?.message);
return "";
}
}
/** Die Warteschlange, in ihrer Reihenfolge. */
function listeHolen() {
return db().prepare("SELECT id, video, titel, platz FROM reaktion_liste"
+ " ORDER BY platz, id").all();
}
/** Das naechste Video aus der Liste ins Bild holen.
*
* EINE STELLE FUER ZWEI KNOEPFE. „Naechstes" und „Jetzt spielen"
* tun dasselbe, nur mit unterschiedlicher Zeile -- zwei Kopien
* desselben Ablaufs waeren zwei Stellen, an denen man spaeter das
* Zuruecksetzen der Sekunde vergessen kann. */
function weiterZu(zeile, personId) {
db().prepare("UPDATE reaktion SET video = ?, video_titel = ?, laeuft = 1,"
+ " sekunde = 0, geaendert = ? WHERE id = 1")
.run(zeile.video, zeile.titel, jetzt());
db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(zeile.id);
protokolliere(personId, "reaktion_weiter", zeile.video, "");
const s = sendung();
/* ZWEI MELDUNGEN, UND BEIDE WERDEN GEBRAUCHT.
„video" bringt jeden Player auf das neue Stueck -- das ist die
eilige. „reaktion" bringt die Liste und den Titel nach; sie
betrifft nur die Anzeige und darf einen Wimpernschlag spaeter
kommen. Nur die erste zu schicken hiesse: Das Video wechselt,
und im Pult steht weiter der alte Name. */
melden("reaktion", { was: "video", video: s.video, laeuft: true, sekunde: 0, gesendet: Date.now() });
return s;
}
function gaesteListe() {
return db().prepare(`
SELECT g.person_id AS id, g.stumm, g.platz, p.name, p.rolle
FROM reaktion_gaeste g JOIN personen p ON p.id = g.person_id
ORDER BY g.platz`).all();
}
/** Wer gerade dabei ist -- und ob noch ein Kameraplatz frei war.
*
* ZWEI MINUTEN OHNE LEBENSZEICHEN HEISST WEG. Ein Browser, der
* zugeklappt wird, schickt kein „ich gehe". Ohne diese Grenze
* waechst die Zuschauerzahl den ganzen Abend und stimmt nie. */
function dabeiListe() {
const grenze = new Date(Date.now() - 120_000).toISOString();
db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?").run(grenze);
return db().prepare(`
SELECT d.person_id AS id, d.sicht, d.seit, p.name, p.rolle
FROM reaktion_dabei d JOIN personen p ON p.id = d.person_id
ORDER BY d.seit`).all();
}
/** Alle, die von einer Aenderung erfahren sollen. */
function empfaenger() {
const ids = new Set(dabeiListe().map((d) => d.id));
for (const g of gaesteListe()) ids.add(g.id);
const s = sendung();
if (s?.host_id) ids.add(s.host_id);
return [...ids];
}
/** Was jeder sehen darf -- egal ob Zuschauer, Gast oder Host. */
function lage(person) {
const s = sendung();
const gaeste = gaesteListe();
const dabei = dabeiListe();
const istHost = HOST_ROLLEN.has(person.rolle);
const binGast = gaeste.some((g) => g.id === person.id);
return {
stand: s.stand,
titel: s.titel,
video: s.video,
video_titel: s.video_titel || "",
vorschau: s.vorschau,
beginnt_am: s.beginnt_am,
layout: s.layout,
laeuft: !!s.laeuft,
sekunde: s.sekunde,
gestartet_am: s.gestartet_am,
host: s.host_id
? db().prepare("SELECT id, name, rolle FROM personen WHERE id = ?").get(s.host_id)
: null,
gaeste: gaeste.map((g) => ({ id: g.id, name: g.name, rolle: g.rolle, stumm: !!g.stumm, platz: g.platz })),
dabei: dabei.length,
/* ==== WER DIE LISTE BEKOMMT -- UND WARUM GENAU DIESE ==========
Namen der Zusehenden bekommt, wer auf der BUEHNE steht (Host,
Gaeste) oder moderiert. Ein Zuschauer bekommt sie nicht: Er
soll nicht ablesen koennen, wer sonst noch zusieht -- im Live
sieht man einander am Chat, und das reicht.
DIE GAESTE STEHEN HIER NICHT AUS HOEFLICHKEIT. Wer sendet,
muss wissen, wen er anrufen soll: Zuschauer rufen grundsaetzlich
nie an (sonst rufen zwei gleichzeitig an, und beide
scheitern -- „glare"). Ohne die Liste kaeme das Bild eines
Gastes deshalb bei keinem Zuschauer an. Zwei fuer sich richtige
Regeln, die zusammen ein Loch ergeben.
DIE MODERATION STAND HIER AUCH -- SEIT DEM 28.09. NICHT MEHR.
Filipe: „nur die zwei sollen alles sehen." Wer einen Beitrag
wegnehmen darf, muss deshalb nicht wissen, wer im Saal sitzt;
das war eine Vermischung zweier Dinge, die nichts miteinander
zu tun haben. Moderiert wird weiter -- im Chat sieht man
ohnehin, wer da ist. */
zuschauer: (istHost || binGast)
? dabei.map((d) => ({ id: d.id, name: d.name, rolle: d.rolle, sicht: !!d.sicht }))
: null,
ich: {
id: person.id,
name: person.name,
rolle: person.rolle,
host: istHost,
gast: binGast,
moderiert: MODERIERT.has(person.rolle),
sicht: !!(dabei.find((d) => d.id === person.id)?.sicht),
},
/* DIE WARTESCHLANGE SEHEN NUR DIE ZWEI. Sie ist Teil der
Vorbereitung, und Filipe hat dazu gesagt: „nur die zwei sollen
alles sehen, vorbereiten und einschakten koennen." */
liste: istHost ? listeHolen() : null,
/* WAS ALS NAECHSTES KOMMT, DARF JEDER WISSEN -- und nur das.
Auf der Wartetafel steht damit etwas, das Lust macht, ohne den
ganzen Abend zu verraten. */
naechstes: (() => {
const n = db().prepare("SELECT titel, video FROM reaktion_liste"
+ " ORDER BY platz, id LIMIT 1").get();
return n ? (n.titel || n.video) : null;
})(),
liste_zahl: db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0,
liste_max: LISTE_MAX,
plaetze: SICHT_PLAETZE,
gaeste_max: GAESTE_MAX,
text_max: TEXT_MAX,
};
}
function melden(art, daten) {
stromAn(empfaenger(), art, daten);
}
/* ======================================================================
DIE WEGE
====================================================================== */
reaktionRouter.use("/workspace/api/reaktion", angemeldet, nurTeamDogi, gleicheHerkunft);
/** Wie steht es? — der eine Aufruf, den jede Seite beim Laden macht. */
reaktionRouter.get("/workspace/api/reaktion", (req, res) => {
try {
res.json(lage(req.person));
} catch (fehler) {
console.error("[reaktion] Lage:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Der Host stellt ein: Titel, Video, Vorschaubild, Beginn. */
reaktionRouter.put("/workspace/api/reaktion", nurHost, express.json({ limit: "32kb" }),
async (req, res) => {
try {
const titel = String(req.body?.titel ?? "").trim().slice(0, 120);
const video = videoKennung(String(req.body?.video ?? ""));
const vorschau = String(req.body?.vorschau ?? "").trim().slice(0, 400);
const beginn = req.body?.beginnt_am ? String(req.body.beginnt_am).slice(0, 40) : null;
if (req.body?.video && !video) {
return res.status(400).json({ fehler: "video_unklar" });
}
/* EIN BEGINN IN DER VERGANGENHEIT IST KEIN BEGINN. Der Countdown
zaehlte sonst rueckwaerts ins Minus, und niemand koennte
sagen, ob das Absicht war. */
if (beginn && Number.isFinite(Date.parse(beginn)) && Date.parse(beginn) < Date.now() - 60_000) {
return res.status(400).json({ fehler: "beginn_vorbei" });
}
/* HOST_ID WIRD BEIM EINSTELLEN NUR GESETZT, WENN NOCH NIEMAND
DASTEHT (28.09.2026).
Vorher stand hier, wer zuletzt etwas eingestellt hat. Mit
zwei Leuten, die vorbereiten duerfen, ist das eine Falle:
VanVan richtet am Nachmittag alles ein, Filipe drueckt
abends auf Sendung -- und im Bild stuende VanVans Name,
waehrend Filipe redet.
Wer die Sendung FUEHRT, entscheidet sich beim Einschalten
(siehe /stand). Hier wird nur der leere Platz gefuellt, damit
die Ankuendigung „Als Naechstes" jemanden nennen kann. */
const bisher = sendung();
/* DEN NAMEN DES VIDEOS NUR HOLEN, WENN ES EIN ANDERES IST.
Sonst faende bei jedem „Uebernehmen" -- auch wenn nur der
Beginn verschoben wurde -- eine Abfrage bei YouTube statt,
und das Speichern haengt an einem fremden Dienst. */
const videoTitelNeu = (video && video !== bisher.video)
? await videoTitel(video)
: (video ? (bisher.video_titel || "") : "");
db().prepare(`UPDATE reaktion SET titel = ?, video = ?, video_titel = ?,
vorschau = ?, beginnt_am = ?, host_id = ?, geaendert = ? WHERE id = 1`)
.run(titel, video, videoTitelNeu, vorschau, beginn,
bisher.host_id || req.person.id, jetzt());
const l = lage(req.person);
melden("reaktion", { was: "eingestellt", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Einstellen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Der Stand wechselt: zu / vorbereitung / live. */
reaktionRouter.post("/workspace/api/reaktion/stand", nurHost, express.json({ limit: "4kb" }),
(req, res) => {
try {
const neu = String(req.body?.stand ?? "");
if (!STAENDE.includes(neu)) return res.status(400).json({ fehler: "stand_unbekannt" });
const s = sendung();
/* OHNE VIDEO KEIN LIVE. Der Knopf waere sonst da, das Ergebnis
ein schwarzes Rechteck -- und die Leute sitzen davor. */
if (neu === "live" && !s.video) {
return res.status(400).json({ fehler: "kein_video" });
}
const felder = { stand: neu, stand_seit: jetzt(), geaendert: jetzt() };
if (neu === "live" && s.stand !== "live") {
felder.gestartet_am = jetzt();
felder.laeuft = 1;
felder.sekunde = 0;
/* WER EINSCHALTET, IST IM BILD. Seine Kamera bekommt den
Platz des Hosts -- und sein Name steht daran. Der andere
kann jederzeit als Gast dazukommen. */
felder.host_id = req.person.id;
}
if (neu === "zu" && s.stand === "live") {
/* WAS VORBEI IST, WANDERT IN DEN VERLAUF -- und der Live-Chat
dieser Sendung ist damit Geschichte. Wuerde er stehen
bleiben, faenge die naechste Sendung mit den Rufen der
letzten an. */
const zahl = db().prepare("SELECT COUNT(*) AS n FROM reaktion_chat WHERE weg_am IS NULL").get();
db().prepare(`INSERT INTO reaktion_verlauf
(titel, video, host_id, begonnen, beendet, zuschauer, beitraege)
VALUES (?,?,?,?,?,?,?)`)
.run(s.titel, s.video, s.host_id, s.gestartet_am, jetzt(),
dabeiListe().length, zahl?.n || 0);
db().exec("DELETE FROM reaktion_chat");
db().exec("DELETE FROM reaktion_gaeste");
felder.laeuft = 0;
felder.sekunde = 0;
felder.gestartet_am = null;
}
if (neu === "zu") db().exec("DELETE FROM reaktion_dabei");
const spalten = Object.keys(felder).map((k) => `${k} = ?`).join(", ");
db().prepare(`UPDATE reaktion SET ${spalten} WHERE id = 1`)
.run(...Object.values(felder));
protokolliere(req.person.id, "reaktion_stand", `${s.stand} -> ${neu}`, echteIp(req));
const l = lage(req.person);
melden("reaktion", { was: "stand", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Stand:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Ich bin da / ich bin noch da. Alle 30 Sekunden. */
reaktionRouter.post("/workspace/api/reaktion/dabei", express.json({ limit: "2kb" }),
(req, res) => {
try {
const s = sendung();
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
const schon = db().prepare("SELECT * FROM reaktion_dabei WHERE person_id = ?")
.get(req.person.id);
if (schon) {
db().prepare("UPDATE reaktion_dabei SET gesehen = ? WHERE person_id = ?")
.run(jetzt(), req.person.id);
} else {
/* DER KAMERAPLATZ WIRD BEIM EINTRETEN VERGEBEN, nicht beim
Zeichnen. Sonst haetten zwei gleichzeitig eintretende
Zuschauer denselben letzten Platz gelesen und beide
gerechnet, sie haetten ihn. */
const mitSicht = db().prepare(
"SELECT COUNT(*) AS n FROM reaktion_dabei WHERE sicht = 1").get()?.n || 0;
db().prepare("INSERT INTO reaktion_dabei (person_id, seit, gesehen, sicht) VALUES (?,?,?,?)")
.run(req.person.id, jetzt(), jetzt(), mitSicht < SICHT_PLAETZE ? 1 : 0);
melden("reaktion", { was: "dabei", lage: oeffentlich(lage(req.person)) });
}
res.json(lage(req.person));
} catch (fehler) {
console.error("[reaktion] Dabei:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
function abmelden(req, res) {
try {
db().prepare("DELETE FROM reaktion_dabei WHERE person_id = ?").run(req.person.id);
melden("reaktion", { was: "weg", lage: oeffentlich(lage(req.person)) });
res.json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Weg:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
}
reaktionRouter.delete("/workspace/api/reaktion/dabei", abmelden);
/* DERSELBE WEG NOCH EINMAL ALS POST.
Beim Schliessen des Fensters wird ein gewoehnlicher Abruf
abgebrochen -- dafuer gibt es `navigator.sendBeacon`. Der schickt
aber IMMER POST; ein DELETE waere nie angekommen, und jeder haette
danach zwei Minuten lang als anwesend gegolten. Die Zuschauerzahl
waere den ganzen Abend gestiegen und nie gefallen.
Kein zweiter Code, nur ein zweiter Eingang: Beide zeigen auf
dieselbe Funktion. */
reaktionRouter.post("/workspace/api/reaktion/dabei-weg", abmelden);
/* ---------- Die Verbindungsadressen -------------------------------------
STUN sagt einem Browser, wie er von aussen aussieht; TURN leitet
weiter, wenn eine direkte Verbindung nicht zustande kommt.
WARUM DIE REACTION EINE EIGENE TUER HAT: Vor den Anruf-Wegen steht
`darfTelefonieren`, und die Community ist dort ausdruecklich nicht
dabei -- ein Zuschauer soll kein Teammitglied anklingeln koennen.
Das ist richtig und bleibt. Hier sitzt derselbe Mensch aber im
Saal, und ohne Adressen bliebe sein Bild schwarz.
Dieselbe Einstellung, dieselben Helfer, andere Tuer. Und die Tuer
ist nur offen, solange etwas laeuft: Bei „zu" gibt es nichts zu
holen -- sonst waere das ein dauerhaft offener Weg zu den
TURN-Zugangsdaten fuer jeden im Haus. */
reaktionRouter.get("/workspace/api/reaktion/adressen", (req, res) => {
try {
if (sendung().stand === "zu") return res.json({ adressen: [] });
let liste = [];
try { liste = JSON.parse(einstellung("anruf_adressen") || "[]"); } catch { liste = []; }
/* Als Kennung die Personennummer und nicht der Name -- sie landet
im Protokoll des TURN-Servers, und das ist kein Ort fuer
Klarnamen. Wortgleich zum Anrufteil. */
const { adressen } = mitZugangsdaten(wegeAusfaechern(liste), `r${req.person.id}`);
res.json({ adressen });
} catch (fehler) {
console.error("[reaktion] Adressen:", fehler?.message);
/* KEIN 503. Ohne eigene Adressen nimmt der Browser seine Vorgabe
und die Sendung laeuft weiter -- eine leere Liste ist hier die
ehrlichere Antwort als ein Fehler, der nach Ausfall aussieht. */
res.json({ adressen: [] });
}
});
/* ---------- Gaeste ------------------------------------------------------ */
reaktionRouter.post("/workspace/api/reaktion/gast", nurHost, express.json({ limit: "4kb" }),
(req, res) => {
try {
const id = Number(req.body?.person_id);
if (!Number.isInteger(id) || id < 1) return res.status(400).json({ fehler: "person_unklar" });
const p = db().prepare("SELECT id, name, rolle FROM personen WHERE id = ? AND aktiv = 1").get(id);
if (!p) return res.status(404).json({ fehler: "nicht_gefunden" });
if (id === req.person.id) return res.status(400).json({ fehler: "host_ist_kein_gast" });
const da = db().prepare("SELECT COUNT(*) AS n FROM reaktion_gaeste").get()?.n || 0;
if (da >= GAESTE_MAX) return res.status(409).json({ fehler: "gaeste_voll" });
/* Der Platz ist die kleinste freie Zahl -- nicht „Anzahl + 1".
Wer Gast 1 entfernt und dann jemanden holt, bekaeme sonst
wieder Platz 2, und Platz 1 bliebe fuer immer leer. */
const belegt = new Set(gaesteListe().map((g) => g.platz));
let platz = 1;
while (belegt.has(platz)) platz++;
db().prepare("INSERT OR REPLACE INTO reaktion_gaeste (person_id, seit, stumm, platz) VALUES (?,?,0,?)")
.run(id, jetzt(), platz);
protokolliere(req.person.id, "reaktion_gast_dazu", `${p.name} (Platz ${platz})`, echteIp(req));
const l = lage(req.person);
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
stromAn([id], "reaktion", { was: "du_bist_gast", lage: oeffentlich(l) });
res.status(201).json(l);
} catch (fehler) {
console.error("[reaktion] Gast:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.delete("/workspace/api/reaktion/gast/:id", nurHost, (req, res) => {
try {
const id = Number(req.params.id);
const weg = db().prepare("DELETE FROM reaktion_gaeste WHERE person_id = ?").run(id);
if (!weg.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
const l = lage(req.person);
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
stromAn([id], "reaktion", { was: "du_bist_kein_gast", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Gast weg:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.put("/workspace/api/reaktion/gast/:id/stumm", nurHost,
express.json({ limit: "2kb" }), (req, res) => {
try {
const id = Number(req.params.id);
const stumm = req.body?.stumm ? 1 : 0;
const a = db().prepare("UPDATE reaktion_gaeste SET stumm = ? WHERE person_id = ?")
.run(stumm, id);
if (!a.changes) return res.status(404).json({ fehler: "nicht_gefunden" });
const l = lage(req.person);
melden("reaktion", { was: "gaeste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Stumm:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Das Video --------------------------------------------------
Der Host sagt, wo das Video steht. Alle anderen folgen.
GESCHICKT WIRD DER SPIELSTAND, NICHT DAS BILD -- die Begruendung
steht in reaktion-tabellen.js. Hier steht nur die Folge davon:
Diese Route ist die einzige, die waehrend der Sendung oft laeuft,
und sie schreibt deshalb so wenig wie moeglich. */
reaktionRouter.post("/workspace/api/reaktion/video", nurHost, express.json({ limit: "2kb" }),
(req, res) => {
try {
const laeuft = req.body?.laeuft ? 1 : 0;
const sekunde = Math.max(0, Number(req.body?.sekunde) || 0);
const video = req.body?.video === undefined ? null : videoKennung(String(req.body.video));
if (req.body?.video !== undefined && !video) {
return res.status(400).json({ fehler: "video_unklar" });
}
const s0 = sendung();
if (video) {
db().prepare("UPDATE reaktion SET video = ?, laeuft = ?, sekunde = ?, geaendert = ? WHERE id = 1")
.run(video, laeuft, sekunde, jetzt());
/* Der Name wird NACHGEREICHT und nicht abgewartet: Diese
Route laeuft waehrend der Sendung im Sekundentakt. Sie
darf unter keinen Umstaenden auf YouTube warten. */
if (video !== s0.video) {
videoTitel(video).then((t) => {
try {
db().prepare("UPDATE reaktion SET video_titel = ? WHERE id = 1 AND video = ?")
.run(t, video);
} catch { /* dann steht dort die Kennung, und das ist ehrlich */ }
});
}
} else {
db().prepare("UPDATE reaktion SET laeuft = ?, sekunde = ? WHERE id = 1")
.run(laeuft, sekunde);
}
const s = sendung();
melden("reaktion", {
was: "video",
video: s.video, laeuft: !!s.laeuft, sekunde: s.sekunde,
/* Die Uhrzeit des Servers geht mit: Der Empfaenger rechnet
die Laufzeit seit dem Absenden dazu. Ohne das waere jeder
genau so weit hinten, wie sein Netz langsam ist. */
gesendet: Date.now(),
});
res.json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Video:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Warteschlange ------------------------------------------
Vorher einraeumen, waehrend der Sendung nur weiterschalten.
WARUM DAS EINE EIGENE SACHE IST UND NICHT „NOCH EIN TEXTFELD":
Bisher gab es genau ein Video. Wer zwei Sachen hintereinander
schauen wollte, musste mitten in der Sendung eine YouTube-Adresse
in ein Feld tippen -- vor Publikum, mit laufender Kamera, mit dem
Risiko eines Tippfehlers und damit eines schwarzen Rechtecks.
Jetzt ist das eine Vorbereitung und kein Handgriff im Live. */
reaktionRouter.post("/workspace/api/reaktion/liste", nurHost, express.json({ limit: "8kb" }),
async (req, res) => {
try {
const video = videoKennung(String(req.body?.video ?? ""));
if (!video) return res.status(400).json({ fehler: "video_unklar" });
const wie_viele = db().prepare("SELECT COUNT(*) AS n FROM reaktion_liste").get()?.n || 0;
if (wie_viele >= LISTE_MAX) return res.status(409).json({ fehler: "liste_voll" });
/* DASSELBE VIDEO NICHT ZWEIMAL. Nicht aus Ordnungsliebe: Wer
es zweimal anhaengt, hat es schlicht vergessen -- und merkt
es erst, wenn es zum zweiten Mal laeuft und alle es schon
kennen. */
const schon = db().prepare("SELECT id FROM reaktion_liste WHERE video = ?").get(video);
if (schon) return res.status(409).json({ fehler: "liste_doppelt" });
const titel = await videoTitel(video);
const letzter = db().prepare("SELECT MAX(platz) AS p FROM reaktion_liste").get()?.p || 0;
db().prepare("INSERT INTO reaktion_liste (video, titel, platz, von_id, gesetzt_am)"
+ " VALUES (?,?,?,?,?)")
.run(video, titel, letzter + 1, req.person.id, jetzt());
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Liste anhaengen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.delete("/workspace/api/reaktion/liste/:id", nurHost, (req, res) => {
try {
db().prepare("DELETE FROM reaktion_liste WHERE id = ?").run(Number(req.params.id) || 0);
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Liste weg:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Eins hoch oder runter.
*
* GETAUSCHT WIRD MIT DEM NACHBARN, nicht neu durchnummeriert. Beim
* Durchnummerieren schreibt ein Klick jede Zeile der Liste neu;
* zwei Klicks gleichzeitig -- Filipe am Rechner, VanVan am Handy --
* ergeben dann eine Reihenfolge, die keiner von beiden wollte. Ein
* Tausch beruehrt zwei Zeilen und ist in sich abgeschlossen. */
reaktionRouter.post("/workspace/api/reaktion/liste/:id/schieben", nurHost,
express.json({ limit: "2kb" }), (req, res) => {
try {
const id = Number(req.params.id) || 0;
const hoch = String(req.body?.richtung ?? "hoch") === "hoch";
const alle = listeHolen();
const wo = alle.findIndex((z) => z.id === id);
if (wo < 0) return res.status(404).json({ fehler: "nicht_in_der_liste" });
const nachbar = alle[hoch ? wo - 1 : wo + 1];
if (!nachbar) return res.json(lage(req.person)); // schon ganz oben/unten
/* `node:sqlite` kennt kein `.transaction()` -- das gibt es nur
in better-sqlite3. Hier wird von Hand geklammert, so wie im
ganzen Haus. Ohne Klammer koennte zwischen den beiden
Schreibvorgaengen gelesen werden, und dann haetten zwei
Zeilen kurz denselben Platz. */
db().exec("BEGIN IMMEDIATE");
try {
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
.run(nachbar.platz, alle[wo].id);
db().prepare("UPDATE reaktion_liste SET platz = ? WHERE id = ?")
.run(alle[wo].platz, nachbar.id);
db().exec("COMMIT");
} catch (schief) {
try { db().exec("ROLLBACK"); } catch { /* war schon zurueck */ }
throw schief;
}
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Liste schieben:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Dieses hier jetzt -- oder schlicht das naechste. */
reaktionRouter.post("/workspace/api/reaktion/weiter", nurHost, express.json({ limit: "2kb" }),
(req, res) => {
try {
const id = Number(req.body?.id) || 0;
const zeile = id
? db().prepare("SELECT * FROM reaktion_liste WHERE id = ?").get(id)
: db().prepare("SELECT * FROM reaktion_liste ORDER BY platz, id LIMIT 1").get();
if (!zeile) return res.status(409).json({ fehler: "liste_leer" });
weiterZu(zeile, req.person.id);
const l = lage(req.person);
melden("reaktion", { was: "liste", lage: oeffentlich(l) });
res.json(l);
} catch (fehler) {
console.error("[reaktion] Weiter:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/layout", nurHost, express.json({ limit: "2kb" }),
(req, res) => {
try {
const l = String(req.body?.layout ?? "");
if (!LAYOUTS.includes(l)) return res.status(400).json({ fehler: "layout_unbekannt" });
db().prepare("UPDATE reaktion SET layout = ? WHERE id = 1").run(l);
melden("reaktion", { was: "layout", layout: l });
res.json({ ok: true, layout: l });
} catch (fehler) {
console.error("[reaktion] Layout:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Der Live-Chat ----------------------------------------------- */
reaktionRouter.get("/workspace/api/reaktion/chat", (req, res) => {
try {
const reihen = db().prepare(`
SELECT id, person_id, name, rolle, text, erstellt
FROM reaktion_chat WHERE weg_am IS NULL ORDER BY id DESC LIMIT 120`).all();
res.json({ beitraege: reihen.reverse() });
} catch (fehler) {
console.error("[reaktion] Chat:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
reaktionRouter.post("/workspace/api/reaktion/chat", express.json({ limit: "8kb" }),
(req, res) => {
try {
const s = sendung();
if (s.stand === "zu") return res.status(409).json({ fehler: "geschlossen" });
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
if (!text) return res.status(400).json({ fehler: "leer" });
if (zuSchnell(req.person.id)) return res.status(429).json({ fehler: "zu_schnell" });
const { lastInsertRowid } = db().prepare(`INSERT INTO reaktion_chat
(person_id, name, rolle, text, erstellt) VALUES (?,?,?,?,?)`)
.run(req.person.id, req.person.name, req.person.rolle, text, jetzt());
const beitrag = db().prepare(
"SELECT id, person_id, name, rolle, text, erstellt FROM reaktion_chat WHERE id = ?")
.get(Number(lastInsertRowid));
melden("reaktion_chat", { was: "neu", beitrag });
res.status(201).json(beitrag);
} catch (fehler) {
console.error("[reaktion] Schreiben:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/** Moderation: einen Beitrag wegnehmen.
*
* WEGGENOMMEN UND NICHT GELOESCHT. Wer moderiert, soll spaeter
* sagen koennen, was er weggenommen hat und warum -- eine geloeschte
* Zeile kann das nicht. */
reaktionRouter.delete("/workspace/api/reaktion/chat/:id", (req, res) => {
try {
const id = Number(req.params.id);
const b = db().prepare("SELECT * FROM reaktion_chat WHERE id = ?").get(id);
if (!b || b.weg_am) return res.status(404).json({ fehler: "nicht_gefunden" });
const eigen = b.person_id === req.person.id;
if (!eigen && !MODERIERT.has(req.person.rolle)) {
return res.status(403).json({ fehler: "nicht_erlaubt" });
}
db().prepare("UPDATE reaktion_chat SET weg_am = ?, weg_von = ? WHERE id = ?")
.run(jetzt(), req.person.id, id);
if (!eigen) {
protokolliere(req.person.id, "reaktion_chat_weg", `Beitrag ${id} von ${b.name}`, echteIp(req));
}
melden("reaktion_chat", { was: "weg", id });
res.json({ ok: true });
} catch (fehler) {
console.error("[reaktion] Wegnehmen:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ---------- Die Kameras ------------------------------------------------
Nur der Hinweg. Was in `signal` steht, liest dieser Server nicht --
es geht unveraendert an den Empfaenger. Genau so macht es der
Anrufteil seit dem 18.09.
GESCHICKT WIRD NUR AN LEUTE, DIE GERADE DABEI SIND. Ohne das
koennte jemand aus dem Haus Verbindungsdaten an jeden Beliebigen
schicken, und die Reaction waere ein Weg, jemanden anzuklingeln. */
reaktionRouter.post("/workspace/api/reaktion/signal", express.json({ limit: "256kb" }),
(req, res) => {
try {
const an = Number(req.body?.an);
const daten = req.body?.daten;
if (!Number.isInteger(an) || daten === undefined) {
return res.status(400).json({ fehler: "unvollstaendig" });
}
const kreis = new Set(empfaenger());
if (!kreis.has(an) || !kreis.has(req.person.id)) {
return res.status(409).json({ fehler: "nicht_dabei" });
}
const wie_viele = stromAn([an], "reaktion_signal", {
von: req.person.id, name: req.person.name, daten,
});
res.json({ ok: true, zugestellt: wie_viele });
} catch (fehler) {
console.error("[reaktion] Signal:", fehler?.message);
res.status(503).json({ fehler: "nicht_verfuegbar" });
}
});
/* ======================================================================
HILFEN
====================================================================== */
/** Aus allem, was jemand einfuegen koennte, die YouTube-Kennung.
*
* Angenommen werden die vier Formen, die man wirklich in der
* Zwischenablage hat: die lange Adresse, die kurze, der
* Einbett-Pfad und die nackte Kennung. Alles andere wird
* abgelehnt -- eine falsche Kennung ist ein schwarzes Rechteck vor
* Publikum, und das faellt erst auf, wenn alle zusehen. */
export function videoKennung(roh) {
const t = String(roh || "").trim();
if (!t) return "";
if (/^[A-Za-z0-9_-]{11}$/.test(t)) return t;
const muster = [
/[?&]v=([A-Za-z0-9_-]{11})/,
/youtu\.be\/([A-Za-z0-9_-]{11})/,
/youtube\.com\/embed\/([A-Za-z0-9_-]{11})/,
/youtube\.com\/shorts\/([A-Za-z0-9_-]{11})/,
/youtube\.com\/live\/([A-Za-z0-9_-]{11})/,
];
for (const m of muster) {
const t2 = t.match(m);
if (t2) return t2[1];
}
return "";
}
/** Die Lage ohne die Namensliste -- fuer den Rundruf.
*
* `lage()` gibt einem Host die Namen aller Zusehenden. Genau diese
* Antwort geht per Strom an ALLE. Ohne dieses Sieb bekaeme jeder
* Zuschauer die Liste, die nur die Leitung sehen darf -- und zwar
* ohne dass es jemandem auffaellt, weil die Oberflaeche sie ja
* nicht anzeigt. */
function oeffentlich(l) {
const { zuschauer, ich, liste, ...rest } = l;
return rest;
}
/** Aufraeumen: alter Verlauf und liegengebliebene Zusehende. */
export function reaktionAufraeumen() {
try {
const grenze = new Date(Date.now() - VERLAUF_TAGE * 86400_000).toISOString();
const a = db().prepare("DELETE FROM reaktion_verlauf WHERE beendet < ?").run(grenze);
const b = db().prepare("DELETE FROM reaktion_dabei WHERE gesehen < ?")
.run(new Date(Date.now() - 600_000).toISOString());
if (a.changes || b.changes) {
console.log(`[reaktion] Aufgeraeumt: ${a.changes} Verlauf, ${b.changes} Zusehende.`);
}
} catch (fehler) {
console.error("[reaktion] Aufraeumen:", fehler?.message);
}
}