This commit is contained in:
team3
2026-08-31 11:54:20 +02:00
parent f29a4106bb
commit e7216138c7
21 changed files with 1517 additions and 87 deletions

View File

@@ -16,6 +16,10 @@ MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
MINIMAX_MODEL = "MiniMax-M3"
PROVIDER_NETFLIX = 8
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
# Dienstnamen, lokal auf localhost
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
REGION = "DE"
LANGUAGE = "de-DE"

View File

@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from . import db, events, search, suggestions, sync
from . import db, events, search, streamproxy, suggestions, sync, watched
from .config import IMAGE_BASE, STATIC_DIR
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
@@ -19,9 +19,23 @@ app = FastAPI(title="Netflix Horror DE")
scheduler = AsyncIOScheduler()
sync_lock = asyncio.Lock()
class GzipOhneLivekanal(GZipMiddleware):
"""Kompression für alles außer den Ereignisstrom.
GZipMiddleware sammelt die Antwort, bevor sie ausgeht. Bei Server-Sent
Events heißt das: Es kommt gar nichts an, solange der Puffer nicht voll
ist — und der wird bei kurzen Ereignissen nie voll.
"""
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and scope["path"].startswith("/api/events"):
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.
# Der Live-Kanal bleibt außen vor: gepufferte Ereignisse kämen zu spät.
app.add_middleware(GZipMiddleware, minimum_size=1024)
app.add_middleware(GzipOhneLivekanal, minimum_size=1024)
app.add_middleware(
CORSMiddleware,
@@ -43,6 +57,7 @@ async def guarded_sync():
async def startup():
db.init()
suggestions.init()
watched.init()
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
scheduler.start()
if not db.get_meta("last_sync"):
@@ -198,14 +213,7 @@ async def vorschlagen(
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
movie = db.get_movie(movie_id)
if not movie:
try:
movie = await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
movie = await film_holen(movie_id)
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
eintrag = suggestions.ersetzen(sid, movie, name)
events.senden("suggestions")
@@ -221,6 +229,90 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
return {"removed": entfernt}
async def film_holen(movie_id: int):
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
movie = db.get_movie(movie_id)
if movie:
return movie
try:
return await search.detail(movie_id)
except Exception:
raise HTTPException(status_code=404, detail="Film nicht gefunden")
@app.get("/api/watched")
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return watched.alle(sid)
@app.post("/api/watched")
async def als_gesehen(movie_id: int = Body(..., embed=True)):
"""Der Host trägt ein, was am Abend lief."""
eintrag = watched.markieren(await film_holen(movie_id))
events.senden("watched")
return eintrag
@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),
name: str | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
stars = max(1, min(watched.MAX_STERNE, stars))
eintrag = watched.bewerten(watched_id, sid, stars, name)
if not eintrag:
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
events.senden("watched")
return eintrag
@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():

View File

@@ -0,0 +1,48 @@
"""Reicht den HLS-Stream von MediaMTX unter demselben Ursprung durch.
Ohne das läge der Player auf Port 8000 und der Stream auf 8890 — zwei
Ursprünge, also CORS und eine zweite Route nach außen. Produktiv ist ein
Reverse-Proxy davor die bessere Wahl: Traefik reicht die Segmente durch,
ohne dass Python jedes Byte anfasst.
"""
import httpx
from fastapi import HTTPException, Request
from fastapi.responses import Response, StreamingResponse
from .config import MEDIAMTX_URL
# Playlists ändern sich im Sekundentakt, Segmente nie
NICHT_ZWISCHENSPEICHERN = {"Cache-Control": "no-store"}
DURCHREICHEN = {
"content-type",
"content-length",
"cache-control",
"last-modified",
"etag",
}
async def weiterleiten(request: Request, pfad: str):
ziel = f"{MEDIAMTX_URL}/{pfad}"
frage = dict(request.query_params)
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http:
try:
antwort = await http.get(ziel, params=frage)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Stream nicht erreichbar")
if antwort.status_code == 404:
raise HTTPException(status_code=404, detail="Kein Stream")
kopf = {k: v for k, v in antwort.headers.items() if k.lower() in DURCHREICHEN}
if pfad.endswith(".m3u8"):
kopf.update(NICHT_ZWISCHENSPEICHERN)
return Response(
content=antwort.content,
status_code=antwort.status_code,
headers=kopf,
)

View File

@@ -126,3 +126,9 @@ def loeschen(session_id):
return conn.execute(
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
).rowcount
def alles_loeschen():
"""Host setzt die Runde zurück."""
with connect() as conn:
return conn.execute("DELETE FROM suggestions").rowcount

169
backend/app/watched.py Normal file
View File

@@ -0,0 +1,169 @@
"""Bereits geschaute Filme und ihre Bewertungen.
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
freiwillig, ohne ihn zählt die Stimme trotzdem.
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
auch wenn der Film später aus dem Katalog fällt.
"""
from datetime import datetime, timezone
from .db import connect
SCHEMA = """
CREATE TABLE IF NOT EXISTS watched (
id INTEGER PRIMARY KEY AUTOINCREMENT,
movie_id INTEGER NOT NULL UNIQUE,
title TEXT NOT NULL,
year INTEGER,
poster_path TEXT,
watched_at TEXT NOT NULL,
hidden INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS ratings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
watched_id INTEGER NOT NULL,
session_id TEXT NOT NULL,
stars INTEGER NOT NULL,
name TEXT,
created_at TEXT NOT NULL,
UNIQUE (watched_id, session_id)
);
"""
MAX_STERNE = 5
def init():
with connect() as conn:
conn.executescript(SCHEMA)
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
if "hidden" not in spalten:
conn.execute(
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
)
def _jetzt():
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def alle(session_id):
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
with connect() as conn:
filme = conn.execute(
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
).fetchall()
stimmen = conn.execute(
"SELECT watched_id, session_id, stars, name FROM ratings "
"ORDER BY created_at ASC"
).fetchall()
je_film = {}
for s in stimmen:
je_film.setdefault(s["watched_id"], []).append(s)
out = []
for film in filme:
eigene = je_film.get(film["id"], [])
werte = [s["stars"] for s in eigene]
out.append(
{
**dict(film),
"ratings": [
{"stars": s["stars"], "name": s["name"]} for s in eigene
],
"average": round(sum(werte) / len(werte), 1) if werte else None,
"count": len(werte),
"mine": next(
(s["stars"] for s in eigene if s["session_id"] == session_id),
None,
),
}
)
return out
def markieren(movie):
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
with connect() as conn:
conn.execute(
"INSERT OR IGNORE INTO watched "
"(movie_id, title, year, poster_path, watched_at) "
"VALUES (?, ?, ?, ?, ?)",
(
movie["id"],
movie["title"],
movie.get("year"),
movie.get("poster_path"),
_jetzt(),
),
)
row = conn.execute(
"SELECT * FROM watched WHERE movie_id = ?", (movie["id"],)
).fetchone()
return dict(row)
def entfernen(watched_id):
with connect() as conn:
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
return conn.execute(
"DELETE FROM watched WHERE id = ?", (watched_id,)
).rowcount
def datum_setzen(watched_id, tag):
"""tag im Format JJJJ-MM-TT. Mittags in UTC, damit die Anzeige in
keiner Zeitzone auf den Nachbartag rutscht."""
with connect() as conn:
return conn.execute(
"UPDATE watched SET watched_at = ? WHERE id = ?",
(f"{tag}T12:00:00+00:00", watched_id),
).rowcount
def sichtbarkeit(watched_id, versteckt):
"""Versteckte Filme sieht nur der Host."""
with connect() as conn:
return conn.execute(
"UPDATE watched SET hidden = ? WHERE id = ?",
(1 if versteckt else 0, watched_id),
).rowcount
def bewerten(watched_id, session_id, stars, name=None):
"""Gibt None zurück, wenn der Film gar nicht in der Liste steht."""
with connect() as conn:
da = conn.execute(
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
).fetchone()
if not da:
return None
# Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte
conn.execute(
"INSERT INTO ratings "
"(watched_id, session_id, stars, name, created_at) "
"VALUES (?, ?, ?, ?, ?) "
"ON CONFLICT (watched_id, session_id) DO UPDATE SET "
"stars = excluded.stars, name = excluded.name, "
"created_at = excluded.created_at",
(
watched_id,
session_id,
stars,
(name or "").strip()[:40] or None,
_jetzt(),
),
)
return {"watched_id": watched_id, "stars": stars}
def ids():
"""movie_ids der geschauten Filme, für die Detailansicht."""
with connect() as conn:
return [r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")]