This commit is contained in:
team3
2026-09-30 00:59:59 +02:00
parent 7bf9667007
commit c589e389df
20 changed files with 1814 additions and 29 deletions

4
.gitignore vendored
View File

@@ -9,3 +9,7 @@ data/
backend/backups/
data/backups/
# Aufnahmen der Abende und geschnittene Clips — groß, nicht ins Git
aufnahmen/
clips/

View File

@@ -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

View File

@@ -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=<RFC3339>&duration=<s>&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

259
backend/app/clips.py Normal file
View 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

View File

@@ -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"

View File

@@ -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=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
den Anmeldefilmen vorbei.
Freischalten geht mit dem Schlüssel aus der .env, einmal über
/?host=<schlüssel>, 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,

View File

@@ -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(

View File

@@ -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:

View File

@@ -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)

177
backend/tests/test_clips.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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)
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
<StreamTab v-else-if="activeTab === 'stream'" />
<StreamTab v-else-if="activeTab === 'stream'" :angemeldet="angemeldet" />
<ClipsTab
v-else-if="activeTab === 'clips'"
:angemeldet="angemeldet"
:host="host"
:abende="gesehen"
/>
<div v-else class="layout">
<div class="results">
@@ -502,6 +513,14 @@ onMounted(load)
Fertig
</button>
<!-- Die Tastenkombination ohne Freischaltung: den Host-Film finden -->
<FilmLogin
v-if="hostLogin"
name="Host"
:pruefen="hostAnmelden"
@abbrechen="hostLogin = false"
/>
<MovieDetail
v-if="selected"
:movie="selected"

View File

@@ -0,0 +1,211 @@
<script setup>
// 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))
</script>
<template>
<Teleport to="body">
<div class="editor-overlay" @click.self="emit('zu')">
<div class="editor">
<header>
<input
v-model="titel"
type="text"
maxlength="80"
placeholder="Titel des Clips"
class="titel"
@change="patch({ titel })"
/>
<button type="button" class="zu" title="Schließen" @click="emit('zu')">×</button>
</header>
<video :src="vorschauUrl" controls playsinline class="vorschau"></video>
<ClipTrimmer
v-model:vorher="vorher"
v-model:dauer="dauer"
:segmente="segmente"
:marke="clip.marke_at"
@ende="vorschauLaden"
/>
<p v-if="fehler" class="fehler">{{ fehler }}</p>
<div class="knoepfe">
<button type="button" class="haupt" :disabled="schneidet" @click="schneiden">
{{ schneidet ? 'Schneidet …' : clip.geschnitten ? 'Speichern' : 'Schneiden' }}
</button>
<a v-if="clip.geschnitten" :href="`/api/clips/${clip.id}/video`" download class="link"
>Herunterladen</a
>
<span class="rest"></span>
<button type="button" class="weg" @click="loeschen">Löschen</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.editor {
display: flex;
width: min(100%, 960px);
max-height: 95vh;
flex-direction: column;
gap: 0.8rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
header {
display: flex;
gap: 0.6rem;
align-items: center;
}
.titel {
flex: 1;
padding: 0.5rem 0.7rem;
font-size: 1rem;
font-weight: 600;
}
.zu {
color: var(--muted);
font-size: 1.8rem;
line-height: 1;
}
.zu:hover {
color: var(--text);
}
.vorschau {
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
border-radius: 8px;
}
.fehler {
color: var(--accent);
font-size: 0.85rem;
}
.knoepfe {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.knoepfe button,
.knoepfe .link {
padding: 0.5rem 1rem;
color: var(--text);
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--line);
border-radius: 8px;
}
.knoepfe button:hover,
.knoepfe .link:hover {
border-color: var(--accent);
}
.knoepfe .haupt {
color: #fff;
background: var(--accent);
border-color: var(--accent);
}
.knoepfe .haupt:disabled {
opacity: 0.5;
}
.rest {
flex: 1;
}
.weg {
color: var(--muted);
}
</style>

View File

@@ -0,0 +1,274 @@
<script setup>
// Der Zeitstrahl der Aufnahme rund um die Marke. Ein Balken zeigt das
// Fenster des Clips; an seinen Enden zieht man Anfang und Ende, in der
// Mitte schiebt man das ganze Fenster. Zeiten in Sekunden relativ zur
// Marke: 0 ist der Moment, an dem C gedrückt wurde.
import { computed, ref } from 'vue'
const props = defineProps({
// Stücke der Aufnahme: [{ von, bis }] relativ zur Marke
segmente: { type: Array, default: () => [] },
maxVorher: { type: Number, default: 600 },
minDauer: { type: Number, default: 3 },
maxDauer: { type: Number, default: 180 },
// Uhrzeit der Marke, für die Beschriftung
marke: { type: String, default: '' },
})
const vorher = defineModel('vorher', { type: Number, default: 30 })
const dauer = defineModel('dauer', { type: Number, default: 35 })
const emit = defineEmits(['ende'])
// Der sichtbare Ausschnitt des Strahls, zoombar
const ZOOMS = [120, 300, 900, 1800, 7200, 14400]
const zoom = ref(1)
const mitte = ref(0)
const spanne = computed(() => ZOOMS[zoom.value])
const links = computed(() => mitte.value - spanne.value / 2)
const rechts = computed(() => mitte.value + spanne.value / 2)
const strahl = ref(null)
function prozent(t) {
return ((t - links.value) / spanne.value) * 100
}
const start = computed(() => -vorher.value)
const ende = computed(() => -vorher.value + dauer.value)
// Wo die Aufnahme überhaupt Material hat
const grenzen = computed(() => {
if (!props.segmente.length) return { von: -props.maxVorher, bis: props.maxDauer }
return {
von: Math.min(...props.segmente.map((s) => s.von)),
bis: Math.max(...props.segmente.map((s) => s.bis)),
}
})
function zoomen(schritt) {
zoom.value = Math.max(0, Math.min(ZOOMS.length - 1, zoom.value + schritt))
mitte.value = (start.value + ende.value) / 2
}
// Beschriftung: runde Schritte, etwa sechs bis acht Striche
const striche = computed(() => {
const schritt = [10, 30, 60, 300, 900, 1800][zoom.value]
const aus = []
for (let t = Math.ceil(links.value / schritt) * schritt; t <= rechts.value; t += schritt) {
aus.push({ t, text: uhrzeit(t) })
}
return aus
})
function uhrzeit(t) {
if (!props.marke) return `${t >= 0 ? '+' : '−'}${Math.abs(t)} s`
const d = new Date(new Date(props.marke).getTime() + t * 1000)
return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
// ── Ziehen ────────────────────────────────────────────────────────────
let griff = null
let ausgang = null
function anfassen(e, was) {
griff = was
ausgang = { x: e.clientX, vorher: vorher.value, dauer: dauer.value }
e.currentTarget.setPointerCapture(e.pointerId)
}
function ziehen(e) {
if (!griff || !strahl.value) return
const dt = ((e.clientX - ausgang.x) / strahl.value.clientWidth) * spanne.value
const g = grenzen.value
if (griff === 'links') {
// Der Anfang wandert, das Ende bleibt stehen
const altesEnde = -ausgang.vorher + ausgang.dauer
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, -props.maxVorher, neuerStart)
neuerStart = Math.min(
altesEnde - props.minDauer,
Math.max(altesEnde - props.maxDauer, neuerStart),
)
vorher.value = Math.round(-neuerStart)
dauer.value = Math.round(altesEnde - neuerStart)
} else if (griff === 'rechts') {
let neuesEnde = -ausgang.vorher + ausgang.dauer + dt
neuesEnde = Math.min(g.bis, neuesEnde)
const d = Math.max(props.minDauer, Math.min(props.maxDauer, neuesEnde + ausgang.vorher))
dauer.value = Math.round(d)
} else {
// Das ganze Fenster verschieben
let neuerStart = -ausgang.vorher + dt
neuerStart = Math.max(g.von, -props.maxVorher, Math.min(g.bis - ausgang.dauer, neuerStart))
vorher.value = Math.round(-neuerStart)
}
}
function loslassen(e) {
if (!griff) return
griff = null
e.currentTarget.releasePointerCapture?.(e.pointerId)
emit('ende')
}
</script>
<template>
<div class="trimmer">
<div class="kopf">
<span class="fenster">
{{ uhrzeit(start) }} – {{ uhrzeit(ende) }} · <b>{{ dauer }} s</b>
</span>
<span class="zoom">
<button type="button" :disabled="zoom === 0" title="Näher" @click="zoomen(-1)">+</button>
<button
type="button"
:disabled="zoom === ZOOMS.length - 1"
title="Weiter"
@click="zoomen(1)"
>
−
</button>
</span>
</div>
<div
ref="strahl"
class="strahl"
@pointermove="ziehen"
@pointerup="loslassen"
@pointercancel="loslassen"
>
<!-- Wo Aufnahme liegt -->
<span
v-for="(s, i) in segmente"
:key="i"
class="aufnahme"
:style="{ left: prozent(s.von) + '%', width: prozent(s.bis) - prozent(s.von) + '%' }"
></span>
<!-- Die Marke: der Moment, an dem C gedrückt wurde -->
<span class="marke" :style="{ left: prozent(0) + '%' }" title="Marke"></span>
<!-- Das Fenster des Clips mit seinen Griffen -->
<span
class="bereich"
:style="{ left: prozent(start) + '%', width: prozent(ende) - prozent(start) + '%' }"
@pointerdown="anfassen($event, 'mitte')"
>
<span class="griff links" @pointerdown.stop="anfassen($event, 'links')"></span>
<span class="griff rechts" @pointerdown.stop="anfassen($event, 'rechts')"></span>
</span>
<span v-for="s in striche" :key="s.t" class="strich" :style="{ left: prozent(s.t) + '%' }">
<span class="text">{{ s.text }}</span>
</span>
</div>
</div>
</template>
<style scoped>
.trimmer {
display: flex;
flex-direction: column;
gap: 0.4rem;
user-select: none;
}
.kopf {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--muted);
font-size: 0.82rem;
font-variant-numeric: tabular-nums;
}
.fenster b {
color: var(--text);
}
.zoom button {
width: 1.7rem;
height: 1.7rem;
color: var(--text);
border: 1px solid var(--line);
border-radius: 6px;
}
.zoom button:disabled {
opacity: 0.4;
}
.strahl {
position: relative;
height: 3.4rem;
overflow: hidden;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 8px;
touch-action: none;
}
.aufnahme {
position: absolute;
top: 0.4rem;
height: 1.6rem;
background: rgba(255, 255, 255, 0.1);
}
.marke {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
margin-left: -1px;
background: #f5c518;
}
.bereich {
position: absolute;
top: 0.4rem;
height: 1.6rem;
background: rgba(229, 9, 20, 0.35);
border: 2px solid var(--accent);
border-radius: 4px;
cursor: grab;
}
.griff {
position: absolute;
top: -2px;
bottom: -2px;
width: 0.7rem;
background: var(--accent);
cursor: ew-resize;
}
.griff.links {
left: -2px;
border-radius: 4px 0 0 4px;
}
.griff.rechts {
right: -2px;
border-radius: 0 4px 4px 0;
}
.strich {
position: absolute;
top: 2.2rem;
bottom: 0;
width: 1px;
background: var(--line);
pointer-events: none;
}
.text {
position: absolute;
top: 0.3rem;
left: 0.25rem;
color: var(--muted);
font-size: 0.68rem;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,407 @@
<script setup>
// Die eigenen Marken und Clips als Karten, nach Film gruppiert. Ein Klick
// öffnet den Editor mit Zeitstrahl. Der Host sieht die Clips aller.
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { abonnieren } from '../live'
import { backdropUrl, posterUrl } from '../images'
import ClipEditor from './ClipEditor.vue'
import PersonName from './PersonName.vue'
const props = defineProps({
angemeldet: Boolean,
host: Boolean,
// Die gesehenen Abende — für Clips aus alten Aufnahmen
abende: { type: Array, default: () => [] },
})
// "Clip erstellen": erst den Abend wählen, dann öffnet der Editor.
// Zur Wahl stehen nur Abende, zu denen es eine Aufnahme gibt.
const wahl = ref(false)
const wahlFehler = ref('')
const tage = ref(null)
async function wahlOeffnen() {
wahl.value = true
wahlFehler.value = ''
tage.value = null
try {
const resp = await fetch('/api/clips/aufnahmen')
tage.value = resp.ok ? (await resp.json()).tage : []
} catch {
tage.value = []
}
}
const abendeMitAufnahme = computed(() =>
tage.value ? props.abende.filter((a) => tage.value.includes(a.watched_at.slice(0, 10))) : [],
)
async function erstellen(abend) {
wahlFehler.value = ''
const resp = await fetch('/api/clips/von-film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: abend.movie_id }),
})
if (!resp.ok) {
const daten = await resp.json().catch(() => ({}))
wahlFehler.value = daten.detail || 'Das hat nicht geklappt.'
return
}
wahl.value = false
offen.value = await resp.json()
laden_()
}
const clips = ref([])
const laden = ref(true)
const fehler = ref('')
const offen = ref(null)
async function laden_() {
try {
const resp = await fetch('/api/clips')
if (!resp.ok) throw new Error('Clips konnten nicht geladen werden')
clips.value = await resp.json()
fehler.value = ''
// Der offene Clip soll den frischen Stand zeigen
if (offen.value) offen.value = clips.value.find((c) => c.id === offen.value.id) || null
} catch (err) {
fehler.value = err.message
} finally {
laden.value = false
}
}
// Nach Film gruppiert, der jüngste Abend zuerst
const gruppen = computed(() => {
const nach = new Map()
for (const c of clips.value) {
const key = c.movie_id ?? 0
if (!nach.has(key)) nach.set(key, { key, titel: c.title || 'Ohne Film', clips: [] })
nach.get(key).clips.push(c)
}
return [...nach.values()]
})
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
}
function datum(iso) {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
let abbestellen = null
onMounted(() => {
laden_()
abbestellen = abonnieren((nachricht) => {
if (nachricht.type === 'clips') laden_()
})
})
onUnmounted(() => abbestellen?.())
</script>
<template>
<div class="clips-tab">
<div v-if="angemeldet" class="oben">
<button type="button" class="erstellen" @click="wahlOeffnen">Clip erstellen</button>
</div>
<!-- Abend wählen: nur, wo es eine Aufnahme gibt, klappt es -->
<Teleport to="body">
<div v-if="wahl" class="abendwahl-overlay" @click.self="wahl = false">
<div class="abendwahl">
<header>
<span>Zu welchem Abend?</span>
<button type="button" class="zu" @click="wahl = false">×</button>
</header>
<p v-if="wahlFehler" class="abendwahl-fehler">{{ wahlFehler }}</p>
<p v-if="tage === null" class="abendwahl-hinweis">Suche Aufnahmen …</p>
<p v-else-if="!abendeMitAufnahme.length" class="abendwahl-hinweis">
Zu keinem Abend gibt es eine Aufnahme.
</p>
<ul>
<li v-for="a in abendeMitAufnahme" :key="a.id">
<button type="button" @click="erstellen(a)">
<img v-if="a.poster_path" :src="posterUrl(a.poster_path, 'w154')" alt="" />
<span class="abend-text">
<span class="abend-titel">{{ a.title }}</span>
<span class="abend-datum">{{ datum(a.watched_at) }}</span>
</span>
</button>
</li>
</ul>
</div>
</div>
</Teleport>
<p v-if="laden" class="note">Lade Clips…</p>
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
<p v-else-if="!clips.length" class="note">
Noch keine Marken. Im Stream-Reiter setzt die Taste C eine.
</p>
<section v-for="g in gruppen" :key="g.key">
<h3>{{ g.titel }}</h3>
<div class="grid">
<button v-for="c in g.clips" :key="c.id" type="button" class="karte" @click="offen = c">
<!-- Geschnitten: das Video selbst als Bild. Sonst das Filmbild
mit dem Hinweis, dass hier erst eine Marke steht. -->
<span class="bild">
<video
v-if="c.geschnitten"
:src="`/api/clips/${c.id}/video#t=0.5`"
preload="metadata"
muted
playsinline
></video>
<img v-else-if="c.poster_path" :src="backdropUrl(c.poster_path, 'w342')" alt="" />
<span v-else class="leer"></span>
<span class="dauer">{{ Math.round(c.dauer) }} s</span>
<span v-if="!c.geschnitten" class="stand">Marke</span>
</span>
<span class="text">
<span class="titel">{{ c.titel || 'Ohne Titel' }}</span>
<span class="meta">
<PersonName :name="c.name" /><span
>· {{ datum(c.marke_at) }} · {{ uhrzeit(c.marke_at) }}</span
>
</span>
</span>
</button>
</div>
</section>
<ClipEditor v-if="offen" :clip="offen" @zu="offen = null" @geaendert="laden_" />
</div>
</template>
<style scoped>
.oben {
display: flex;
justify-content: flex-end;
margin-bottom: 1.2rem;
}
.erstellen {
padding: 0.55rem 1.1rem;
color: #fff;
font-size: 0.88rem;
font-weight: 600;
background: var(--accent);
border-radius: 8px;
}
.erstellen:hover {
filter: brightness(1.15);
}
section {
margin-bottom: 2rem;
}
h3 {
margin-bottom: 0.7rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 1rem;
}
.karte {
display: flex;
flex-direction: column;
overflow: hidden;
text-align: left;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
transition:
transform 0.15s ease,
border-color 0.15s ease;
}
.karte:hover {
transform: translateY(-3px);
border-color: var(--accent);
}
.bild {
position: relative;
display: block;
aspect-ratio: 16 / 9;
background: #000;
}
.bild video,
.bild img,
.bild .leer {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.bild img {
opacity: 0.5;
}
.dauer,
.stand {
position: absolute;
bottom: 0.45rem;
padding: 0.1rem 0.45rem;
font-size: 0.72rem;
font-weight: 600;
background: rgba(0, 0, 0, 0.75);
border-radius: 4px;
}
.dauer {
right: 0.45rem;
}
.stand {
left: 0.45rem;
color: #f5c518;
}
.text {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.7rem 0.85rem 0.8rem;
}
.titel {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 0 0.3rem;
align-items: center;
color: var(--muted);
font-size: 0.78rem;
}
.note {
padding: 2.5rem 0;
color: var(--muted);
text-align: center;
}
.note.error {
color: var(--accent);
}
@media (max-width: 640px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>
<style>
/* Die Abendwahl liegt per Teleport im body — daher ohne Scope */
.abendwahl-overlay {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
padding: 1.5rem;
background: rgba(0, 0, 0, 0.8);
}
.abendwahl {
display: flex;
width: min(100%, 520px);
max-height: 90vh;
flex-direction: column;
gap: 0.6rem;
padding: 1rem;
overflow-y: auto;
background: var(--bg-soft);
border: 1px solid var(--line);
border-radius: 12px;
}
.abendwahl header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
}
.abendwahl .zu {
color: var(--muted);
font-size: 1.6rem;
line-height: 1;
}
.abendwahl-fehler {
color: var(--accent);
font-size: 0.85rem;
}
.abendwahl-hinweis {
padding: 1rem 0;
color: var(--muted);
font-size: 0.9rem;
text-align: center;
}
.abendwahl ul {
list-style: none;
}
.abendwahl li button {
display: flex;
gap: 0.8rem;
align-items: center;
width: 100%;
padding: 0.45rem 0.5rem;
text-align: left;
border-radius: 8px;
}
.abendwahl li button:hover {
background: rgba(255, 255, 255, 0.06);
}
.abendwahl li img {
width: 40px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.abend-text {
display: flex;
flex-direction: column;
}
.abend-datum {
color: var(--muted);
font-size: 0.8rem;
}
</style>

View File

@@ -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))"
/>
<span class="live">live</span>
<button
v-if="angemeldet"
type="button"
class="knopf clip"
:class="{ an: markeStand === 'gesetzt' }"
title="Marke setzen (Taste C)"
@click="markieren"
>
{{ markeStand === 'gesetzt' ? '✓ Marke' : markeStand === 'fehler' ? 'Fehler' : 'Clip' }}
</button>
<button
type="button"
class="knopf"
@@ -305,6 +347,19 @@ onUnmounted(() => {
}
}
.knopf.clip {
padding: 0 0.7rem;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--line);
border-radius: 6px;
}
.knopf.clip.an {
color: #6bbf7a;
border-color: #6bbf7a;
}
.knopf {
padding: 0.1rem 0.3rem;
font-size: 1.05rem;

View File

@@ -12,7 +12,7 @@ const props = defineProps({
})
const liste = computed(() =>
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id)
props.host ? props.items : props.items.filter((u) => u.id === props.ich?.id),
)
const neuerName = ref('')
@@ -47,6 +47,30 @@ async function schutzZeigen(user) {
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
}
// Der Host-Film: das Passwort für den Hostmodus, gesetzt vom Host
const hostFilmDialog = ref(false)
const hostFilm = ref(null)
async function hostFilmZeigen() {
if (hostFilm.value) {
hostFilm.value = null
return
}
hostFilm.value = { laden: true }
const resp = await fetch('/api/host/film')
hostFilm.value = resp.ok ? await resp.json() : { fehler: true }
}
async function hostFilmSetzen(movieId) {
await fetch('/api/host/film', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ movie_id: movieId }),
})
hostFilm.value = null
hostFilmDialog.value = false
}
async function anlegen() {
fehler.value = ''
const resp = await fetch('/api/users', {
@@ -69,8 +93,8 @@ async function entfernen(user) {
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
<p v-if="!host" class="erklaerung">
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen
Namen danach. Ohne Film bleibt dein Name für alle offen.
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen Namen danach. Ohne Film
bleibt dein Name für alle offen.
</p>
<div v-if="host" class="anlegen">
@@ -148,6 +172,43 @@ async function entfernen(user) {
<p v-else class="leer">Noch niemand in der Runde.</p>
</section>
<section v-if="host" class="hostfilm">
<h3>Host-Film</h3>
<p class="erklaerung">
Die Tastenkombination fragt ohne Freischaltung nach diesem Film. Wer ihn anklickt, ist Host
— ohne Link.
</p>
<div class="zeile">
<button type="button" class="klein" @click="hostFilmDialog = true">Film setzen</button>
<button type="button" class="klein" title="Anzeigen" @click="hostFilmZeigen">👁</button>
<button type="button" class="klein" @click="hostFilmSetzen(null)">Aufheben</button>
<span v-if="hostFilm" class="film-anzeige">
<template v-if="hostFilm.laden">…</template>
<template v-else-if="hostFilm.fehler">nicht lesbar</template>
<template v-else-if="hostFilm.film"
>{{ hostFilm.film.title }} ({{ hostFilm.film.year }})</template
>
<template v-else>keiner gesetzt</template>
</span>
</div>
</section>
<Teleport to="body">
<div v-if="hostFilmDialog" class="hintergrund" @click.self="hostFilmDialog = false">
<div class="fenster" role="dialog" aria-modal="true">
<p class="label">Host-Film</p>
<h2>Passwort für den Hostmodus</h2>
<p class="frage">Diesen Film klickt man nach der Tastenkombination an.</p>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="hostFilmSetzen($event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="hostFilmDialog = false">
Abbrechen
</button>
</div>
</div>
</div>
</Teleport>
<Teleport to="body">
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
<div class="fenster" role="dialog" aria-modal="true">
@@ -161,15 +222,10 @@ async function entfernen(user) {
}}
</p>
<FilmSuche
platzhalter="Film als Passwort …"
@waehlen="schutzSetzen(setzt, $event.id)"
/>
<FilmSuche platzhalter="Film als Passwort …" @waehlen="schutzSetzen(setzt, $event.id)" />
<div class="fuss">
<button type="button" class="abbrechen" @click="setzt = null">
Abbrechen
</button>
<button type="button" class="abbrechen" @click="setzt = null">Abbrechen</button>
</div>
</div>
</div>
@@ -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;
}
</style>

View File

@@ -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 }
}

View File

@@ -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