update
This commit is contained in:
259
backend/app/clips.py
Normal file
259
backend/app/clips.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Clips aus der Aufnahme des Abends.
|
||||
|
||||
Eine Marke ist erst nur ein Zeitpunkt: Taste C, fertig, nichts lenkt ab.
|
||||
Wann immer man will, zieht man ein Fenster darum, sieht es sich an und
|
||||
schneidet. Dann holt die App den Ausschnitt vom Playback-Server von
|
||||
MediaMTX und legt ihn als MP4 ab. Jeder sieht nur seine eigenen Clips,
|
||||
der Host alle — er räumt auf.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import CLIPS_DIR, MEDIAMTX_PLAYBACK_URL
|
||||
from .db import connect
|
||||
from .users import WEM, besitzer, gehoert_mir
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
movie_id INTEGER,
|
||||
marke_at TEXT NOT NULL,
|
||||
vorher REAL NOT NULL,
|
||||
dauer REAL NOT NULL,
|
||||
titel TEXT,
|
||||
datei TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
STREAM_PFAD = "filmabend"
|
||||
# Der Player hängt so weit hinter der Live-Kante — die Marke zeigt auf
|
||||
# das, was die Person gerade gesehen hat, nicht auf das, was gerade ankommt
|
||||
PLAYER_LATENZ = 4
|
||||
# Das Fenster um die Marke: Standard und Grenzen
|
||||
VORHER = 30
|
||||
DAUER = 35
|
||||
MAX_VORHER = 600
|
||||
MIN_DAUER = 3
|
||||
MAX_DAUER = 180
|
||||
MAX_TITEL = 80
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
|
||||
def _jetzt():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _iso(zeit):
|
||||
return zeit.isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def markieren(session_id, movie_id, zeit=None):
|
||||
"""Eine Marke setzen — vier Sekunden vor jetzt, wegen der Latenz.
|
||||
Oder zu einem gegebenen Zeitpunkt, für Clips aus alten Aufnahmen."""
|
||||
with connect() as conn:
|
||||
clip_id = conn.execute(
|
||||
"INSERT INTO clips (session_id, user_id, movie_id, marke_at, vorher, dauer, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
session_id,
|
||||
besitzer(session_id),
|
||||
movie_id,
|
||||
_iso(zeit or _jetzt() - timedelta(seconds=PLAYER_LATENZ)),
|
||||
VORHER,
|
||||
DAUER,
|
||||
_iso(_jetzt()),
|
||||
),
|
||||
).lastrowid
|
||||
# Erst nach dem Commit lesen — sonst sieht die neue Verbindung nichts
|
||||
return holen(clip_id, session_id)
|
||||
|
||||
|
||||
def _oeffentlich(row, session_id, ich):
|
||||
clip = {k: v for k, v in dict(row).items() if k not in ("session_id", "user_id", "freigegeben")}
|
||||
clip["geschnitten"] = clip.pop("datei") is not None
|
||||
clip["mine"] = gehoert_mir(row, session_id, ich)
|
||||
return clip
|
||||
|
||||
|
||||
def holen(clip_id, session_id):
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
|
||||
"LEFT JOIN users u ON u.id = c.user_id "
|
||||
"LEFT JOIN movies m ON m.id = c.movie_id WHERE c.id = ?",
|
||||
(clip_id,),
|
||||
).fetchone()
|
||||
return _oeffentlich(row, session_id, besitzer(session_id)) if row else None
|
||||
|
||||
|
||||
def alle(session_id, host=False):
|
||||
"""Die eigenen Clips; der Host sieht alle — er räumt auf."""
|
||||
ich = besitzer(session_id)
|
||||
with connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.name, m.title, m.poster_path FROM clips c "
|
||||
"LEFT JOIN users u ON u.id = c.user_id "
|
||||
"LEFT JOIN movies m ON m.id = c.movie_id "
|
||||
"ORDER BY c.marke_at DESC"
|
||||
).fetchall()
|
||||
return [
|
||||
_oeffentlich(r, session_id, ich)
|
||||
for r in rows
|
||||
if host or gehoert_mir(r, session_id, ich)
|
||||
]
|
||||
|
||||
|
||||
def _zeile(conn, clip_id):
|
||||
return conn.execute("SELECT * FROM clips WHERE id = ?", (clip_id,)).fetchone()
|
||||
|
||||
|
||||
def _darf(row, session_id, host):
|
||||
return row is not None and (host or gehoert_mir(row, session_id, besitzer(session_id)))
|
||||
|
||||
|
||||
def aendern(clip_id, session_id, host, titel=None, vorher=None, dauer=None):
|
||||
"""Titel oder Fenster. Ein neues Fenster macht den Schnitt ungültig —
|
||||
die Datei fliegt, bis neu geschnitten wird."""
|
||||
with connect() as conn:
|
||||
row = _zeile(conn, clip_id)
|
||||
if not _darf(row, session_id, host):
|
||||
return None
|
||||
felder = {}
|
||||
if titel is not None:
|
||||
felder["titel"] = titel.strip()[:MAX_TITEL] or None
|
||||
if vorher is not None:
|
||||
felder["vorher"] = max(0, min(MAX_VORHER, float(vorher)))
|
||||
if dauer is not None:
|
||||
felder["dauer"] = max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
|
||||
fenster_neu = any(
|
||||
k in felder and felder[k] != row[k] for k in ("vorher", "dauer")
|
||||
)
|
||||
if fenster_neu and row["datei"]:
|
||||
_datei_weg(row["datei"])
|
||||
felder["datei"] = None
|
||||
if felder:
|
||||
setzt = ", ".join(f"{k} = ?" for k in felder)
|
||||
conn.execute(f"UPDATE clips SET {setzt} WHERE id = ?", (*felder.values(), clip_id))
|
||||
return holen(clip_id, session_id)
|
||||
|
||||
|
||||
def loeschen(clip_id, session_id, host):
|
||||
with connect() as conn:
|
||||
row = _zeile(conn, clip_id)
|
||||
if not _darf(row, session_id, host):
|
||||
return False
|
||||
if row["datei"]:
|
||||
_datei_weg(row["datei"])
|
||||
conn.execute("DELETE FROM clips WHERE id = ?", (clip_id,))
|
||||
return True
|
||||
|
||||
|
||||
def _datei_weg(name):
|
||||
pfad = CLIPS_DIR / name
|
||||
if pfad.exists():
|
||||
pfad.unlink()
|
||||
|
||||
|
||||
def fenster(row, vorher=None, dauer=None):
|
||||
"""Start (RFC3339) und Dauer des Ausschnitts um die Marke."""
|
||||
marke = datetime.fromisoformat(row["marke_at"])
|
||||
v = row["vorher"] if vorher is None else max(0, min(MAX_VORHER, float(vorher)))
|
||||
d = row["dauer"] if dauer is None else max(MIN_DAUER, min(MAX_DAUER, float(dauer)))
|
||||
return _iso(marke - timedelta(seconds=v)), d
|
||||
|
||||
|
||||
def playback_url(row, vorher=None, dauer=None):
|
||||
start, d = fenster(row, vorher, dauer)
|
||||
return (
|
||||
f"{MEDIAMTX_PLAYBACK_URL}/get",
|
||||
{"path": STREAM_PFAD, "start": start, "duration": d, "format": "mp4"},
|
||||
)
|
||||
|
||||
|
||||
def zeile(clip_id, session_id, host):
|
||||
"""Die rohe Zeile, wenn man sie sehen darf: Besitzer oder Host."""
|
||||
with connect() as conn:
|
||||
row = _zeile(conn, clip_id)
|
||||
return row if _darf(row, session_id, host) else None
|
||||
|
||||
|
||||
async def schneiden(clip_id, session_id, host):
|
||||
"""Den Ausschnitt vom Playback-Server holen und als Datei ablegen.
|
||||
Gibt None zurück, wenn der Clip nicht gehört; wirft httpx-Fehler,
|
||||
wenn MediaMTX nicht liefert."""
|
||||
with connect() as conn:
|
||||
row = _zeile(conn, clip_id)
|
||||
if not _darf(row, session_id, host):
|
||||
return None
|
||||
url, params = playback_url(row)
|
||||
CLIPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{clip_id}.mp4"
|
||||
ziel = CLIPS_DIR / name
|
||||
async with httpx.AsyncClient(timeout=120.0) as http:
|
||||
async with http.stream("GET", url, params=params) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(ziel, "wb") as f:
|
||||
async for stueck in resp.aiter_bytes():
|
||||
f.write(stueck)
|
||||
with connect() as conn:
|
||||
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (name, clip_id))
|
||||
return holen(clip_id, session_id)
|
||||
|
||||
|
||||
# Ein Abend dauert ein paar Stunden — so weit reicht der Zeitstrahl
|
||||
SPANNE = 4 * 3600
|
||||
|
||||
|
||||
async def _segmente(von, bis):
|
||||
async with httpx.AsyncClient(timeout=15.0) as http:
|
||||
resp = await http.get(
|
||||
f"{MEDIAMTX_PLAYBACK_URL}/list",
|
||||
params={"path": STREAM_PFAD, "start": _iso(von), "end": _iso(bis)},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def tage_mit_aufnahme():
|
||||
"""An welchen Abenden es Aufnahmen gibt — als Datum, wobei der Abend
|
||||
bis zum nächsten Mittag reicht. Für die Auswahl im Clips-Reiter."""
|
||||
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
|
||||
tage = set()
|
||||
for s in segmente:
|
||||
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
||||
tage.add((start - timedelta(hours=12)).strftime("%Y-%m-%d"))
|
||||
return sorted(tage)
|
||||
|
||||
|
||||
async def aufnahme_des_abends(watched_at):
|
||||
"""Die Aufnahme zu einem Abend: alles vom Mittag des Tages bis zum
|
||||
nächsten Mittag — so gehört auch die Nacht nach Mitternacht dazu.
|
||||
Liefert den Beginn des ersten Stücks, oder None ohne Aufnahme."""
|
||||
tag = datetime.fromisoformat(watched_at[:10]).replace(tzinfo=timezone.utc)
|
||||
mittag = tag + timedelta(hours=12)
|
||||
segmente = await _segmente(mittag, mittag + timedelta(days=1))
|
||||
if not segmente:
|
||||
return None
|
||||
return min(datetime.fromisoformat(s["start"].replace("Z", "+00:00")) for s in segmente)
|
||||
|
||||
|
||||
async def aufnahme(um, spanne=SPANNE):
|
||||
"""Welche Stücke der Aufnahme es um einen Zeitpunkt herum gibt — für
|
||||
den Zeitstrahl im Editor. Relativ zur Marke, in Sekunden."""
|
||||
marke = datetime.fromisoformat(um)
|
||||
segmente = await _segmente(marke - timedelta(seconds=spanne), marke + timedelta(seconds=spanne))
|
||||
aus = []
|
||||
for s in segmente:
|
||||
start = datetime.fromisoformat(s["start"].replace("Z", "+00:00"))
|
||||
von = (start - marke).total_seconds()
|
||||
aus.append({"von": round(von, 1), "bis": round(von + s["duration"], 1)})
|
||||
return aus
|
||||
@@ -25,6 +25,10 @@ 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")
|
||||
# Der Playback-Server von MediaMTX: liefert Ausschnitte der Aufnahme
|
||||
MEDIAMTX_PLAYBACK_URL = os.getenv("MEDIAMTX_PLAYBACK_URL", "http://127.0.0.1:9996")
|
||||
# Wohin die geschnittenen Clips kommen
|
||||
CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips"))
|
||||
REGION = "DE"
|
||||
LANGUAGE = "de-DE"
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ einem Textfeld, in das jeder schreiben kann, was er will.
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from . import db
|
||||
from .db import connect
|
||||
|
||||
SCHEMA = """
|
||||
@@ -146,6 +147,18 @@ def aktuell(session_id):
|
||||
return _oeffentlich(row) if row else None
|
||||
|
||||
|
||||
# Der Host-Film: das Passwort für den Hostmodus, wie der Anmeldefilm
|
||||
# für einen Namen. Steht in der meta-Tabelle, gesetzt aus der Verwaltung.
|
||||
def host_film_setzen(movie_id):
|
||||
with connect() as conn:
|
||||
db.set_meta(conn, "host_film", str(movie_id) if movie_id else "")
|
||||
|
||||
|
||||
def host_film():
|
||||
wert = db.get_meta("host_film")
|
||||
return int(wert) if wert else None
|
||||
|
||||
|
||||
def ist_host(session_id):
|
||||
with connect() as conn:
|
||||
return bool(
|
||||
|
||||
@@ -232,6 +232,21 @@ def alle(session_id, host=False):
|
||||
return out
|
||||
|
||||
|
||||
def juengster():
|
||||
"""Der zuletzt geschaute Abend — für Marken ohne Dreh."""
|
||||
with connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def von_film(movie_id):
|
||||
with connect() as conn:
|
||||
row = conn.execute("SELECT * FROM watched WHERE movie_id = ?", (movie_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def markieren(movie):
|
||||
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
||||
with connect() as conn:
|
||||
|
||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
||||
|
||||
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
|
||||
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
||||
os.environ["CLIPS_DIR"] = str(_ORDNER / "clips")
|
||||
os.environ.setdefault("TMDB_TOKEN", "test")
|
||||
os.environ.setdefault("MINIMAX_KEY", "test")
|
||||
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
|
||||
@@ -18,7 +19,7 @@ os.environ["HOST_SCHLUESSEL"] = "test-host-schluessel"
|
||||
import pytest # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
from app import db, features, main, suggestions, users, watched, wishlist # noqa: E402
|
||||
from app import clips, db, features, main, suggestions, users, watched, wishlist # noqa: E402
|
||||
|
||||
# Zählt, ob der Startup doch einen Sync anstoßen wollte
|
||||
_SYNC_AUFRUFE = []
|
||||
@@ -49,7 +50,7 @@ def sync_aufrufe():
|
||||
def frische_db(app_start):
|
||||
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
|
||||
db.DB_PATH.unlink(missing_ok=True)
|
||||
for modul in (db, users, suggestions, watched, wishlist, features):
|
||||
for modul in (db, users, suggestions, watched, clips, wishlist, features):
|
||||
modul.init()
|
||||
main.letzter_dreh.update(ergebnis=None, start=0.0)
|
||||
|
||||
|
||||
177
backend/tests/test_clips.py
Normal file
177
backend/tests/test_clips.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""Clips: Marke setzen, Fenster ziehen, schneiden."""
|
||||
|
||||
import httpx
|
||||
|
||||
from app import clips, main, punkte
|
||||
|
||||
|
||||
def marke(client):
|
||||
antwort = client.post("/api/clips")
|
||||
assert antwort.status_code == 200, antwort.text
|
||||
return antwort.json()
|
||||
|
||||
|
||||
def test_marke_ist_nur_ein_zeitpunkt(client, film):
|
||||
"""Ohne Dreh und ohne Abend gehört die Marke keinem Film; das Fenster
|
||||
hat Standardwerte."""
|
||||
m = marke(client)
|
||||
assert m["movie_id"] is None
|
||||
assert (m["vorher"], m["dauer"]) == (clips.VORHER, clips.DAUER)
|
||||
assert m["mine"] is True and m["geschnitten"] is False
|
||||
assert m["name"] == "Anna"
|
||||
|
||||
|
||||
def test_marke_haengt_am_gezogenen_film(client, zuschauer, hostmodus, film):
|
||||
client.post("/api/suggestions", json={"movie_id": film(1)})
|
||||
hostmodus(zuschauer)
|
||||
zuschauer.post("/api/spin")
|
||||
m = marke(client)
|
||||
assert m["movie_id"] == film(1)
|
||||
assert m["title"] == "Film 1"
|
||||
main.letzter_dreh["ergebnis"] = None
|
||||
zuschauer.delete("/api/suggestions/alle")
|
||||
|
||||
|
||||
def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
|
||||
client.post("/api/watched", json={"movie_id": film(1)})
|
||||
client.post("/api/watched", json={"movie_id": film(2)})
|
||||
assert marke(client)["movie_id"] == film(2)
|
||||
|
||||
|
||||
def test_tage_mit_aufnahme(client, monkeypatch):
|
||||
async def segmente(von, bis):
|
||||
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
|
||||
return [
|
||||
{"start": "2026-09-23T20:00:00Z", "duration": 30},
|
||||
{"start": "2026-09-24T00:30:00Z", "duration": 30},
|
||||
{"start": "2026-09-25T19:00:00Z", "duration": 30},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(clips, "_segmente", segmente)
|
||||
assert client.get("/api/clips/aufnahmen").json()["tage"] == ["2026-09-23", "2026-09-25"]
|
||||
|
||||
|
||||
def test_clip_zu_altem_abend(client, film, monkeypatch):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
client.post("/api/watched", json={"movie_id": film(4)})
|
||||
# Kein Abend zu diesem Film
|
||||
assert client.post("/api/clips/von-film", json={"movie_id": 999}).status_code == 404
|
||||
|
||||
# Keine Aufnahme an dem Tag
|
||||
async def keine(watched_at):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(clips, "aufnahme_des_abends", keine)
|
||||
antwort = client.post("/api/clips/von-film", json={"movie_id": film(4)})
|
||||
assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"]
|
||||
|
||||
# Mit Aufnahme: die Marke liegt kurz nach ihrem Beginn
|
||||
beginn = datetime(2026, 9, 23, 19, 0, tzinfo=timezone.utc)
|
||||
|
||||
async def da(watched_at):
|
||||
return beginn
|
||||
|
||||
monkeypatch.setattr(clips, "aufnahme_des_abends", da)
|
||||
m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json()
|
||||
assert m["movie_id"] == film(4)
|
||||
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
|
||||
|
||||
|
||||
def test_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
|
||||
m = marke(client)
|
||||
assert [c for c in client2.get("/api/clips").json() if c["id"] == m["id"]] == []
|
||||
assert client2.patch(f"/api/clips/{m['id']}", json={"titel": "x"}).status_code == 404
|
||||
assert client2.get(f"/api/clips/{m['id']}/video").status_code == 404
|
||||
hostmodus(zuschauer)
|
||||
assert zuschauer.patch(f"/api/clips/{m['id']}", json={"titel": "Vom Host"}).json()["titel"] == "Vom Host"
|
||||
assert zuschauer.get("/api/clips").json()[0]["id"] == m["id"]
|
||||
|
||||
|
||||
def test_fenster_wird_begrenzt_und_macht_schnitt_ungueltig(client, monkeypatch):
|
||||
m = marke(client)
|
||||
geaendert = client.patch(f"/api/clips/{m['id']}", json={"vorher": 5000, "dauer": 1, "titel": " Hallo "}).json()
|
||||
assert (geaendert["vorher"], geaendert["dauer"], geaendert["titel"]) == (clips.MAX_VORHER, clips.MIN_DAUER, "Hallo")
|
||||
|
||||
# Schneiden: der Playback-Server wird nachgestellt
|
||||
class Antwort:
|
||||
status_code = 200
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
async def aiter_bytes(self):
|
||||
yield b"mp4-daten"
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
aufrufe = []
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
def stream(self, methode, url, params=None):
|
||||
aufrufe.append(params)
|
||||
return Antwort()
|
||||
|
||||
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
|
||||
fertig = client.post(f"/api/clips/{m['id']}/schneiden").json()
|
||||
assert fertig["geschnitten"] is True
|
||||
assert aufrufe[0]["path"] == "filmabend" and aufrufe[0]["duration"] == clips.MIN_DAUER
|
||||
assert (clips.CLIPS_DIR / f"{m['id']}.mp4").read_bytes() == b"mp4-daten"
|
||||
video = client.get(f"/api/clips/{m['id']}/video")
|
||||
assert video.status_code == 200 and video.content == b"mp4-daten"
|
||||
|
||||
# Neues Fenster: Datei weg, muss neu geschnitten werden
|
||||
neu = client.patch(f"/api/clips/{m['id']}", json={"dauer": 20}).json()
|
||||
assert neu["geschnitten"] is False
|
||||
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
|
||||
assert client.get(f"/api/clips/{m['id']}/video").status_code == 404
|
||||
|
||||
|
||||
def test_schneiden_ohne_aufnahme_meldet_502(client, monkeypatch):
|
||||
m = marke(client)
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kw):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
pass
|
||||
|
||||
def stream(self, *a, **kw):
|
||||
raise httpx.ConnectError("weg")
|
||||
|
||||
monkeypatch.setattr(clips.httpx, "AsyncClient", Client)
|
||||
assert client.post(f"/api/clips/{m['id']}/schneiden").status_code == 502
|
||||
|
||||
|
||||
def test_loeschen_nimmt_die_datei_mit(client):
|
||||
m = marke(client)
|
||||
clips.CLIPS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
(clips.CLIPS_DIR / f"{m['id']}.mp4").write_bytes(b"x")
|
||||
from app import db
|
||||
|
||||
with db.connect() as conn:
|
||||
conn.execute("UPDATE clips SET datei = ? WHERE id = ?", (f"{m['id']}.mp4", m["id"]))
|
||||
assert client.delete(f"/api/clips/{m['id']}").json()["removed"] == 1
|
||||
assert not (clips.CLIPS_DIR / f"{m['id']}.mp4").exists()
|
||||
assert client.get("/api/clips").json() == []
|
||||
|
||||
|
||||
def test_zuschauer_darf_keine_marke_setzen(zuschauer):
|
||||
assert zuschauer.post("/api/clips").status_code == 403
|
||||
@@ -183,3 +183,24 @@ def test_ausschalten_geht_ohne_schluessel(zuschauer, hostmodus):
|
||||
def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus):
|
||||
hostmodus(zuschauer)
|
||||
assert client2.get("/api/host").json()["host"] is False
|
||||
|
||||
|
||||
def test_hostfilm_oeffnet_den_hostmodus(zuschauer, client, hostmodus, film):
|
||||
# Nur der Host darf den Host-Film setzen oder sehen
|
||||
assert client.post("/api/host/film", json={"movie_id": film(1)}).status_code == 403
|
||||
assert client.get("/api/host/film").status_code == 403
|
||||
hostmodus(zuschauer)
|
||||
assert zuschauer.post("/api/host/film", json={"movie_id": film(1)}).json()["gesetzt"] is True
|
||||
assert zuschauer.get("/api/host/film").json()["film"]["id"] == film(1)
|
||||
|
||||
# Falscher Film: nichts; richtiger Film: Host
|
||||
assert client.post("/api/host", json={"an": True, "movie_id": film(2)}).status_code == 403
|
||||
assert client.get("/api/host").json()["host"] is False
|
||||
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).json()["host"] is True
|
||||
assert client.get("/api/host").json()["host"] is True
|
||||
|
||||
# Aufheben: der Film öffnet nichts mehr
|
||||
zuschauer.post("/api/host/film", json={"movie_id": None})
|
||||
assert zuschauer.get("/api/host/film").json()["film"] is None
|
||||
client.post("/api/host", json={"an": False})
|
||||
assert client.post("/api/host", json={"an": True, "movie_id": film(1)}).status_code == 403
|
||||
|
||||
Reference in New Issue
Block a user