356 lines
14 KiB
Markdown
356 lines
14 KiB
Markdown
# Filmabend
|
||
|
||
Privater Katalog-Browser für den wöchentlichen Filmabend. Vier Reiter:
|
||
|
||
- **Netflix** — alles im Netflix-DE-Abo ab 50 Stimmen, aktuell 2567 Filme.
|
||
- **Kanon** — 194 Einträge aus Horror-Bestenlisten, davon 28 Filmreihen.
|
||
Zusammen 319 Filme, unabhängig davon, wo sie laufen.
|
||
- **Suche** — freie TMDB-Suche für alles, was in keinem der beiden steht.
|
||
- **Rad** — Glücksrad über die eingereichten Vorschläge.
|
||
|
||
Daten kommen von TMDB (Provider-Info stammt dort aus der JustWatch-Partnerschaft).
|
||
Die deutschen Teaser schreibt MiniMax M3 beim Sync und landen in der SQLite-Datei.
|
||
|
||
## Einrichten
|
||
|
||
```bash
|
||
cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen
|
||
make install
|
||
```
|
||
|
||
## Sync
|
||
|
||
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige
|
||
Minuten, weil für jeden Film ein Teaser generiert wird.
|
||
|
||
```bash
|
||
make sync
|
||
```
|
||
|
||
Teaser werden nur einmal erzeugt. Spätere Syncs schreiben nur für neue Filme.
|
||
Im Betrieb läuft der Sync täglich um 4:30 von selbst.
|
||
|
||
## Entwickeln
|
||
|
||
```bash
|
||
make dev # MediaMTX, Backend :8000, Frontend :5173
|
||
make stop # alles wieder aus
|
||
```
|
||
|
||
`make dev` startet MediaMTX als Container mit. Ohne ihn liefe der
|
||
Stream-Reiter ins Leere und OBS fände keinen Server. Einzeln geht es über
|
||
`make mtx` und `make mtx-stop`.
|
||
|
||
Der Vite-Dev-Server leitet `/api` ans Backend und `/stream` an MediaMTX.
|
||
|
||
## Produktiv
|
||
|
||
Auf dem Server im Projektverzeichnis. Läuft hinter Traefik im externen
|
||
Docker-Netz `web`, wie die anderen Projekte.
|
||
|
||
```bash
|
||
make prod # docker compose up -d --build
|
||
make logs
|
||
make down
|
||
```
|
||
|
||
Die SQLite-Datei liegt im Bind-Mount `./data`, überlebt also jeden Rebuild.
|
||
|
||
### Datenbank abgleichen
|
||
|
||
```bash
|
||
make push # lokaler Stand → Server
|
||
make pull # Server-Stand → lokal
|
||
```
|
||
|
||
Beide ziehen erst eine in sich stimmige Kopie über SQLites `backup`, statt die
|
||
Datei einer laufenden App direkt zu kopieren. Die überschriebene Datenbank
|
||
bleibt als `.bak` daneben liegen.
|
||
|
||
`make push` überschreibt auch die Vorschläge auf dem Server. Wer schon einen
|
||
Film fürs Rad eingereicht hat, verliert ihn.
|
||
|
||
Ohne Docker geht es auch:
|
||
|
||
```bash
|
||
make build # Vue nach backend/static
|
||
cd backend && .venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||
```
|
||
|
||
## Wie es läuft
|
||
|
||
```
|
||
APScheduler (täglich 4:30)
|
||
│
|
||
▼
|
||
TMDB /discover/movie ──► pro Film /movie/{id} (Bilder, Videos, Übersetzungen)
|
||
│ │
|
||
│ ▼
|
||
│ MiniMax M3 schreibt den Teaser
|
||
▼ │
|
||
SQLite (backend/horror.db) ◄────────────────┘
|
||
│
|
||
▼
|
||
FastAPI /api/movies ──► Vue-Grid, Detail-Overlay
|
||
```
|
||
|
||
## API
|
||
|
||
| Endpoint | Zweck |
|
||
| --- | --- |
|
||
| `GET /api/movies` | ganzer Katalog, das Frontend filtert clientseitig |
|
||
| `GET /api/movies/{id}` | einzelner Film |
|
||
| `GET /api/status` | Anzahl Filme, letzter Sync, läuft gerade einer |
|
||
| `GET /api/search?q=` | freie TMDB-Suche, nicht auf den Katalog beschränkt |
|
||
| `GET /api/search/{id}` | volle Daten, erst aus dem Katalog, sonst von TMDB |
|
||
| `GET /api/health` | für den Docker-Healthcheck |
|
||
| `POST /api/sync` | Sync von Hand anstoßen |
|
||
|
||
## Filmreihen
|
||
|
||
Gehört ein Film zu einer Reihe, zeigt die Detailansicht die anderen Teile —
|
||
aber nur die, die selbst auf Netflix liegen. Grundlage ist TMDBs
|
||
`belongs_to_collection`. Ein Klick springt direkt zum nächsten Teil.
|
||
|
||
Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus
|
||
2567 Netflix-Kacheln werden so 2369 — Herr der Ringe belegt eine statt drei.
|
||
Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr
|
||
Platz im Raster.
|
||
|
||
## Der Stream-Reiter
|
||
|
||
Du sendest von deinem Rechner per OBS an den Server, alle schauen im Browser
|
||
zu. Voice läuft weiter über Discord.
|
||
|
||
```
|
||
OBS ──RTMP──► MediaMTX ──LL-HLS──► FastAPI ──► Browser (hls.js)
|
||
```
|
||
|
||
**Senden:**
|
||
|
||
```bash
|
||
make stream # an den Server, beendet vorher ein laufendes OBS
|
||
make stream-local # an den lokalen MediaMTX, zum Ausprobieren
|
||
make stream-stop # OBS beenden und den Tonweg zurückstellen
|
||
```
|
||
|
||
Strg-C im Terminal beendet OBS nicht zuverlässig — deshalb `make stream-stop`
|
||
oder einfach das OBS-Fenster schließen.
|
||
|
||
Das Skript legt ein eigenes OBS-Profil `filmabend` an und startet OBS damit.
|
||
Deine normale OBS-Konfiguration bleibt unberührt. Beim Start fragt der
|
||
Desktop, welches Fenster übertragen werden soll — erst nach dieser Auswahl
|
||
fließen Bilder.
|
||
|
||
Bitrate und Auflösung stehen in
|
||
`~/.config/obs-studio/basic/profiles/filmabend/basic.ini` und werden nur
|
||
beim ersten Mal geschrieben. Das Sendeziel aktualisiert das Skript jedes Mal.
|
||
|
||
### Der sendende Rechner braucht BBR
|
||
|
||
Einmalige Einstellung, ohne die der Stream ruckelt:
|
||
|
||
```bash
|
||
printf 'tcp_bbr\n' | sudo tee /etc/modules-load.d/bbr.conf
|
||
printf 'net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n' \
|
||
| sudo tee /etc/sysctl.d/99-bbr.conf
|
||
sudo sysctl --system
|
||
```
|
||
|
||
Warum: Kabelanschlüsse puffern den Upload großzügig. Unter Last stieg die
|
||
Latenz hier von 26 auf 181 ms, ohne einen einzigen Paketverlust. Der
|
||
Standard-Algorithmus `cubic` liest den Latenzanstieg als Stau und drosselt.
|
||
Eine einzelne Verbindung kam so auf 4,5 statt 25 Mbit/s — und RTMP nutzt
|
||
genau eine. OBS warf daraufhin 45 % der Bilder weg.
|
||
|
||
BBR misst stattdessen den tatsächlichen Durchsatz und lässt sich davon nicht
|
||
täuschen. Danach: 0 % verworfen. `fq` gehört dazu, BBR braucht diese
|
||
Warteschlange für seine Sendetaktung.
|
||
|
||
Prüfen mit `sysctl -n net.ipv4.tcp_congestion_control` — es muss `bbr`
|
||
antworten.
|
||
|
||
Von Hand geht es auch:
|
||
|
||
| Feld | Wert |
|
||
| --- | --- |
|
||
| Server | `rtmp://horror.marha.de:1935/` |
|
||
| Streamschlüssel | `filmabend?user=sender&pass=<MTX_SENDEPASSWORT>` |
|
||
|
||
Ohne Passwort weist MediaMTX die Verbindung ab. Zuschauen braucht keins.
|
||
|
||
**Ton:** OBS nimmt den Desktop-Ton über PulseAudio mit. Im Browser ginge
|
||
das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und
|
||
Firefox gar keinen.
|
||
|
||
Der sendende Rechner bleibt dabei still. Der Ton läuft über eine Null-Senke,
|
||
deren Monitor OBS abgreift; auf die Lautsprecher geht nichts. Am Pegel im
|
||
Stream ändert das nichts, weil PipeWire die Monitor-Ports vom Regler der
|
||
Senke unberührt lässt. `make stream MITHOEREN=1` legt den Ton zusätzlich auf
|
||
dein gewohntes Gerät.
|
||
|
||
**Warum alle dasselbe Bild sehen**
|
||
|
||
Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`).
|
||
Wer zurückfällt, spielt bis zu 1,5-fach schneller ab, bis er wieder auf
|
||
Ziellatenz ist — das regelt hls.js über `maxLiveSyncPlaybackRate`. Ab 7
|
||
Sekunden Rückstand springt es stattdessen.
|
||
|
||
Gemessen mit zwei unabhängigen Playern am selben Stream:
|
||
|
||
| Fall | Ergebnis |
|
||
| --- | --- |
|
||
| Dauerlauf, 45 s | Latenz konstant 4,05 s, keine Regelung nötig |
|
||
| Zwei Player | **11 ms** Bildversatz |
|
||
| Nach 8 s Aussetzer | zurück auf 12 ms in 9 Sekunden |
|
||
|
||
Die Anzeige rechts zeigt Verzögerung, Abweichung, Tempo und die Sendezeit
|
||
aus dem Manifest. Zwei Zuschauer mit gleicher Sendezeit sehen exakt dasselbe
|
||
Bild — damit lässt sich der Gleichlauf im Betrieb prüfen.
|
||
|
||
**Grenzen**
|
||
|
||
- Dein Upload muss stabil 6–8 Mbit/s halten. Reicht er nicht, wird es für
|
||
alle schlechter.
|
||
- Der Server schiebt pro Zuschauer die volle Bitrate raus. Bei 15 Leuten
|
||
und 6 Mbit/s sind das 90 Mbit/s und rund 80 GB je Filmabend. Hetzner
|
||
liefert 10 Gbit und 20 TB im Monat.
|
||
- Der HLS-Proxy in FastAPI reicht jedes Byte durch Python. Für einen
|
||
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
|
||
MediaMTX davor.
|
||
|
||
## Vorschläge und Rad
|
||
|
||
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
||
vorschlagen — erkannt über ein Cookie, ohne Anmeldung. Ein zweiter Vorschlag
|
||
ersetzt den ersten, statt abgelehnt zu werden.
|
||
|
||
Vorgeschlagen wird aus der Detailansicht heraus, mit optionalem Namen. Das
|
||
klappt auch mit Filmen aus dem Suche-Reiter, die gar nicht im Katalog stehen:
|
||
Titel, Jahr und Poster werden beim Vorschlagen mitgespeichert.
|
||
|
||
Das Rad verteilt die Vorschläge auf gleich große Segmente und dreht fünf bis
|
||
sieben Runden. Der Zeiger steht oben; das Ziel wird ausgelost und die Drehung
|
||
so berechnet, dass die Segmentmitte dort landet.
|
||
|
||
**Gedreht wird für alle gleichzeitig.** Die Ziehung passiert im Server, nicht
|
||
im Browser — sonst sähe jede Seite ein anderes Ergebnis. Wer auf „Drehen"
|
||
klickt, stößt nur an; das Ergebnis samt Zielwinkel geht per Server-Sent Events
|
||
an alle offenen Seiten. Neue Vorschläge kommen auf demselben Weg an.
|
||
|
||
Wer die Seite mitten im Dreh lädt, fragt `GET /api/spin` und steigt in die
|
||
Restanimation ein. Ist der Dreh schon durch, springt das Rad ohne Animation
|
||
auf die Endstellung — sonst zeigte der Zeiger woanders hin als die
|
||
Gewinnerkarte.
|
||
|
||
Die Abonnenten liegen im Speicher des Prozesses. Das trägt genau so weit wie
|
||
ein einzelner uvicorn-Worker; bei mehreren bekäme jeder nur seine eigenen.
|
||
|
||
| Endpoint | Zweck |
|
||
| --- | --- |
|
||
| `GET /api/suggestions` | alle Vorschläge, plus die eigene ID |
|
||
| `POST /api/suggestions` | vorschlagen oder ersetzen |
|
||
| `DELETE /api/suggestions` | eigenen Vorschlag zurückziehen |
|
||
| `GET /api/events` | Live-Kanal (SSE) für Dreh und Vorschläge |
|
||
| `POST /api/spin` | Dreh für alle auslösen |
|
||
| `GET /api/spin` | laufenden oder letzten Dreh abfragen |
|
||
|
||
## Die Filterleiste
|
||
|
||
Rechts am Fensterrand, fest stehend. Von oben nach unten:
|
||
|
||
- Suche und Sortierung
|
||
- Vier Bereichsregler: Bewertungen, Dauer, Jahr, Bewertung. Die Skalen kommen
|
||
aus dem offenen Reiter und wechseln beim Umschalten mit. Bei den Stimmen ist
|
||
die Skala gestaucht — linear säßen fast alle Filme im linken Prozent.
|
||
- Genres zum Zeigen und zum Ausblenden, je als Badge-Gruppe mit "alle"
|
||
|
||
Eingeschlossene Genres wirken als ODER. Ein Ausschluss sticht immer: Ein
|
||
Actionfilm mit Komödie fliegt raus, wenn Komödie ausgeblendet ist.
|
||
|
||
Ein früherer Versuch mit acht Subgenres (Geister, Zombies, Slasher …) ist
|
||
wieder raus. TMDBs Schlagwörter reichten dafür nicht: 77 Filme hatten gar
|
||
keine, der Rest nur allgemeine wie "supernatural horror".
|
||
|
||
## Der Kanon-Reiter
|
||
|
||
Die Liste steht in `app/canon_list.py`. Ein Eintrag ist ein Film oder eine
|
||
Reihe. Reihen bekommen eine breitere Karte, die durch ihre Teile blättert —
|
||
immer ein Teil vollständig sichtbar.
|
||
|
||
`app/canon.py` löst die deutschen Verleihtitel gegen TMDB auf. Das klappt bei
|
||
309 von 320 Titeln allein über die Suche. Der Rest steht in `OVERRIDES` mit
|
||
fester TMDB-ID, weil die Suche danebengriff:
|
||
|
||
- Ohne Jahresangabe liefert TMDB den jüngsten Titel zuerst. "Final Destination"
|
||
traf deshalb "Bloodlines", "A Quiet Place" den zweiten Teil.
|
||
- "Das Böse" ist auf Deutsch sowohl Phantasm als auch Amityville Horror.
|
||
- "Us" traf "Forgive Us All" von 2025.
|
||
|
||
Die Vorlage führte "Black Christmas" und "Jessy – Die Treppe in den Tod"
|
||
getrennt. Das ist derselbe Film, hier nur einmal.
|
||
|
||
Innerhalb einer Reihe wird nach Erscheinungsjahr sortiert, nicht nach der
|
||
Reihenfolge der Vorlage. Die gruppiert das Conjuring-Universum nach
|
||
Sub-Reihen — erst alle Conjuring, dann Nun, dann Annabelle.
|
||
|
||
## Woher der Netflix-Link kommt
|
||
|
||
Die TMDB-API nennt nur den Anbieternamen, nicht die Titel-ID. Die steht
|
||
ausschließlich im HTML der Watch-Seite, URL-kodiert im JustWatch-Link.
|
||
`app/netflix.py` liest sie dort aus und merkt sie sich in der Spalte
|
||
`netflix_id`, damit spätere Syncs die Seite nicht erneut laden.
|
||
|
||
Der Abruf läuft seriell mit 1,5 Sekunden Pause. Bei dichteren Abrufen
|
||
antwortet TMDB mit Status 200, aber ohne Anbieterblock — die ID fehlt dann
|
||
stillschweigend. Filme ohne ID versucht der nächste Sync erneut.
|
||
|
||
Hat ein Film mehrere IDs, gewinnt die erste. Godzilla Minus One liegt bei
|
||
Netflix in Farbe und in Schwarzweiß.
|
||
|
||
## Warum der Katalog bei 50 Stimmen abschneidet
|
||
|
||
JustWatch ordnet unbekannte Titel gelegentlich der Netflix-ID eines
|
||
gleichnamigen bekannten Films zu. TMDB übernimmt das ungeprüft. Der Film
|
||
steht dann im Katalog, obwohl Netflix ihn nicht hat.
|
||
|
||
Nachgewiesen an fünf Fällen, alle nach demselben Muster:
|
||
|
||
| Eintrag | Stimmen | bekam die ID von |
|
||
| --- | --- | --- |
|
||
| CREEP (2014, 8 Min) | 2 | Creep (2014, 1678 Stimmen) |
|
||
| Muerte, muerte, muerte | 2 | Bodies Bodies Bodies |
|
||
| LIENZO | 1 | Die Leinwand / Canvas |
|
||
| NOISE! | 1 | Noise |
|
||
| The Roommate (2022) | 1 | The Roommate (2011) |
|
||
|
||
Nachweis über TMDBs Watch-Seiten: Dort steht die Netflix-ID im
|
||
Weiterleitungslink. Zwei Filme mit derselben ID können nicht beide stimmen.
|
||
Oberhalb von zwei Stimmen trat kein einziger Fall auf. Die Grenze liegt
|
||
inzwischen bei 50 Stimmen, weil der Katalog alle Genres umfasst — das hält
|
||
die Fehlzuordnungen ohnehin draußen.
|
||
|
||
## Grenzen
|
||
|
||
- Verfügbarkeit hinkt Netflix 24–48 h hinterher. So oft liefert JustWatch an TMDB.
|
||
- Es gibt keine Info, ob ein Film deutsche Synchronisation hat. TMDB kennt nur
|
||
die Originalsprache, nicht die Tonspuren bei Netflix.
|
||
- Der Netflix-Knopf führt bei 730 von 2567 Filmen direkt auf die Titelseite.
|
||
Der Rest landet auf TMDBs Watch-Seite, von dort ist es ein Klick weiter.
|
||
Pro Sync kommen 250 dazu, meistgesehene zuerst — mehr auf einmal drosselt
|
||
TMDB.
|
||
- Die Szenenbilder sind TMDB-Backdrops. Der Sync wirft Duplikate raus, indem
|
||
er die Bilder selbst vergleicht (dHash plus Farbhistogramm, siehe
|
||
`app/imagehash.py`). Motive mit Titeltext fliegen ganz raus. Werbemotive
|
||
ohne Text bleiben drin — die lassen sich nicht von Szenenfotos trennen.
|
||
- Neun Filme haben gar keine Bilder. Dann zeigt die Detailansicht weniger.
|
||
- Der Teaser-Ton richtet sich nach dem Genre. Horror, Thriller, Mystery und
|
||
Krimi bleiben reißerisch, alles andere bekommt die Anweisung, den Ton des
|
||
Films zu treffen. Vorher wurde aus einer Bollywood-Romanze ein Dorf mit
|
||
verschlingenden Almwiesen.
|
||
|
||
## Attribution
|
||
|
||
This product uses the TMDB API but is not endorsed or certified by TMDB.
|
||
Verfügbarkeitsdaten stammen von JustWatch.
|