diff --git a/README.md b/README.md index 530bb58..c9e023a 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,10 @@ in `docker-compose.yml` als Volume). Gelöscht wird nichts von selbst — wer Platz braucht, räumt den Ordner auf oder schiebt ihn per rclone auf eine Storage Box. Rund 5 GB je Abend bei 1080p. +Löschen kann der Host in der Verwaltung, Stück für Stück: die App +schickt das an die MediaMTX-API (Port 9997, nur intern, angemeldet als +`sender` mit dem Sendepasswort), die die Segmente wegräumt. + Daraus kommen die Clips. Im Stream-Reiter setzt die Taste C (oder der Knopf „Clip") eine Marke: nur der Zeitpunkt, vier Sekunden vor jetzt, weil der Player so weit hinter der Live-Kante hängt. Der Film ist der diff --git a/backend/app/clips.py b/backend/app/clips.py index cadc495..d8e34bf 100644 --- a/backend/app/clips.py +++ b/backend/app/clips.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone import httpx -from .config import CLIPS_DIR, MEDIAMTX_PLAYBACK_URL +from .config import CLIPS_DIR, MEDIAMTX_API_URL, MEDIAMTX_PLAYBACK_URL, MTX_SENDEPASSWORT from .db import connect from .users import WEM, besitzer, gehoert_mir @@ -252,6 +252,50 @@ async def aufnahmen_des_abends(watched_at): return sorted(stuecke, key=lambda s: s["start"]) +def _stueck(s): + return { + "start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))), + "dauer": round(s["duration"], 1), + } + + +async def aufnahmen_alle(): + """Alle Aufnahmen, neueste zuerst — der Host räumt damit auf.""" + 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) + + +def _im_stueck(segmente, start, dauer): + """Die Segmente (aus der API), die zu einem Stück der Aufnahme gehören: + ihr Beginn liegt zwischen dessen Start und Ende. Eine Sekunde Luft an + beiden Enden — Playback rundet die Zeiten anders als die API.""" + von = datetime.fromisoformat(start) - timedelta(seconds=1) + bis = datetime.fromisoformat(start) + timedelta(seconds=dauer + 1) + aus = [] + for s in segmente: + beginn = datetime.fromisoformat(s["start"].replace("Z", "+00:00")) + if von <= beginn <= bis: + aus.append(s["start"]) + return aus + + +async def aufnahme_loeschen(start, dauer): + """Ein Stück der Aufnahme löschen — MediaMTX räumt die Segmente weg. + Gibt zurück, wie viele es waren.""" + auth = ("sender", MTX_SENDEPASSWORT) + async with httpx.AsyncClient(timeout=30.0, auth=auth) as http: + resp = await http.get(f"{MEDIAMTX_API_URL}/v3/recordings/get/{STREAM_PFAD}") + resp.raise_for_status() + starts = _im_stueck(resp.json().get("segments") or [], start, dauer) + for s in starts: + resp = await http.delete( + f"{MEDIAMTX_API_URL}/v3/recordings/deletesegment", + params={"path": STREAM_PFAD, "start": s}, + ) + resp.raise_for_status() + return len(starts) + + 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.""" diff --git a/backend/app/config.py b/backend/app/config.py index 41eea8b..6a82546 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -27,6 +27,10 @@ PROVIDER_NETFLIX = 8 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") +# Die Steuer-API von MediaMTX, zum Löschen von Aufnahmen. Angemeldet +# wird sich als der Sender-Nutzer mit dem Sendepasswort aus der .env. +MEDIAMTX_API_URL = os.getenv("MEDIAMTX_API_URL", "http://127.0.0.1:9997") +MTX_SENDEPASSWORT = os.getenv("MTX_SENDEPASSWORT", "") # Wohin die geschnittenen Clips kommen CLIPS_DIR = Path(os.getenv("CLIPS_DIR", ROOT / "clips")) REGION = "DE" diff --git a/backend/app/main.py b/backend/app/main.py index 239b9b6..3ef2309 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -332,6 +332,33 @@ async def clip_aufnahmen(): 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") + try: + return {"stuecke": await clips.aufnahmen_alle()} + except httpx.HTTPError: + raise HTTPException(status_code=502, detail="Die Aufnahmen sind gerade nicht erreichbar.") + + +@app.delete("/api/aufnahmen") +async def aufnahme_loeschen( + response: Response, start: str, dauer: float, filmabend_sid: str | None = Cookie(None) +): + """Ein Stück der Aufnahme löschen — nur der Host.""" + sid = sitzung(response, filmabend_sid) + if not users.ist_host(sid): + raise HTTPException(status_code=403, detail="Nur für den Host") + try: + anzahl = await clips.aufnahme_loeschen(start, dauer) + except httpx.HTTPError: + raise HTTPException(status_code=502, detail="Löschen hat nicht geklappt.") + 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) diff --git a/backend/tests/test_clips.py b/backend/tests/test_clips.py index 7fb9c17..d8fc992 100644 --- a/backend/tests/test_clips.py +++ b/backend/tests/test_clips.py @@ -204,3 +204,42 @@ def test_loeschen_nimmt_die_datei_mit(client): def test_zuschauer_darf_keine_marke_setzen(zuschauer): assert zuschauer.post("/api/clips").status_code == 403 + + +def test_segmente_eines_stuecks(): + segmente = [ + {"start": "2026-09-23T19:00:00.5Z"}, + {"start": "2026-09-23T19:00:30.5Z"}, + {"start": "2026-09-23T19:01:00.5Z"}, + {"start": "2026-09-23T21:00:00Z"}, + ] + # Das Stück von 19:00 mit 61 s Dauer: drei Segmente, das von 21:00 nicht + assert clips._im_stueck(segmente, "2026-09-23T19:00:00+00:00", 61.0) == [ + "2026-09-23T19:00:00.5Z", + "2026-09-23T19:00:30.5Z", + "2026-09-23T19:01:00.5Z", + ] + + +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 + + 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 + + hostmodus(zuschauer) + assert zuschauer.get("/api/aufnahmen").json()["stuecke"] == stuecke + antwort = zuschauer.delete("/api/aufnahmen", params={"start": stuecke[0]["start"], "dauer": 60}) + assert antwort.json() == {"segmente": 2} + assert geloescht == [(stuecke[0]["start"], 60.0)] diff --git a/docker-compose.yml b/docker-compose.yml index 2a2835c..8d84b87 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: DB_PATH: /data/horror.db MEDIAMTX_URL: http://mediamtx:8890 MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996 + MEDIAMTX_API_URL: http://mediamtx:9997 depends_on: - mediamtx labels: diff --git a/frontend/src/components/VerwaltungTab.vue b/frontend/src/components/VerwaltungTab.vue index a09fb27..19ae257 100644 --- a/frontend/src/components/VerwaltungTab.vue +++ b/frontend/src/components/VerwaltungTab.vue @@ -1,5 +1,5 @@