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`. --> - - + + - +