This commit is contained in:
team3
2026-09-17 15:40:28 +02:00
parent a1684cf54f
commit ea54a86db4
30 changed files with 3680 additions and 989 deletions

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
import secrets
import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -22,7 +23,7 @@ from . import (
watched,
wishlist,
)
from .config import IMAGE_BASE, STATIC_DIR
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.
@@ -93,12 +94,19 @@ async def startup():
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
asyncio.create_task(sync.katalog_nachtragen())
@app.on_event("shutdown")
@@ -364,14 +372,21 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
async def film_holen(movie_id: int):
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
"""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:
return await search.detail(movie_id)
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")
@@ -493,7 +508,11 @@ def vergessen(
@app.get("/api/users")
def runde(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return {"items": users.alle(), "mine": users.aktuell(sid)}
return {
"items": users.alle(),
"mine": users.aktuell(sid),
"host": users.ist_host(sid),
}
@app.post("/api/users")
@@ -514,17 +533,116 @@ def name_entfernen(user_id: int):
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),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
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)}
darf = users.ist_host(sid) or (
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@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."""
"""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")
@@ -576,14 +694,13 @@ def bewerten(
watched_id: int,
response: Response,
stars: int = Body(..., embed=True),
text: str | None = Body(None, 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), text)
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")
@@ -599,6 +716,40 @@ def wertung_loeschen(rating_id: int):
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."""