This commit is contained in:
team3
2026-09-30 00:59:59 +02:00
parent 7bf9667007
commit c589e389df
20 changed files with 1814 additions and 29 deletions

View File

@@ -1,4 +1,5 @@
import asyncio
from datetime import timedelta
import logging
import secrets
import time
@@ -6,13 +7,14 @@ import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import httpx
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
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,
@@ -93,6 +95,7 @@ async def startup():
users.init()
suggestions.init()
watched.init()
clips.init()
wishlist.init()
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
@@ -296,6 +299,160 @@ async def ki_suche(
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."""
@@ -699,13 +856,14 @@ 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 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.
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
@@ -715,14 +873,51 @@ def host_modus(
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)
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,