update
This commit is contained in:
259
backend/app/clips.py
Normal file
259
backend/app/clips.py
Normal 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
|
||||
Reference in New Issue
Block a user