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

259
backend/app/clips.py Normal file
View 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

View File

@@ -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"

View File

@@ -1,4 +1,5 @@
import asyncio
from datetime import timedelta
import logging
import secrets
import time
@@ -6,13 +7,14 @@ import time
from apscheduler.schedulers.asyncio import AsyncIOScheduler
import httpx
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.staticfiles import StaticFiles
from . import (
backup,
clips,
punkte,
db,
events,
@@ -93,6 +95,7 @@ async def startup():
users.init()
suggestions.init()
watched.init()
clips.init()
wishlist.init()
features.init()
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
@@ -296,6 +299,160 @@ async def ki_suche(
return await ki.aufloesen(vorschlaege, filter_, ausser=ausser)
# ── Clips: Marken setzen, Fenster ziehen, schneiden, freigeben ──────────
def clip_oder_404(clip):
if clip is None:
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
return clip
@app.post("/api/clips")
def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der
zuletzt gezogene, sonst der neueste Abend unter Gesehen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
gezogen = letzter_dreh["ergebnis"]
if gezogen:
movie_id = gezogen["gewinner"]["movie_id"]
else:
abend = watched.juengster()
movie_id = abend["movie_id"] if abend else None
clip = clips.markieren(sid, movie_id)
events.senden("clips")
return clip
@app.get("/api/clips/aufnahmen")
async def clip_aufnahmen():
"""Zu welchen Abenden es eine Aufnahme gibt."""
try:
return {"tage": await clips.tage_mit_aufnahme()}
except httpx.HTTPError:
return {"tage": []}
@app.post("/api/clips/von-film")
async def clip_von_film(
response: Response,
movie_id: int = Body(..., embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Ein Clip zu einem alten Abend: die Marke kommt an den Anfang der
Aufnahme, das Fenster schiebt man dann im Editor hin."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
abend = watched.von_film(movie_id)
if not abend:
raise HTTPException(status_code=404, detail="Dieser Film steht nicht unter Gesehen.")
try:
beginn = await clips.aufnahme_des_abends(abend["watched_at"])
except httpx.HTTPError:
beginn = None
if beginn is None:
raise HTTPException(status_code=404, detail="Zu diesem Abend gibt es keine Aufnahme.")
clip = clips.markieren(sid, movie_id, beginn + timedelta(seconds=clips.VORHER + 30))
events.senden("clips")
return clip
@app.get("/api/clips")
def clips_liste(response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
return clips.alle(sid, users.ist_host(sid))
@app.patch("/api/clips/{clip_id}")
def clip_aendern(
clip_id: int,
response: Response,
titel: str | None = Body(None, embed=True),
vorher: float | None = Body(None, embed=True),
dauer: float | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
sid = sitzung(response, filmabend_sid)
clip = clip_oder_404(clips.aendern(clip_id, sid, users.ist_host(sid), titel, vorher, dauer))
events.senden("clips")
return clip
@app.delete("/api/clips/{clip_id}")
def clip_loeschen(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
if not clips.loeschen(clip_id, sid, users.ist_host(sid)):
raise HTTPException(status_code=404, detail="Clip nicht gefunden oder nicht deiner")
events.senden("clips")
return {"removed": 1}
@app.post("/api/clips/{clip_id}/schneiden")
async def clip_schneiden(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
"""Den Ausschnitt aus der Aufnahme als Datei ablegen."""
sid = sitzung(response, filmabend_sid)
try:
clip = clip_oder_404(await clips.schneiden(clip_id, sid, users.ist_host(sid)))
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
events.senden("clips")
return clip
@app.get("/api/clips/{clip_id}/aufnahme")
async def clip_aufnahme(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
"""Die Stücke der Aufnahme rund um die Marke, für den Zeitstrahl."""
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
try:
return await clips.aufnahme(row["marke_at"])
except httpx.HTTPError:
return []
@app.get("/api/clips/{clip_id}/vorschau")
async def clip_vorschau(
clip_id: int,
response: Response,
vorher: float | None = None,
dauer: float | None = None,
filmabend_sid: str | None = Cookie(None),
):
"""Ein Fenster um die Marke ansehen, ohne zu schneiden — beim Ziehen
der Regler. Kommt direkt vom Playback-Server durch."""
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
url, params = clips.playback_url(row, vorher, dauer)
http = httpx.AsyncClient(timeout=120.0)
try:
antwort = await http.send(http.build_request("GET", url, params=params), stream=True)
except httpx.HTTPError:
await http.aclose()
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
if antwort.status_code != 200:
await antwort.aclose()
await http.aclose()
raise HTTPException(status_code=502, detail="Die Aufnahme liefert diesen Ausschnitt nicht.")
async def weiterreichen():
try:
async for stueck in antwort.aiter_bytes():
yield stueck
finally:
await antwort.aclose()
await http.aclose()
return StreamingResponse(weiterreichen(), media_type="video/mp4")
@app.get("/api/clips/{clip_id}/video")
def clip_video(clip_id: int, response: Response, filmabend_sid: str | None = Cookie(None)):
sid = sitzung(response, filmabend_sid)
row = clip_oder_404(clips.zeile(clip_id, sid, users.ist_host(sid)))
if not row["datei"] or not (clips.CLIPS_DIR / row["datei"]).exists():
raise HTTPException(status_code=404, detail="Noch nicht geschnitten")
return FileResponse(clips.CLIPS_DIR / row["datei"], media_type="video/mp4")
@app.get("/api/movies/{movie_id}/credits")
async def film_credits(movie_id: int):
"""Regie und Besetzung, live von TMDB."""
@@ -699,13 +856,14 @@ def host_modus(
response: Response,
an: bool = Body(..., embed=True),
schluessel: str | None = Body(None, embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
fragt danach, wie beim Anmeldefilm eines Namens.
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
@@ -715,14 +873,51 @@ def host_modus(
if not an:
return {"host": users.host_setzen(sid, False)}
darf = users.ist_host(sid) or (
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
film = users.host_film()
darf = (
users.ist_host(sid)
or (bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL))
or (film is not None and movie_id == film)
)
if not darf:
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
return {"host": users.host_setzen(sid, True)}
@app.get("/api/host/film")
async def host_film_zeigen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Welcher Film den Hostmodus öffnet — nur der Host sieht ihn."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
movie_id = users.host_film()
if movie_id is None:
return {"film": None}
film = await film_holen(movie_id)
return {
"film": {
"id": film["id"],
"title": film["title"],
"year": film.get("year"),
"poster_path": film.get("poster_path"),
}
}
@app.post("/api/host/film")
def host_film_setzen(
response: Response,
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Den Host-Film setzen oder aufheben — nur als Host."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
users.host_film_setzen(movie_id)
return {"gesetzt": movie_id is not None}
@app.post("/api/users/waehlen")
def name_waehlen(
response: Response,

View File

@@ -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(

View File

@@ -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:

View File

@@ -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
View 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

View File

@@ -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