Files
horror/backend/app/main.py
2026-09-30 15:15:00 +02:00

1177 lines
38 KiB
Python

import asyncio
from datetime import datetime, 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")
async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
Abend unter Gesehen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie_id = await clips.film_live()
gezogen = letzter_dreh["ergebnis"]
if movie_id is None and gezogen:
movie_id = gezogen["gewinner"]["movie_id"]
if movie_id is None:
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/aufnahmen")
async def aufnahmen_liste():
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
„Clip erstellen" und die Verwaltung."""
try:
return {"stuecke": await clips.aufnahmen_alle()}
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
@app.put("/api/aufnahmen/film")
async def aufnahme_film_setzen(
response: Response,
start: str = Body(..., embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Der Host sagt, welcher Film in einer Aufnahme lief — oder nimmt es zurück."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if movie_id is not None:
# Suchtreffer stehen nicht im Katalog — Titel und Poster müssen aber her
await film_holen(movie_id)
clips.film_zuweisen(start, movie_id)
return {"zugewiesen": movie_id is not None}
@app.delete("/api/aufnahmen")
async def aufnahme_loeschen(
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
):
"""Ein Stück der Aufnahme löschen — nur der Host."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
try:
anzahl = await clips.aufnahme_loeschen(start, dauer)
except httpx.HTTPStatusError as e:
raise HTTPException(
status_code=502, detail=f"MediaMTX lehnt ab ({e.response.status_code})."
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
clips.film_zuweisen(start, None)
return {"segmente": anzahl}
@app.post("/api/clips/von-aufnahme")
def clip_von_aufnahme(
response: Response,
start: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
die Aufnahme mit — oder keinen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
beginn = datetime.fromisoformat(start)
clip = clips.markieren(
sid, clips.film_der_aufnahme(start), 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, filmabend_sid: str | None = Cookie(None)):
queue = events.anmelden(filmabend_sid)
# Die anderen Seiten sollen den grünen Punkt anmachen
events.senden("users")
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
# ── Info: Bezahlen und Regeln ───────────────────────────────────────────
INFO_MAX = 8000
@app.get("/api/info")
def info():
"""Ein Markdown-Text des Hosts: PayPal, IBAN, Regeln, was man wissen
muss. Solange keiner da ist, steht die Vorlage mit den Punkteregeln."""
return {"text": db.get_meta("info_text") or punkte.vorlage()}
@app.put("/api/info")
def info_setzen(
response: Response,
text: str = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
with db.connect() as conn:
db.set_meta(conn, "info_text", text.strip()[:INFO_MAX])
events.senden("info")
return {"text": db.get_meta("info_text")}
@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)
online = users.online_ids(events.sitzungen_online())
return {
"items": [
{**u, **staende.get(u["id"], leer), "online": u["id"] in online} 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=<schlüssel>, 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)
events.senden("users")
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")
events.senden("users")
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")