Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. 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 Halo-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: HALO_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.HALO_STATIC ?? join(HIER, '..', 'app', 'dist');
|
|
const ZIEL = (process.env.HALO_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(`Halo Web läuft auf http://localhost:${PORT}`);
|
|
console.log(`Medienserver: ${ZIEL}`);
|
|
console.log(`Dateien aus: ${DATEIEN}`);
|
|
});
|