This commit is contained in:
team3
2026-09-30 14:31:50 +02:00
parent 624df7e7ee
commit fd7893b5d2
7 changed files with 302 additions and 247 deletions

View File

@@ -28,6 +28,13 @@ CREATE TABLE IF NOT EXISTS clips (
datei TEXT,
created_at TEXT NOT NULL
);
-- Der Film einer Aufnahme, vom Host gesetzt. Ohne Eintrag gilt der Abend
-- mit dem gleichen Datum — oder keiner.
CREATE TABLE IF NOT EXISTS aufnahme_film (
start TEXT PRIMARY KEY,
movie_id INTEGER NOT NULL
);
"""
STREAM_PFAD = "filmabend"
@@ -223,33 +230,9 @@ async def _segmente(von, bis):
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 aufnahmen_des_abends(watched_at):
"""Die Aufnahmen zu einem Abend: alles vom Mittag des Tages bis zum
nächsten Mittag — so gehört auch die Nacht nach Mitternacht dazu.
Wurde der Stream zwischendurch beendet, sind es mehrere Stücke,
nach Beginn geordnet."""
tag = datetime.fromisoformat(watched_at[:10]).replace(tzinfo=timezone.utc)
mittag = tag + timedelta(hours=12)
segmente = await _segmente(mittag, mittag + timedelta(days=1))
stuecke = [
{
"start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))),
"dauer": round(s["duration"], 1),
}
for s in segmente
]
return sorted(stuecke, key=lambda s: s["start"])
# ── Aufnahmen: die Stücke aus MediaMTX, jedes mit seinem Film ──────────
# Eine Aufnahme hängt an keinem Abend. Der Host weist ihr einen Film zu;
# ohne Zuweisung gilt der Abend mit dem gleichen Datum, sonst keiner.
def _stueck(s):
@@ -259,10 +242,83 @@ def _stueck(s):
}
def _abendtag(iso):
"""Der Abend reicht bis zum nächsten Mittag — die Nacht gehört dazu."""
zeit = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (zeit - timedelta(hours=12)).strftime("%Y-%m-%d")
def film_zuweisen(start, movie_id):
"""Der Host hängt einen Film an eine Aufnahme — oder nimmt ihn weg."""
with connect() as conn:
if movie_id is None:
conn.execute("DELETE FROM aufnahme_film WHERE start = ?", (start,))
else:
conn.execute(
"INSERT OR REPLACE INTO aufnahme_film (start, movie_id) VALUES (?, ?)",
(start, movie_id),
)
def _filme(conn):
"""Zuweisungen je Start und Abende je Tag — um Aufnahmen ihren Film zu geben."""
zugewiesen = {
r["start"]: {"id": r["movie_id"], "title": r["title"], "poster_path": r["poster_path"]}
for r in conn.execute(
"SELECT a.start, a.movie_id, m.title, m.poster_path FROM aufnahme_film a "
"LEFT JOIN movies m ON m.id = a.movie_id"
)
}
abende = {}
for w in conn.execute(
"SELECT movie_id, title, poster_path, watched_at FROM watched ORDER BY id"
):
abende.setdefault(
_abendtag(w["watched_at"]),
{"id": w["movie_id"], "title": w["title"], "poster_path": w["poster_path"]},
)
return zugewiesen, abende
def _mit_film(stueck, zugewiesen, abende):
film = zugewiesen.get(stueck["start"])
return {
**stueck,
"film": film or abende.get(_abendtag(stueck["start"])),
"zugewiesen": film is not None,
}
async def aufnahmen_alle():
"""Alle Aufnahmen, neueste zuerst — der Host räumt damit auf."""
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film."""
segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt())
return sorted((_stueck(s) for s in segmente), key=lambda s: s["start"], reverse=True)
with connect() as conn:
zugewiesen, abende = _filme(conn)
stuecke = [_mit_film(_stueck(s), zugewiesen, abende) for s in segmente]
return sorted(stuecke, key=lambda s: s["start"], reverse=True)
def film_der_aufnahme(start):
"""Nur die id — für neue Marken."""
with connect() as conn:
zugewiesen, abende = _filme(conn)
film = _mit_film({"start": start}, zugewiesen, abende)["film"]
return film["id"] if film else None
async def film_live():
"""Der Film der gerade laufenden Aufnahme — für die Taste C. None,
wenn nichts aufgenommen wird oder MediaMTX nicht antwortet."""
try:
segmente = await _segmente(_jetzt() - timedelta(hours=6), _jetzt())
except httpx.HTTPError:
return None
for s in segmente:
stueck = _stueck(s)
ende = datetime.fromisoformat(stueck["start"]) + timedelta(seconds=stueck["dauer"])
if ende >= _jetzt() - timedelta(seconds=90):
return film_der_aufnahme(stueck["start"])
return None
def _im_stueck(segmente, start, dauer):

View File

@@ -307,15 +307,17 @@ def clip_oder_404(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."""
async def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
"""Taste C: der Moment wird gemerkt, mehr nicht. Der Film ist der der
laufenden Aufnahme, sonst der zuletzt gezogene, sonst der neueste
Abend unter Gesehen."""
sid = sitzung(response, filmabend_sid)
mitglied(sid)
movie_id = await clips.film_live()
gezogen = letzter_dreh["ergebnis"]
if gezogen:
if movie_id is None and gezogen:
movie_id = gezogen["gewinner"]["movie_id"]
else:
if movie_id is None:
abend = watched.juengster()
movie_id = abend["movie_id"] if abend else None
clip = clips.markieren(sid, movie_id)
@@ -323,27 +325,34 @@ def marke_setzen(response: Response, filmabend_sid: str | None = Cookie(None)):
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")
async def aufnahmen_liste():
"""Alle Aufnahmen, neueste zuerst, jede mit ihrem Film — für
„Clip erstellen" und die Verwaltung."""
try:
return {"stuecke": await clips.aufnahmen_alle()}
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.")
@app.put("/api/aufnahmen/film")
async def aufnahme_film_setzen(
response: Response,
start: str = Body(..., embed=True),
movie_id: int | None = Body(None, embed=True),
filmabend_sid: str | None = Cookie(None),
):
"""Der Host sagt, welcher Film in einer Aufnahme lief — oder nimmt es zurück."""
sid = sitzung(response, filmabend_sid)
if not users.ist_host(sid):
raise HTTPException(status_code=403, detail="Nur für den Host")
if movie_id is not None:
# Suchtreffer stehen nicht im Katalog — Titel und Poster müssen aber her
await film_holen(movie_id)
clips.film_zuweisen(start, movie_id)
return {"zugewiesen": movie_id is not None}
@app.delete("/api/aufnahmen")
async def aufnahme_loeschen(
response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None)
@@ -360,51 +369,25 @@ async def aufnahme_loeschen(
)
except httpx.HTTPError:
raise HTTPException(status_code=502, detail="Die MediaMTX-API ist nicht erreichbar.")
clips.film_zuweisen(start, None)
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(
@app.post("/api/clips/von-aufnahme")
def clip_von_aufnahme(
response: Response,
movie_id: int = Body(..., embed=True),
start: str | None = Body(None, embed=True),
start: str = Body(..., 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."""
"""Ein Clip aus einer alten Aufnahme: die Marke kommt kurz nach deren
Anfang, das Fenster schiebt man dann im Editor hin. Den Film bringt
die Aufnahme mit — oder keinen."""
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))
clip = clips.markieren(
sid, clips.film_der_aufnahme(start), beginn + timedelta(seconds=clips.VORHER + 30)
)
events.senden("clips")
return clip

View File

@@ -2,7 +2,7 @@
import httpx
from app import clips, main, punkte
from app import clips, db, main, punkte
def marke(client):
@@ -38,7 +38,7 @@ def test_ohne_dreh_zaehlt_der_neueste_abend(client, film):
assert marke(client)["movie_id"] == film(2)
def test_tage_mit_aufnahme(client, monkeypatch):
def stuecke_mocken(monkeypatch):
async def segmente(von, bis):
# Ein Abend mit Stück vor und nach Mitternacht, ein weiterer am Tag danach
return [
@@ -48,63 +48,72 @@ def test_tage_mit_aufnahme(client, monkeypatch):
]
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
def test_aufnahmen_bekommen_ihren_film(client, zuschauer, hostmodus, film, monkeypatch):
"""Zuweisung schlägt den Abend am gleichen Datum; sonst kein Film."""
stuecke_mocken(monkeypatch)
client.post("/api/watched", json={"movie_id": film(1)})
with db.connect() as conn:
conn.execute("UPDATE watched SET watched_at = '2026-09-23T20:41:00+00:00'")
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 []
monkeypatch.setattr(clips, "aufnahmen_des_abends", keine)
assert client.get(f"/api/clips/von-film/{film(4)}").json()["stuecke"] == []
antwort = client.post("/api/clips/von-film", json={"movie_id": film(4)})
assert antwort.status_code == 404 and "Aufnahme" in antwort.json()["detail"]
# Zwei Stücke, weil der Stream neu gestartet wurde
stuecke = [
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
{"start": "2026-09-23T20:00:00+00:00", "dauer": 600.0},
liste = client.get("/api/aufnahmen").json()["stuecke"]
assert [s["start"] for s in liste] == [
"2026-09-25T19:00:00+00:00",
"2026-09-24T00:30:00+00:00",
"2026-09-23T20:00:00+00:00",
]
# Der Abend vom 23. gilt auch für die Nacht danach
assert liste[2]["film"]["id"] == film(1) and liste[2]["zugewiesen"] is False
assert liste[1]["film"]["id"] == film(1)
assert liste[0]["film"] is None
async def da(watched_at):
return stuecke
# Zuweisen darf nur der Host
zuweisung = {"start": "2026-09-25T19:00:00+00:00", "movie_id": film(2)}
assert client.put("/api/aufnahmen/film", json=zuweisung).status_code == 403
hostmodus(zuschauer)
assert zuschauer.put("/api/aufnahmen/film", json=zuweisung).json() == {"zugewiesen": True}
liste = client.get("/api/aufnahmen").json()["stuecke"]
assert liste[0]["film"]["title"] == "Film 2" and liste[0]["zugewiesen"] is True
# Die Zuweisung schlägt den Abend
zuschauer.put("/api/aufnahmen/film", json={"start": liste[2]["start"], "movie_id": film(2)})
assert client.get("/api/aufnahmen").json()["stuecke"][2]["film"]["id"] == film(2)
# Aufheben
zuschauer.put("/api/aufnahmen/film", json={"start": zuweisung["start"], "movie_id": None})
assert client.get("/api/aufnahmen").json()["stuecke"][0]["film"] is None
monkeypatch.setattr(clips, "aufnahmen_des_abends", da)
assert client.get(f"/api/clips/von-film/{film(4)}").json()["stuecke"] == stuecke
# Ohne Wahl: die Marke liegt kurz nach dem Beginn des ersten Stücks
m = client.post("/api/clips/von-film", json={"movie_id": film(4)}).json()
assert m["movie_id"] == film(4)
def test_clip_aus_aufnahme(client, zuschauer, hostmodus, film):
hostmodus(zuschauer)
start = "2026-09-23T19:00:00+00:00"
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(4)})
m = client.post("/api/clips/von-aufnahme", json={"start": start}).json()
assert m["movie_id"] == film(4) and m["title"] == "Film 4"
# Die Marke liegt kurz nach dem Anfang der Aufnahme
assert m["marke_at"] == "2026-09-23T19:01:00+00:00"
# Mit Wahl: nach dem Beginn des gewählten Stücks
m = client.post(
"/api/clips/von-film", json={"movie_id": film(4), "start": stuecke[1]["start"]}
).json()
assert m["marke_at"] == "2026-09-23T20:01:00+00:00"
# Ohne Film gibt es die Marke trotzdem — unter Sonstige
m = client.post("/api/clips/von-aufnahme", json={"start": "2026-09-25T19:00:00+00:00"}).json()
assert m["movie_id"] is None
def test_stuecke_eines_abends_nach_beginn_geordnet(monkeypatch):
import asyncio
def test_taste_c_nimmt_den_film_der_laufenden_aufnahme(client, zuschauer, hostmodus, film, monkeypatch):
from datetime import timedelta
start = clips._iso(clips._jetzt() - timedelta(seconds=100))
async def segmente(von, bis):
return [
{"start": "2026-09-23T21:00:00Z", "duration": 600.0},
{"start": "2026-09-23T19:00:00Z", "duration": 1800.04},
]
return [{"start": start, "duration": 60}]
monkeypatch.setattr(clips, "_segmente", segmente)
assert asyncio.run(clips.aufnahmen_des_abends("2026-09-23T20:00:00+00:00")) == [
{"start": "2026-09-23T19:00:00+00:00", "dauer": 1800.0},
{"start": "2026-09-23T21:00:00+00:00", "dauer": 600.0},
]
# Es läuft eine Aufnahme, aber ohne Film: dann wie bisher der neueste Abend
client.post("/api/watched", json={"movie_id": film(1)})
with db.connect() as conn:
conn.execute("UPDATE watched SET watched_at = '2020-01-01T20:00:00+00:00'")
assert marke(client)["movie_id"] == film(1)
# Zugewiesen: der Film der Aufnahme
hostmodus(zuschauer)
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(3)})
assert marke(client)["movie_id"] == film(3)
def test_fremde_sehen_nichts_der_host_alles(client, client2, zuschauer, hostmodus):
@@ -221,25 +230,21 @@ def test_segmente_eines_stuecks():
]
def test_aufnahmen_verwaltet_nur_der_host(client, zuschauer, hostmodus, monkeypatch):
stuecke = [{"start": "2026-09-23T19:00:00+00:00", "dauer": 60.0}]
async def alle():
return stuecke
def test_aufnahme_loescht_nur_der_host(client, zuschauer, hostmodus, film, monkeypatch):
geloescht = []
async def loeschen(start, dauer):
geloescht.append((start, dauer))
return 2
monkeypatch.setattr(clips, "aufnahmen_alle", alle)
monkeypatch.setattr(clips, "aufnahme_loeschen", loeschen)
assert client.get("/api/aufnahmen").status_code == 403
assert client.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}).status_code == 403
start = "2026-09-23T19:00:00+00:00"
assert client.delete("/api/aufnahmen", params={"start": start, "dauer": 60}).status_code == 403
hostmodus(zuschauer)
assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke
antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60})
zuschauer.put("/api/aufnahmen/film", json={"start": start, "movie_id": film(1)})
antwort = zuschauer.delete("/api/aufnahmen", params={"start": start, "dauer": 60})
assert antwort.json() == {"segmente": 2}
assert geloescht == [(stuecke[0]["start"], 60.0)]
assert geloescht == [(start, 60.0)]
# Mit der Aufnahme geht auch die Zuweisung
assert clips.film_der_aufnahme(start) is None