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
cp .env.example .env # TMDB_TOKEN und MINIMAX_KEY eintragen
make install
HOST_SCHLUESSEL in der .env ist frei wählbar, am besten lang und
zufällig:
python3 -c "import secrets; print(secrets.token_urlsafe(24))"
Ohne Eintrag bleiben die Hostknöpfe für alle zu.
Namen und Host
Der Host pflegt in der Verwaltung die Namensliste, alle anderen wählen sich einen davon aus. Wer keinen Namen wählt, ist Zuschauer: lesen ja, eintragen nein — keine Vorschläge, keine Sammlung, keine Wertungen, keine Wünsche.
Anmeldefilm statt Passwort. In der Verwaltung hinterlegt jeder für seinen Namen einen Film. Wer den Namen danach wählt, bekommt die Filmsuche vorgesetzt und muss den richtigen anklicken. Der Server gibt den Film nie ungefragt heraus; über das Augensymbol sieht ihn nur der Host und der Name selbst. Ein Name ohne Film bleibt für alle offen.
Hostmodus. Einmal https://…/?host=<HOST_SCHLUESSEL> aufrufen, dann
merkt sich die Sitzung das und der Schlüssel fliegt aus der Adresszeile.
Danach blendet Strg+Shift+Alt+S die Hostknöpfe ein und aus — nützlich,
um die Gastansicht zu prüfen. Ohne freigeschaltete Sitzung tut die
Tastenkombination nichts.
Der Host überspringt den Anmeldefilm und wechselt die Namen frei. Wer den Link kennt, kann das also auch — für einen Filmabend unter Freunden reicht das.
Sync
Holt den Katalog und erzeugt fehlende Teaser. Der erste Lauf dauert einige Minuten, weil für jeden Film ein Teaser generiert wird.
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
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.
make prod # docker compose up -d --build
make logs
make down
Die SQLite-Datei liegt im Bind-Mount ./data, überlebt also jeden Rebuild.
Datenbank vom Server holen
make pull # Server-Stand → lokal
Zieht erst eine in sich stimmige Kopie über SQLites backup, statt die Datei
einer laufenden App direkt zu kopieren. Die überschriebene lokale Datenbank
bleibt als .bak daneben liegen.
Den umgekehrten Weg gibt es absichtlich nicht: Die Sammlungen, Vorschläge und Wertungen auf dem Server sind die echten. Ein lokaler Stand würde sie überschreiben.
Ohne Docker geht es auch:
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:
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:
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.
Aufnahme und Clips
MediaMTX schneidet jeden Abend mit: ohne Neukodierung, als fMP4 in
30-Sekunden-Segmenten unter aufnahmen/ (im Container /recordings,
in docker-compose.yml als Volume). Gelöscht wird nichts von selbst —
wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf
eine Storage Box. Rund 5 GB je Abend bei 1080p.
Löschen kann der Host in der Verwaltung, Stück für Stück: die App
schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als
sender mit dem Sendepasswort), die die Segmente wegräumt.
Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der
Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt,
weil der Player so weit hinter der Live-Kante hängt. Der Film ist der
zuletzt gezogene. Im Reiter „Clips" zieht man später das Fenster um die
Marke, sieht es sich an, schneidet und gibt frei. Geschnittene Clips
liegen als MP4 unter clips/, freigegebene erscheinen im Buch auf der
Seite des Abends. Bis zur Freigabe sieht sie nur, wer sie gesetzt hat —
und der Host, der auch aufräumen darf. Ein freigegebener Clip bringt
3 Punkte, einmal je Film.
Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996,
nur intern): /get?path=filmabend&start=<RFC3339>&duration=<s>&format=mp4.
Er fügt Segmente selbst zusammen und liefert auch aus dem gerade
laufenden Segment. Die App reicht das als Vorschau durch und legt beim
Schneiden die Datei ab; ffmpeg braucht es dafür nicht.
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.