update
This commit is contained in:
@@ -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"
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
48
backend/app/streamproxy.py
Normal file
48
backend/app/streamproxy.py
Normal 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,
|
||||
)
|
||||
@@ -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
169
backend/app/watched.py
Normal 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")]
|
||||
Reference in New Issue
Block a user