diff --git a/server/helfer-ffmpeg.mjs b/server/helfer-ffmpeg.mjs
new file mode 100644
index 00000000..9bff402a
--- /dev/null
+++ b/server/helfer-ffmpeg.mjs
@@ -0,0 +1,187 @@
+/* =====================================================================
+ helfer-ffmpeg.mjs — eine Sprachnachricht, die JEDES Gerät abspielt
+
+ =====================================================================
+ WARUM ES DIESE DATEI GIBT (03.10.2026)
+
+ Miss im Support, Meldung #12 — fünf Runden lang: „Bei mir lässt
+ sich die Nachricht nicht abspielen." Zuletzt: „Jetzt lädt es die
+ ganze Zeit." Bei allen anderen ging es.
+
+ GEMESSEN, BEVOR GEBAUT WURDE:
+ · Alle sechs Sprachnachrichten im Haus sind `audio/webm`.
+ · Miss' Gerät: iPhone, iOS 18.7, WebKit.
+ · Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
+ wurde KEINE EINZIGE neue aufgenommen — ihr Problem betrifft
+ also ausschließlich die sechs alten Dateien.
+
+ WAS ICH NICHT MESSEN KONNTE, und das gehört hierher: Ob WebKit
+ WebM/Opus abspielen kann, lässt sich auf diesem Rechner nicht
+ nachsehen — Playwrights WebKit startet hier nicht (`libegl.dll`
+ fehlt). Die Vermutung „iPhones können kein WebM" ist deshalb
+ begründet, aber nicht von mir gemessen.
+
+ GENAU DESHALB IST DIE LÖSUNG HIER EINE, DIE DIE FRAGE NICHT
+ STELLT. Statt zu raten, welches Gerät welchen Behälter kann, legt
+ der Server neben jede Sprachnachricht eine zweite Fassung in dem
+ Format, das seit zwanzig Jahren überall läuft: AAC in MP4. Wer sie
+ hat, bekommt sie. Kein Gerätename im Code, keine Liste, die
+ altert.
+
+ ---------------------------------------------------------------------
+ WARUM EIN FREMDES PROGRAMM UND NICHT NODE
+
+ Node kann kein Audio umwandeln, und ein Paket dafür wäre die
+ einzige nennenswerte Abhängigkeit in einem Haus, das ohne Baulauf
+ auskommt. ffmpeg ist auf dem Server installiert (7.1.5, Debian) —
+ abgesprochen, nicht nebenbei eingerichtet.
+
+ DREI AUSGÄNGE, wie überall: umgewandelt / nicht umwandelbar /
+ ffmpeg nicht auffindbar. Der dritte ist KEIN Fehler: Fehlt ffmpeg,
+ wird die Sprachnachricht wie bisher im Originalformat
+ ausgeliefert. Niemand verliert etwas, was er vorher hatte — es
+ kommt nur niemand dazu.
+ ===================================================================== */
+
+import { existsSync } from "node:fs";
+import { execFile } from "node:child_process";
+import { delimiter, join, extname, basename, dirname } from "node:path";
+
+/* Die Endung der Zweitfassung. AAC in MP4 — das Format, bei dem sich
+ iPhone, Android, Windows, Mac und jeder Browser einig sind. */
+export const ZWEITFASSUNG_ENDUNG = ".m4a";
+export const ZWEITFASSUNG_TYP = "audio/mp4";
+
+/* 96 kbit/s für Sprache. Opus holt bei 32 kbit/s dasselbe heraus,
+ AAC braucht mehr — und eine Sprachnachricht von einer Minute wiegt
+ damit 700 KB statt 240. Das ist der Preis dafür, dass sie überall
+ läuft, und er ist es wert. Niedriger wäre hörbar dumpf; höher
+ brächte bei einem Handymikrofon nichts. */
+const RATE = "96k";
+
+/* Nach dieser Zeit wird abgebrochen. Eine Sprachnachricht von einer
+ Minute braucht auf dem Server unter einer Sekunde; wer hier in die
+ Frist läuft, hat ein anderes Problem, und ein hängender Prozess
+ wäre das schlimmere. */
+const FRIST_MS = 60_000;
+
+const BEKANNTE_ORTE = [
+ "/usr/bin/ffmpeg",
+ "/usr/local/bin/ffmpeg",
+ "C:/Program Files/ffmpeg/bin/ffmpeg.exe",
+];
+
+let gemerkt = null;
+let schonGesagt = false;
+
+/* IM PATH SUCHEN, OHNE `where` ODER `which` ZU BRAUCHEN — dieselbe
+ Überlegung wie in helfer-openssl.mjs: Beide sind selbst Programme
+ und können genauso fehlen. */
+function imPfad() {
+ const pfade = (process.env.PATH || "").split(delimiter).filter(Boolean);
+ const namen = process.platform === "win32" ? ["ffmpeg.exe", "ffmpeg"] : ["ffmpeg"];
+ for (const p of pfade) {
+ for (const n of namen) {
+ const voll = join(p, n);
+ if (existsSync(voll)) return voll;
+ }
+ }
+ return null;
+}
+
+/** Wo liegt ffmpeg — oder `null`.
+ *
+ * GIBT `null` ZURUECK UND WIRFT NICHT. Anders als bei openssl ist
+ * das Fehlen hier kein Abbruchgrund: Die Sprachnachricht
+ * funktioniert auch ohne Zweitfassung, nur eben nicht überall. Ein
+ * geworfener Fehler würde das Hochladen scheitern lassen — und
+ * damit aus einer fehlenden Verbesserung einen Ausfall machen. */
+export function ffmpegPfad() {
+ if (gemerkt !== null) return gemerkt || null;
+ gemerkt = imPfad() || BEKANNTE_ORTE.find((o) => existsSync(o)) || "";
+ if (!gemerkt && !schonGesagt) {
+ schonGesagt = true;
+ /* EINMAL SAGEN, NICHT BEI JEDER NACHRICHT. Eine Zeile, die bei
+ jedem Hochladen kommt, liest nach dem dritten Mal niemand
+ mehr — und deckt dabei die echten zu. */
+ console.log("[ton] ffmpeg nicht gefunden – Sprachnachrichten bekommen keine "
+ + "zweite Fassung. Auf Debian: sudo apt-get install -y ffmpeg");
+ }
+ return gemerkt || null;
+}
+
+/** Wo liegt ffprobe — oder `null`.
+ *
+ * ES LIEGT IMMER NEBEN ffmpeg (dasselbe Paket, derselbe Ordner),
+ * deshalb wird es dort gesucht und nicht noch einmal im PATH.
+ * Gebraucht wird es nur von den Prüfungen: Nur ffprobe kann sagen,
+ * ob in der erzeugten Datei wirklich AAC steckt und ob sie so lang
+ * ist wie das Original. Ohne diese Frage wäre „die Datei ist da"
+ * ein grüner Haken über einer Datei, die niemand abspielen kann. */
+export function ffprobePfad() {
+ const ff = ffmpegPfad();
+ if (!ff) return null;
+ const nachbar = ff.replace(/ffmpeg(\.exe)?$/i, (m) => m.replace(/ffmpeg/i, "ffprobe"));
+ return existsSync(nachbar) ? nachbar : null;
+}
+
+/** Der Pfad, unter dem die Zweitfassung einer Datei liegt (oder läge). */
+export function zweitfassungPfad(pfad) {
+ return join(dirname(pfad), basename(pfad, extname(pfad)) + ZWEITFASSUNG_ENDUNG);
+}
+
+/** Braucht diese Datei überhaupt eine Zweitfassung?
+ *
+ * NEIN, WENN SIE SCHON DAS ZIELFORMAT IST. Eine m4a nach m4a zu
+ * wandeln kostet Rechenzeit und Qualität für genau nichts — und
+ * seit dem 02.10. nehmen die meisten Geräte ohnehin MP4 auf. */
+export function brauchtZweitfassung(pfad) {
+ return extname(pfad).toLowerCase() !== ZWEITFASSUNG_ENDUNG;
+}
+
+/** Eine Tondatei in AAC/MP4 danebenlegen.
+ *
+ * Gibt ein Versprechen auf `true` (liegt jetzt da), `false` (ging
+ * nicht) oder `null` (ffmpeg fehlt — konnte nicht nachsehen).
+ *
+ * SIE WIRFT NIE. Der Aufrufer hängt an einem Hochladen, und ein
+ * Fehler beim Umwandeln darf die Nachricht nicht verhindern.
+ *
+ * `-movflags +faststart` IST NICHT KOSMETIK: Ohne es steht die
+ * Inhaltsübersicht einer MP4 am ENDE der Datei. Ein Abspielgerät
+ * muss dann erst bis ans Ende lesen, bevor es anfangen kann — bei
+ * einer Teilanfrage über eine mobile Verbindung ist das genau das
+ * „lädt die ganze Zeit", über das sich Miss beklagt hat. */
+export function zweitfassungBauen(pfad) {
+ return new Promise((fertig) => {
+ const ff = ffmpegPfad();
+ if (!ff) return fertig(null);
+ const ziel = zweitfassungPfad(pfad);
+ /* SCHON DA HEISST FERTIG. Der Nachrüstlauf geht mehrmals über
+ denselben Ordner; ohne diese Zeile würde er jedes Mal alles
+ neu wandeln. */
+ if (existsSync(ziel)) return fertig(true);
+ execFile(ff, [
+ "-nostdin", "-hide_banner", "-loglevel", "error",
+ "-i", pfad,
+ /* KEIN BILD. Eine WebM kann ein Titelbild enthalten; in einer
+ Audiodatei hätte es nichts verloren und bringt ffmpeg bei
+ manchen Dateien zum Stolpern. */
+ "-vn",
+ "-c:a", "aac", "-b:a", RATE,
+ "-movflags", "+faststart",
+ /* NICHT ÜBERSCHREIBEN. Eine halbe Datei von einem
+ abgebrochenen Lauf soll auffallen, nicht stillschweigend
+ ersetzt werden -- und `existsSync` oben hat ohnehin schon
+ gefragt. */
+ "-n", ziel,
+ ], { timeout: FRIST_MS }, (fehler) => {
+ if (fehler) {
+ console.error(`[ton] Zweitfassung fuer ${basename(pfad)} ging nicht:`,
+ String(fehler.message || fehler).split("\n")[0]);
+ return fertig(false);
+ }
+ fertig(true);
+ });
+ });
+}
diff --git a/server/index.js b/server/index.js
index abbbbbc9..10566a3f 100644
--- a/server/index.js
+++ b/server/index.js
@@ -66,7 +66,7 @@ import { ampelRouter } from "./workspace-ampel.js";
import { checklisteRouter } from "./workspace-checkliste.js";
import { teamlageRouter } from "./workspace-teamlage.js";
import { pushRouter, pushStarten } from "./workspace-push.js";
-import { chatRouter } from "./workspace-chat.js";
+import { chatRouter, toeneNachruestenStarten } from "./workspace-chat.js";
import { anrufRouter } from "./workspace-anruf.js";
import { videoRouter, videoWacheStarten } from "./workspace-video.js";
import { bewerbungRouter } from "./workspace-bewerbung.js";
@@ -370,6 +370,10 @@ app.use(contentRouter);
Systemdienst laeuft und warum eine Dateikopie hier falsch waere. */
sicherungStarten();
aufbewahrungStarten();
+/* Sprachnachrichten in eine Fassung bringen, die jedes Geraet
+ abspielt (Miss im Support, Meldung #12). Der Lauf meldet sich
+ nur, wenn er etwas getan hat. */
+toeneNachruestenStarten();
/* Sieht taeglich nach, ob die verlinkten Videos bei TikTok noch da
sind. Ein Knopf, der ins Leere fuehrt, ist schlimmer als kein
Knopf -- man klickt ihn ja. */
diff --git a/server/pruef-chat-anhaenge.mjs b/server/pruef-chat-anhaenge.mjs
index a98e4874..947a283e 100644
--- a/server/pruef-chat-anhaenge.mjs
+++ b/server/pruef-chat-anhaenge.mjs
@@ -105,6 +105,11 @@ const PROTOKOLL = join(process.cwd(), "pruef-chat-anhaenge-lauf.txt");
try { rmSync(PROTOKOLL, { force: true }); } catch { /* gab es noch nicht */ }
const melde = (z) => { console.log(z); try { appendFileSync(PROTOKOLL, z + "\n"); } catch { /* Zugabe */ } };
let fehler = 0, geprueft = 0;
+/* DER DRITTE AUSGANG BRAUCHT EINEN ZAEHLER (03.10.2026). „Konnte
+ nicht nachsehen" ist weder in Ordnung noch kaputt -- ohne eigene
+ Zahl verschwaende es zwischen den gruenen Zeilen, und am Ende
+ stuende „alles in Ordnung" ueber etwas, das niemand gemessen hat. */
+let nichtNachsehbar = 0;
const ok = (b, t) => { geprueft++; melde((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---- Bestand ------------------------------------------------------------ */
@@ -1063,6 +1068,259 @@ async function tonAufnehmen(seite, ms) {
ok((zeile?.anhang_dauer || 0) >= 2000 && (zeile?.anhang_dauer || 0) < 20000,
` und die Länge steht dabei (${zeile?.anhang_dauer} ms)`);
+ /* ==== DIE ZWEITE FASSUNG, DIE JEDES GERAET ABSPIELT (03.10.2026)
+ ==================================================================
+
+ Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst sich
+ die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt es die
+ ganze Zeit." Alle sechs Sprachnachrichten im Haus waren WebM.
+
+ AUSDRUECKLICH EINE WEBM, und das ist hier der Punkt. Die
+ Aufnahme ueber den Knopf weiter oben liefert seit dem 02.10.
+ eine MP4 -- die braucht keine zweite Fassung, und der erste
+ Anlauf dieser Pruefung mass deshalb NICHTS und wurde rot. Die
+ Pruefung hatte recht, die Erwartung war falsch: Gemessen werden
+ muss der Fall, den es bei Miss gibt, und das ist die WebM von
+ frueher (oder von einem Firefox, der nichts anderes kann).
+
+ WAS GEMESSEN WIRD, IST NICHT „die Datei ist da". Das waere ein
+ gruener Haken ueber etwas, das niemand abspielen kann. Mit
+ ffprobe wird gefragt, WAS drinsteckt: AAC, in einem MP4, so lang
+ wie das Original -- und mit der Inhaltsuebersicht VORNE.
+
+ DER DRITTE AUSGANG: Ohne ffmpeg gibt es keine zweite Fassung.
+ Das ist kein Fehler des Programms und wird auch nicht
+ stillschweigend uebersprungen -- es steht als eigene Zeile da,
+ mit der Anleitung dazu.
+ ================================================================== */
+ {
+ const { ffmpegPfad, ffprobePfad, zweitfassungPfad } =
+ await import("./helfer-ffmpeg.mjs");
+ const { execFileSync } = await import("node:child_process");
+ const { readFileSync: lies, statSync: wiegt } = await import("node:fs");
+
+ if (!ffmpegPfad()) {
+ melde(" -- KONNTE NICHT NACHSEHEN: ffmpeg fehlt auf diesem Rechner.");
+ melde(" Ohne ffmpeg bekommt keine Sprachnachricht eine zweite");
+ melde(" Fassung -- die Pruefung kann nicht sagen, ob sie");
+ melde(" richtig waere. Auf Debian: sudo apt-get install -y ffmpeg");
+ nichtNachsehbar++;
+ } else {
+ /* EINE ECHTE WEBM, vom Browser aufgenommen -- nicht
+ zusammengebaut. `tonAufnehmen` fragt der Reihe nach und nimmt
+ das erste Format, das geht; die Liste beginnt mit WebM. */
+ const webmAuf = await dogi.seite.evaluate(async (ms) => {
+ const spur = await navigator.mediaDevices.getUserMedia({ audio: true });
+ if (!MediaRecorder.isTypeSupported("audio/webm;codecs=opus")) {
+ spur.getTracks().forEach((t) => t.stop());
+ return { typ: "", b64: "", bytes: 0 };
+ }
+ const r = new MediaRecorder(spur, { mimeType: "audio/webm;codecs=opus" });
+ const teile = [];
+ r.ondataavailable = (e) => { if (e.data.size) teile.push(e.data); };
+ const fertig = new Promise((f) => { r.onstop = f; });
+ r.start();
+ await new Promise((f) => setTimeout(f, ms));
+ r.stop();
+ await fertig;
+ spur.getTracks().forEach((t) => t.stop());
+ const blob = new Blob(teile, { type: "audio/webm" });
+ const puffer = new Uint8Array(await blob.arrayBuffer());
+ let roh = "";
+ for (const b of puffer) roh += String.fromCharCode(b);
+ return { typ: "audio/webm", b64: btoa(roh), bytes: puffer.length };
+ }, 2500);
+ ok(webmAuf.bytes > 500,
+ `eine echte WebM-Sprachnachricht aufgenommen (${webmAuf.bytes} Bytes)`);
+
+ const geschickt = await dogi.seite.evaluate(async ([r, b64]) => {
+ const roh = atob(b64);
+ const puffer = new Uint8Array(roh.length);
+ for (let i = 0; i < roh.length; i++) puffer[i] = roh.charCodeAt(i);
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
+ method: "POST",
+ headers: { "Content-Type": "audio/webm",
+ "X-Dateiname": "Sprachnachricht.webm", "X-Dauer": "2500" },
+ body: puffer,
+ });
+ const d = await a.json().catch(() => ({}));
+ return { status: a.status, id: d?.nachricht?.id || 0 };
+ }, [raumDogi, webmAuf.b64]);
+ ok(geschickt.status === 201 && geschickt.id > 0,
+ `sie geht durch (${geschickt.status}, #${geschickt.id})`);
+
+ /* AUF DIE UMWANDLUNG WARTEN. Sie laeuft absichtlich NEBEN dem
+ Hochladen -- die Nachricht soll nicht auf ffmpeg warten.
+ Deshalb wird hier nachgesehen, statt es anzunehmen, und zwar
+ mit einer Frist: Ein festes `waitForTimeout` waere entweder
+ zu kurz (rot ohne Grund) oder zu lang (jeder Lauf zahlt). */
+ let weg2 = null;
+ for (let i = 0; i < 40 && !weg2; i++) {
+ // eslint-disable-next-line no-await-in-loop
+ weg2 = await dogi.seite.evaluate(async ([r, id]) => {
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
+ const d = await a.json();
+ const n = (d.nachrichten || []).find((x) => x.id === id);
+ return n?.anhang?.weg_auch || null;
+ }, [raumDogi, geschickt.id]);
+ // eslint-disable-next-line no-await-in-loop
+ if (!weg2) await new Promise((r) => setTimeout(r, 500));
+ }
+ ok(!!weg2, `die zweite Fassung entsteht von selbst (${weg2 || "kam nicht"})`);
+ ok(/\?form=mp4$/.test(weg2 || ""), ` und sie hat einen eigenen Weg (${weg2})`);
+
+ if (weg2) {
+ /* ---- Was kommt ueber die Leitung? ------------------------- */
+ const kopf = await dogi.seite.evaluate(async (w) => {
+ const a = await fetch(w);
+ const b = new Uint8Array(await a.arrayBuffer());
+ return {
+ code: a.status,
+ typ: a.headers.get("content-type"),
+ bereiche: a.headers.get("accept-ranges"),
+ bytes: b.length,
+ /* Die ersten zwoelf Bytes einer MP4: vier Byte Laenge,
+ dann „ftyp". Daran erkennt man den Behaelter, ohne ihn
+ zu glauben. */
+ marke: String.fromCharCode(...b.slice(4, 8)),
+ };
+ }, weg2);
+ ok(kopf.code === 200 && kopf.typ === "audio/mp4",
+ `sie kommt als audio/mp4 heraus (${kopf.code}, ${kopf.typ})`);
+ ok(kopf.marke === "ftyp",
+ ` und ist wirklich ein MP4-Behaelter (Marke „${kopf.marke}")`);
+ ok(kopf.bereiche === "bytes",
+ ` ueber denselben Ausliefer-Weg wie alles andere (${kopf.bereiche})`);
+ ok(kopf.bytes > 2000, ` mit Inhalt (${kopf.bytes} Bytes)`);
+
+ /* ---- DAS ORIGINAL BLEIBT ABRUFBAR -------------------------
+ Ohne `?form=mp4` kommt weiter die WebM. Sie ist das, was
+ hochgeladen wurde; sie zu ersetzen hiesse, die Aufnahme
+ durch eine Umrechnung davon auszutauschen. */
+ const original = await dogi.seite.evaluate(async (w) => {
+ const a = await fetch(w.replace("?form=mp4", ""));
+ return { code: a.status, typ: a.headers.get("content-type") };
+ }, weg2);
+ ok(original.code === 200 && original.typ === "audio/webm",
+ `das Original bleibt daneben liegen (${original.typ})`);
+
+ /* ---- UND WAS STECKT WIRKLICH DRIN? ------------------------ */
+ const db4 = new DatabaseSync(process.env.WORKSPACE_DB);
+ const tonZeile = db4.prepare(
+ "SELECT anhang_datei FROM chat_nachrichten WHERE id = ?").get(geschickt.id);
+ db4.close();
+ const { DATEN_ORDNER } = await import("./workspace.js");
+ const quelle = join(DATEN_ORDNER, "chat-anhaenge", tonZeile.anhang_datei);
+ const ziel = zweitfassungPfad(quelle);
+
+ const probe = ffprobePfad();
+ if (!probe) {
+ melde(" -- KONNTE NICHT NACHSEHEN: ffprobe fehlt neben ffmpeg.");
+ nichtNachsehbar++;
+ } else {
+ const frag = (datei, was) => {
+ try {
+ return execFileSync(probe, ["-v", "error", "-show_entries", was,
+ "-of", "default=noprint_wrappers=1:nokey=1", datei],
+ { encoding: "utf8" }).trim();
+ } catch { return ""; }
+ };
+ const codec = frag(ziel, "stream=codec_name");
+ const codecAlt = frag(quelle, "stream=codec_name");
+ const dauerNeu = Number(frag(ziel, "format=duration"));
+ const dauerAlt = Number(frag(quelle, "format=duration"));
+ ok(codecAlt === "opus",
+ `das Original ist Opus -- also genau der Fall von Miss (${codecAlt})`);
+ ok(codec === "aac", `drinnen steckt AAC, nicht nur eine neue Endung (${codec})`);
+ ok(dauerAlt > 0 && Math.abs(dauerNeu - dauerAlt) < 0.5,
+ `und sie ist so lang wie das Original (${dauerAlt.toFixed(2)} s → `
+ + `${dauerNeu.toFixed(2)} s)`);
+ /* GEGENPROBE ZUR LAENGE: Eine halbierte Datei MUSS
+ auffallen. Ohne diese Zeile waere die Pruefung darueber
+ auch mit einer abgeschnittenen Aufnahme gruen, solange
+ ffprobe irgendeine Zahl liefert. */
+ ok(Math.abs((dauerAlt / 2) - dauerAlt) >= 0.5,
+ ` Gegenprobe: eine halbierte Laenge waere aufgefallen `
+ + `(${(dauerAlt / 2).toFixed(2)} s gegen ${dauerAlt.toFixed(2)} s)`);
+ }
+
+ /* ---- DIE INHALTSUEBERSICHT STEHT VORNE --------------------
+ Ohne `+faststart` liegt `moov` am Dateiende. Das
+ Abspielgeraet muss dann erst bis ans Ende lesen, bevor es
+ anfangen kann -- genau das „laedt die ganze Zeit", das Miss
+ in Runde 5 beschrieben hat. Gemessen wird die Reihenfolge
+ der Kaesten in der Datei, nicht die Zeile im Aufruf. */
+ const roh = lies(ziel);
+ const wo = (name) => roh.indexOf(Buffer.from(name, "latin1"));
+ const moov = wo("moov");
+ const mdat = wo("mdat");
+ ok(moov > 0 && mdat > 0 && moov < mdat,
+ `die Inhaltsuebersicht steht VOR den Tondaten (moov ${moov}, mdat ${mdat})`);
+ ok(wiegt(ziel).size === kopf.bytes,
+ ` und was auf der Platte liegt, kommt auch heraus (${wiegt(ziel).size} Bytes)`);
+
+ /* ---- UND BEIM LOESCHEN GEHEN BEIDE ------------------------
+ Sonst bliebe bei jeder zurueckgenommenen Sprachnachricht
+ eine m4a liegen -- ohne Zeile, die auf sie zeigt, und damit
+ fuer immer unzuordenbar. Genau diese Luecke gab es gestern
+ bei den Supportbildern. */
+ const weggenommen = await dogi.seite.evaluate(async (id) => {
+ const a = await fetch(`/workspace/api/chat/nachrichten/${id}`, { method: "DELETE" });
+ return a.status;
+ }, geschickt.id);
+ await new Promise((r) => setTimeout(r, 600));
+ const nochDa = [quelle, ziel].filter((f) => { try { wiegt(f); return true; } catch { return false; } });
+ ok(weggenommen < 400 && nochDa.length === 0,
+ `beim Loeschen gehen BEIDE Dateien (${weggenommen}, ${nochDa.length} liegen noch)`);
+ }
+
+ /* ---- GEGENPROBE: EIN BILD BEKOMMT KEINE ZWEITE FASSUNG -----
+ Sonst liefe ffmpeg ueber jedes Foto und jedes PDF -- Arbeit
+ fuer nichts, und eine Tuer, nach der niemand gefragt hat.
+ Das Bild wird hier selbst geschickt und nicht im Verlauf
+ gesucht: Mein erster Anlauf suchte eines, fand keines (an
+ dieser Stelle gab es noch keins) und meldete `undefined`
+ statt `null` -- gruen waere das nie gewesen, aber es hat
+ auch nichts geprueft. */
+ const einBild = await dogi.seite.evaluate(async (r) => {
+ const png = Uint8Array.from(atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="),
+ (c) => c.charCodeAt(0));
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/anhang`, {
+ method: "POST",
+ headers: { "Content-Type": "image/png", "X-Dateiname": "punkt.png" },
+ body: png,
+ });
+ const d = await a.json().catch(() => ({}));
+ return { status: a.status, auch: d?.nachricht?.anhang?.weg_auch };
+ }, raumDogi);
+ ok(einBild.status === 201 && einBild.auch === null,
+ `Gegenprobe: ein Bild bekommt keine zweite Fassung `
+ + `(${einBild.status}, ${JSON.stringify(einBild.auch)})`);
+
+ /* ==== DIESER BLOCK HINTERLAESST NICHTS (03.10.2026) =========
+
+ Die Zeilen weiter unten fragen die Gespraechsliste und
+ erwarten dort das Wort „Sprachnachricht" -- also das, was
+ ZULETZT in diesem Raum stand. Mein Probebild liess sie rot
+ werden, und zwar zu Recht: Nicht die Liste war kaputt,
+ sondern diese Pruefung hatte den Raum veraendert.
+
+ Eine Pruefung, die den Stand fuer die naechste verschiebt,
+ ist schlimmer als keine: Der Fehler taucht woanders auf, und
+ dort sucht man ihn dann. Also wird aufgeraeumt. */
+ await dogi.seite.evaluate(async (r) => {
+ const a = await fetch(`/workspace/api/chat/raeume/${r}/nachrichten`);
+ const d = await a.json();
+ const bild = (d.nachrichten || []).filter((n) => n.anhang?.art === "bild").pop();
+ if (bild) {
+ await fetch(`/workspace/api/chat/nachrichten/${bild.id}`, { method: "DELETE" });
+ }
+ }, raumDogi);
+ await new Promise((r) => setTimeout(r, 400));
+ }
+ }
+
/* ---- In der Gesprächsliste steht ein Wort dafür ------------------ */
const vorschau = await dogi.seite.evaluate(async () => {
const a = await fetch("/workspace/api/chat/raeume");
@@ -1453,6 +1711,7 @@ await dogi.ctx.close();
await browser.close();
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ }
-melde(`\n${geprueft} Prüfungen`);
+melde(`\n${geprueft} Prüfungen`
+ + (nichtNachsehbar ? `, ${nichtNachsehbar} konnten nicht nachsehen` : ""));
melde(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);
diff --git a/server/workspace-chat.js b/server/workspace-chat.js
index 05daff34..b744b0d7 100644
--- a/server/workspace-chat.js
+++ b/server/workspace-chat.js
@@ -43,6 +43,126 @@ import { join, extname, basename } from "node:path";
import { mkdirSync, writeFileSync, statSync, unlinkSync,
copyFileSync } from "node:fs";
import { liefereDatei } from "./helfer-ausliefern.mjs";
+/* Die zweite Fassung jeder Sprachnachricht -- siehe
+ helfer-ffmpeg.mjs. Sie ist der Grund, warum Miss sie ab heute
+ hoeren kann (Support-Meldung #12). */
+import {
+ zweitfassungBauen, zweitfassungPfad, brauchtZweitfassung, ZWEITFASSUNG_TYP,
+} from "./helfer-ffmpeg.mjs";
+
+/** Eine Anhangsdatei entfernen -- MITSAMT ihrer zweiten Fassung.
+ *
+ * ==== SONST BLEIBT DIE HAELFTE LIEGEN (03.10.2026) ===============
+ *
+ * Zwei Stellen loeschen Anhaenge: das Wegraeumen eines ganzen
+ * Gespraechs und das Zuruecknehmen einer einzelnen Nachricht. Beide
+ * haben bis heute genau eine Datei entfernt. Mit der Zweitfassung
+ * waere ab sofort bei jeder geloeschten Sprachnachricht eine
+ * m4a liegengeblieben -- ohne Zeile, die auf sie zeigt, und damit
+ * fuer immer unzuordenbar.
+ *
+ * Genau diese Luecke habe ich gestern bei den Supportbildern
+ * gefunden, und sie war dort schon einen Tag alt. Hier steht sie
+ * deshalb von Anfang an an EINER Stelle: Zwei Aufrufer koennen
+ * nicht auseinanderlaufen.
+ *
+ * Gibt `true` zurueck, wenn danach nichts mehr daliegt -- auch wenn
+ * schon vorher nichts da war (ENOENT ist kein Fehlschlag). */
+function anhangWeg(datei) {
+ let gut = true;
+ for (const pfad of [join(ANHANG_ORDNER, datei), zweitfassungPfad(join(ANHANG_ORDNER, datei))]) {
+ try { unlinkSync(pfad); } catch (f) {
+ if (f?.code !== "ENOENT") {
+ console.error("[chat] Anhang bleibt liegen:", f?.message);
+ gut = false;
+ }
+ }
+ }
+ return gut;
+}
+
+/** Liegt neben dieser Tondatei schon die universelle Fassung?
+ *
+ * GEFRAGT WIRD DIE PLATTE, nicht eine Spalte in der Datenbank. Eine
+ * Spalte muesste gepflegt werden -- und sie waere genau dann falsch,
+ * wenn der Nachruestlauf gerade eine Datei erzeugt hat oder jemand
+ * eine von Hand entfernt. Was wirklich da ist, weiss nur der Ordner.
+ *
+ * `statSync` je Nachricht klingt nach viel; gemessen ist es ein
+ * Verzeichniszugriff aus dem Betriebssystem-Zwischenspeicher, und er
+ * faellt nur bei Toenen an -- davon gibt es im ganzen Haus sechs. */
+function zweitfassungDa(art, datei) {
+ if (art !== "ton" || !datei) return false;
+ if (!brauchtZweitfassung(datei)) return false;
+ try {
+ statSync(zweitfassungPfad(join(ANHANG_ORDNER, datei)));
+ return true;
+ } catch { return false; }
+}
+
+/** Was noch keine Zweitfassung hat, bekommt eine.
+ *
+ * ==== WARUM EIN NACHRUESTLAUF UND NICHT NUR BEIM HOCHLADEN =======
+ *
+ * Beim Hochladen zu wandeln hilft ab heute. Miss' Problem sind aber
+ * die SECHS, die seit September liegen -- und genau die waeren mit
+ * einer Loesung „ab jetzt" weiter stumm geblieben. Das ist dieselbe
+ * Falle wie bei den Supportbildern gestern: Ein Umbau, der nur
+ * vorwaerts gilt, laesst den vorhandenen Bestand still zurueck.
+ *
+ * GEDECKELT. Hoechstens `hoechstens` Dateien je Durchgang -- bei
+ * einem Ordner mit tausend Toenen soll der Rundgang nicht eine
+ * Viertelstunde rechnen. Was uebrig bleibt, kommt beim naechsten
+ * Mal dran; der Lauf merkt sich nichts, er sieht einfach nach.
+ *
+ * Gibt zurueck, wie viele neu entstanden sind. */
+export async function toeneNachruesten(hoechstens = 20) {
+ try {
+ const zeilen = db().prepare(`SELECT anhang_datei FROM chat_nachrichten
+ WHERE anhang_art = 'ton' AND anhang_datei IS NOT NULL AND weg_am IS NULL`).all();
+ let gebaut = 0;
+ for (const z of zeilen) {
+ if (gebaut >= hoechstens) break;
+ if (!brauchtZweitfassung(z.anhang_datei)) continue;
+ if (zweitfassungDa("ton", z.anhang_datei)) continue;
+ const pfad = join(ANHANG_ORDNER, z.anhang_datei);
+ try { statSync(pfad); } catch { continue; }
+ /* NACHEINANDER, nicht alle auf einmal: Zwanzig ffmpeg-Laeufe
+ gleichzeitig nehmen dem Server genau die Rechenzeit weg, die
+ er fuer die Seite braucht. */
+ // eslint-disable-next-line no-await-in-loop
+ if (await zweitfassungBauen(pfad)) gebaut++;
+ }
+ if (gebaut) console.log(`[ton] ${gebaut} Sprachnachricht(en) zweitfassung ergaenzt.`);
+ return gebaut;
+ } catch (fehler) {
+ console.error("[ton] Nachruesten:", fehler?.message);
+ return 0;
+ }
+}
+
+/** Einmal kurz nach dem Start, danach alle sechs Stunden.
+ *
+ * NICHT IM STARTMOMENT -- erst soll die Seite antworten. Dieselbe
+ * Ueberlegung wie beim Aufbewahrungslauf: Ein ffmpeg-Durchgang im
+ * Startmoment macht aus einem Fehler dort einen Startfehler.
+ *
+ * SECHS STUNDEN UND NICHT EINE MINUTE: Was beim Hochladen entsteht,
+ * wird dort schon gewandelt. Dieser Lauf ist das Netz darunter --
+ * fuer die Dateien von frueher und fuer den Fall, dass eine
+ * Umwandlung einmal nicht geklappt hat. Haeufiger waere Rechenzeit
+ * fuer eine Liste, die fast immer leer ist.
+ *
+ * `unref` DAMIT ER NICHTS AM LEBEN HAELT: Ein Taktgeber ohne das
+ * haelt den Prozess offen, und eine Pruefung, die den Server
+ * einbindet, endet dann nie. Genau daran hing im Haus schon einmal
+ * ein dreistuendiger Stillstand. */
+export function toeneNachruestenStarten() {
+ const ersterLauf = setTimeout(() => { toeneNachruesten().catch(() => {}); }, 120_000);
+ const takt = setInterval(() => { toeneNachruesten().catch(() => {}); }, 6 * 3600_000);
+ ersterLauf.unref?.();
+ takt.unref?.();
+}
import {
db, protokolliere, echteIp, sitzungLesen, istDogFather,
schreibbareIds, darfSchreibenMit, ROLLEN_SORTIERUNG, DATEN_ORDNER,
@@ -79,6 +199,7 @@ const NAME_MAX = 80;
ein PDF mit Bildern selten über 10. */
const ANHANG_MAX = 12 * 1024 * 1024;
const ANHANG_ORDNER = join(DATEN_ORDNER, "chat-anhaenge");
+
const ANHANG_NAME_MAX = 200;
/* EIGENE GRENZE FUER TON (23.09.2026).
@@ -1662,6 +1783,18 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
n.antwort_auf, n.angeheftet_am, n.rudel,
n.anhang_name, n.anhang_art, n.anhang_typ, n.anhang_groesse,
n.anhang_breite, n.anhang_hoehe, n.anhang_dauer,
+ /* DER DATEINAME WIRD GEBRAUCHT, ABER NICHT AUSGELIEFERT
+ (03.10.2026). zweitfassungDa() fragt mit ihm die
+ Platte; nach draussen geht er nicht -- was dort liegt,
+ geht niemanden etwas an, der nur zuhoeren will.
+ Dass er hier fehlte, war der Grund, warum die zweite
+ Fassung nie angeboten wurde: Die Funktion bekam nichts
+ und sagte brav „gibt es nicht". Gefunden hat das die
+ Pruefung, nicht das Lesen.
+ (Und KEINE Gegen-Apostrophe in diesem Text: Er steht
+ in einem Template-Literal, und einer davon beendet
+ es. Heute schon das zweite Mal.) */
+ n.anhang_datei,
a.text AS zitat_text, a.weg_am AS zitat_weg, ap.name AS zitat_von,
a.anhang_art AS zitat_anhang
FROM chat_nachrichten n
@@ -1912,6 +2045,14 @@ chatRouter.get("/workspace/api/chat/raeume/:id/nachrichten", (req, res) => {
breite: n.anhang_breite || 0, hoehe: n.anhang_hoehe || 0,
dauer: n.anhang_dauer || 0,
weg: `/workspace/api/chat/anhang/${n.id}`,
+ /* WO DIE ZWEITE FASSUNG LIEGT -- oder `null`, wenn es
+ keine gibt (Bild, PDF, oder ffmpeg hat noch nicht).
+ GEFRAGT WIRD DIE PLATTE, nicht eine Spalte: Eine Spalte
+ muesste gepflegt werden, und sie waere genau dann
+ falsch, wenn jemand eine Datei von Hand nachlegt oder
+ der Nachruestlauf gerade durchgelaufen ist. */
+ weg_auch: zweitfassungDa(n.anhang_art, n.anhang_datei)
+ ? `/workspace/api/chat/anhang/${n.id}?form=mp4` : null,
} : null,
}));
})(),
@@ -2244,6 +2385,29 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
const aufPlatte = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${erkannt.endung}`;
writeFileSync(join(ANHANG_ORDNER, aufPlatte), req.body, { flag: "wx" });
+ /* ==== DIE ZWEITE FASSUNG (03.10.2026) =======================
+
+ Miss im Support, Meldung #12, fuenf Runden: „Bei mir laesst
+ sich die Nachricht nicht abspielen." Bei allen anderen ging
+ es; alle sechs vorhandenen Sprachnachrichten waren WebM.
+
+ NEBENHER UND NICHT DAVOR. `await` hier hiesse: Wer eine
+ Sprachnachricht schickt, wartet auf ffmpeg, bevor sie im
+ Chat steht -- und wenn ffmpeg haengt, haengt die Nachricht.
+ Sie geht sofort raus; die zweite Fassung kommt Sekunden
+ spaeter dazu, und bis dahin spielt das Original. Genau
+ deshalb sagt die Antwort unten auch nichts ueber sie: Was
+ es noch nicht gibt, wird nicht versprochen.
+
+ NUR FUER TON. Ein Bild braucht keine zweite Fassung, und
+ ffmpeg auf ein PDF loszulassen waere eine Tuer, nach der
+ niemand gefragt hat. */
+ if (erkannt.art === "ton" && brauchtZweitfassung(aufPlatte)) {
+ zweitfassungBauen(join(ANHANG_ORDNER, aufPlatte))
+ .catch(() => { /* der Helfer meldet es selbst; die Nachricht
+ steht ohnehin schon */ });
+ }
+
const d = db();
const n = jetzt();
d.prepare(`INSERT INTO chat_nachrichten
@@ -2269,6 +2433,12 @@ chatRouter.post("/workspace/api/chat/raeume/:id/anhang",
breite: erkannt.breite || 0, hoehe: erkannt.hoehe || 0,
dauer: dauer || 0,
weg: `/workspace/api/chat/anhang/${id}`,
+ /* Gleich nach dem Hochladen gibt es sie noch nicht -- die
+ Umwandlung laeuft nebenher. Dass hier `null` steht, ist
+ deshalb richtig und keine Luecke: Beim naechsten Laden
+ der Nachrichten ist sie da. */
+ weg_auch: zweitfassungDa(erkannt.art, aufPlatte)
+ ? `/workspace/api/chat/anhang/${id}?form=mp4` : null,
},
rudel: false,
};
@@ -2508,6 +2678,17 @@ chatRouter.post("/workspace/api/chat/raeume/:id/gif", gleicheHerkunft, nurRudel,
groesse: g.groesse, breite: g.breite || 0, hoehe: g.hoehe || 0,
dauer: 0,
weg: `/workspace/api/chat/anhang/${id}`,
+ /* EIN GIF HAT KEINE ZWEITE FASSUNG, und zwar nie -- das ist
+ hier ein Bild, kein Ton. Die Zeile steht trotzdem da:
+ Faehlte sie, haette dieselbe Nachricht je nach Weg einmal
+ das Feld und einmal nicht, und der Browser muesste beide
+ Faelle kennen. Ein ausdrueckliches `null` ist eine
+ Auskunft, ein fehlendes Feld ist eine Frage.
+ (Mein erster Anlauf rief hier `zweitfassungDa(erkannt...)`
+ auf -- `erkannt` gibt es in dieser Route gar nicht. Das
+ waere ein Absturz beim ersten GIF gewesen, und zwar erst
+ im Betrieb.) */
+ weg_auch: null,
},
};
const empfaenger = teilnehmerVon(raumId).filter((t) => t.id !== req.person.id);
@@ -2545,7 +2726,29 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
return res.status(404).json({ fehler: "nicht_gefunden" });
}
- const pfad = join(ANHANG_ORDNER, n.anhang_datei);
+ /* ==== DIE ZWEITE FASSUNG, WENN SIE VERLANGT WIRD (03.10.2026)
+
+ `?form=mp4` liefert die AAC-Fassung derselben Sprachnachricht.
+ Miss im Support, #12: Auf ihrem iPhone laesst sich WebM nicht
+ abspielen; dieselbe Nachricht in MP4 schon.
+
+ EINE ROUTE, KEINE ZWEITE. Es ist dieselbe Nachricht mit
+ denselben Rechten -- wer sie sehen darf, darf sie in beiden
+ Fassungen sehen. Eine eigene Route haette dieselbe
+ Sichtbarkeitspruefung ein zweites Mal gebraucht, und es ist
+ immer die zweite, die beim naechsten Umbau vergessen wird.
+
+ STILL ZURUECK AUF DAS ORIGINAL, wenn es die Fassung nicht
+ gibt: Wer sie anfragt, hat sie in der Nachrichtenliste
+ angeboten bekommen -- ist sie seitdem verschwunden, soll er
+ etwas hoeren und keinen Fehler lesen. */
+ let pfad = join(ANHANG_ORDNER, n.anhang_datei);
+ let typ = n.anhang_typ || "application/octet-stream";
+ if (req.query.form === "mp4" && n.anhang_art === "ton"
+ && brauchtZweitfassung(n.anhang_datei)) {
+ const zweit = zweitfassungPfad(pfad);
+ try { statSync(zweit); pfad = zweit; typ = ZWEITFASSUNG_TYP; } catch { /* Original */ }
+ }
try { statSync(pfad); } catch { return res.status(410).json({ fehler: "Datei fehlt auf der Platte." }); }
/* AUSGELIEFERT WIRD DER TYP AUS DER ERKENNUNG, nie ein
@@ -2557,7 +2760,7 @@ chatRouter.get("/workspace/api/chat/anhang/:id", (req, res) => {
Dokument jede Ausführung -- das ist die Sicherung für den Fall,
dass in einem PDF ein Skript steckt (PDF kann das). Bei einem
Bild kostet sie nichts. */
- res.setHeader("Content-Type", n.anhang_typ || "application/octet-stream");
+ res.setHeader("Content-Type", typ);
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
res.setHeader("Cache-Control", "private, max-age=86400");
@@ -2940,11 +3143,7 @@ chatRouter.delete("/workspace/api/chat/raeume/:id/ganz", gleicheHerkunft, (req,
aber kein Datenschaden, und es wird gemeldet statt verschwiegen. */
let dateienWeg = 0;
for (const name of dateien) {
- try { unlinkSync(join(ANHANG_ORDNER, name)); dateienWeg++; }
- catch (f) {
- if (f?.code === "ENOENT") dateienWeg++;
- else console.error("[chat] Anhang bleibt liegen:", f?.message);
- }
+ if (anhangWeg(name)) dateienWeg++;
}
protokolliere("chat_aufgeloest", {
@@ -3395,10 +3594,7 @@ chatRouter.delete("/workspace/api/chat/nachrichten/:id", gleicheHerkunft, (req,
schon weg), ist der Verweis trotzdem fort -- andersherum bliebe
im schlechten Fall eine Zeile ohne Datei stehen. */
d.prepare("DELETE FROM chat_nachrichten WHERE id = ?").run(id);
- if (dateiWeg) {
- try { unlinkSync(join(ANHANG_ORDNER, dateiWeg)); }
- catch (f) { if (f?.code !== "ENOENT") console.error("[chat] Anhang bleibt liegen:", f?.message); }
- }
+ if (dateiWeg) anhangWeg(dateiWeg);
/* DER RAUM RECHNET SEINEN LETZTEN ZEITPUNKT NEU. Ohne das stuende
er in der Liste weiterhin ganz oben, mit einem Zeitpunkt, zu dem
es nichts mehr gibt. */
diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html
index f4cc24bf..6fdcd544 100644
--- a/workspace/anruf-probe.html
+++ b/workspace/anruf-probe.html
@@ -16,8 +16,8 @@
verdeckt. Gefunden hat es pruef-struktur.
Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. -->
-
-
+
+
-
+