Files
Flaro/docs/superpowers/specs/2026-10-03-halo-server-design.md
T
ScarriffleandClaude Opus 5 156c740330 Halo-Server, Stufe 1: Benutzer, Anmeldung, Einrichtung
Der Anfang eines eigenen Medienservers — C#, .NET 10, SQLite, im Container für
amd64 und arm64. Stufe 1 von fünf: er spielt noch keinen Film ab, aber ein
Fremder kann ihn in Betrieb nehmen, und genau daran scheitern solche Projekte
sonst als Erstes.

**Was läuft, gegen den laufenden Container gemessen** — nicht gegen einen
Prüfstand: Gesundheitsfrage, Einrichtungsassistent, zweiter Einrichtungsversuch
abgelehnt (409), Anmeldung, falsches Passwort abgelehnt (401), und der ganze
Weg der Code-Anmeldung vom Bildschirm bis zum Token.

**Drei Entscheidungen, die beim Lesen Fragen aufwerfen:**

*Zeitpunkte liegen als Unix-Millisekunden in der Datenbank.* SQLite hat keinen
Zeittyp; EF legt einen `DateTimeOffset` sonst als Text mitsamt Zonenversatz ab,
und dann steht `…T12:00+02:00` neben `…T11:00Z` — derselbe Augenblick, als Text
falsch herum sortiert. EF weigert sich deshalb, `ExpiresAt > jetzt` überhaupt zu
übersetzen; das war der erste echte Fehlschlag und ist jetzt eine
Typumwandlung für das ganze Modell statt je Eigenschaft.

*Token sind keine JWT.* Der Server schlägt sie nach, statt sie zu prüfen — nur
so lässt sich ein verlorenes Telefon sofort aussperren. In der Datenbank steht
allein der Fingerabdruck.

*Der Code am Fernseher holt nie selbst ein Token ab.* Dafür gibt es den
Abholschein, den nur das anfragende Gerät kennt. Ohne ihn wären sechsstellige
Codes ein Türschloss mit einer Million Schlüsseln — in Minuten durchprobiert.
Dass ein erratener Schein nichts bekommt, ist die wichtigste der 55 Prüfungen.

**Zwei Fehler, die erst der echte Container zeigte:**

`EnsureExists` legte nur an und prüfte nicht, ob sich schreiben lässt — bei
einem vorhandenen, aber gesperrten Verzeichnis meldete es Erfolg, und der
Server starb zehn Zeilen später an „unable to open database file". Diese
Meldung nennt weder das Verzeichnis noch verrät sie, dass es um Dateirechte
geht. Jetzt wird tatsächlich geschrieben, und die Meldung nennt Variable,
Pfad, die eigene Kennung und den nächsten Schritt.

Und: Docker legt den Einhängepunkt eines frischen benannten Volumes als `root`
an, wenn das Verzeichnis im Image fehlt. Das traf jeden, der ohne Bind Mount
startet. Die drei Verzeichnisse werden jetzt im Image angelegt und übereignet.

**Die Lizenz ist eine Bauvorschrift, keine Fussnote.** GPL-2.0-only verträgt
sich nicht mit Apache-2.0 — dessen Patentklausel gilt unter Abschnitt 6 als
„weitere Einschränkung". Darum steht in `LICENSES.md`, dass vor jedem
`PackageReference` die Lizenz zu prüfen ist, und darum ist das Testwerk NUnit
(MIT) und nicht xUnit (Apache-2.0). Alle vier Pakete sind geprüft MIT.

Die Apps stehen unter MPL-2.0 statt GPL, weil GPL und App Store sich nicht
vertragen — Apple entfernte 2011 VLC aus dem Store, nachdem ein Rechteinhaber
sich auf Abschnitt 6 berufen hatte.

55 Tests grün. Image baut für linux/amd64 und linux/arm64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 12:13:47 +02:00

254 lines
10 KiB
Markdown

# Halo-Server — Entwurf
*3. Oktober 2026*
## Warum es ihn gibt
Ein Download brach die Sprachwahl. Jellyfins Transcode-Endpunkt bildet **eine
Tonspur je Sitzung** ab; was offline landet, ist einspurig, und welche Spur es
ist, entscheidet der Server. Einmal war es die Audiodeskription — drei Gigabyte
Mobilfunk für eine Datei, die niemand hören wollte.
Am Server gemessen, nicht vermutet:
- `static=true` liefert die Datei unverändert — alle Spuren, aber Matroska. Die
Magie am Dateianfang ist `1a45dfa3`, auch wenn im Pfad `.mp4` steht.
- `stream.mp4` bildet genau eine Tonspur ab.
- Die HLS-Liste führt **eine** Variante und **keine** `EXT-X-MEDIA`-Tonspuren.
- Beim Umpacken verliert Jellyfin die Sprachkennungen: alle Spuren kommen als
`und` an.
Das liegt weder an MP4 — das trägt mehrere Tonspuren — noch an AVFoundation.
Es liegt am Transcoder. Ein Jellyfin-Plugin könnte es beheben; die Entscheidung
fiel für einen eigenen Server.
**Was Halo werden soll:** ein Server, der *„eigentlich nichts mit Jellyfin zu
tun hat, aber grundsätzlich die gleichen Funktionen bietet"*. Funktionsgleich,
unabhängig, ohne Nachziehen bei fremden Veröffentlichungen. Open Source, für
jeden, nicht für einen Haushalt.
---
## Entscheidungen
| | | Preis, der bewusst gezahlt wird |
|---|---|---|
| **Sprache** | C# / .NET 10 | — |
| **Schnittstelle** | eigene, von Anfang an | Die App braucht ein eigenes Backend-Modul; bis es steht, gibt es keinen Zwischenstand zum Ausprobieren |
| **Datenbank** | SQLite | Schreibzugriffe laufen nacheinander. Bei 40 000 Titeln und laufendem Scan spürbar — Jellyfins bekanntester Schmerzpunkt |
| **Metadaten** | TMDB | Jeder Betreiber braucht einen eigenen Schlüssel; gepflegte Bestände werden unnötig neu nachgeschlagen |
| **Anmeldung** | Passwort, Geräte-Token, Code-Verfahren | Zwei Anmeldewege statt einem, jeder mit eigenen Sicherheitsüberlegungen |
| **Oberfläche** | Client **und** `/admin`, getrennte Bereiche, vom Server ausgeliefert | — |
| **Live TV** | HDHomeRun und IPTV (m3u + XMLTV) | Keine DVB-Karte. Wer eine hat, bekommt sie nicht unterstützt |
| **Übernahme** | keine | Jeder Umsteiger verliert Gesehen-Status und Fortschritt |
| **Laufzeit** | Docker, amd64 und arm64 | — |
| **Hardware** | VAAPI über `/dev/dri` | Keine NVENC-Unterstützung in der ersten Fassung |
| **Lizenz** | GPL-2.0-only | Siehe unten — die folgenreichste Zeile dieses Dokuments |
### Die Lizenz ist eine Bauvorschrift
GPL-2.0-only ist dieselbe Lizenz wie Jellyfin. Deren Quellcode darf also
gelesen **und übernommen** werden.
**Der Preis trifft jede Abhängigkeit, die je hinzukommt:** GPL-2.0-only ist
**unverträglich mit Apache-2.0**. Die Patentklausel der Apache-Lizenz gilt
unter GPL-2.0 Abschnitt 6 als „weitere Einschränkung". Ein nennenswerter Teil
des .NET-Umfelds steht unter Apache-2.0.
> **Regel für jede neue Abhängigkeit: MIT, BSD oder GPL-2.0-verträglich.
> Apache-2.0 ist ausgeschlossen.** Vor jedem `PackageReference` die Lizenz
> prüfen und im Commit nennen.
Microsofts eigene Pakete — ASP.NET Core, EF Core — stehen unter MIT und sind
unbedenklich.
ffmpeg wird als **eigener Prozess** aufgerufen, nicht über Bindings
eingebunden. Das hält die Lizenzen sauber getrennt und erlaubt nebenbei, die
Argumente zu protokollieren — ohne das ist „warum ruckelt das" nicht zu
beantworten.
**Die App** steht unter **MPL-2.0**, nicht unter GPL: GPL und App Store
vertragen sich nicht. Apple entfernte 2011 VLC aus dem Store, nachdem ein
Rechteinhaber sich auf Abschnitt 6 berufen hatte — Apples Bedingungen
schreiben Gerätezahl und Kopierschutz vor. MPL-2.0 ist Copyleft je Datei, ohne
Relink-Pflicht, und App-Store-tauglich.
*Umlizenzieren geht nur, solange ein einziger Rechteinhaber da ist. Sobald
Fremde beitragen, braucht jede Änderung deren Zustimmung.*
---
## Grundsätze
- **Nichts fest verdrahtet.** Jeder Pfad, jeder Port, jede Kennung aus
Konfiguration — Umgebungsvariablen und `appsettings`, beides überschreibbar.
Das Setup des Autors ist ein *Beispiel in der Dokumentation*, keine Annahme
im Code.
- **Kein Zustand im Image.** Datenbank, Konfiguration und Bilder-Cache liegen
in eingehängten Verzeichnissen.
- **Medien werden nur gelesen.** Schreibgeschützt eingehängt.
- **Mehrbenutzer von Anfang an.** Fortschritt und Gesehen-Status je Benutzer
nachträglich einzuziehen ist ein Umbau am Datenmodell, kein Zusatz.
---
## Aufbau
```
server/
Halo.sln
src/
Halo.Api/ HTTP-Schnittstelle, Authentifizierung, Auslieferung der Oberfläche
Halo.Core/ Domäne: Benutzer, Bibliothek, Fortschritt. Kennt kein HTTP
Halo.Data/ EF Core, SQLite, Migrationen
tests/
Halo.Core.Tests/
Halo.Api.Tests/
Dockerfile
docker-compose.example.yml
web/ Client (vorhanden) + /admin (neu)
```
Später, je Stufe: `Halo.Media` (ffmpeg), `Halo.Metadata` (TMDB),
`Halo.LiveTV` (HDHomeRun, m3u).
**Der Node-Proxy unter `web/server` entfällt.** Er löste CORS und das
http/https-Problem, weil Jellyfin woanders lief. Liefert Halo die Seite selbst
aus, ist er die Adresse, an der alles hängt — beide Probleme verschwinden mit
ihm.
---
## Stufen
| | Inhalt |
|---|---|
| **1** | Gerüst: Benutzer, Anmeldung, Einrichtung, Oberflächen-Gerüst, Docker |
| **2** | Bibliothek und Metadaten: Scanner, Dateinamen-Erkennung, TMDB, Bilder |
| **3** | Ausliefern: direktes Abspielen, Umpacken, VAAPI, **Download mit allen Spuren** |
| **4** | Fortschritt, Gesehen-Status, Favoriten, Vorschläge |
| **5** | Live TV: HDHomeRun, m3u mit XMLTV |
Gewählt in Kenntnis des Preises: Stufe 1 spielt noch keinen Film ab. Für ein
offenes Projekt, bei dem Fremde den Server in Betrieb nehmen müssen, ist es
trotzdem die ehrliche Reihenfolge — ein Server ohne Einrichtung ist für einen
Fremden kein Server.
### Was Stufe 3 anders macht als Jellyfin
```
ffmpeg -i datei.mkv -map 0 -c copy -c:s mov_text out.mp4
```
`-map 0` nimmt **alle** Ströme mit, `-c copy` fasst Bild und Ton nicht an. Das
kostet den Server fast nichts und liefert eine MP4 mit allen Ton- und
Textspuren — offline wählbar, wie bei jedem anderen Anbieter auch.
Sprachkennungen werden dabei ausdrücklich durchgereicht.
### Was Stufe 4 anders macht als Jellyfin
**Fortschritt ohne Schwelle.** Jellyfin merkt sich unterhalb eines
Mindestanteils und einer Mindestlaufzeit gar nichts; wer zehn Minuten eines
Films sieht und abbricht, findet beim nächsten Mal keinen Anknüpfungspunkt.
Halo speichert von der ersten Sekunde an, laufend, je Benutzer.
*Die genauen Jellyfin-Werte stehen aus dem Gedächtnis hier nicht; sie werden
vor dem Bauen gegen die laufende Installation geprüft, damit die Begründung
belegt ist und nicht behauptet.*
---
## Stufe 1 im Einzelnen
### Daten
| Tabelle | Felder |
|---|---|
| `User` | Id, UserName, PasswordHash, IsAdministrator, CreatedAt |
| `DeviceToken` | Id, UserId, TokenHash, DeviceName, DeviceId, CreatedAt, LastSeenAt, RevokedAt |
| `PendingAuthCode` | Id, Code, Handle, DeviceName, CreatedAt, ExpiresAt, ApprovedByUserId, ConsumedAt |
| `Library` | Id, Name, Kind, Path, CreatedAt |
| `ServerSetting` | Key, Value |
**Token werden gehasht abgelegt, nie im Klartext.** Was der Client bekommt,
sieht der Server danach nie wieder — gestohlene Datenbank heißt dann gestohlene
Hashes, nicht gestohlene Zugänge.
### Schnittstelle
```
GET /api/v1/system/info Fassung, ob eingerichtet werden muss
POST /api/v1/system/setup Erstes Administratorkonto — nur solange keines existiert
POST /api/v1/auth/login Benutzername und Passwort → Token
POST /api/v1/auth/logout Widerruft das eigene Token
GET /api/v1/auth/me Wer bin ich
POST /api/v1/auth/code Fernseher startet: → sechsstelliger Code + Abholschein
GET /api/v1/auth/code/{schein} Fernseher fragt nach → wartend | bestätigt + Token | abgelaufen
POST /api/v1/auth/code/{code} Telefon bestätigt (angemeldet)
GET /api/v1/users Liste (Administrator)
POST /api/v1/users Anlegen (Administrator)
GET /api/v1/libraries Bibliotheken
POST /api/v1/libraries Anlegen (Administrator)
GET /health Gesundheit, ohne Anmeldung
```
### Anmeldung mit Code
Für die Fernbedienung: Passwörter dort einzutippen ist eine Zumutung, die man
selbst jedes Mal spürt.
1. Der Fernseher fordert einen Code an und bekommt zusätzlich einen
**Abholschein**, den nur er kennt.
2. Auf dem Bildschirm steht der sechsstellige Code.
3. Am Telefon, angemeldet, wird er bestätigt.
4. Der Fernseher fragt mit seinem Abholschein nach und bekommt sein Token.
**Der Code allein genügt nie**, um ein Token abzuholen — sonst könnte jemand
sechsstellige Zahlen durchprobieren. Dazu: Gültigkeit wenige Minuten,
Versuchsbegrenzung, einmalige Verwendung.
### Einrichtung
`POST /api/v1/system/setup` arbeitet **nur**, solange kein Benutzer existiert.
Danach antwortet es ablehnend, auch unangemeldet — sonst wäre ein frisch
gestarteter Server für den ersten Fremden im Netz offen.
### Konfiguration
| Variable | Bedeutung | Voreinstellung |
|---|---|---|
| `HALO_DATA` | Datenbank und Konfiguration | `/config` |
| `HALO_CACHE` | Bilder und Zwischenstände | `/cache` |
| `HALO_TRANSCODE` | Kratzplatz fürs Umrechnen | `/transcode` |
| `HALO_PORT` | Port | `8080` |
| `TZ` | Zeitzone | `UTC` |
| `HALO_RATELIMIT_SIGNIN` | Anmeldeversuche je Minute und Adresse | `10` |
Die Benutzerkennung steht **nicht** in der Umgebung: das Image läuft als
`1000:1000`, und wer andere Kennungen hat, setzt Dockers eigenes
`user: "<uid>:<gid>"`. Ein eigenes Startskript mit `PUID`/`PGID` — wie es im
selbstgehosteten Umfeld üblich ist — bräuchte root im Container, und dafür gibt
es hier keinen Grund.
Medienpfade stehen **nicht** hier: sie werden in der Oberfläche als
Bibliotheken angelegt und liegen in der Datenbank. Wer sie in die Compose-Datei
schriebe, müsste den Container neu starten, um eine Bibliothek hinzuzufügen.
---
## Nachweis
- `dotnet test` für Domäne und Schnittstelle.
- Der Einrichtungsassistent wird gegen eine **leere** Datenbank gefahren: ein
frisch gestarteter Container führt durch die Einrichtung und legt einen
Administrator an.
- `POST /api/v1/system/setup` ein zweites Mal muss **abgelehnt** werden.
- Ein Code wird am Telefon bestätigt und am Fernseher abgeholt, ohne dass ein
Passwort getippt wurde.
- Ein Abholschein, der nicht zum Code gehört, bekommt **kein** Token.
- Das Image baut für `linux/amd64` und `linux/arm64`.