update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ data/
|
|||||||
|
|
||||||
backend/backups/
|
backend/backups/
|
||||||
data/backups/
|
data/backups/
|
||||||
|
|
||||||
|
# Aufnahmen der Abende und geschnittene Clips — groß, nicht ins Git
|
||||||
|
aufnahmen/
|
||||||
|
clips/
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -17,8 +17,9 @@ mtx:
|
|||||||
--env-file .env \
|
--env-file .env \
|
||||||
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
|
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
|
||||||
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
|
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
|
||||||
|
-v "$(PWD)/aufnahmen:/recordings" \
|
||||||
bluenviron/mediamtx:latest > /dev/null
|
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:
|
mtx-stop:
|
||||||
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
||||||
|
|||||||
24
README.md
24
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
|
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
|
||||||
MediaMTX davor.
|
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
|
## Vorschläge und Rad
|
||||||
|
|
||||||
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
||||||
|
|||||||
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
|
||||||
@@ -25,6 +25,10 @@ PROVIDER_NETFLIX = 8
|
|||||||
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
|
# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den
|
||||||
# Dienstnamen, lokal auf localhost
|
# Dienstnamen, lokal auf localhost
|
||||||
MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890")
|
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"
|
REGION = "DE"
|
||||||
LANGUAGE = "de-DE"
|
LANGUAGE = "de-DE"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import timedelta
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
@@ -6,13 +7,14 @@ import time
|
|||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import Body, Cookie, FastAPI, HTTPException, Request, Response
|
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.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import (
|
from . import (
|
||||||
backup,
|
backup,
|
||||||
|
clips,
|
||||||
punkte,
|
punkte,
|
||||||
db,
|
db,
|
||||||
events,
|
events,
|
||||||
@@ -93,6 +95,7 @@ async def startup():
|
|||||||
users.init()
|
users.init()
|
||||||
suggestions.init()
|
suggestions.init()
|
||||||
watched.init()
|
watched.init()
|
||||||
|
clips.init()
|
||||||
wishlist.init()
|
wishlist.init()
|
||||||
features.init()
|
features.init()
|
||||||
# Erst die Sicherung, dann der Sync — so liegt vor jedem Katalog-Update
|
# 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)
|
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")
|
@app.get("/api/movies/{movie_id}/credits")
|
||||||
async def film_credits(movie_id: int):
|
async def film_credits(movie_id: int):
|
||||||
"""Regie und Besetzung, live von TMDB."""
|
"""Regie und Besetzung, live von TMDB."""
|
||||||
@@ -699,13 +856,14 @@ def host_modus(
|
|||||||
response: Response,
|
response: Response,
|
||||||
an: bool = Body(..., embed=True),
|
an: bool = Body(..., embed=True),
|
||||||
schluessel: str | None = Body(None, embed=True),
|
schluessel: str | None = Body(None, embed=True),
|
||||||
|
movie_id: int | None = Body(None, embed=True),
|
||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
|
"""Die Sitzung als Host freischalten — oder die Rechte abgeben.
|
||||||
|
|
||||||
Freischalten geht nur mit dem Schlüssel aus der .env, einmal über
|
Freischalten geht mit dem Schlüssel aus der .env, einmal über
|
||||||
/?host=<schlüssel>. Ohne das käme jeder mit der Tastenkombination an
|
/?host=<schlüssel>, oder mit dem Host-Film: die Tastenkombination
|
||||||
den Anmeldefilmen vorbei.
|
fragt danach, wie beim Anmeldefilm eines Namens.
|
||||||
|
|
||||||
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
|
Die Freischaltung bleibt, bis jemand sie abgibt. Ob die Hostknöpfe
|
||||||
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
|
gerade sichtbar sind, entscheidet der Browser für sich — dafür ist
|
||||||
@@ -715,14 +873,51 @@ def host_modus(
|
|||||||
if not an:
|
if not an:
|
||||||
return {"host": users.host_setzen(sid, False)}
|
return {"host": users.host_setzen(sid, False)}
|
||||||
|
|
||||||
darf = users.ist_host(sid) or (
|
film = users.host_film()
|
||||||
bool(HOST_SCHLUESSEL) and secrets.compare_digest(schluessel or "", HOST_SCHLUESSEL)
|
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:
|
if not darf:
|
||||||
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
|
raise HTTPException(status_code=403, detail="Kein Hostschlüssel")
|
||||||
return {"host": users.host_setzen(sid, True)}
|
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")
|
@app.post("/api/users/waehlen")
|
||||||
def name_waehlen(
|
def name_waehlen(
|
||||||
response: Response,
|
response: Response,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ einem Textfeld, in das jeder schreiben kann, was er will.
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import db
|
||||||
from .db import connect
|
from .db import connect
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
@@ -146,6 +147,18 @@ def aktuell(session_id):
|
|||||||
return _oeffentlich(row) if row else None
|
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):
|
def ist_host(session_id):
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
return bool(
|
return bool(
|
||||||
|
|||||||
@@ -232,6 +232,21 @@ def alle(session_id, host=False):
|
|||||||
return out
|
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):
|
def markieren(movie):
|
||||||
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
|
_ORDNER = Path(tempfile.mkdtemp(prefix="filmabend-tests-"))
|
||||||
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
os.environ["DB_PATH"] = str(_ORDNER / "test.db")
|
||||||
|
os.environ["CLIPS_DIR"] = str(_ORDNER / "clips")
|
||||||
os.environ.setdefault("TMDB_TOKEN", "test")
|
os.environ.setdefault("TMDB_TOKEN", "test")
|
||||||
os.environ.setdefault("MINIMAX_KEY", "test")
|
os.environ.setdefault("MINIMAX_KEY", "test")
|
||||||
# Der Hostmodus hängt am Schlüssel — in den Tests ein fester
|
# 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
|
import pytest # noqa: E402
|
||||||
from fastapi.testclient import TestClient # 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
|
# Zählt, ob der Startup doch einen Sync anstoßen wollte
|
||||||
_SYNC_AUFRUFE = []
|
_SYNC_AUFRUFE = []
|
||||||
@@ -49,7 +50,7 @@ def sync_aufrufe():
|
|||||||
def frische_db(app_start):
|
def frische_db(app_start):
|
||||||
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
|
"""Jeder Test beginnt mit leeren Tabellen und ohne laufende Ziehung."""
|
||||||
db.DB_PATH.unlink(missing_ok=True)
|
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()
|
modul.init()
|
||||||
main.letzter_dreh.update(ergebnis=None, start=0.0)
|
main.letzter_dreh.update(ergebnis=None, start=0.0)
|
||||||
|
|
||||||
|
|||||||
177
backend/tests/test_clips.py
Normal file
177
backend/tests/test_clips.py
Normal 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
|
||||||
@@ -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):
|
def test_der_schluessel_gilt_nur_fuer_die_eigene_sitzung(zuschauer, client2, hostmodus):
|
||||||
hostmodus(zuschauer)
|
hostmodus(zuschauer)
|
||||||
assert client2.get("/api/host").json()["host"] is False
|
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
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DB_PATH: /data/horror.db
|
DB_PATH: /data/horror.db
|
||||||
MEDIAMTX_URL: http://mediamtx:8890
|
MEDIAMTX_URL: http://mediamtx:8890
|
||||||
|
MEDIAMTX_PLAYBACK_URL: http://mediamtx:9996
|
||||||
depends_on:
|
depends_on:
|
||||||
- mediamtx
|
- mediamtx
|
||||||
labels:
|
labels:
|
||||||
@@ -41,6 +42,8 @@ services:
|
|||||||
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
|
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
|
||||||
volumes:
|
volumes:
|
||||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
- ./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
|
# RTMP nach außen für OBS; HLS bleibt intern und läuft über die App
|
||||||
ports:
|
ports:
|
||||||
- "1935:1935"
|
- "1935:1935"
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import FilterSidebar from './components/FilterSidebar.vue'
|
|||||||
import AehnlichTab from './components/AehnlichTab.vue'
|
import AehnlichTab from './components/AehnlichTab.vue'
|
||||||
import KiTab from './components/KiTab.vue'
|
import KiTab from './components/KiTab.vue'
|
||||||
import PersonenTab from './components/PersonenTab.vue'
|
import PersonenTab from './components/PersonenTab.vue'
|
||||||
|
import ClipsTab from './components/ClipsTab.vue'
|
||||||
import MerklisteTab from './components/MerklisteTab.vue'
|
import MerklisteTab from './components/MerklisteTab.vue'
|
||||||
import SammlungFilter from './components/SammlungFilter.vue'
|
import SammlungFilter from './components/SammlungFilter.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
import NamensWahl from './components/NamensWahl.vue'
|
import NamensWahl from './components/NamensWahl.vue'
|
||||||
|
import FilmLogin from './components/FilmLogin.vue'
|
||||||
import VerwaltungTab from './components/VerwaltungTab.vue'
|
import VerwaltungTab from './components/VerwaltungTab.vue'
|
||||||
import WuenscheTab from './components/WuenscheTab.vue'
|
import WuenscheTab from './components/WuenscheTab.vue'
|
||||||
import SearchTab from './components/SearchTab.vue'
|
import SearchTab from './components/SearchTab.vue'
|
||||||
@@ -22,7 +24,8 @@ import { matches, useKatalog } from './composables/useKatalog'
|
|||||||
import { REGLER, useFilter } from './composables/useFilter'
|
import { REGLER, useFilter } from './composables/useFilter'
|
||||||
import { useLangeListe } from './composables/useLangeListe'
|
import { useLangeListe } from './composables/useLangeListe'
|
||||||
|
|
||||||
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host } = useEinstellungen()
|
const { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden } =
|
||||||
|
useEinstellungen()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
runde,
|
runde,
|
||||||
@@ -140,6 +143,7 @@ const tabs = computed(() => [
|
|||||||
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
...(host.value ? [] : [{ id: 'merkliste', label: 'Sammlungen', count: null }]),
|
||||||
{ id: 'wheel', label: 'Rad', count: null },
|
{ id: 'wheel', label: 'Rad', count: null },
|
||||||
{ id: 'stream', label: 'Stream', 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: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||||
{
|
{
|
||||||
id: 'wuensche',
|
id: 'wuensche',
|
||||||
@@ -428,7 +432,14 @@ onMounted(load)
|
|||||||
|
|
||||||
<VerwaltungTab v-else-if="activeTab === 'verwaltung'" :items="runde" :host="host" :ich="ich" />
|
<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 v-else class="layout">
|
||||||
<div class="results">
|
<div class="results">
|
||||||
@@ -502,6 +513,14 @@ onMounted(load)
|
|||||||
Fertig
|
Fertig
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Die Tastenkombination ohne Freischaltung: den Host-Film finden -->
|
||||||
|
<FilmLogin
|
||||||
|
v-if="hostLogin"
|
||||||
|
name="Host"
|
||||||
|
:pruefen="hostAnmelden"
|
||||||
|
@abbrechen="hostLogin = false"
|
||||||
|
/>
|
||||||
|
|
||||||
<MovieDetail
|
<MovieDetail
|
||||||
v-if="selected"
|
v-if="selected"
|
||||||
:movie="selected"
|
:movie="selected"
|
||||||
|
|||||||
211
frontend/src/components/ClipEditor.vue
Normal file
211
frontend/src/components/ClipEditor.vue
Normal 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>
|
||||||
274
frontend/src/components/ClipTrimmer.vue
Normal file
274
frontend/src/components/ClipTrimmer.vue
Normal 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>
|
||||||
407
frontend/src/components/ClipsTab.vue
Normal file
407
frontend/src/components/ClipsTab.vue
Normal 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>
|
||||||
@@ -5,6 +5,11 @@ import Hls from 'hls.js'
|
|||||||
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
|
// Alle Zuschauer halten dieselbe Distanz zur Live-Kante. Damit laufen sie
|
||||||
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
|
// synchron, ohne dass ein Server sie taktet. Ausgeglichen wird nur über
|
||||||
// Sprünge, nicht über die Geschwindigkeit — siehe maxLiveSyncPlaybackRate.
|
// 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 ZIEL_LATENZ = 4
|
||||||
|
|
||||||
const video = ref(null)
|
const video = ref(null)
|
||||||
@@ -86,6 +91,31 @@ function tasteAbfangen(e) {
|
|||||||
if (e.code === 'Space' && e.target === video.value) e.preventDefault()
|
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() {
|
function verbinden() {
|
||||||
const quelle = '/stream/filmabend/index.m3u8'
|
const quelle = '/stream/filmabend/index.m3u8'
|
||||||
|
|
||||||
@@ -163,6 +193,7 @@ function vollbildGewechselt() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', tasteC)
|
||||||
verbinden()
|
verbinden()
|
||||||
wacheStarten()
|
wacheStarten()
|
||||||
document.addEventListener('fullscreenchange', vollbildGewechselt)
|
document.addEventListener('fullscreenchange', vollbildGewechselt)
|
||||||
@@ -170,6 +201,7 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('keydown', tasteC)
|
||||||
document.removeEventListener('fullscreenchange', vollbildGewechselt)
|
document.removeEventListener('fullscreenchange', vollbildGewechselt)
|
||||||
window.removeEventListener('keydown', tasteAbfangen)
|
window.removeEventListener('keydown', tasteAbfangen)
|
||||||
window.clearInterval(wache)
|
window.clearInterval(wache)
|
||||||
@@ -214,6 +246,16 @@ onUnmounted(() => {
|
|||||||
@input="lautstaerkeSetzen(Number($event.target.value))"
|
@input="lautstaerkeSetzen(Number($event.target.value))"
|
||||||
/>
|
/>
|
||||||
<span class="live">live</span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="knopf"
|
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 {
|
.knopf {
|
||||||
padding: 0.1rem 0.3rem;
|
padding: 0.1rem 0.3rem;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const liste = computed(() =>
|
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('')
|
const neuerName = ref('')
|
||||||
@@ -47,6 +47,30 @@ async function schutzZeigen(user) {
|
|||||||
gezeigt[user.id] = resp.ok ? await resp.json() : { fehler: true }
|
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() {
|
async function anlegen() {
|
||||||
fehler.value = ''
|
fehler.value = ''
|
||||||
const resp = await fetch('/api/users', {
|
const resp = await fetch('/api/users', {
|
||||||
@@ -69,8 +93,8 @@ async function entfernen(user) {
|
|||||||
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
|
<h3>{{ host ? 'Benutzer' : 'Dein Zugang' }}</h3>
|
||||||
|
|
||||||
<p v-if="!host" class="erklaerung">
|
<p v-if="!host" class="erklaerung">
|
||||||
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen
|
Hinterleg einen Film, dann fragt die Seite beim Wechsel auf deinen Namen danach. Ohne Film
|
||||||
Namen danach. Ohne Film bleibt dein Name für alle offen.
|
bleibt dein Name für alle offen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="host" class="anlegen">
|
<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>
|
<p v-else class="leer">Noch niemand in der Runde.</p>
|
||||||
</section>
|
</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">
|
<Teleport to="body">
|
||||||
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
|
<div v-if="setzt" class="hintergrund" @click.self="setzt = null">
|
||||||
<div class="fenster" role="dialog" aria-modal="true">
|
<div class="fenster" role="dialog" aria-modal="true">
|
||||||
@@ -161,15 +222,10 @@ async function entfernen(user) {
|
|||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<FilmSuche
|
<FilmSuche platzhalter="Film als Passwort …" @waehlen="schutzSetzen(setzt, $event.id)" />
|
||||||
platzhalter="Film als Passwort …"
|
|
||||||
@waehlen="schutzSetzen(setzt, $event.id)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="fuss">
|
<div class="fuss">
|
||||||
<button type="button" class="abbrechen" @click="setzt = null">
|
<button type="button" class="abbrechen" @click="setzt = null">Abbrechen</button>
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -415,4 +471,16 @@ h3 {
|
|||||||
border-radius: 0;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|||||||
|
|
||||||
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
|
// Freigeschaltet wird die Sitzung auf dem Server, und zwar nur mit dem
|
||||||
// Schlüssel aus der .env. Sie bleibt es, bis jemand sie abgibt.
|
// 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 {
|
try {
|
||||||
const resp = await fetch('/api/host', {
|
const resp = await fetch('/api/host', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ an, schluessel }),
|
body: JSON.stringify({ an, schluessel, movie_id: movieId }),
|
||||||
})
|
})
|
||||||
if (!resp.ok) return false
|
if (!resp.ok) return false
|
||||||
return (await resp.json()).host
|
return (await resp.json()).host
|
||||||
@@ -29,7 +29,15 @@ async function hostStand() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TAB_IDS = [
|
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
|
// 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.
|
// Schon gelaufene Filme stehen beim Aussuchen nur im Weg — ab Werk raus.
|
||||||
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
// Wer sie sehen will, schaltet um; das bleibt über den Neustart erhalten.
|
||||||
const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
|
const ohneGesehene = ref(localStorage.getItem('filmabend_ohne_gesehene') !== 'aus')
|
||||||
watch(ohneGesehene, (an) =>
|
watch(ohneGesehene, (an) => localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus'))
|
||||||
localStorage.setItem('filmabend_ohne_gesehene', an ? 'an' : 'aus')
|
|
||||||
)
|
|
||||||
|
|
||||||
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
|
// Nach dem Neuladen soll derselbe Reiter offen sein. Ein gemerkter
|
||||||
// alter Reiter "netflix" oder "canon" landet im Katalog.
|
// 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'))
|
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) {
|
function tastatur(e) {
|
||||||
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
// e.code statt e.key: mit drei Zusatztasten liefert das Layout sonst
|
||||||
// je nach System ein anderes Zeichen
|
// je nach System ein anderes Zeichen
|
||||||
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
if (e.ctrlKey && e.shiftKey && e.altKey && e.code === 'KeyS') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
// Ohne freigeschaltete Sitzung gibt es nichts umzuschalten
|
|
||||||
if (freigeschaltet.value) anzeige.value = !anzeige.value
|
if (freigeschaltet.value) anzeige.value = !anzeige.value
|
||||||
|
else hostLogin.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => window.addEventListener('keydown', tastatur))
|
onMounted(() => window.addEventListener('keydown', tastatur))
|
||||||
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
||||||
|
|
||||||
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host }
|
return { ansicht, ohneGesehene, activeTab, quelle, suchArt, host, hostLogin, hostAnmelden }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ hlsAlwaysRemux: yes
|
|||||||
rtmp: yes
|
rtmp: yes
|
||||||
rtmpAddress: :1935
|
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
|
# Nicht gebraucht, also zu
|
||||||
webrtc: no
|
webrtc: no
|
||||||
rtsp: no
|
rtsp: no
|
||||||
@@ -39,6 +44,16 @@ authInternalUsers:
|
|||||||
ips: []
|
ips: []
|
||||||
permissions:
|
permissions:
|
||||||
- action: read
|
- action: read
|
||||||
|
# Ausschnitte der Aufnahme holen — der Port ist nur intern erreichbar
|
||||||
|
- action: playback
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
filmabend:
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user