1161 lines
38 KiB
Python
1161 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")
|
|
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.get("/api/aufnahmen")
|
|
async def aufnahmen_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
|
|
"""Alle Stücke der Aufnahme, neueste zuerst — für die Verwaltung."""
|
|
sid = sitzung(response, filmabend_sid)
|
|
if not users.ist_host(sid):
|
|
raise HTTPException(status_code=403, detail="Nur für den Host")
|
|
try:
|
|
return {"stuecke": await clips.aufnahmen_alle()}
|
|
except httpx.HTTPError:
|
|
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
|
|
|
|
|
|
@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.")
|
|
return {"segmente": anzahl}
|
|
|
|
|
|
@app.get("/api/clips/von-film/{movie_id}")
|
|
async def clips_stuecke(
|
|
movie_id: int, response: Response, filmabend_sid: str | None = Cookie(None)
|
|
):
|
|
"""Die Aufnahmen zu einem Abend — mehrere, wenn der Stream neu
|
|
gestartet wurde. Daraus wählt man das Stück für den Clip."""
|
|
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:
|
|
return {"stuecke": await clips.aufnahmen_des_abends(abend["watched_at"])}
|
|
except httpx.HTTPError:
|
|
return {"stuecke": []}
|
|
|
|
|
|
@app.post("/api/clips/von-film")
|
|
async def clip_von_film(
|
|
response: Response,
|
|
movie_id: int = Body(..., embed=True),
|
|
start: str | None = Body(None, embed=True),
|
|
filmabend_sid: str | None = Cookie(None),
|
|
):
|
|
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang des
|
|
gewählten Stücks — ohne Wahl an den des ersten. 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.")
|
|
if start is None:
|
|
try:
|
|
stuecke = await clips.aufnahmen_des_abends(abend["watched_at"])
|
|
except httpx.HTTPError:
|
|
stuecke = []
|
|
if not stuecke:
|
|
raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.")
|
|
start = stuecke[0]["start"]
|
|
beginn = datetime.fromisoformat(start)
|
|
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=<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)
|
|
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")
|