"""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_API_URL, MEDIAMTX_PLAYBACK_URL, MTX_SENDEPASSWORT 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 ); -- 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" # 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() # ── 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): return { "start": _iso(datetime.fromisoformat(s["start"].replace("Z", "+00:00"))), "dauer": round(s["duration"], 1), } 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, jede mit ihrem Film.""" segmente = await _segmente(datetime(2000, 1, 1, tzinfo=timezone.utc), _jetzt()) 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): """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.""" 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