Jede Sprachnachricht in einer Fassung, die JEDES Geraet abspielt

Miss im Support, Meldung #12 -- fuenf Runden seit dem 23.09.: „Bei
mir laesst sich die Nachricht nicht abspielen." Zuletzt: „Jetzt laedt
es die ganze Zeit." Bei allen anderen ging es.

GEMESSEN, BEVOR GEBAUT WURDE

  · Alle sechs Sprachnachrichten im Haus sind audio/webm (Opus).
  · Miss' Geraet: iPhone, iOS 18.7, WebKit.
  · Seit der Umstellung am 02.10. (neue Aufnahmen bevorzugen MP4)
    wurde KEINE EINZIGE neue aufgenommen. Ihr Problem betrifft also
    ausschliesslich die sechs alten Dateien -- und jede kuenftige aus
    einem Firefox, der nichts anderes kann.

WAS ICH NICHT MESSEN KONNTE, und das gehoert dazu: Ob WebKit
WebM/Opus abspielen kann, laesst sich auf diesem Rechner nicht
nachsehen -- Playwrights WebKit startet hier nicht (libegl.dll
fehlt). Die Vermutung „iPhones koennen kein WebM" ist begruendet,
aber von mir nicht gemessen.

GENAU DESHALB STELLT DIE LOESUNG DIE FRAGE NICHT. Statt zu raten,
welches Geraet welchen Behaelter kann, legt der Server neben jede
Sprachnachricht eine zweite Fassung in dem Format, bei dem sich seit
zwanzig Jahren alle einig sind: AAC in MP4. Gibt es sie, wird sie
abgespielt -- bei jedem, nicht nur auf iPhones. Kein Geraetename im
Code, keine Liste, die altert.

  helfer-ffmpeg.mjs     ffmpeg finden, umwandeln, drei Ausgaenge
  beim Hochladen        nebenher, nicht davor: Die Nachricht wartet
                        nicht auf ffmpeg
  toeneNachruesten()    das Netz darunter -- fuer die sechs von
                        frueher und fuer den Fall, dass eine
                        Umwandlung einmal nicht geklappt hat
  ?form=mp4             dieselbe Route, dieselben Rechte; eine
                        zweite haette dieselbe Sichtbarkeitspruefung
                        ein zweites Mal gebraucht

ffmpeg IST ABGESPROCHEN INSTALLIERT (Debian 7.1.5, auf Nachfrage
freigegeben). Fehlt es, passiert nichts Schlimmes: Die
Sprachnachricht geht wie bisher im Originalformat hinaus, und es
steht EINMAL eine Zeile im Protokoll -- nicht bei jedem Hochladen.

DREI ENTSCHEIDUNGEN, DIE NICHT NAHELIEGEND WAREN

 1. `-movflags +faststart` IST NICHT KOSMETIK. Ohne es steht die
    Inhaltsuebersicht einer MP4 am ENDE. Das Abspielgeraet muss dann
    erst bis ans Ende lesen, bevor es anfangen kann -- genau das
    „laedt die ganze Zeit" aus Miss' Runde 5. Geprueft wird die
    Reihenfolge der Kaesten in der Datei, nicht die Zeile im Aufruf.

 2. DAS ORIGINAL BLEIBT LIEGEN. Ohne `?form=mp4` kommt weiter die
    WebM. Sie ist das, was aufgenommen wurde; sie durch eine
    Umrechnung zu ersetzen hiesse, das Original wegzuwerfen.

 3. EIN GEMEINSAMER LOESCHER. Zwei Stellen entfernen Anhaenge
    (Gespraech wegraeumen, Nachricht zuruecknehmen). Beide nahmen
    genau eine Datei -- ab heute waere bei jeder geloeschten
    Sprachnachricht eine m4a liegengeblieben, ohne Zeile, die auf
    sie zeigt. Dieselbe Luecke hatte ich gestern bei den
    Supportbildern gefunden; hier steht sie von Anfang an an EINER
    Stelle.

ZWEI FUNDE DER PRUEFUNG, BEIDE MEINE EIGENEN

 · `anhang_datei` STAND NICHT IN DER ABFRAGE der Nachrichtenliste.
   Die Funktion, die auf der Platte nachsieht, bekam deshalb nichts
   und sagte brav „gibt es keine zweite Fassung" -- fuer JEDE
   Nachricht. Alles war gebaut, nichts kam an. Gefunden hat das die
   Pruefung, nicht das Lesen.
 · Mein erster Anlauf mass die Aufnahme ueber den Knopf -- und die
   ist seit dem 02.10. schon MP4, braucht also gar keine zweite
   Fassung. Die Pruefung wurde rot und hatte recht: Gemessen werden
   muss der Fall, den es bei Miss gibt. Jetzt nimmt sie ausdruecklich
   eine WebM auf.

Dazu zweimal derselbe alte Tritt: ein Gegen-Apostroph in einem
Kommentar INNERHALB eines Template-Literals. Der Server startet dann
gar nicht. Steht jetzt als Warnung an beiden Stellen.

GEPRUEFT -- pruef-chat-anhaenge 146 -> 163 ok

  Eine echte WebM/Opus-Aufnahme (31 972 Bytes) wird hochgeladen; die
  zweite Fassung entsteht von selbst und kommt als audio/mp4 heraus,
  mit „ftyp"-Marke, mit Accept-Ranges. ffprobe sagt: Original Opus,
  Zweitfassung AAC, 2,40 s gegen 2,46 s. Die Inhaltsuebersicht steht
  bei Byte 32, die Tondaten ab 1270 -- also vorne. Beim Loeschen
  gehen beide Dateien. Gegenproben: ein Bild bekommt keine zweite
  Fassung; eine halbierte Laenge waere aufgefallen; das Original
  bleibt unter seiner eigenen Adresse abrufbar.

  Der dritte Ausgang hat einen eigenen Zaehler: Fehlt ffmpeg, steht
  „KONNTE NICHT NACHSEHEN" mit Anleitung da -- nicht gruen und nicht
  rot. Und der Block raeumt hinter sich auf: Mein Probebild liess
  eine spaetere Pruefung rot werden, weil es die Gespraechsliste
  veraendert hatte. Eine Pruefung, die den Stand fuer die naechste
  verschiebt, ist schlimmer als keine.

  Dazu gruen: pruef-struktur 102 (92 Module) · pruef-ports 10 ·
  pruef-gifs 17 · pruef-treffchat 114 · pruef-chat-optik ·
  pruef-aufbewahrung 45.

NUR DAS AGENTURHAUS? NEIN -- der Chat gehoert beiden Haeusern, und
die Aenderung gilt fuer beide gleich. Sie aendert nichts an
Sichtbarkeit oder Rechten: Wer eine Nachricht hoeren darf, hoert sie
jetzt in einem Format, das sein Geraet kann.

OFFEN BLEIBT DIE ANTWORT VON MISS. Ob es bei ihr jetzt laeuft, weiss
nur sie -- deshalb steht ihre Meldung weiter offen und nicht auf
erledigt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-03 01:41:34 +02:00
co-authored by Claude Opus 5
parent 1ce2c3785e
commit d70d00cedc
51 changed files with 1368 additions and 698 deletions
+207 -11
View File
@@ -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. */