Plakate, Hintergründe und NFO-Dateien landen jetzt neben dem Film — und eine
falsche Zuordnung lässt sich richtigstellen, ohne dass der nächste Scan sie
wieder überschreibt.
Der Untergang (2004)/
├── Der Untergang (2004).mkv
├── poster.jpg
├── fanart.jpg
└── movie.nfo
Bei Serien liegt `tvshow.nfo` eine Ebene über dem Staffelordner, je Folge
kommen NFO und Standbild daneben. Liegen mehrere Filme in einem Ordner, heisst
das Beiwerk `Titel (Jahr)-poster.jpg` — sonst überschrieben sie sich
gegenseitig.
**Die Erlaubnisliste ist der ganze Schutz.** Damit das geht, muss die
Medieneinhängung ohne `:ro` auskommen, und damit könnte der Server dort alles
anstellen. Dass er es nicht tut, hängt an `SidecarNames`: sechs Arten von
Datei, nichts sonst. Ein Name, der nicht daraufsteht, wirft — laut, nicht
still, denn das wäre ein Fehler im Programm. Mediendateien werden nie
geändert, umbenannt oder gelöscht; es gibt keine Stelle, die das könnte.
Geprüft an echten Dateien, einschliesslich der Versuche mit `../`, mit
absoluten Pfaden und mit einer Mediendatei als Ziel.
Standardmässig **aus**. In fremde Medienordner zu schreiben ist nichts, was
ein Server ungefragt tun sollte.
**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
Zuordnung, die jemand getroffen hat — Halo nimmt sie und sucht gar nicht
erst. Dasselbe gilt für Ordnernamen der Form `[tmdbid-613]`. Halo liest sie
und benennt **nichts** um: Umbenennen ist der einzige Eingriff, der eine
Bibliothek wirklich zerlegen kann.
Nebenbei: eine NFO aus dem Medienordner hat Halo nicht geschrieben. DTDs sind
beim Lesen abgeschaltet — eine untergeschobene Datei mit externer Entität
könnte sonst den Server dazu bringen, `/config/halo.db` einzulesen und als
Titel auszuliefern. Mit Prüfung.
---
**Der Fehler, den erst der echte Lauf zeigte** — und er ist die zweite
Auflage eines schon einmal behobenen:
Nach der Korrektur stand im Ordner „Dark (2017)", in der Datenbank aber 2004.
Der nächste Scan suchte nach *(Dark, 2017)*, fand nichts, legte den Film neu
an und löschte den korrigierten als verwaist. Überlebt hat die Zuordnung nur,
weil sie in der NFO stand — das System hat sich selbst gerettet, aber der
Eintrag war ein anderer und das Jahr wieder falsch.
Dieselbe Klasse wie bei `ParsedTitle`: zugeordnet wurde über einen Wert, den
die Metadatenquelle verändert. Jetzt gibt es `ParsedYear`, und das ändert sich
nie. Mit Prüfung, die genau diesen Ablauf festhält.
---
Dazu von TMDB: die IMDb-Kennung wird nachgeschlagen, damit sie in der NFO
steht — wer später auf ein anderes Werkzeug wechselt, findet denselben Titel
wieder, auch wenn das TMDB gar nicht kennt.
308 Tests grün. Im Container nachgewiesen: Zuordnung falsch, korrigiert,
zweiter Scan — derselbe Eintrag, dieselbe Kennung, Jahr bleibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
11 KiB
Markdown
266 lines
11 KiB
Markdown
# Halo-Server
|
|
|
|
Ein Medienserver, der dieselben Dinge kann wie Jellyfin — und zwei, die dort
|
|
nicht gehen.
|
|
|
|
**Stand: Stufe 3 von 5.** Benutzer, Anmeldung, Einrichtung, Bestand einlesen,
|
|
Metadaten — und **Download mit allen Ton- und Textspuren**, der Grund für
|
|
dieses Projekt. Was noch fehlt: Streamen mit Umrechnen (HLS), Fortschritt und
|
|
Gesehen-Status, Live TV. Der Entwurf samt Begründungen steht
|
|
in [`docs/superpowers/specs/2026-10-03-halo-server-design.md`](../docs/superpowers/specs/2026-10-03-halo-server-design.md).
|
|
|
|
## Warum
|
|
|
|
**Download mit allen Tonspuren.** 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.
|
|
|
|
**Fortschritt ohne Schwelle.** Jellyfin merkt sich unterhalb eines
|
|
Mindestanteils gar nichts. Wer zehn Minuten sieht und abbricht, findet beim
|
|
nächsten Mal keinen Anknüpfungspunkt.
|
|
|
|
## Betreiben
|
|
|
|
```bash
|
|
cp docker-compose.example.yml docker-compose.yml
|
|
# Pfade anpassen — jeder darin ist ein Beispiel, keiner stimmt bei dir.
|
|
docker compose up -d
|
|
```
|
|
|
|
Dann `http://localhost:8080` öffnen; der Einrichtungsassistent legt das erste
|
|
Administratorkonto an. Danach verschliesst sich dieser Weg von selbst.
|
|
|
|
### Einstellungen
|
|
|
|
| Variable | Bedeutung | Voreinstellung im Image |
|
|
|---|---|---|
|
|
| `HALO_DATA` | Datenbank und Einstellungen. **Das Einzige, was gesichert werden muss.** | `/config` |
|
|
| `HALO_CACHE` | Bilder und Zwischenstände. Darf verlorengehen. | `/cache` |
|
|
| `HALO_TRANSCODE` | Kratzplatz fürs Umrechnen. Am besten auf eine SSD. | `/transcode` |
|
|
| `ASPNETCORE_URLS` | Worauf gelauscht wird | `http://+:8080` |
|
|
| `TZ` | Zeitzone | `UTC` |
|
|
| `HALO_RATELIMIT_SIGNIN` | Anmeldeversuche je Minute und Adresse | `10` |
|
|
| `HALO_RATELIMIT_POLLING` | Nachfragen je Minute und Adresse | `120` |
|
|
|
|
**Medienpfade stehen hier nicht.** Sie werden in der Oberfläche als
|
|
Bibliotheken angelegt und liegen in der Datenbank — sonst müsste der Container
|
|
neu starten, um eine Bibliothek hinzuzufügen.
|
|
|
|
### Rechte
|
|
|
|
Das Image läuft als `1000:1000`, **nicht als root**. Gehören die eingehängten
|
|
Verzeichnisse jemand anderem, trage in der Compose-Datei
|
|
`user: "<uid>:<gid>"` ein (`id -u` und `id -g` auf dem Wirt sagen, welche)
|
|
oder übereigne sie mit `chown`.
|
|
|
|
Stimmt etwas nicht, bricht der Server **beim Start** ab und nennt Variable,
|
|
Pfad, seine eigene Kennung und den nächsten Schritt. Das ist Absicht: ohne
|
|
diese Meldung stünde dort nur „unable to open database file".
|
|
|
|
### Hardware fürs Umrechnen
|
|
|
|
`/dev/dri` durchreichen — VAAPI deckt Intel wie AMD ab. Fehlt es, rechnet Halo
|
|
in Software; das ist langsamer, aber es läuft. **Zum blossen Umpacken wird gar
|
|
keine Hardware gebraucht**, und das ist der Weg, der den meisten Beständen
|
|
hilft.
|
|
|
|
## Entwickeln
|
|
|
|
```bash
|
|
dotnet test # 293 Tests
|
|
dotnet run --project src/Halo.Api # auf http://localhost:5000
|
|
```
|
|
|
|
Ohne gesetzte Variablen legt der Server `data/`, `cache/` und `transcode/`
|
|
neben dem Programm an.
|
|
|
|
```bash
|
|
# Image bauen, beide Architekturen
|
|
docker buildx build --platform linux/amd64,linux/arm64 -t halo:dev .
|
|
|
|
# Eine Migration nach einer Modelländerung
|
|
dotnet ef migrations add <Name> --project src/Halo.Data --startup-project src/Halo.Api --output-dir Migrations
|
|
```
|
|
|
|
Migrationen wandern **beim Start** des Servers, nicht über ein eigenes
|
|
Kommando.
|
|
|
|
### Aufbau
|
|
|
|
| Projekt | Aufgabe |
|
|
|---|---|
|
|
| `Halo.Core` | Domäne: Benutzer, Anmeldung, Bibliotheken. Kennt kein HTTP und keine Datenbank |
|
|
| `Halo.Data` | EF Core, SQLite, Migrationen |
|
|
| `Halo.Media` | ffprobe und ffmpeg. Als eigene Prozesse, nicht eingebunden |
|
|
| `Halo.Metadata` | TMDB und die Bildablage. Hinter einer Schnittstelle, damit alles andere ohne Netz prüfbar bleibt |
|
|
| `Halo.Api` | Endpunkte, Authentifizierung, Auslieferung der Oberfläche |
|
|
|
|
Was sicherheitsrelevant ist, liegt in `Halo.Core` — dort ist es ohne Server
|
|
prüfbar, und die Tests dazu sind die, die am meisten wert sind.
|
|
|
|
### Zwei Entscheidungen, die beim Lesen Fragen aufwerfen
|
|
|
|
**Zeitpunkte liegen als Unix-Millisekunden in der Datenbank, nicht als Text.**
|
|
SQLite hat keinen Zeittyp; ein `DateTimeOffset` landet sonst als Text mitsamt
|
|
Zonenversatz, und dann steht `…T12:00+02:00` neben `…T11:00Z` — derselbe
|
|
Augenblick, als Text aber falsch herum sortiert. EF weigert sich deshalb,
|
|
`ExpiresAt > jetzt` überhaupt zu übersetzen.
|
|
|
|
**Token sind keine JWT.** Der Server schlägt sie nach, statt sie zu prüfen.
|
|
Ein nachgeschlagenes Token lässt sich **sofort** widerrufen; ein signiertes
|
|
gilt bis zum Ablauf weiter, und für ein verlorenes Telefon ist das der falsche
|
|
Satz. In der Datenbank steht nur der Fingerabdruck.
|
|
|
|
## Bestand einlesen
|
|
|
|
```
|
|
POST /api/v1/libraries/{id}/scan stösst an, antwortet sofort mit 202
|
|
GET /api/v1/libraries/{id}/scan Stand: idle | running | enriching | finished | failed
|
|
GET /api/v1/movies?search=&skip=&take=
|
|
GET /api/v1/series
|
|
GET /api/v1/series/{id}/episodes?season=
|
|
GET /api/v1/images/{schlüssel}
|
|
```
|
|
|
|
**Der Scan läuft im Hintergrund**, nicht in der Anfrage: ein Bestand mit 40 000
|
|
Titeln braucht Minuten, und eine so lange offene Anfrage läuft hinter jedem
|
|
Reverse Proxy ins Zeitlimit. Einer je Bibliothek gleichzeitig.
|
|
|
|
Zweimal scannen ergibt dasselbe wie einmal. Zugeordnet wird über den Titel
|
|
**aus dem Dateinamen** — der angezeigte kommt nach dem ersten Lauf von TMDB
|
|
und ändert sich dabei.
|
|
|
|
### Metadaten
|
|
|
|
**Halo bringt einen TMDB-Zugang mit** — es läuft ohne Einrichtung. Wer lieber
|
|
seinen eigenen benutzt, hinterlegt ihn unter `PUT /api/v1/settings/metadata`;
|
|
der sticht den mitgelieferten. Abgefragt werden kann nur, *ob* einer da ist und
|
|
ob es der mitgelieferte ist — der Zugang selbst kommt nie zurück.
|
|
|
|
Hinterlegt werden kann beides, was auf TMDBs Kontoseite steht: der **API Read
|
|
Access Token** (empfohlen — er geht als `Authorization`-Kopfzeile hinaus, nicht
|
|
in der Adresse, und kann nur lesen) oder der ältere **API Key**.
|
|
|
|
Die Sprache steht auf `en-US` und wird in denselben Einstellungen umgestellt.
|
|
|
|
Bilder werden einmal heruntergeladen und liegen unter `HALO_CACHE`. Die App
|
|
holt sie bei Halo, nicht bei TMDB: sonst bräuchte sie dafür Internet, und TMDB
|
|
erführe, wer wann was ansieht.
|
|
|
|
**Der Hinweis auf TMDB ist Pflicht, keine Höflichkeit.** Deren Bedingungen
|
|
verlangen Logo und einen festen Satz. Beides liefert
|
|
`GET /api/v1/system/info` unter `attributions` — ohne Anmeldung abrufbar,
|
|
damit ein Client es auch auf dem Anmeldebildschirm zeigen kann. Jeder Client
|
|
muss es anzeigen.
|
|
|
|
## Herunterladen — mit allen Spuren
|
|
|
|
```
|
|
GET /api/v1/files/{id}/delivery was passieren wird, bevor etwas passiert
|
|
GET /api/v1/files/{id}/download die Datei — oder 202, solange umgepackt wird
|
|
```
|
|
|
|
**`delivery` zuerst fragen.** Dort steht, ob die Datei unverändert hinausgeht
|
|
oder umgepackt wird, welche Tonspuren dabei neu gerechnet werden müssen und
|
|
welche Untertitel nicht mitkönnen. Drei Gigabyte zu laden und erst danach zu
|
|
merken, dass die Untertitel fehlen, war der Anlass für dieses Projekt.
|
|
|
|
**Alle Tonspuren kommen mit.** Der Plan hat kein Feld „welche Tonspur" — es
|
|
gibt nichts auszuwählen, weil alle mitgehen. Sprachkennungen und Spurnamen
|
|
überstehen das Umpacken; nachgewiesen an einer Datei mit „Deutsch" und
|
|
„Audiodeskription", die sich allein am Namen unterscheiden.
|
|
|
|
**Das Bild wird nie angefasst.** Es wandert Byte für Byte in die MP4. Nur
|
|
Tonspuren in Codecs, die das Gerät nicht kennt — DTS, TrueHD —, werden neu
|
|
gerechnet, und auch dann keine einzige Bildzeile.
|
|
|
|
**Bildbasierte Untertitel fallen weg.** PGS und VobSub passen in keine MP4.
|
|
Das steht in `delivery` unter `subtitlesDropped`, statt stillschweigend zu
|
|
geschehen.
|
|
|
|
**Umgepackt wird auf die Platte, nicht in die Antwort.** Der bequeme Weg wäre,
|
|
ffmpeg unmittelbar durchzureichen — dann kennt aber niemand die Länge im
|
|
Voraus: kein Fortschrittsbalken, kein Fortsetzen, kein Springen. Dafür gibt es
|
|
`HALO_TRANSCODE`.
|
|
|
|
| Variable | Bedeutung | Voreinstellung |
|
|
|---|---|---|
|
|
| `HALO_FFMPEG` | Pfad zu ffmpeg | `ffmpeg` |
|
|
| `HALO_FFPROBE` | Pfad zu ffprobe | `ffprobe` |
|
|
|
|
## Zuordnung korrigieren
|
|
|
|
```
|
|
GET /api/v1/movies/{id}/matches?query=&year= Vorschläge
|
|
PUT /api/v1/movies/{id}/match { "tmdbId": 70523 }
|
|
GET /api/v1/series/{id}/matches?query=&year=
|
|
PUT /api/v1/series/{id}/match
|
|
```
|
|
|
|
**Weil Raten manchmal danebengeht.** Die Suche nach „Dark" lieferte „Dark
|
|
Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der beliebteste
|
|
Treffer. Dagegen hilft keine bessere Heuristik, sondern nur die Möglichkeit,
|
|
es richtigzustellen.
|
|
|
|
Eine Korrektur wird **festgeschrieben**: in der Datenbank, in der NFO-Datei
|
|
und als `lockdata`. Danach sucht kein Lauf mehr danach, und ein Neuaufbau der
|
|
Datenbank oder ein Umzug findet die Entscheidung wieder. Bei einer Serie
|
|
werden die Folgen gleich mit neu geholt — die alten gehörten zur falschen
|
|
Serie.
|
|
|
|
Nur Administratoren, denn es ändert die Mediathek für alle.
|
|
|
|
## Beiwerk im Medienordner
|
|
|
|
Mit `HALO_WRITE_SIDECARS=true` legt Halo Plakate, Hintergründe und
|
|
NFO-Dateien neben den Film:
|
|
|
|
```
|
|
Der Untergang (2004)/
|
|
├── Der Untergang (2004).mkv
|
|
├── poster.jpg
|
|
├── fanart.jpg
|
|
└── movie.nfo
|
|
```
|
|
|
|
Liegen mehrere Filme in einem Ordner, heisst das Beiwerk
|
|
`Der Untergang (2004)-poster.jpg` — sonst überschrieben sie sich gegenseitig.
|
|
Serien bekommen `tvshow.nfo` eine Ebene über dem Staffelordner, Folgen je eine
|
|
NFO und ein Standbild neben der Datei.
|
|
|
|
**Standardmässig aus**, und die Medieneinhängung muss dafür ohne `:ro`
|
|
auskommen. Was Halo schreiben darf, steht als Erlaubnisliste im Code
|
|
(`SidecarNames`): alles andere wird abgelehnt, und Mediendateien werden nie
|
|
geändert, umbenannt oder gelöscht. Transcode-Zwischenstände kommen nie in
|
|
einen Medienordner — die liegen unter `HALO_TRANSCODE`.
|
|
|
|
**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
|
|
Zuordnung, die jemand getroffen hat; Halo nimmt sie und sucht gar nicht erst.
|
|
Dasselbe gilt für Ordnernamen der Form `Der Untergang (2004) [tmdbid-613]` —
|
|
Halo liest sie, benennt aber **nichts** um. Umbenennen ist der einzige
|
|
Eingriff, der eine Bibliothek wirklich zerlegen kann.
|
|
|
|
| Variable | Bedeutung | Voreinstellung |
|
|
|---|---|---|
|
|
| `HALO_WRITE_SIDECARS` | Beiwerk in den Medienordner schreiben | `false` |
|
|
|
|
## Anmeldung am Fernseher
|
|
|
|
Ein Passwort auf der Apple-TV-Fernbedienung zu tippen ist eine Zumutung.
|
|
Stattdessen:
|
|
|
|
1. Der Fernseher fordert an und bekommt einen **sechsstelligen Code** für den
|
|
Bildschirm und einen **Abholschein**, den nur er kennt.
|
|
2. Am Telefon, angemeldet, wird der Code bestätigt — dort steht, welches Gerät.
|
|
3. Der Fernseher holt mit seinem Abholschein das Token ab.
|
|
|
|
**Der Code allein holt nie ein Token ab.** Ohne den Abholschein wären
|
|
sechsstellige Codes ein Türschloss mit einer Million Schlüsseln.
|
|
|
|
## Lizenz
|
|
|
|
**GPL-2.0-only.** Abhängigkeiten müssen dazu passen: MIT, BSD oder GPL-2.0.
|
|
**Apache-2.0 ist ausgeschlossen** — Einzelheiten und der Grund stehen in
|
|
[`LICENSES.md`](../LICENSES.md).
|