Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
135 lines
5.2 KiB
JavaScript
135 lines
5.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/* Der Flaro-Web-Server.
|
|
----------------------------------------------------------------------------
|
|
Zwei Aufgaben, und beide lösen etwas, woran die einzelne HTML-Datei
|
|
gescheitert ist:
|
|
|
|
1. **Er reicht die Anfragen weiter.** Damit haben Seite und Daten dieselbe
|
|
Herkunft. Kein CORS — auch nicht beim Streamen, wo es am ehesten weh
|
|
tut. Und kein Ärger mit gemischtem HTTP und HTTPS: Läuft der
|
|
Medienserver im Klartext und die Seite verschlüsselt, blockiert der
|
|
Browser den Strom, und keine Zeile im Frontend kann etwas dagegen tun.
|
|
|
|
2. **Er liefert die gebauten Dateien aus.**
|
|
|
|
Ohne Abhängigkeiten, nur mit dem, was Node mitbringt — ein Server, der auf
|
|
einem kleinen Container läuft, soll nicht sein halbes Verzeichnis
|
|
mitschleppen.
|
|
|
|
Aufruf: FLARO_SERVER=http://jellyfin:8096 node index.js
|
|
*/
|
|
|
|
import { createServer } from 'node:http';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import { join, extname, normalize } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HIER = fileURLToPath(new URL('.', import.meta.url));
|
|
const DATEIEN = process.env.FLARO_STATIC ?? join(HIER, '..', 'app', 'dist');
|
|
const ZIEL = (process.env.FLARO_SERVER ?? 'http://localhost:8096').replace(/\/+$/, '');
|
|
const PORT = Number(process.env.PORT ?? 8080);
|
|
|
|
const TYPEN = {
|
|
'.html': 'text/html; charset=utf-8',
|
|
'.js': 'text/javascript; charset=utf-8',
|
|
'.css': 'text/css; charset=utf-8',
|
|
'.json': 'application/json; charset=utf-8',
|
|
'.svg': 'image/svg+xml',
|
|
'.png': 'image/png',
|
|
'.jpg': 'image/jpeg',
|
|
'.webp': 'image/webp',
|
|
'.woff2': 'font/woff2',
|
|
'.map': 'application/json',
|
|
};
|
|
|
|
/* Reicht eine Anfrage an den Medienserver weiter.
|
|
----------------------------------------------------------------------------
|
|
Der Körper wird **durchgereicht, nicht gepuffert**: ein Film ist ein Strom,
|
|
und ihn erst vollständig einzulesen hieße, ihn zwischenzuspeichern. Deshalb
|
|
`duplex: 'half'` und das Weiterleiten des Leseendes. */
|
|
async function weiterreichen(req, res) {
|
|
const pfad = req.url.slice('/api'.length) || '/';
|
|
const ziel = ZIEL + pfad;
|
|
|
|
// Die Kopfzeilen des Browsers gehen mit — bis auf die, die nur für die
|
|
// Verbindung hierher galten. `host` insbesondere: mit ihm meldete sich der
|
|
// Medienserver unter der falschen Adresse.
|
|
const kopf = new Headers();
|
|
for (const [k, v] of Object.entries(req.headers)) {
|
|
if (['host', 'connection', 'content-length'].includes(k)) continue;
|
|
if (Array.isArray(v)) v.forEach((x) => kopf.append(k, x));
|
|
else if (v != null) kopf.set(k, v);
|
|
}
|
|
|
|
try {
|
|
const antwort = await fetch(ziel, {
|
|
method: req.method,
|
|
headers: kopf,
|
|
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
|
|
duplex: 'half',
|
|
redirect: 'manual',
|
|
});
|
|
|
|
const raus = Object.fromEntries(antwort.headers);
|
|
// Die Länge stimmt nach dem Durchreichen nicht mehr zwingend, und eine
|
|
// falsche Angabe bricht den Strom mitten im Film ab.
|
|
delete raus['content-encoding'];
|
|
delete raus['content-length'];
|
|
res.writeHead(antwort.status, raus);
|
|
|
|
if (!antwort.body) return res.end();
|
|
for await (const stueck of antwort.body) res.write(stueck);
|
|
res.end();
|
|
} catch (fehler) {
|
|
// Eine abgebrochene Verbindung ist kein Serverfehler — der Zuschauer hat
|
|
// nur weitergeklickt.
|
|
if (res.headersSent) return res.destroy();
|
|
res.writeHead(502, { 'content-type': 'application/json; charset=utf-8' });
|
|
res.end(JSON.stringify({
|
|
fehler: 'Der Medienserver ist nicht erreichbar.',
|
|
ziel: ZIEL,
|
|
ursache: String(fehler?.cause?.code ?? fehler?.message ?? fehler),
|
|
}));
|
|
}
|
|
}
|
|
|
|
/** Liefert eine gebaute Datei aus — oder die Seite selbst, denn die
|
|
* Navigation läuft im Browser und jeder Weg führt auf dieselbe Datei. */
|
|
async function ausliefern(req, res) {
|
|
const angefragt = decodeURIComponent(new URL(req.url, 'http://x').pathname);
|
|
// `normalize` allein genügt nicht — erst das Abschneiden führender Punkte
|
|
// verhindert, dass jemand mit „../" aus dem Verzeichnis herausspaziert.
|
|
const sicher = normalize(angefragt).replace(/^(\.\.[/\\])+/, '');
|
|
let datei = join(DATEIEN, sicher);
|
|
|
|
try {
|
|
const info = await stat(datei);
|
|
if (info.isDirectory()) datei = join(datei, 'index.html');
|
|
} catch {
|
|
datei = join(DATEIEN, 'index.html');
|
|
}
|
|
|
|
try {
|
|
const inhalt = await readFile(datei);
|
|
res.writeHead(200, {
|
|
'content-type': TYPEN[extname(datei)] ?? 'application/octet-stream',
|
|
// Die Seite selbst nie zwischenspeichern, alles andere gern: gebaute
|
|
// Dateien tragen ihren Fingerabdruck im Namen.
|
|
'cache-control': datei.endsWith('index.html') ? 'no-cache' : 'max-age=31536000, immutable',
|
|
});
|
|
res.end(inhalt);
|
|
} catch {
|
|
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
res.end('Nicht gefunden. Wurde das Frontend gebaut? (npm run build in web/app)');
|
|
}
|
|
}
|
|
|
|
createServer((req, res) => {
|
|
if (req.url?.startsWith('/api')) return weiterreichen(req, res);
|
|
return ausliefern(req, res);
|
|
}).listen(PORT, () => {
|
|
console.log(`Flaro Web läuft auf http://localhost:${PORT}`);
|
|
console.log(`Medienserver: ${ZIEL}`);
|
|
console.log(`Dateien aus: ${DATEIEN}`);
|
|
});
|