From c589e389df305a256b10f3367a413ff29e17785f Mon Sep 17 00:00:00 2001 From: team3 Date: Wed, 30 Sep 2026 00:59:59 +0200 Subject: [PATCH] update --- .gitignore | 4 + Makefile | 3 +- README.md | 24 ++ backend/app/clips.py | 259 ++++++++++++ backend/app/config.py | 4 + backend/app/main.py | 207 +++++++++- backend/app/users.py | 13 + backend/app/watched.py | 15 + backend/tests/conftest.py | 5 +- backend/tests/test_clips.py | 177 ++++++++ backend/tests/test_login.py | 21 + docker-compose.yml | 3 + frontend/src/App.vue | 23 +- frontend/src/components/ClipEditor.vue | 211 ++++++++++ frontend/src/components/ClipTrimmer.vue | 274 +++++++++++++ frontend/src/components/ClipsTab.vue | 407 +++++++++++++++++++ frontend/src/components/StreamTab.vue | 55 +++ frontend/src/components/VerwaltungTab.vue | 88 +++- frontend/src/composables/useEinstellungen.js | 35 +- mediamtx/mediamtx.yml | 15 + 20 files changed, 1814 insertions(+), 29 deletions(-) create mode 100644 backend/app/clips.py create mode 100644 backend/tests/test_clips.py create mode 100644 frontend/src/components/ClipEditor.vue create mode 100644 frontend/src/components/ClipTrimmer.vue create mode 100644 frontend/src/components/ClipsTab.vue diff --git a/.gitignore b/.gitignore index 43416b8..4aaa8aa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,7 @@ data/ backend/backups/ data/backups/ + +# Aufnahmen der Abende und geschnittene Clips — groß, nicht ins Git +aufnahmen/ +clips/ diff --git a/Makefile b/Makefile index f4dc682..e56514f 100644 --- a/Makefile +++ b/Makefile @@ -17,8 +17,9 @@ mtx: --env-file .env \ -e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \ -v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \ + -v "$(PWD)/aufnahmen:/recordings" \ bluenviron/mediamtx:latest > /dev/null - @echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890" + @echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890, Aufnahme in aufnahmen/" mtx-stop: -@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null diff --git a/README.md b/README.md index 7ad91a4..530bb58 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,30 @@ Bild — damit lässt sich der Gleichlauf im Betrieb prüfen. Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf MediaMTX davor. +## Aufnahme und Clips + +MediaMTX schneidet jeden Abend mit: ohne Neukodierung, als fMP4 in +30-Sekunden-Segmenten unter `aufnahmen/` (im Container `/recordings`, +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. + +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 +zuletzt gezogene. Im Reiter „Clips" zieht man später das Fenster um die +Marke, sieht es sich an, schneidet und gibt frei. Geschnittene Clips +liegen als MP4 unter `clips/`, freigegebene erscheinen im Buch auf der +Seite des Abends. Bis zur Freigabe sieht sie nur, wer sie gesetzt hat — +und der Host, der auch aufräumen darf. Ein freigegebener Clip bringt +3 Punkte, einmal je Film. + +Die Ausschnitte liefert der Playback-Server von MediaMTX (Port 9996, +nur intern): `/get?path=filmabend&start=&duration=&format=mp4`. +Er fügt Segmente selbst zusammen und liefert auch aus dem gerade +laufenden Segment. Die App reicht das als Vorschau durch und legt beim +Schneiden die Datei ab; ffmpeg braucht es dafür nicht. + ## Vorschläge und Rad Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film diff --git a/backend/app/clips.py b/backend/app/clips.py new file mode 100644 index 0000000..76657cc --- /dev/null +++ b/backend/app/clips.py @@ -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 diff --git a/backend/app/config.py b/backend/app/config.py index e9f6c91..41eea8b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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" diff --git a/backend/app/main.py b/backend/app/main.py index 8780299..50ea39f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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=. Ohne das käme jeder mit der Tastenkombination an - den Anmeldefilmen vorbei. + Freischalten geht mit dem Schlüssel aus der .env, einmal über + /?host=, 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, diff --git a/backend/app/users.py b/backend/app/users.py index 15e6a8d..166e2d1 100644 --- a/backend/app/users.py +++ b/backend/app/users.py @@ -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( diff --git a/backend/app/watched.py b/backend/app/watched.py index 336c64b..745ec85 100644 --- a/backend/app/watched.py +++ b/backend/app/watched.py @@ -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: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 2197ac8..8b5e464 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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) diff --git a/backend/tests/test_clips.py b/backend/tests/test_clips.py new file mode 100644 index 0000000..7907fb2 --- /dev/null +++ b/backend/tests/test_clips.py @@ -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 diff --git a/backend/tests/test_login.py b/backend/tests/test_login.py index dfdabac..1281af1 100644 --- a/backend/tests/test_login.py +++ b/backend/tests/test_login.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 00b9c74..2a2835c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: environment: DB_PATH: /data/horror.db MEDIAMTX_URL: http://mediamtx:8890 + MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996 depends_on: - mediamtx labels: @@ -41,6 +42,8 @@ services: MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT} volumes: - ./mediamtx/mediamtx.yml:/mediamtx.yml:ro + # Die Aufnahmen der Abende — bleiben über Neustarts erhalten + - ./aufnahmen:/recordings # RTMP nach außen für OBS; HLS bleibt intern und läuft über die App ports: - "1935:1935" diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ed820c9..93bcd77 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,10 +5,12 @@ import FilterSidebar from './components/FilterSidebar.vue' import AehnlichTab from './components/AehnlichTab.vue' import KiTab from './components/KiTab.vue' import PersonenTab from './components/PersonenTab.vue' +import ClipsTab from './components/ClipsTab.vue' import MerklisteTab from './components/MerklisteTab.vue' import SammlungFilter from './components/SammlungFilter.vue' import MovieDetail from './components/MovieDetail.vue' import NamensWahl from './components/NamensWahl.vue' +import FilmLogin from './components/FilmLogin.vue' import VerwaltungTab from './components/VerwaltungTab.vue' import WuenscheTab from './components/WuenscheTab.vue' import SearchTab from './components/SearchTab.vue' @@ -22,7 +24,8 @@ import { matches, useKatalog } from './composables/useKatalog' import { REGLER, useFilter } from './composables/useFilter' import { useLangeListe } from './composables/useLangeListe' -const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen() +const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden } = + useEinstellungen() const { runde, @@ -140,6 +143,7 @@ const tabs = computed(() => [ ...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]), { id: 'wheel', label: 'Rad', count: null }, { id: 'stream', label: 'Stream', count: null }, + ...(angemeldet.value || host.value ? [{ id: 'clips', label: 'Clips', count: null }] : []), { id: 'watched', label: 'Gesehen', count: gesehenZahl.value }, { id: 'wuensche', @@ -428,7 +432,14 @@ onMounted(load) - + + +
@@ -502,6 +513,14 @@ onMounted(load) Fertig + + + +// Ein Clip im Großen: Vorschau, der Zeitstrahl zum Zuschneiden, Titel, +// Schneiden. Liegt als Fenster über der Seite. +import { onMounted, onUnmounted, ref } from 'vue' +import ClipTrimmer from './ClipTrimmer.vue' + +const props = defineProps({ + clip: { type: Object, required: true }, +}) +const emit = defineEmits(['zu', 'geaendert']) + +const vorher = ref(props.clip.vorher) +const dauer = ref(props.clip.dauer) +const titel = ref(props.clip.titel || '') +const segmente = ref([]) +const vorschauUrl = ref('') +const schneidet = ref(false) +const fehler = ref('') + +function vorschauLaden() { + vorschauUrl.value = `/api/clips/${props.clip.id}/vorschau?vorher=${vorher.value}&dauer=${dauer.value}&t=${Date.now()}` +} + +async function patch(felder) { + const resp = await fetch(`/api/clips/${props.clip.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(felder), + }) + if (!resp.ok) fehler.value = 'Das hat nicht geklappt.' + else emit('geaendert') + return resp.ok +} + +async function schneiden() { + schneidet.value = true + fehler.value = '' + await patch({ vorher: vorher.value, dauer: dauer.value, titel: titel.value }) + const resp = await fetch(`/api/clips/${props.clip.id}/schneiden`, { method: 'POST' }) + if (!resp.ok) fehler.value = 'Die Aufnahme liefert diesen Ausschnitt nicht.' + else emit('geaendert') + schneidet.value = false +} + +async function loeschen() { + await fetch(`/api/clips/${props.clip.id}`, { method: 'DELETE' }) + emit('geaendert') + emit('zu') +} + +function escape(e) { + if (e.key === 'Escape') emit('zu') +} + +onMounted(async () => { + window.addEventListener('keydown', escape) + vorschauLaden() + try { + const resp = await fetch(`/api/clips/${props.clip.id}/aufnahme`) + if (resp.ok) segmente.value = await resp.json() + } catch { + /* dann ohne Aufnahmebalken */ + } +}) + +onUnmounted(() => window.removeEventListener('keydown', escape)) + + + + + diff --git a/frontend/src/components/ClipTrimmer.vue b/frontend/src/components/ClipTrimmer.vue new file mode 100644 index 0000000..55590c9 --- /dev/null +++ b/frontend/src/components/ClipTrimmer.vue @@ -0,0 +1,274 @@ + + + + + diff --git a/frontend/src/components/ClipsTab.vue b/frontend/src/components/ClipsTab.vue new file mode 100644 index 0000000..f6f4b52 --- /dev/null +++ b/frontend/src/components/ClipsTab.vue @@ -0,0 +1,407 @@ + + + + + + + diff --git a/frontend/src/components/StreamTab.vue b/frontend/src/components/StreamTab.vue index 48f4eed..5857545 100644 --- a/frontend/src/components/StreamTab.vue +++ b/frontend/src/components/StreamTab.vue @@ -5,6 +5,11 @@ import Hls from 'hls.js' // Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie // synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über // Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate. +const props = defineProps({ + // Marken setzen darf nur, wer einen Namen hat + angemeldet: Boolean, +}) + const ZIEL_LATENZ = 4 const video = ref(null) @@ -86,6 +91,31 @@ function tasteAbfangen(e) { if (e.code === 'Space' && e.target === video.value) e.preventDefault() } +// Eine Marke setzen: der Moment wird gemerkt, den Clip schneidet man +// später im Clips-Reiter. Taste C, damit nichts vom Film ablenkt. +const markeStand = ref('') +let markeTimer = null + +async function markieren() { + if (!props.angemeldet || markeStand.value === 'läuft') return + markeStand.value = 'läuft' + try { + const resp = await fetch('/api/clips', { method: 'POST' }) + markeStand.value = resp.ok ? 'gesetzt' : 'fehler' + } catch { + markeStand.value = 'fehler' + } + clearTimeout(markeTimer) + markeTimer = setTimeout(() => (markeStand.value = ''), 1800) +} + +function tasteC(e) { + if (e.key !== 'c' && e.key !== 'C') return + if (e.ctrlKey || e.metaKey || e.altKey) return + if (e.target instanceof Element && e.target.matches('input, textarea')) return + markieren() +} + function verbinden() { const quelle = '/stream/filmabend/index.m3u8' @@ -163,6 +193,7 @@ function vollbildGewechselt() { } onMounted(() => { + window.addEventListener('keydown', tasteC) verbinden() wacheStarten() document.addEventListener('fullscreenchange', vollbildGewechselt) @@ -170,6 +201,7 @@ onMounted(() => { }) onUnmounted(() => { + window.removeEventListener('keydown', tasteC) document.removeEventListener('fullscreenchange', vollbildGewechselt) window.removeEventListener('keydown', tasteAbfangen) window.clearInterval(wache) @@ -214,6 +246,16 @@ onUnmounted(() => { @input="lautstaerkeSetzen(Number($event.target.value))" /> live + + + + + + + + + +
+ + + +
+ +
+
+
@@ -415,4 +471,16 @@ h3 { border-radius: 0; } } + +.hostfilm .zeile { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.film-anzeige { + color: var(--muted); + font-size: 0.88rem; +} diff --git a/frontend/src/composables/useEinstellungen.js b/frontend/src/composables/useEinstellungen.js index ea425ee..fa84e37 100644 --- a/frontend/src/composables/useEinstellungen.js +++ b/frontend/src/composables/useEinstellungen.js @@ -5,12 +5,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue' // Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem // Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt. -async function hostSetzen(an, schluessel = null) { +async function hostSetzen(an, schluessel = null, movieId = null) { try { const resp = await fetch('/api/host', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ an, schluessel }), + body: JSON.stringify({ an, schluessel, movie_id: movieId }), }) if (!resp.ok) return false return (await resp.json()).host @@ -29,7 +29,15 @@ async function hostStand() { } const TAB_IDS = [ - 'katalog', 'suche', 'wheel', 'merkliste', 'watched', 'wuensche', 'verwaltung', 'stream', + 'katalog', + 'suche', + 'wheel', + 'merkliste', + 'watched', + 'wuensche', + 'verwaltung', + 'stream', + 'clips', ] // Netflix und Kanon waren einmal zwei Reiter — heute eine Quelle im Katalog @@ -50,9 +58,7 @@ export function useEinstellungen() { // Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus. // Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten. const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus') - watch(ohneGesehene, (an) => - localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus') - ) + watch(ohneGesehene, (an) => localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')) // Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter // alter Reiter "netflix" oder "canon" landet im Katalog. @@ -101,18 +107,31 @@ export function useEinstellungen() { watch(anzeige, (an) => localStorage.setItem('filmabend_host', an ? '1' : '0')) + // Ohne Freischaltung fragt die Tastenkombination nach dem Host-Film — + // wie der Anmeldefilm bei einem Namen + const hostLogin = ref(false) + + async function hostAnmelden(movieId) { + const an = await hostSetzen(true, null, movieId) + if (!an) return false + freigeschaltet.value = true + anzeige.value = true + hostLogin.value = false + return true + } + function tastatur(e) { // e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst // je nach System ein anderes Zeichen if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') { e.preventDefault() - // Ohne freigeschaltete Sitzung gibt es nichts umzuschalten if (freigeschaltet.value) anzeige.value = !anzeige.value + else hostLogin.value = true } } onMounted(() => window.addEventListener('keydown', tastatur)) onUnmounted(() => window.removeEventListener('keydown', tastatur)) - return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } + return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden } } diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml index b359b93..fbaff18 100644 --- a/mediamtx/mediamtx.yml +++ b/mediamtx/mediamtx.yml @@ -20,6 +20,11 @@ hlsAlwaysRemux: yes rtmp: yes rtmpAddress: :1935 +# Liefert Zeitbereiche der Aufnahme als MP4 — daraus schneidet die App +# die Clips. Nur intern erreichbar, nicht nach außen geöffnet. +playback: yes +playbackAddress: :9996 + # Nicht gebraucht, also zu webrtc: no rtsp: no @@ -39,6 +44,16 @@ authInternalUsers: ips: [] permissions: - action: read + # Ausschnitte der Aufnahme holen — der Port ist nur intern erreichbar + - action: playback paths: filmabend: + # Der Abend wird mitgeschnitten, ohne Neukodierung, in 30-Sekunden- + # Segmenten. Kurze Segmente, damit ein Clip kurz nach der Marke schon + # geschnitten werden kann. Gelöscht wird nichts von selbst. + record: yes + recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f + recordFormat: fmp4 + recordSegmentDuration: 30s + recordDeleteAfter: 0s