import asyncio from datetime import timedelta import logging import secrets import time from apscheduler.schedulers.asyncio import AsyncIOScheduler import httpx from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from . import ( backup, clips, punkte, db, events, features, ki, search, streamproxy, suggestions, sync, users, watched, wishlist, ) from .config import HOST_SCHLUESSEL, IMAGE_BASE, STATIC_DIR logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") # Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde. # Jede davon zu protokollieren füllt bei fünfzehn Leuten die Platte und # sagt über den Betrieb nichts aus. Fehler kommen weiterhin durch. logging.getLogger("httpx").setLevel(logging.WARNING) class OhneStreamZugriffe(logging.Filter): def filter(self, eintrag): return "/stream/" not in eintrag.getMessage() logging.getLogger("uvicorn.access").addFilter(OhneStreamZugriffe()) log = logging.getLogger("app") app = FastAPI(title="Netflix Horror DE") scheduler = AsyncIOScheduler() sync_lock = asyncio.Lock() # Der Ereignisstrom darf nicht durch die Kompression, und Video auch nicht: # GZipMiddleware sammelt die Antwort, bevor sie ausgeht. Bei Server-Sent # Events kommt dadurch gar nichts an, solange der Puffer nicht voll ist — # und der wird bei kurzen Ereignissen nie voll. Videosegmente sind bereits # komprimiert; sie noch einmal durch gzip zu schicken kostet nur Rechenzeit. OHNE_KOMPRESSION = ("/api/events", "/stream") class GzipMitAusnahmen(GZipMiddleware): async def __call__(self, scope, receive, send): if scope["type"] == "http" and scope["path"].startswith(OHNE_KOMPRESSION): await self.app(scope, receive, send) return await super().__call__(scope, receive, send) # Der Katalog ist reines JSON und schrumpft dabei auf etwa ein Fünftel. app.add_middleware(GzipMitAusnahmen, minimum_size=1024) app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_methods=["*"], allow_headers=["*"], ) async def sicherung(): # Die Kopie blockiert kurz — nicht im Ereignisschleifen-Thread await asyncio.to_thread(backup.sichern) async def guarded_sync(): if sync_lock.locked(): log.info("Sync läuft bereits") return None async with sync_lock: return await sync.run() @app.on_event("startup") async def startup(): db.init() users.init() suggestions.init() watched.init() clips.init() wishlist.init() features.init() # Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update # ein sauberer Stand if not HOST_SCHLUESSEL: log.warning( "HOST_SCHLUESSEL fehlt in der .env — der Hostmodus bleibt zu." ) scheduler.add_job(sicherung, "cron", hour=4, minute=0, id="daily-backup") scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync") scheduler.start() if not db.get_meta("last_sync"): log.info("Leere Datenbank, starte ersten Sync im Hintergrund") asyncio.create_task(guarded_sync()) else: # Altbestand: gemerkte Filme, die noch keine Katalogdaten haben, # und Filme, bei denen noch nicht steht, ob man sie leihen kann asyncio.create_task(sync.katalog_nachtragen()) asyncio.create_task(sync.leihbarkeit_nachtragen()) @app.on_event("shutdown") async def shutdown(): await streamproxy.schliessen() @app.get("/api/health") def health(): return {"ok": True} @app.get("/api/status") def status(): return { "last_sync": db.get_meta("last_sync"), "movie_count": int(db.get_meta("movie_count") or 0), "canon_count": int(db.get_meta("canon_count") or 0), "syncing": sync_lock.locked(), "image_base": IMAGE_BASE, } def katalog_etag(): """Der Katalog ändert sich nur beim Sync — der Zeitstempel reicht.""" return f'W/"{db.get_meta("last_sync")}-{db.get_meta("movie_count")}"' @app.get("/api/movies") def movies(request: Request, response: Response): etag = katalog_etag() # Unverändert? Dann spart der Browser sich fünf Megabyte. if request.headers.get("if-none-match") == etag: return Response(status_code=304, headers={"ETag": etag}) response.headers["ETag"] = etag response.headers["Cache-Control"] = "no-cache" return db.all_movies() @app.get("/api/movies/{movie_id}") def movie(movie_id: int): found = db.get_movie(movie_id) if not found: raise HTTPException(status_code=404, detail="Film nicht gefunden") return found @app.get("/api/search") async def search_movies(q: str, limit: int = 24): """Freie TMDB-Suche für Filme außerhalb des Katalogs.""" if len(q.strip()) < 2: return [] return await search.search(q.strip(), limit=max(1, min(search.MAX_TREFFER, limit))) @app.get("/api/discover") async def stoebern( limit: int = 40, sort: str = "rating", include: str = "", exclude: str = "", stimmen_min: int | None = None, stimmen_max: int | None = None, dauer_min: int | None = None, dauer_max: int | None = None, jahr_min: int | None = None, jahr_max: int | None = None, note_min: float | None = None, note_max: float | None = None, ): """Suche ohne Suchbegriff: TMDB stöbert nach den Filtern der Leiste.""" return await search.discover( { "sort": sort, "include": [g for g in include.split(",") if g], "exclude": [g for g in exclude.split(",") if g], "stimmen_min": stimmen_min, "stimmen_max": stimmen_max, "dauer_min": dauer_min, "dauer_max": dauer_max, "jahr_min": jahr_min, "jahr_max": jahr_max, "note_min": note_min, "note_max": note_max, }, limit=max(1, min(search.MAX_TREFFER, limit)), ) @app.get("/api/movies/{movie_id}/aehnliche") async def aehnliche_filme( movie_id: int, limit: int = 12, voll: bool = False, sort: str = "", include: str = "", exclude: str = "", stimmen_min: int | None = None, stimmen_max: int | None = None, jahr_min: int | None = None, jahr_max: int | None = None, note_min: float | None = None, note_max: float | None = None, ): """Was Leute noch geschaut haben, die diesen Film mochten.""" try: return await search.aehnliche( movie_id, limit=limit, voll=voll, filter_={ "sort": sort, "include": [g for g in include.split(",") if g], "exclude": [g for g in exclude.split(",") if g], "stimmen_min": stimmen_min, "stimmen_max": stimmen_max, "jahr_min": jahr_min, "jahr_max": jahr_max, "note_min": note_min, "note_max": note_max, }, ) except Exception: return [] @app.post("/api/ki-suche") async def ki_suche( response: Response, beschreibung: str = Body("", embed=True), mit_sammlung: bool = Body(False, embed=True), ohne_gesehene: bool = Body(True, embed=True), include: str = Body("", embed=True), exclude: str = Body("", embed=True), stimmen_min: int | None = Body(None, embed=True), stimmen_max: int | None = Body(None, embed=True), dauer_min: int | None = Body(None, embed=True), dauer_max: int | None = Body(None, embed=True), jahr_min: int | None = Body(None, embed=True), jahr_max: int | None = Body(None, embed=True), note_min: float | None = Body(None, embed=True), note_max: float | None = Body(None, embed=True), limit: int = Body(ki.ANZAHL, embed=True), filmabend_sid: str | None = Cookie(None), ): """Vorschläge von der KI, bei TMDB nachgeschlagen. Die eigene Sammlung geht auf Wunsch mit, die gesehenen Filme als Ausschluss. Die Filter der Leiste sind Vorgaben an die KI und danach ein Sieb.""" sid = sitzung(response, filmabend_sid) filter_ = { "include": [g for g in include.split(",") if g], "exclude": [g for g in exclude.split(",") if g], "stimmen_min": stimmen_min, "stimmen_max": stimmen_max, "dauer_min": dauer_min, "dauer_max": dauer_max, "jahr_min": jahr_min, "jahr_max": jahr_max, "note_min": note_min, "note_max": note_max, } beschreibung = beschreibung.strip()[: ki.MAX_BESCHREIBUNG] sammlung = ( [ki.etikett(e) for e in wishlist.alle(sid) if e["mine"]] if mit_sammlung else [] ) if not beschreibung and not sammlung and not ki.vorgaben(filter_): raise HTTPException( status_code=400, detail="Beschreib, was du suchst, oder gib die Sammlung mit." ) gesehen = watched.alle(sid) try: vorschlaege = await ki.fragen( beschreibung, sammlung, [ki.etikett(g) for g in gesehen], filter_, max(1, min(ki.MAX_ANZAHL, limit)), ) except (httpx.HTTPError, KeyError, IndexError): raise HTTPException(status_code=502, detail="Die KI antwortet gerade nicht.") ausser = {g["movie_id"] for g in gesehen} if ohne_gesehene else set() return await ki.aufloesen(vorschlaege, filter_, ausser=ausser) # ── Clips: Marken setzen, Fenster ziehen, schneiden, freigeben ────────── def clip_oder_404(clip): if clip is None: raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner") return clip @app.post("/api/clips") def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)): """Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der zuletzt gezogene, sonst der neueste Abend unter Gesehen.""" sid = sitzung(response, filmabend_sid) mitglied(sid) gezogen = letzter_dreh["ergebnis"] if gezogen: movie_id = gezogen["gewinner"]["movie_id"] else: abend = watched.juengster() movie_id = abend["movie_id"] if abend else None clip = clips.markieren(sid, movie_id) events.senden("clips") return clip @app.get("/api/clips/aufnahmen") async def clip_aufnahmen(): """Zu welchen Abenden es eine Aufnahme gibt.""" try: return {"tage": await clips.tage_mit_aufnahme()} except httpx.HTTPError: return {"tage": []} @app.post("/api/clips/von-film") async def clip_von_film( response: Response, movie_id: int = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): """Ein Clip zu einem alten Abend: die Marke kommt an den Anfang der Aufnahme, das Fenster schiebt man dann im Editor hin.""" sid = sitzung(response, filmabend_sid) mitglied(sid) abend = watched.von_film(movie_id) if not abend: raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.") try: beginn = await clips.aufnahme_des_abends(abend["watched_at"]) except httpx.HTTPError: beginn = None if beginn is None: raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.") clip = clips.markieren(sid, movie_id, beginn + timedelta(seconds=clips.VORHER + 30)) events.senden("clips") return clip @app.get("/api/clips") def clips_liste(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) return clips.alle(sid, users.ist_host(sid)) @app.patch("/api/clips/{clip_id}") def clip_aendern( clip_id: int, response: Response, titel: str | None = Body(None, embed=True), vorher: float | None = Body(None, embed=True), dauer: float | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) clip = clip_oder_404(clips.aendern(clip_id, sid, users.ist_host(sid), titel, vorher, dauer)) events.senden("clips") return clip @app.delete("/api/clips/{clip_id}") def clip_loeschen(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) if not clips.loeschen(clip_id, sid, users.ist_host(sid)): raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner") events.senden("clips") return {"removed": 1} @app.post("/api/clips/{clip_id}/schneiden") async def clip_schneiden(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)): """Den Ausschnitt aus der Aufnahme als Datei ablegen.""" sid = sitzung(response, filmabend_sid) try: clip = clip_oder_404(await clips.schneiden(clip_id, sid, users.ist_host(sid))) except httpx.HTTPError: raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.") events.senden("clips") return clip @app.get("/api/clips/{clip_id}/aufnahme") async def clip_aufnahme(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)): """Die Stücke der Aufnahme rund um die Marke, für den Zeitstrahl.""" sid = sitzung(response, filmabend_sid) row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid))) try: return await clips.aufnahme(row["marke_at"]) except httpx.HTTPError: return [] @app.get("/api/clips/{clip_id}/vorschau") async def clip_vorschau( clip_id: int, response: Response, vorher: float | None = None, dauer: float | None = None, filmabend_sid: str | None = Cookie(None), ): """Ein Fenster um die Marke ansehen, ohne zu schneiden — beim Ziehen der Regler. Kommt direkt vom Playback-Server durch.""" sid = sitzung(response, filmabend_sid) row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid))) url, params = clips.playback_url(row, vorher, dauer) http = httpx.AsyncClient(timeout=120.0) try: antwort = await http.send(http.build_request("GET", url, params=params), stream=True) except httpx.HTTPError: await http.aclose() raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.") if antwort.status_code != 200: await antwort.aclose() await http.aclose() raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.") async def weiterreichen(): try: async for stueck in antwort.aiter_bytes(): yield stueck finally: await antwort.aclose() await http.aclose() return StreamingResponse(weiterreichen(), media_type="video/mp4") @app.get("/api/clips/{clip_id}/video") def clip_video(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid))) if not row["datei"] or not (clips.CLIPS_DIR / row["datei"]).exists(): raise HTTPException(status_code=404, detail="Noch nicht geschnitten") return FileResponse(clips.CLIPS_DIR / row["datei"], media_type="video/mp4") @app.get("/api/movies/{movie_id}/credits") async def film_credits(movie_id: int): """Regie und Besetzung, live von TMDB.""" return await search.credits(movie_id) @app.get("/api/personen") async def personen_suchen(q: str): """Schauspieler und Regisseure bei TMDB suchen.""" if len(q.strip()) < 2: return [] return await search.personen(q.strip()) @app.get("/api/personen/{person_id}/filme") async def personen_filme(person_id: int): """Was jemand gespielt hat und wo er Regie geführt hat.""" try: return await search.person_filme(person_id) except httpx.HTTPError: raise HTTPException(status_code=502, detail="TMDB antwortet gerade nicht.") @app.get("/api/search/{movie_id}") async def search_detail(movie_id: int): """Volle Daten zu einem Suchtreffer. Erst der Katalog, dann TMDB.""" found = db.get_movie(movie_id) if found: return found try: return await search.detail(movie_id) except Exception: raise HTTPException(status_code=404, detail="Film nicht gefunden") def sitzung(response: Response, sid): """Vorhandene Sitzung weiterverwenden, sonst eine neue setzen.""" if sid: return sid neu = suggestions.neue_session() response.set_cookie( suggestions.COOKIE, neu, max_age=60 * 60 * 24 * 365, httponly=True, samesite="lax", ) return neu def mitglied(sid): """Wer keinen Namen gewählt hat, darf zusehen, aber nichts eintragen. Vorschläge, Sammlungen, Wertungen und Wünsche gehören einer Person. Ohne Namen hingen sie nur am Cookie: im geteilten Browser schriebe jeder auf dasselbe Konto, und nach dem Löschen der Cookies wäre alles weg. Die Knöpfe blendet die Oberfläche aus — das hier ist der Riegel. Der Host braucht dafür ebenfalls einen Namen. Seine eigenen Knöpfe (drehen, eintragen, aufräumen) laufen über andere Routen. """ ich = users.besitzer(sid) if ich is None: raise HTTPException( status_code=403, detail="Dafür musst du oben deinen Namen wählen." ) return ich # Der letzte Dreh, damit eine Seite mitten in der Animation nachziehen # kann — samt Anwesenheit der Runde, die beim Eintragen am Abend landet letzter_dreh = {"ergebnis": None, "start": 0.0, "teilnehmer": []} @app.get("/api/events") async def ereignisse(request: Request): queue = events.anmelden() return StreamingResponse( events.stream(queue, request), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.post("/api/spin") def drehen(): """Einer dreht, alle sehen dasselbe Ergebnis.""" laeuft = ( letzter_dreh["ergebnis"] and time.monotonic() - letzter_dreh["start"] < letzter_dreh["ergebnis"]["dauer"] ) if laeuft: return {"started": False, "reason": "dreht bereits"} ergebnis = suggestions.ziehen() if not ergebnis: raise HTTPException(status_code=400, detail="Keine Vorschläge") letzter_dreh["ergebnis"] = ergebnis letzter_dreh["start"] = time.monotonic() letzter_dreh["teilnehmer"] = suggestions.teilnehmer() events.senden("spin", ergebnis) return {"started": True, **ergebnis} @app.get("/api/spin") def letzter(): """Für Seiten, die mitten im Dreh geladen werden.""" ergebnis = letzter_dreh["ergebnis"] if not ergebnis: return {"running": False, "result": None} vergangen = time.monotonic() - letzter_dreh["start"] return { "running": vergangen < ergebnis["dauer"], # Wer später dazukommt, überspringt den bereits gelaufenen Teil "elapsed": round(vergangen, 2), "result": ergebnis, } @app.get("/api/suggestions") def liste_vorschlaege(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) eintraege = suggestions.alle() ich = users.besitzer(sid) dabei = suggestions.dabei_alle() return { "items": [ {k: v for k, v in e.items() if k not in ("session_id", "user_id")} for e in eintraege ], # Damit die Oberfläche den eigenen Vorschlag hervorheben kann "mine": next( (e["id"] for e in eintraege if users.gehoert_mir(e, sid, ich)), None, ), # Wer ohne Vorschlag dabei ist "dabei": [d["name"] or "Anonym" for d in dabei], "dabei_mine": any(users.gehoert_mir(d, sid, ich) for d in dabei), } @app.post("/api/dabei") def dabei(response: Response, filmabend_sid: str | None = Cookie(None)): """Dabei ohne Vorschlag — an oder wieder aus.""" sid = sitzung(response, filmabend_sid) mitglied(sid) steht = suggestions.dabei_umschalten(sid) events.senden("suggestions") return {"dabei": steht} @app.post("/api/suggestions") async def vorschlagen( response: Response, movie_id: int = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) movie = await film_holen(movie_id) # Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden eintrag = suggestions.ersetzen(sid, movie, users.name_von(sid)) events.senden("suggestions") return { k: v for k, v in eintrag.items() if k not in ("session_id", "user_id") } @app.delete("/api/suggestions") def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) mitglied(sid) entfernt = suggestions.loeschen(sid) if entfernt: events.senden("suggestions") return {"removed": entfernt} async def film_holen(movie_id: int): """Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB. Was von TMDB kommt, landet gleich im Katalog. Sonst blieben die Karten in Sammlung, Rad und Gesehen leer: die lesen ihre Daten von dort. """ movie = db.get_movie(movie_id) if movie: return movie try: movie = await search.detail(movie_id) except Exception: raise HTTPException(status_code=404, detail="Film nicht gefunden") with db.connect() as conn: db.upsert(conn, movie) return movie @app.get("/api/features") def wuensche(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) return features.alle(sid) @app.post("/api/features") def wunsch_anlegen( response: Response, text: str = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) liste = features.anlegen(sid, text) if liste is None: raise HTTPException(status_code=400, detail="Kein Text") events.senden("features") return liste @app.patch("/api/features/{feature_id}") def wunsch_aendern(feature_id: int, text: str = Body(..., embed=True)): if not features.aendern(feature_id, text): raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") events.senden("features") return {"ok": True} @app.delete("/api/features/{feature_id}") def wunsch_loeschen(feature_id: int): entfernt = features.loeschen(feature_id) if entfernt: events.senden("features") return {"removed": entfernt} @app.post("/api/features/{feature_id}/vote") def wunsch_abstimmen( feature_id: int, response: Response, filmabend_sid: str | None = Cookie(None), ): """Ein Klick setzt die Stimme, der nächste nimmt sie zurück.""" sid = sitzung(response, filmabend_sid) mitglied(sid) steht = features.abstimmen(feature_id, sid) if steht is None: raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") events.senden("features") return {"voted": steht} @app.patch("/api/features/{feature_id}/done") def wunsch_erledigen(feature_id: int, done: bool = Body(..., embed=True)): """Der Host hakt ab — den Knopf sieht nur er.""" if not features.erledigen(feature_id, done): raise HTTPException(status_code=404, detail="Wunsch nicht gefunden") events.senden("features") return {"ok": True} @app.post("/api/features/{feature_id}/notes") def wunsch_anmerken( feature_id: int, response: Response, text: str = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) if not features.anmerken(feature_id, sid, text): raise HTTPException(status_code=400, detail="Kein Text oder Wunsch unbekannt") events.senden("features") return {"ok": True} # Eigener Pfad statt /api/features/notes/{id}: der stünde sonst mit # /api/features/{feature_id} im Weg @app.delete("/api/feature-notes/{note_id}") def anmerkung_loeschen(note_id: int): entfernt = features.anmerkung_loeschen(note_id) if entfernt: events.senden("features") return {"removed": entfernt} @app.get("/api/wishlist") def merkliste(response: Response, filmabend_sid: str | None = Cookie(None)): """Was diese Sitzung sich vorgemerkt hat.""" sid = sitzung(response, filmabend_sid) return wishlist.alle(sid) @app.post("/api/wishlist") async def merken( response: Response, movie_id: int = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) return wishlist.merken(sid, await film_holen(movie_id)) @app.delete("/api/wishlist/{movie_id}") def vergessen( movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) return {"removed": wishlist.vergessen(sid, movie_id)} @app.get("/api/users") def runde(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) staende = punkte.alle() leer = punkte.stand(0) return { "items": [{**u, **staende.get(u["id"], leer)} for u in users.alle()], "mine": users.aktuell(sid), "host": users.ist_host(sid), } @app.post("/api/users") def name_anlegen(name: str = Body(..., embed=True)): """Der Host erweitert die Runde.""" benutzer = users.anlegen(name) if not benutzer: raise HTTPException(status_code=400, detail="Name leer oder schon vergeben") events.senden("users") return benutzer @app.delete("/api/users/{user_id}") def name_entfernen(user_id: int): if not users.entfernen(user_id): raise HTTPException(status_code=404, detail="Unbekannter Name") events.senden("users") return {"removed": 1} @app.get("/api/users/{user_id}/schutz") async def schutz_zeigen( user_id: int, response: Response, filmabend_sid: str | None = Cookie(None), ): """Welcher Film hinterlegt ist — nur für den Host und den Namen selbst. Wer ihn ändern darf, darf ihn auch sehen: sonst müsste man raten, was man gerade überschreibt. """ sid = sitzung(response, filmabend_sid) if not users.ist_host(sid) and users.besitzer(sid) != user_id: raise HTTPException(status_code=403, detail="Nicht dein Name") movie_id = users.schutz_von(user_id) if movie_id is None: return {"film": None} film = await film_holen(movie_id) return { "film": { "id": film["id"], "title": film["title"], "year": film.get("year"), "poster_path": film.get("poster_path"), } } @app.post("/api/users/{user_id}/schutz") def schutz_setzen( user_id: int, response: Response, movie_id: int | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): """Anmeldefilm hinterlegen — oder mit movie_id null wieder freigeben. Der Host pflegt die Runde, sonst darf jeder nur den eigenen Namen ändern. Wer angemeldet ist, hat den alten Film ohnehin gewusst. """ sid = sitzung(response, filmabend_sid) if not users.ist_host(sid) and users.besitzer(sid) != user_id: raise HTTPException(status_code=403, detail="Nicht dein Name") if not users.schutz_setzen(user_id, movie_id): raise HTTPException(status_code=404, detail="Unbekannter Name") events.senden("users") return {"geschuetzt": movie_id is not None} @app.get("/api/host") def host_stand(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) return {"host": users.ist_host(sid)} @app.post("/api/host") def host_modus( response: Response, an: bool = Body(..., embed=True), schluessel: str | None = Body(None, embed=True), movie_id: int | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): """Die Sitzung als Host freischalten — oder die Rechte abgeben. Freischalten geht mit dem Schlüssel aus der .env, einmal über /?host=, oder mit dem Host-Film: die Tastenkombination fragt danach, wie beim Anmeldefilm eines Namens. Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe gerade sichtbar sind, entscheidet der Browser für sich — dafür ist diese Route nicht zuständig. """ sid = sitzung(response, filmabend_sid) if not an: return {"host": users.host_setzen(sid, False)} film = users.host_film() darf = ( users.ist_host(sid) or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)) or (film is not None and movie_id == film) ) if not darf: raise HTTPException(status_code=403, detail="Kein Hostschlüssel") return {"host": users.host_setzen(sid, True)} @app.get("/api/host/film") async def host_film_zeigen(response: Response, filmabend_sid: str | None = Cookie(None)): """Welcher Film den Hostmodus öffnet — nur der Host sieht ihn.""" sid = sitzung(response, filmabend_sid) if not users.ist_host(sid): raise HTTPException(status_code=403, detail="Nur für den Host") movie_id = users.host_film() if movie_id is None: return {"film": None} film = await film_holen(movie_id) return { "film": { "id": film["id"], "title": film["title"], "year": film.get("year"), "poster_path": film.get("poster_path"), } } @app.post("/api/host/film") def host_film_setzen( response: Response, movie_id: int | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): """Den Host-Film setzen oder aufheben — nur als Host.""" sid = sitzung(response, filmabend_sid) if not users.ist_host(sid): raise HTTPException(status_code=403, detail="Nur für den Host") users.host_film_setzen(movie_id) return {"gesetzt": movie_id is not None} @app.post("/api/users/waehlen") def name_waehlen( response: Response, user_id: int | None = Body(None, embed=True), movie_id: int | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): """Wer bin ich? Ohne user_id wieder anonym. Hat der Name einen Anmeldefilm, muss movie_id ihn treffen. Der Host und wer schon angemeldet ist, kommen ohne durch. """ sid = sitzung(response, filmabend_sid) if user_id is None: users.abwaehlen(sid) return {"mine": None} film = users.schutz_von(user_id) frei = ( film is None or users.ist_host(sid) or users.besitzer(sid) == user_id ) if not frei and movie_id != film: raise HTTPException(status_code=403, detail="Das ist nicht der Film.") benutzer = users.waehlen(sid, user_id) if not benutzer: raise HTTPException(status_code=404, detail="Unbekannter Name") return {"mine": benutzer} @app.get("/api/watched") def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)): sid = sitzung(response, filmabend_sid) return watched.alle(sid, users.ist_host(sid)) @app.post("/api/watched") async def als_gesehen(movie_id: int = Body(..., embed=True)): """Der Host trägt ein, was am Abend lief. Ist es der gezogene Film, hängt die Runde vom Rad als Anwesenheit am Abend.""" eintrag = watched.markieren(await film_holen(movie_id)) gezogen = letzter_dreh["ergebnis"] if gezogen and gezogen["gewinner"]["movie_id"] == movie_id and letzter_dreh["teilnehmer"]: watched.dabei_setzen(eintrag["id"], letzter_dreh["teilnehmer"]) events.senden("watched") return eintrag @app.post("/api/watched/{watched_id}/dabei") def dabei_aendern(watched_id: int, user_ids: list[int] = Body(..., embed=True)): """Der Host berichtigt, wer dabei war — etwa wer spät kam.""" watched.dabei_setzen(watched_id, user_ids) events.senden("watched") return {"dabei": user_ids} @app.post("/api/watched/hearts") def herz_setzen( response: Response, note_id: int = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): """Ein Klick setzt das Herz, der nächste nimmt es zurück.""" sid = sitzung(response, filmabend_sid) mitglied(sid) steht = watched.herz(sid, note_id) if steht is None: raise HTTPException(status_code=404, detail="Kommentar nicht gefunden") events.senden("watched") return {"hearted": steht} @app.delete("/api/watched/{watched_id}") def gesehen_entfernen(watched_id: int): entfernt = watched.entfernen(watched_id) if entfernt: events.senden("watched") return {"removed": entfernt} @app.patch("/api/watched/{watched_id}") def gesehen_aendern( watched_id: int, watched_at: str | None = Body(None, embed=True), hidden: bool | None = Body(None, embed=True), ): """Der Host rückt das Datum nach oder blendet einen Film aus.""" geaendert = 0 if watched_at: geaendert += watched.datum_setzen(watched_id, watched_at) if hidden is not None: geaendert += watched.sichtbarkeit(watched_id, hidden) if not geaendert: raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") events.senden("watched") return {"ok": True} @app.post("/api/watched/{watched_id}/rating") def bewerten( watched_id: int, response: Response, stars: int = Body(..., embed=True), filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) mitglied(sid) stars = max(1, min(watched.MAX_STERNE, stars)) # Der Name kommt aus der Runde, nicht aus der Anfrage eintrag = watched.bewerten(watched_id, sid, stars, users.name_von(sid)) if not eintrag: raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") events.senden("watched") return eintrag @app.delete("/api/watched/rating/{rating_id}") def wertung_loeschen(rating_id: int): """Der Host räumt eine einzelne Wertung weg.""" if not watched.stimme_loeschen(rating_id): raise HTTPException(status_code=404, detail="Wertung nicht gefunden") events.senden("watched") return {"removed": 1} @app.post("/api/watched/{watched_id}/notes") def gaestebuch_schreiben( watched_id: int, response: Response, text: str = Body(..., embed=True), parent_id: int | None = Body(None, embed=True), filmabend_sid: str | None = Cookie(None), ): """Kommentar zum Abend, oder mit parent_id eine Antwort auf einen anderen.""" sid = sitzung(response, filmabend_sid) mitglied(sid) note_id = watched.anmerken(watched_id, sid, text, parent_id) if note_id is None: raise HTTPException(status_code=400, detail="Kein Text oder Ziel unbekannt") events.senden("watched") return {"id": note_id} # Eigener Pfad, damit er /api/watched/{watched_id} nicht in die Quere kommt @app.delete("/api/watched-notes/{note_id}") def gaestebuch_loeschen( note_id: int, response: Response, filmabend_sid: str | None = Cookie(None), ): """Der Verfasser oder der Host — samt allen Antworten darunter.""" sid = sitzung(response, filmabend_sid) entfernt = watched.anmerkung_loeschen(note_id, sid, users.ist_host(sid)) if not entfernt: raise HTTPException(status_code=404, detail="Nicht gefunden oder nicht deins") events.senden("watched") return {"removed": entfernt} @app.delete("/api/suggestions/alle") def runde_zuruecksetzen(): """Host leert die Runde: Vorschläge weg, Rad wieder leer.""" entfernt = suggestions.alles_loeschen() letzter_dreh["ergebnis"] = None letzter_dreh["start"] = 0.0 events.senden("reset") return {"removed": entfernt} @app.get("/stream/{pfad:path}") async def stream(request: Request, pfad: str): """HLS von MediaMTX unter eigenem Ursprung ausliefern.""" return await streamproxy.weiterleiten(request, pfad) @app.post("/api/sync") async def trigger_sync(): if sync_lock.locked(): return {"started": False, "reason": "läuft bereits"} asyncio.create_task(guarded_sync()) return {"started": True} if STATIC_DIR.is_dir(): app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static")