update
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
TMDB_TOKEN=
|
||||
MINIMAX_KEY=
|
||||
MTX_SENDEPASSWORT=
|
||||
|
||||
33
Makefile
33
Makefile
@@ -1,15 +1,30 @@
|
||||
.PHONY: install dev stop sync build prod logs down push pull
|
||||
.PHONY: install dev stop sync build prod logs down push pull stream stream-stop mtx mtx-stop
|
||||
|
||||
install:
|
||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||
cd frontend && npm install
|
||||
|
||||
dev:
|
||||
# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins
|
||||
# Leere und OBS findet keinen Server.
|
||||
mtx:
|
||||
@docker ps --filter name=horror-mtx-dev --format '{{.Names}}' | grep -q . || \
|
||||
docker run -d --name horror-mtx-dev --network host \
|
||||
--env-file .env \
|
||||
-e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \
|
||||
-v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \
|
||||
bluenviron/mediamtx:latest > /dev/null
|
||||
@echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890"
|
||||
|
||||
mtx-stop:
|
||||
-@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null
|
||||
@echo "MediaMTX gestoppt."
|
||||
|
||||
dev: mtx
|
||||
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
||||
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
|
||||
@cd frontend && npx vite --port 5173
|
||||
|
||||
stop:
|
||||
stop: mtx-stop stream-stop
|
||||
-@pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||
@echo "gestoppt."
|
||||
@@ -32,6 +47,18 @@ logs:
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# ── Senden ───────────────────────────────────────────────────────────────────
|
||||
# Ohne Argument an den lokalen MediaMTX, sonst an den Server:
|
||||
# make stream ZIEL=horror.marha.de
|
||||
ZIEL ?= 127.0.0.1
|
||||
|
||||
stream: mtx
|
||||
@scripts/stream.sh $(ZIEL)
|
||||
|
||||
stream-stop:
|
||||
-@pkill -f "obs --profile filmabend" 2>/dev/null
|
||||
@echo "OBS beendet."
|
||||
|
||||
# ── Datenbank hin und her (lokal ausführen) ───────────────────────────────────
|
||||
SERVER = root@178.104.67.87
|
||||
REMOTE = /var/www/horror
|
||||
|
||||
82
README.md
82
README.md
@@ -33,11 +33,15 @@ Im Betrieb läuft der Sync täglich um 4:30 von selbst.
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
make dev # Backend :8000, Frontend :5173
|
||||
make stop
|
||||
make dev # MediaMTX, Backend :8000, Frontend :5173
|
||||
make stop # alles wieder aus
|
||||
```
|
||||
|
||||
Der Vite-Dev-Server leitet `/api` an den Backend-Port weiter.
|
||||
`make dev` startet MediaMTX als Container mit. Ohne ihn liefe der
|
||||
Stream-Reiter ins Leere und OBS fände keinen Server. Einzeln geht es über
|
||||
`make mtx` und `make mtx-stop`.
|
||||
|
||||
Der Vite-Dev-Server leitet `/api` ans Backend und `/stream` an MediaMTX.
|
||||
|
||||
## Produktiv
|
||||
|
||||
@@ -113,6 +117,78 @@ Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus
|
||||
Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr
|
||||
Platz im Raster.
|
||||
|
||||
## Der Stream-Reiter
|
||||
|
||||
Du sendest von deinem Rechner per OBS an den Server, alle schauen im Browser
|
||||
zu. Voice läuft weiter über Discord.
|
||||
|
||||
```
|
||||
OBS ──RTMP──► MediaMTX ──LL-HLS──► FastAPI ──► Browser (hls.js)
|
||||
```
|
||||
|
||||
**Senden:**
|
||||
|
||||
```bash
|
||||
make stream # an den lokalen MediaMTX
|
||||
make stream ZIEL=horror.marha.de # an den Server
|
||||
make stream-stop # OBS beenden
|
||||
```
|
||||
|
||||
Strg-C im Terminal beendet OBS nicht zuverlässig — deshalb `make stream-stop`
|
||||
oder einfach das OBS-Fenster schließen.
|
||||
|
||||
Das Skript legt ein eigenes OBS-Profil `filmabend` an und startet OBS damit.
|
||||
Deine normale OBS-Konfiguration bleibt unberührt. Beim Start fragt der
|
||||
Desktop, welches Fenster übertragen werden soll — erst nach dieser Auswahl
|
||||
fließen Bilder.
|
||||
|
||||
Bitrate und Auflösung stehen in
|
||||
`~/.config/obs-studio/basic/profiles/filmabend/basic.ini` und werden nur
|
||||
beim ersten Mal geschrieben. Das Sendeziel aktualisiert das Skript jedes Mal.
|
||||
|
||||
Von Hand geht es auch:
|
||||
|
||||
| Feld | Wert |
|
||||
| --- | --- |
|
||||
| Server | `rtmp://horror.marha.de:1935/` |
|
||||
| Streamschlüssel | `filmabend?user=sender&pass=<MTX_SENDEPASSWORT>` |
|
||||
|
||||
Ohne Passwort weist MediaMTX die Verbindung ab. Zuschauen braucht keins.
|
||||
|
||||
**Ton:** OBS nimmt den Desktop-Ton über PulseAudio mit. Im Browser ginge
|
||||
das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und
|
||||
Firefox gar keinen.
|
||||
|
||||
**Warum alle dasselbe Bild sehen**
|
||||
|
||||
Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`).
|
||||
Wer zurückfällt, spielt bis zu 1,5-fach schneller ab, bis er wieder auf
|
||||
Ziellatenz ist — das regelt hls.js über `maxLiveSyncPlaybackRate`. Ab 7
|
||||
Sekunden Rückstand springt es stattdessen.
|
||||
|
||||
Gemessen mit zwei unabhängigen Playern am selben Stream:
|
||||
|
||||
| Fall | Ergebnis |
|
||||
| --- | --- |
|
||||
| Dauerlauf, 45 s | Latenz konstant 4,05 s, keine Regelung nötig |
|
||||
| Zwei Player | **11 ms** Bildversatz |
|
||||
| Nach 8 s Aussetzer | zurück auf 12 ms in 9 Sekunden |
|
||||
|
||||
Die Anzeige rechts zeigt Verzögerung, Abweichung, Tempo und die Sendezeit
|
||||
aus dem Manifest. Zwei Zuschauer mit gleicher Sendezeit sehen exakt dasselbe
|
||||
Bild — damit lässt sich der Gleichlauf im Betrieb prüfen.
|
||||
|
||||
**Grenzen**
|
||||
|
||||
- Dein Upload muss stabil 6–8 Mbit/s halten. Reicht er nicht, wird es für
|
||||
alle schlechter.
|
||||
- Der Server schiebt pro Zuschauer die volle Bitrate raus. Bei 15 Leuten
|
||||
und 6 Mbit/s sind das 90 Mbit/s und rund 80 GB je Filmabend. Hetzner
|
||||
liefert 10 Gbit und 20 TB im Monat.
|
||||
- Der HLS-Proxy in FastAPI reicht jedes Byte durch Python. Für einen
|
||||
Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf
|
||||
MediaMTX davor.
|
||||
|
||||
## Vorschläge und Rad
|
||||
|
||||
Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film
|
||||
|
||||
@@ -16,6 +16,10 @@ MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
|
||||
MINIMAX_MODEL = "MiniMax-M3"
|
||||
|
||||
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")
|
||||
REGION = "DE"
|
||||
LANGUAGE = "de-DE"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import db, events, search, suggestions, sync
|
||||
from . import db, events, search, streamproxy, suggestions, sync, watched
|
||||
from .config import IMAGE_BASE, STATIC_DIR
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
@@ -19,9 +19,23 @@ app = FastAPI(title="Netflix Horror DE")
|
||||
scheduler = AsyncIOScheduler()
|
||||
sync_lock = asyncio.Lock()
|
||||
|
||||
class GzipOhneLivekanal(GZipMiddleware):
|
||||
"""Kompression für alles außer den Ereignisstrom.
|
||||
|
||||
GZipMiddleware sammelt die Antwort, bevor sie ausgeht. Bei Server-Sent
|
||||
Events heißt das: Es kommt gar nichts an, solange der Puffer nicht voll
|
||||
ist — und der wird bei kurzen Ereignissen nie voll.
|
||||
"""
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http" and scope["path"].startswith("/api/events"):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
# Der Katalog ist reines JSON und schrumpft dabei auf etwa ein Fünftel.
|
||||
# Der Live-Kanal bleibt außen vor: gepufferte Ereignisse kämen zu spät.
|
||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
||||
app.add_middleware(GzipOhneLivekanal, minimum_size=1024)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -43,6 +57,7 @@ async def guarded_sync():
|
||||
async def startup():
|
||||
db.init()
|
||||
suggestions.init()
|
||||
watched.init()
|
||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||
scheduler.start()
|
||||
if not db.get_meta("last_sync"):
|
||||
@@ -198,14 +213,7 @@ async def vorschlagen(
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
|
||||
movie = db.get_movie(movie_id)
|
||||
if not movie:
|
||||
try:
|
||||
movie = await search.detail(movie_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||
|
||||
movie = await film_holen(movie_id)
|
||||
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
||||
eintrag = suggestions.ersetzen(sid, movie, name)
|
||||
events.senden("suggestions")
|
||||
@@ -221,6 +229,90 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
|
||||
return {"removed": entfernt}
|
||||
|
||||
|
||||
async def film_holen(movie_id: int):
|
||||
"""Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB."""
|
||||
movie = db.get_movie(movie_id)
|
||||
if movie:
|
||||
return movie
|
||||
try:
|
||||
return await search.detail(movie_id)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=404, detail="Film nicht gefunden")
|
||||
|
||||
|
||||
@app.get("/api/watched")
|
||||
def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
return watched.alle(sid)
|
||||
|
||||
|
||||
@app.post("/api/watched")
|
||||
async def als_gesehen(movie_id: int = Body(..., embed=True)):
|
||||
"""Der Host trägt ein, was am Abend lief."""
|
||||
eintrag = watched.markieren(await film_holen(movie_id))
|
||||
events.senden("watched")
|
||||
return eintrag
|
||||
|
||||
|
||||
@app.delete("/api/watched/{watched_id}")
|
||||
def gesehen_entfernen(watched_id: int):
|
||||
entfernt = watched.entfernen(watched_id)
|
||||
if entfernt:
|
||||
events.senden("watched")
|
||||
return {"removed": entfernt}
|
||||
|
||||
|
||||
@app.patch("/api/watched/{watched_id}")
|
||||
def gesehen_aendern(
|
||||
watched_id: int,
|
||||
watched_at: str | None = Body(None, embed=True),
|
||||
hidden: bool | None = Body(None, embed=True),
|
||||
):
|
||||
"""Der Host rückt das Datum nach oder blendet einen Film aus."""
|
||||
geaendert = 0
|
||||
if watched_at:
|
||||
geaendert += watched.datum_setzen(watched_id, watched_at)
|
||||
if hidden is not None:
|
||||
geaendert += watched.sichtbarkeit(watched_id, hidden)
|
||||
if not geaendert:
|
||||
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
||||
events.senden("watched")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/watched/{watched_id}/rating")
|
||||
def bewerten(
|
||||
watched_id: int,
|
||||
response: Response,
|
||||
stars: int = Body(..., embed=True),
|
||||
name: str | None = Body(None, embed=True),
|
||||
filmabend_sid: str | None = Cookie(None),
|
||||
):
|
||||
sid = sitzung(response, filmabend_sid)
|
||||
stars = max(1, min(watched.MAX_STERNE, stars))
|
||||
eintrag = watched.bewerten(watched_id, sid, stars, name)
|
||||
if not eintrag:
|
||||
raise HTTPException(status_code=404, detail="Film steht nicht in der Liste")
|
||||
events.senden("watched")
|
||||
return eintrag
|
||||
|
||||
|
||||
@app.delete("/api/suggestions/alle")
|
||||
def runde_zuruecksetzen():
|
||||
"""Host leert die Runde: Vorschläge weg, Rad wieder leer."""
|
||||
entfernt = suggestions.alles_loeschen()
|
||||
letzter_dreh["ergebnis"] = None
|
||||
letzter_dreh["start"] = 0.0
|
||||
events.senden("reset")
|
||||
return {"removed": entfernt}
|
||||
|
||||
|
||||
@app.get("/stream/{pfad:path}")
|
||||
async def stream(request: Request, pfad: str):
|
||||
"""HLS von MediaMTX unter eigenem Ursprung ausliefern."""
|
||||
return await streamproxy.weiterleiten(request, pfad)
|
||||
|
||||
|
||||
@app.post("/api/sync")
|
||||
async def trigger_sync():
|
||||
if sync_lock.locked():
|
||||
|
||||
48
backend/app/streamproxy.py
Normal file
48
backend/app/streamproxy.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Reicht den HLS-Stream von MediaMTX unter demselben Ursprung durch.
|
||||
|
||||
Ohne das läge der Player auf Port 8000 und der Stream auf 8890 — zwei
|
||||
Ursprünge, also CORS und eine zweite Route nach außen. Produktiv ist ein
|
||||
Reverse-Proxy davor die bessere Wahl: Traefik reicht die Segmente durch,
|
||||
ohne dass Python jedes Byte anfasst.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
||||
from .config import MEDIAMTX_URL
|
||||
|
||||
# Playlists ändern sich im Sekundentakt, Segmente nie
|
||||
NICHT_ZWISCHENSPEICHERN = {"Cache-Control": "no-store"}
|
||||
|
||||
DURCHREICHEN = {
|
||||
"content-type",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"last-modified",
|
||||
"etag",
|
||||
}
|
||||
|
||||
|
||||
async def weiterleiten(request: Request, pfad: str):
|
||||
ziel = f"{MEDIAMTX_URL}/{pfad}"
|
||||
frage = dict(request.query_params)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as http:
|
||||
try:
|
||||
antwort = await http.get(ziel, params=frage)
|
||||
except httpx.HTTPError:
|
||||
raise HTTPException(status_code=502, detail="Stream nicht erreichbar")
|
||||
|
||||
if antwort.status_code == 404:
|
||||
raise HTTPException(status_code=404, detail="Kein Stream")
|
||||
|
||||
kopf = {k: v for k, v in antwort.headers.items() if k.lower() in DURCHREICHEN}
|
||||
if pfad.endswith(".m3u8"):
|
||||
kopf.update(NICHT_ZWISCHENSPEICHERN)
|
||||
|
||||
return Response(
|
||||
content=antwort.content,
|
||||
status_code=antwort.status_code,
|
||||
headers=kopf,
|
||||
)
|
||||
@@ -126,3 +126,9 @@ def loeschen(session_id):
|
||||
return conn.execute(
|
||||
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
|
||||
).rowcount
|
||||
|
||||
|
||||
def alles_loeschen():
|
||||
"""Host setzt die Runde zurück."""
|
||||
with connect() as conn:
|
||||
return conn.execute("DELETE FROM suggestions").rowcount
|
||||
|
||||
169
backend/app/watched.py
Normal file
169
backend/app/watched.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Bereits geschaute Filme und ihre Bewertungen.
|
||||
|
||||
Der Host trägt ein, was am Abend lief. Bewerten darf jeder, eine Stimme
|
||||
je Sitzung — dieselbe Cookie-Sitzung wie bei den Vorschlägen. Der Name ist
|
||||
freiwillig, ohne ihn zählt die Stimme trotzdem.
|
||||
|
||||
Titel und Poster werden mitgespeichert, damit ein Eintrag lesbar bleibt,
|
||||
auch wenn der Film später aus dem Katalog fällt.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .db import connect
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS watched (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
movie_id INTEGER NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
year INTEGER,
|
||||
poster_path TEXT,
|
||||
watched_at TEXT NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
watched_id INTEGER NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
stars INTEGER NOT NULL,
|
||||
name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (watched_id, session_id)
|
||||
);
|
||||
"""
|
||||
|
||||
MAX_STERNE = 5
|
||||
|
||||
|
||||
def init():
|
||||
with connect() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
# Die Tabelle stand vor der Spalte schon in bestehenden Datenbanken
|
||||
spalten = {r["name"] for r in conn.execute("PRAGMA table_info(watched)")}
|
||||
if "hidden" not in spalten:
|
||||
conn.execute(
|
||||
"ALTER TABLE watched ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
|
||||
|
||||
def _jetzt():
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def alle(session_id):
|
||||
"""Zuletzt geschaut zuerst, mit Schnitt und der eigenen Stimme."""
|
||||
with connect() as conn:
|
||||
filme = conn.execute(
|
||||
"SELECT * FROM watched ORDER BY watched_at DESC, id DESC"
|
||||
).fetchall()
|
||||
stimmen = conn.execute(
|
||||
"SELECT watched_id, session_id, stars, name FROM ratings "
|
||||
"ORDER BY created_at ASC"
|
||||
).fetchall()
|
||||
|
||||
je_film = {}
|
||||
for s in stimmen:
|
||||
je_film.setdefault(s["watched_id"], []).append(s)
|
||||
|
||||
out = []
|
||||
for film in filme:
|
||||
eigene = je_film.get(film["id"], [])
|
||||
werte = [s["stars"] for s in eigene]
|
||||
out.append(
|
||||
{
|
||||
**dict(film),
|
||||
"ratings": [
|
||||
{"stars": s["stars"], "name": s["name"]} for s in eigene
|
||||
],
|
||||
"average": round(sum(werte) / len(werte), 1) if werte else None,
|
||||
"count": len(werte),
|
||||
"mine": next(
|
||||
(s["stars"] for s in eigene if s["session_id"] == session_id),
|
||||
None,
|
||||
),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def markieren(movie):
|
||||
"""Film als geschaut eintragen. Ein zweiter Klick ändert nichts."""
|
||||
with connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO watched "
|
||||
"(movie_id, title, year, poster_path, watched_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(
|
||||
movie["id"],
|
||||
movie["title"],
|
||||
movie.get("year"),
|
||||
movie.get("poster_path"),
|
||||
_jetzt(),
|
||||
),
|
||||
)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM watched WHERE movie_id = ?", (movie["id"],)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
def entfernen(watched_id):
|
||||
with connect() as conn:
|
||||
conn.execute("DELETE FROM ratings WHERE watched_id = ?", (watched_id,))
|
||||
return conn.execute(
|
||||
"DELETE FROM watched WHERE id = ?", (watched_id,)
|
||||
).rowcount
|
||||
|
||||
|
||||
def datum_setzen(watched_id, tag):
|
||||
"""tag im Format JJJJ-MM-TT. Mittags in UTC, damit die Anzeige in
|
||||
keiner Zeitzone auf den Nachbartag rutscht."""
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"UPDATE watched SET watched_at = ? WHERE id = ?",
|
||||
(f"{tag}T12:00:00+00:00", watched_id),
|
||||
).rowcount
|
||||
|
||||
|
||||
def sichtbarkeit(watched_id, versteckt):
|
||||
"""Versteckte Filme sieht nur der Host."""
|
||||
with connect() as conn:
|
||||
return conn.execute(
|
||||
"UPDATE watched SET hidden = ? WHERE id = ?",
|
||||
(1 if versteckt else 0, watched_id),
|
||||
).rowcount
|
||||
|
||||
|
||||
def bewerten(watched_id, session_id, stars, name=None):
|
||||
"""Gibt None zurück, wenn der Film gar nicht in der Liste steht."""
|
||||
with connect() as conn:
|
||||
da = conn.execute(
|
||||
"SELECT 1 FROM watched WHERE id = ?", (watched_id,)
|
||||
).fetchone()
|
||||
if not da:
|
||||
return None
|
||||
# Eine Stimme je Sitzung — eine neue Bewertung ersetzt die alte
|
||||
conn.execute(
|
||||
"INSERT INTO ratings "
|
||||
"(watched_id, session_id, stars, name, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT (watched_id, session_id) DO UPDATE SET "
|
||||
"stars = excluded.stars, name = excluded.name, "
|
||||
"created_at = excluded.created_at",
|
||||
(
|
||||
watched_id,
|
||||
session_id,
|
||||
stars,
|
||||
(name or "").strip()[:40] or None,
|
||||
_jetzt(),
|
||||
),
|
||||
)
|
||||
return {"watched_id": watched_id, "stars": stars}
|
||||
|
||||
|
||||
def ids():
|
||||
"""movie_ids der geschauten Filme, für die Detailansicht."""
|
||||
with connect() as conn:
|
||||
return [r["movie_id"] for r in conn.execute("SELECT movie_id FROM watched")]
|
||||
@@ -5,8 +5,6 @@ services:
|
||||
container_name: horror
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
DB_PATH: /data/horror.db
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||
interval: 30s
|
||||
@@ -20,6 +18,11 @@ services:
|
||||
- web
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
DB_PATH: /data/horror.db
|
||||
MEDIAMTX_URL: http://mediamtx:8890
|
||||
depends_on:
|
||||
- mediamtx
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.horrorapp.rule=Host(`horror.marha.de`)"
|
||||
@@ -27,6 +30,23 @@ services:
|
||||
- "traefik.http.routers.horrorapp.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.horrorapp.loadbalancer.server.port=8000"
|
||||
|
||||
mediamtx:
|
||||
image: bluenviron/mediamtx:latest
|
||||
container_name: horror-mediamtx
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
# MediaMTX liest Konfiguration auch aus der Umgebung. Damit steht das
|
||||
# Sendepasswort nicht in der Datei, die im Git liegt.
|
||||
MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT}
|
||||
volumes:
|
||||
- ./mediamtx/mediamtx.yml:/mediamtx.yml:ro
|
||||
# RTMP nach außen für OBS; HLS bleibt intern und läuft über die App
|
||||
ports:
|
||||
- "1935:1935"
|
||||
networks:
|
||||
- web
|
||||
|
||||
networks:
|
||||
web:
|
||||
external: true
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -6,6 +6,7 @@
|
||||
"": {
|
||||
"name": "horror-frontend",
|
||||
"dependencies": {
|
||||
"hls.js": "^1.7.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -1134,6 +1135,12 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hls.js": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz",
|
||||
"integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.7.1",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import MovieCard from './components/MovieCard.vue'
|
||||
import FilterSidebar from './components/FilterSidebar.vue'
|
||||
import MovieDetail from './components/MovieDetail.vue'
|
||||
import SearchTab from './components/SearchTab.vue'
|
||||
import StreamTab from './components/StreamTab.vue'
|
||||
import WatchedTab from './components/WatchedTab.vue'
|
||||
import WheelTab from './components/WheelTab.vue'
|
||||
import SeriesCard from './components/SeriesCard.vue'
|
||||
|
||||
@@ -14,7 +16,13 @@ const error = ref('')
|
||||
const search = ref('')
|
||||
const sort = ref('rating')
|
||||
const selected = ref(null)
|
||||
const activeTab = ref('netflix')
|
||||
// Nach dem Neuladen soll derselbe Reiter offen sein
|
||||
const TAB_SCHLUESSEL = 'filmabend_tab'
|
||||
const TAB_IDS = ['netflix', 'canon', 'search', 'wheel', 'watched', 'stream']
|
||||
const gemerkterTab = localStorage.getItem(TAB_SCHLUESSEL)
|
||||
const activeTab = ref(TAB_IDS.includes(gemerkterTab) ? gemerkterTab : 'netflix')
|
||||
|
||||
watch(activeTab, (id) => localStorage.setItem(TAB_SCHLUESSEL, id))
|
||||
const include = ref([])
|
||||
const exclude = ref([])
|
||||
|
||||
@@ -187,13 +195,48 @@ const netflixEntries = computed(() => {
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
||||
{ id: 'canon', label: 'Kanon', count: canonGroups.value.length },
|
||||
{ id: 'canon', label: 'Horror-Kanon', count: canonGroups.value.length },
|
||||
{ id: 'search', label: 'Suche', count: null },
|
||||
{ id: 'wheel', label: 'Rad', count: null },
|
||||
{ id: 'watched', label: 'Gesehen', count: gesehenZahl.value },
|
||||
{ id: 'stream', label: 'Stream', count: null },
|
||||
])
|
||||
|
||||
const rad = ref(null)
|
||||
|
||||
// Die movie_ids der geschauten Filme — die Detailansicht zeigt damit an,
|
||||
// ob der Film schon lief
|
||||
const gesehen = ref([])
|
||||
|
||||
async function ladeGesehen() {
|
||||
try {
|
||||
const resp = await fetch('/api/watched')
|
||||
if (resp.ok) gesehen.value = await resp.json()
|
||||
} catch {
|
||||
/* dann eben ohne */
|
||||
}
|
||||
}
|
||||
|
||||
// Ausgeblendete Filme zählen für Gäste nicht mit
|
||||
const gesehenZahl = computed(
|
||||
() => gesehen.value.filter((e) => host.value || !e.hidden).length
|
||||
)
|
||||
|
||||
// Wer den Abend leitet, darf drehen und die Runde leeren. Kein Login —
|
||||
// die Tastenkombination reicht, die Seite steht ohnehin nur Freunden offen.
|
||||
const HOST_SCHLUESSEL = 'filmabend_host'
|
||||
const host = ref(localStorage.getItem(HOST_SCHLUESSEL) === '1')
|
||||
|
||||
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()
|
||||
host.value = !host.value
|
||||
localStorage.setItem(HOST_SCHLUESSEL, host.value ? '1' : '0')
|
||||
}
|
||||
}
|
||||
|
||||
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||
const meinVorschlag = ref(null)
|
||||
|
||||
@@ -326,11 +369,15 @@ async function load() {
|
||||
onMounted(() => {
|
||||
load()
|
||||
ladeVorschlag()
|
||||
ladeGesehen()
|
||||
window.addEventListener('keydown', tastatur)
|
||||
})
|
||||
|
||||
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
|
||||
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
|
||||
<nav class="tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
@@ -342,11 +389,27 @@ onMounted(() => {
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||
</button>
|
||||
<span v-if="host" class="host">Host</span>
|
||||
</nav>
|
||||
|
||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
||||
|
||||
<WheelTab v-else-if="activeTab === 'wheel'" ref="rad" @open="selected = $event" />
|
||||
<WheelTab
|
||||
v-else-if="activeTab === 'wheel'"
|
||||
ref="rad"
|
||||
:host="host"
|
||||
@open="selected = $event"
|
||||
@reset="meinVorschlag = null"
|
||||
/>
|
||||
|
||||
<WatchedTab
|
||||
v-else-if="activeTab === 'watched'"
|
||||
:host="host"
|
||||
@open="selected = $event"
|
||||
@changed="ladeGesehen"
|
||||
/>
|
||||
|
||||
<StreamTab v-else-if="activeTab === 'stream'" />
|
||||
|
||||
<div v-else class="layout">
|
||||
<div class="results">
|
||||
@@ -402,7 +465,10 @@ onMounted(() => {
|
||||
@close="selected = null"
|
||||
@open="selected = $event"
|
||||
:mein-vorschlag="meinVorschlag"
|
||||
:host="host"
|
||||
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||
@suggested="nachVorschlag"
|
||||
@watched="ladeGesehen"
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -446,6 +512,19 @@ onMounted(() => {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.host {
|
||||
align-self: center;
|
||||
margin-left: auto;
|
||||
padding: 0.15rem 0.5rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tabs .count {
|
||||
padding: 0.1rem 0.45rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -6,9 +6,11 @@ const props = defineProps({
|
||||
movie: { type: Object, required: true },
|
||||
series: { type: Array, default: () => [] },
|
||||
meinVorschlag: { type: Object, default: null },
|
||||
host: { type: Boolean, default: false },
|
||||
gesehen: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'open', 'suggested'])
|
||||
const emit = defineEmits(['close', 'open', 'suggested', 'watched'])
|
||||
|
||||
const vorschlagen = ref({ zustand: 'bereit', name: '' })
|
||||
|
||||
@@ -35,6 +37,24 @@ async function abschicken() {
|
||||
}
|
||||
}
|
||||
|
||||
const merken = ref({ zustand: 'bereit' })
|
||||
|
||||
async function alsGesehen() {
|
||||
merken.value = { zustand: 'laeuft' }
|
||||
try {
|
||||
const resp = await fetch('/api/watched', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ movie_id: props.movie.id }),
|
||||
})
|
||||
if (!resp.ok) throw new Error()
|
||||
merken.value = { zustand: 'fertig' }
|
||||
emit('watched')
|
||||
} catch {
|
||||
merken.value = { zustand: 'fehler' }
|
||||
}
|
||||
}
|
||||
|
||||
const poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||
const shots = computed(() =>
|
||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||
@@ -143,6 +163,23 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="host || gesehen" class="gesehen">
|
||||
<span v-if="gesehen || merken.zustand === 'fertig'" class="ok">
|
||||
Steht unter „Gesehen“.
|
||||
</span>
|
||||
<button
|
||||
v-else-if="host"
|
||||
type="button"
|
||||
:disabled="merken.zustand === 'laeuft'"
|
||||
@click="alsGesehen"
|
||||
>
|
||||
Als gesehen markieren
|
||||
</button>
|
||||
<span v-if="merken.zustand === 'fehler'" class="fehler">
|
||||
Hat nicht geklappt.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -348,6 +385,32 @@ h2 {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gesehen {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.gesehen button {
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.gesehen button:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gesehen button:disabled {
|
||||
color: var(--muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hinweis {
|
||||
flex-basis: 100%;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -63,11 +63,6 @@ async function open(movie) {
|
||||
<p v-else-if="searched && !results.length" class="note">
|
||||
Nichts gefunden für „{{ query }}“.
|
||||
</p>
|
||||
<p v-else-if="!searched" class="note">
|
||||
Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur
|
||||
der Katalog.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<MovieCard
|
||||
v-for="movie in results"
|
||||
|
||||
180
frontend/src/components/StreamTab.vue
Normal file
180
frontend/src/components/StreamTab.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import Hls from 'hls.js'
|
||||
|
||||
// Alle Zuschauer regeln auf dieselbe Distanz zur Live-Kante. Damit laufen
|
||||
// sie synchron, ohne dass ein Server sie taktet. hls.js passt dafür die
|
||||
// Abspielgeschwindigkeit an (maxLiveSyncPlaybackRate).
|
||||
const ZIEL_LATENZ = 4
|
||||
|
||||
const video = ref(null)
|
||||
const zustand = ref('lade')
|
||||
const fehler = ref('')
|
||||
|
||||
let hls = null
|
||||
|
||||
function verbinden() {
|
||||
const quelle = '/stream/filmabend/index.m3u8'
|
||||
|
||||
if (!Hls.isSupported()) {
|
||||
// Safari spielt HLS selbst ab, dann übernimmt der Browser die Regelung
|
||||
if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.value.src = quelle
|
||||
zustand.value = 'laeuft'
|
||||
return
|
||||
}
|
||||
fehler.value = 'Dieser Browser kann den Stream nicht abspielen.'
|
||||
zustand.value = 'fehler'
|
||||
return
|
||||
}
|
||||
|
||||
hls = new Hls({
|
||||
lowLatencyMode: true,
|
||||
liveSyncDuration: ZIEL_LATENZ,
|
||||
// Ab hier springt hls.js selbst auf die Zielposition zurück, statt
|
||||
// ewig mit 1,5-fachem Tempo aufzuholen. Zu dicht an ZIEL_LATENZ
|
||||
// führt laut Doku zu Stockern.
|
||||
liveMaxLatencyDuration: ZIEL_LATENZ + 3,
|
||||
// Über 1 erlaubt hls.js, die Geschwindigkeit anzuheben, um die
|
||||
// Ziel-Latenz zu halten. Standard ist 1, also aus.
|
||||
maxLiveSyncPlaybackRate: 1.5,
|
||||
backBufferLength: 30,
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_, daten) => {
|
||||
if (!daten.fatal) return
|
||||
if (daten.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||
zustand.value = 'wartet'
|
||||
// Nicht sofort neu anfragen — sonst hämmert der Browser den Server
|
||||
window.setTimeout(() => hls?.startLoad(), 3000)
|
||||
} else if (daten.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||
hls.recoverMediaError()
|
||||
} else {
|
||||
fehler.value = 'Stream nicht erreichbar.'
|
||||
zustand.value = 'fehler'
|
||||
}
|
||||
})
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
zustand.value = 'laeuft'
|
||||
video.value.play().catch(() => {
|
||||
// Autoplay ohne Ton ist erlaubt, mit Ton nicht — dann wartet es
|
||||
// auf einen Klick
|
||||
zustand.value = 'bereit'
|
||||
})
|
||||
})
|
||||
|
||||
hls.loadSource(quelle)
|
||||
hls.attachMedia(video.value)
|
||||
// Für Diagnose im Betrieb: Latenzwerte in der Konsole nachsehen
|
||||
window.__stream = hls
|
||||
|
||||
}
|
||||
|
||||
function starten() {
|
||||
video.value?.play()
|
||||
zustand.value = 'laeuft'
|
||||
}
|
||||
|
||||
onMounted(verbinden)
|
||||
|
||||
onUnmounted(() => {
|
||||
hls?.destroy()
|
||||
hls = null
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stream-tab">
|
||||
<div class="buehne">
|
||||
<!-- Ohne Stream zeigt der Browser nur seinen eigenen Ladekringel und
|
||||
verrät nicht, warum nichts kommt -->
|
||||
<video
|
||||
v-show="zustand === 'laeuft' || zustand === 'bereit'"
|
||||
ref="video"
|
||||
class="player"
|
||||
controls
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<div v-if="zustand === 'wartet' || zustand === 'lade'" class="platzhalter">
|
||||
<p class="gross">
|
||||
{{ zustand === 'lade' ? 'Verbinde…' : 'Es wird gerade nichts gesendet.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button v-if="zustand === 'bereit'" class="start" type="button" @click="starten">
|
||||
Wiedergabe starten
|
||||
</button>
|
||||
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stream-tab {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.buehne {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.player {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.platzhalter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.gross {
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.start {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 0.8rem 1.8rem;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 1rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
425
frontend/src/components/WatchedTab.vue
Normal file
425
frontend/src/components/WatchedTab.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
const props = defineProps({ host: Boolean })
|
||||
const emit = defineEmits(['open', 'changed'])
|
||||
|
||||
const items = ref([])
|
||||
|
||||
// Ausgeblendete Filme bleiben für den Host sichtbar, damit er sie
|
||||
// zurückholen kann
|
||||
const sichtbar = computed(() =>
|
||||
props.host ? items.value : items.value.filter((e) => !e.hidden)
|
||||
)
|
||||
const fehler = ref('')
|
||||
const laden = ref(true)
|
||||
|
||||
// Der Name gilt für alle Bewertungen und überlebt das Neuladen
|
||||
const NAME_SCHLUESSEL = 'filmabend_name'
|
||||
const name = ref(localStorage.getItem(NAME_SCHLUESSEL) || '')
|
||||
|
||||
const STERNE = [1, 2, 3, 4, 5]
|
||||
|
||||
// Beim Schweben füllen sich alle Sterne bis zum Zeiger, wie gewohnt
|
||||
const schwebt = ref({ id: null, wert: 0 })
|
||||
|
||||
function gefuellt(item, stern) {
|
||||
if (schwebt.value.id === item.id) return stern <= schwebt.value.wert
|
||||
return item.mine >= stern
|
||||
}
|
||||
|
||||
let abbestellen = null
|
||||
|
||||
async function laden_() {
|
||||
try {
|
||||
const resp = await fetch('/api/watched')
|
||||
if (!resp.ok) throw new Error('Liste konnte nicht geladen werden')
|
||||
items.value = await resp.json()
|
||||
} catch (err) {
|
||||
fehler.value = err.message
|
||||
} finally {
|
||||
laden.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bewerten(item, sterne) {
|
||||
localStorage.setItem(NAME_SCHLUESSEL, name.value)
|
||||
// Sofort anzeigen, der Live-Kanal liefert gleich die richtigen Zahlen
|
||||
item.mine = sterne
|
||||
try {
|
||||
await fetch(`/api/watched/${item.id}/rating`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stars: sterne, name: name.value || null }),
|
||||
})
|
||||
} catch {
|
||||
fehler.value = 'Bewerten hat nicht geklappt.'
|
||||
}
|
||||
}
|
||||
|
||||
async function sichtbarkeit(item) {
|
||||
await fetch(`/api/watched/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden: !item.hidden }),
|
||||
})
|
||||
}
|
||||
|
||||
async function entfernen(item) {
|
||||
await fetch(`/api/watched/${item.id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
}
|
||||
|
||||
// Für das Datumsfeld zählt der lokale Tag, nicht der in UTC
|
||||
function alsFeld(iso) {
|
||||
const d = new Date(iso)
|
||||
const zwei = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${zwei(d.getMonth() + 1)}-${zwei(d.getDate())}`
|
||||
}
|
||||
|
||||
async function datumSetzen(item, tag) {
|
||||
if (!tag) return
|
||||
await fetch(`/api/watched/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ watched_at: tag }),
|
||||
})
|
||||
}
|
||||
|
||||
function datum(iso) {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
laden_()
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type !== 'watched') return
|
||||
laden_()
|
||||
emit('changed')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => abbestellen?.())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="watched-tab">
|
||||
<div class="kopf">
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="name"
|
||||
placeholder="Dein Name (optional)"
|
||||
maxlength="40"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!laden && !sichtbar.length" class="note">
|
||||
Noch kein Film geschaut.
|
||||
</p>
|
||||
|
||||
<div class="grid">
|
||||
<article
|
||||
v-for="item in sichtbar"
|
||||
:key="item.id"
|
||||
class="karte"
|
||||
:class="{ versteckt: item.hidden }"
|
||||
>
|
||||
<button type="button" class="cover" @click="oeffnen(item)">
|
||||
<img
|
||||
v-if="posterUrl(item.poster_path, 'w342')"
|
||||
:src="posterUrl(item.poster_path, 'w342')"
|
||||
:alt="item.title"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span v-else class="kein-cover">Kein Cover</span>
|
||||
</button>
|
||||
|
||||
<div class="text">
|
||||
<h3 @click="oeffnen(item)">{{ item.title }}</h3>
|
||||
<p class="meta">
|
||||
<span v-if="item.year">{{ item.year }}</span>
|
||||
<span v-if="host" class="datum-feld">
|
||||
Geschaut am
|
||||
<input
|
||||
type="date"
|
||||
:value="alsFeld(item.watched_at)"
|
||||
@change="datumSetzen(item, $event.target.value)"
|
||||
/>
|
||||
</span>
|
||||
<span v-else>Geschaut am {{ datum(item.watched_at) }}</span>
|
||||
</p>
|
||||
|
||||
<p class="schnitt">
|
||||
<template v-if="item.count">
|
||||
<span class="wert">{{ item.average.toFixed(1) }}</span>
|
||||
<span class="von">von 5 ({{ item.count }})</span>
|
||||
</template>
|
||||
<span v-else class="von">Noch keine Wertung</span>
|
||||
</p>
|
||||
|
||||
<div class="wertung" @mouseleave="schwebt = { id: null, wert: 0 }">
|
||||
<button
|
||||
v-for="s in STERNE"
|
||||
:key="s"
|
||||
type="button"
|
||||
class="stern"
|
||||
:class="{ an: gefuellt(item, s) }"
|
||||
:aria-label="`${s} von 5`"
|
||||
@mouseenter="schwebt = { id: item.id, wert: s }"
|
||||
@click="bewerten(item, s)"
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="item.ratings.length" class="stimmen">
|
||||
<li v-for="(r, i) in item.ratings" :key="i">
|
||||
<span class="wer">{{ r.name || 'Anonym' }}</span>
|
||||
<span class="punkte">{{ '★'.repeat(r.stars) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="host" class="aktionen">
|
||||
<button
|
||||
type="button"
|
||||
class="icon"
|
||||
:title="item.hidden ? 'Wieder einblenden' : 'Ausblenden'"
|
||||
@click="sichtbarkeit(item)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M1 12s4-7.5 11-7.5S23 12 23 12s-4 7.5-11 7.5S1 12 1 12z" />
|
||||
<circle cx="12" cy="12" r="3.2" />
|
||||
<line v-if="item.hidden" x1="3" y1="21" x2="21" y2="3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="icon loeschen"
|
||||
title="Samt Bewertungen löschen"
|
||||
@click="entfernen(item)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kopf {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 14rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.karte {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
padding: 0.8rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cover {
|
||||
flex-shrink: 0;
|
||||
width: 96px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cover img,
|
||||
.kein-cover {
|
||||
width: 96px;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.kein-cover {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
line-height: 1.25;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
h3:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.datum-feld {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.datum-feld input {
|
||||
padding: 0.15rem 0.35rem;
|
||||
color: var(--text);
|
||||
font-size: 0.78rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.schnitt {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
align-items: baseline;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.wert {
|
||||
color: #f5c518;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.von {
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.wertung {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stern {
|
||||
padding: 0 0.05rem;
|
||||
color: var(--line);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stern.an {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.stimmen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
margin-top: 0.25rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.stimmen li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.wer {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.punkte {
|
||||
color: #f5c518;
|
||||
}
|
||||
|
||||
.aktionen {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 1.9rem;
|
||||
height: 1.9rem;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.icon svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.icon.loeschen:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Der Host sieht sofort, was für die anderen weg ist */
|
||||
.karte.versteckt {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.note {
|
||||
padding: 2.5rem 0;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note.error {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { posterUrl } from '../images'
|
||||
import { abonnieren } from '../live'
|
||||
|
||||
const emit = defineEmits(['open'])
|
||||
defineProps({ host: Boolean })
|
||||
const emit = defineEmits(['open', 'reset'])
|
||||
|
||||
const items = ref([])
|
||||
const mine = ref(null)
|
||||
@@ -26,16 +28,14 @@ const FARBEN = [
|
||||
]
|
||||
|
||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||
let quelle = null
|
||||
let abbestellen = null
|
||||
|
||||
function verbinden() {
|
||||
quelle = new EventSource('/api/events')
|
||||
quelle.onmessage = (ereignis) => {
|
||||
const nachricht = JSON.parse(ereignis.data)
|
||||
abbestellen = abonnieren((nachricht) => {
|
||||
if (nachricht.type === 'suggestions') laden_()
|
||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||
}
|
||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||
else if (nachricht.type === 'reset') zurueckgesetzt()
|
||||
})
|
||||
}
|
||||
|
||||
function abspielen(ergebnis, uebersprungen = 0) {
|
||||
@@ -101,7 +101,7 @@ onMounted(async () => {
|
||||
verbinden()
|
||||
})
|
||||
|
||||
onUnmounted(() => quelle?.close())
|
||||
onUnmounted(() => abbestellen?.())
|
||||
|
||||
defineExpose({ neu: laden_ })
|
||||
|
||||
@@ -165,6 +165,25 @@ async function drehen() {
|
||||
}
|
||||
}
|
||||
|
||||
function zurueckgesetzt() {
|
||||
// Ohne Dauer 0 würde das Rad die Strecke zurück sichtbar abfahren
|
||||
dauer.value = 0
|
||||
winkel.value = 0
|
||||
gewinner.value = null
|
||||
dreht.value = false
|
||||
emit('reset')
|
||||
laden_()
|
||||
}
|
||||
|
||||
async function zuruecksetzen() {
|
||||
// Der Server meldet den Reset an alle zurück, auch an uns
|
||||
try {
|
||||
await fetch('/api/suggestions/alle', { method: 'DELETE' })
|
||||
} catch {
|
||||
fehler.value = 'Zurücksetzen hat nicht geklappt.'
|
||||
}
|
||||
}
|
||||
|
||||
async function oeffnen(item) {
|
||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||
if (resp.ok) emit('open', await resp.json())
|
||||
@@ -174,64 +193,70 @@ async function oeffnen(item) {
|
||||
<template>
|
||||
<div class="wheel-tab">
|
||||
<div class="buehne">
|
||||
<p v-if="laden" class="note">Lade Vorschläge…</p>
|
||||
<p v-else-if="fehler" class="note error">{{ fehler }}</p>
|
||||
<p v-else-if="!items.length" class="note">
|
||||
Noch keine Vorschläge. Öffne einen Film und klicke auf „Vorschlagen“.
|
||||
</p>
|
||||
<p v-if="fehler" class="note error">{{ fehler }}</p>
|
||||
|
||||
<template v-else>
|
||||
<div class="rad-halter">
|
||||
<div class="zeiger"></div>
|
||||
<svg
|
||||
class="rad"
|
||||
:class="{ dreht }"
|
||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||
:style="{
|
||||
transform: `rotate(${winkel}deg)`,
|
||||
transitionDuration: `${dauer}s`,
|
||||
}"
|
||||
>
|
||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<text
|
||||
v-if="seg.breit"
|
||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||
:y="MITTE + 4"
|
||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||
class="beschriftung"
|
||||
>
|
||||
{{ seg.kurz }}
|
||||
</text>
|
||||
</g>
|
||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rad-halter">
|
||||
<div class="zeiger"></div>
|
||||
<svg
|
||||
class="rad"
|
||||
:class="{ dreht }"
|
||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||
:style="{
|
||||
transform: `rotate(${winkel}deg)`,
|
||||
transitionDuration: `${dauer}s`,
|
||||
}"
|
||||
>
|
||||
<circle :cx="MITTE" :cy="MITTE" :r="RADIUS" fill="var(--bg-soft)" />
|
||||
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||
<text
|
||||
v-if="seg.breit"
|
||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||
:y="MITTE + 4"
|
||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||
class="beschriftung"
|
||||
>
|
||||
{{ seg.kurz }}
|
||||
</text>
|
||||
</g>
|
||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<button class="dreh" type="button" :disabled="dreht" @click="drehen">
|
||||
<div v-if="host" class="knoepfe">
|
||||
<button class="dreh" type="button" :disabled="dreht || !items.length" @click="drehen">
|
||||
{{ dreht ? 'dreht…' : 'Drehen' }}
|
||||
</button>
|
||||
<button
|
||||
class="reset"
|
||||
type="button"
|
||||
:disabled="dreht || !items.length"
|
||||
@click="zuruecksetzen"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||
<img
|
||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:alt="gewinner.title"
|
||||
/>
|
||||
<div>
|
||||
<p class="label">Der Film des Abends</p>
|
||||
<h2>{{ gewinner.title }}</h2>
|
||||
<p class="jahr">
|
||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||
<img
|
||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||
:alt="gewinner.title"
|
||||
/>
|
||||
<div>
|
||||
<p class="label">Der Film des Abends</p>
|
||||
<h2>{{ gewinner.title }}</h2>
|
||||
<p class="jahr">
|
||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside v-if="items.length" class="liste">
|
||||
<aside class="liste">
|
||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||
<ul>
|
||||
<li v-for="(item, i) in items" :key="item.id">
|
||||
<button
|
||||
@@ -300,6 +325,35 @@ async function oeffnen(item) {
|
||||
border-left: 11px solid transparent;
|
||||
}
|
||||
|
||||
.knoepfe {
|
||||
display: flex;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.reset {
|
||||
padding: 0.7rem 1.4rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
background: var(--bg-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reset:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
border-color: var(--muted);
|
||||
}
|
||||
|
||||
.reset:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.leer {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.dreh {
|
||||
padding: 0.7rem 2.2rem;
|
||||
color: #fff;
|
||||
|
||||
18
frontend/src/live.js
Normal file
18
frontend/src/live.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Ein Kanal für die ganze Seite. Mehrere Komponenten lauschen mit, ohne
|
||||
// dass jede eine eigene Verbindung aufmacht.
|
||||
|
||||
let quelle = null
|
||||
const hoerer = new Set()
|
||||
|
||||
export function abonnieren(fn) {
|
||||
hoerer.add(fn)
|
||||
if (!quelle) {
|
||||
quelle = new EventSource('/api/events')
|
||||
quelle.onmessage = (ereignis) => {
|
||||
const nachricht = JSON.parse(ereignis.data)
|
||||
hoerer.forEach((h) => h(nachricht))
|
||||
}
|
||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
||||
}
|
||||
return () => hoerer.delete(fn)
|
||||
}
|
||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8000',
|
||||
// Nicht direkt zu MediaMTX: das antwortet mit einem Redirect auf
|
||||
// seinen eigenen Port, und der Browser landet damit außerhalb des
|
||||
// Proxys. Das Backend folgt dem Redirect selbst.
|
||||
'/stream': 'http://localhost:8000',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
38
mediamtx/mediamtx.yml
Normal file
38
mediamtx/mediamtx.yml
Normal file
@@ -0,0 +1,38 @@
|
||||
logLevel: info
|
||||
|
||||
# Low-Latency-HLS: 1s-Segmente aus 200ms-Teilen. Der Player hält damit
|
||||
# zuverlässig 4 Sekunden Abstand zur Live-Kante.
|
||||
hls: yes
|
||||
hlsAddress: :8890
|
||||
hlsVariant: lowLatency
|
||||
hlsSegmentCount: 7
|
||||
hlsSegmentDuration: 1s
|
||||
hlsPartDuration: 200ms
|
||||
# Die Seite reicht den Stream unter eigenem Ursprung durch
|
||||
hlsAllowOrigin: '*'
|
||||
|
||||
rtmp: yes
|
||||
rtmpAddress: :1935
|
||||
|
||||
# Nicht gebraucht, also zu
|
||||
webrtc: no
|
||||
rtsp: no
|
||||
srt: no
|
||||
|
||||
# Senden nur mit Passwort — sonst könnte jeder auf den Server streamen,
|
||||
# der Port 1935 erreicht. Das Passwort kommt aus der Umgebung:
|
||||
# MTX_AUTHINTERNALUSERS_0_PASS, gesetzt über die .env.
|
||||
authInternalUsers:
|
||||
- user: sender
|
||||
pass: bitte-ueberschreiben
|
||||
ips: []
|
||||
permissions:
|
||||
- action: publish
|
||||
- user: any
|
||||
pass:
|
||||
ips: []
|
||||
permissions:
|
||||
- action: read
|
||||
|
||||
paths:
|
||||
filmabend:
|
||||
123
scripts/stream.sh
Executable file
123
scripts/stream.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# Startet OBS mit einem eigenen Profil und sendet sofort an MediaMTX.
|
||||
#
|
||||
# Das Profil legt fest, wohin gesendet wird; die Szene, was gesendet wird.
|
||||
# Beide liegen neben deiner normalen OBS-Konfiguration und fassen sie nicht an.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
[ -f .env ] || { echo "Keine .env gefunden."; exit 1; }
|
||||
set -a; . ./.env; set +a
|
||||
|
||||
: "${MTX_SENDEPASSWORT:?MTX_SENDEPASSWORT fehlt in der .env}"
|
||||
|
||||
# Ohne Argument lokal, sonst der übergebene Host
|
||||
ZIEL="${1:-127.0.0.1}"
|
||||
PROFIL="filmabend"
|
||||
BASIS="$HOME/.config/obs-studio/basic"
|
||||
P="$BASIS/profiles/$PROFIL"
|
||||
|
||||
command -v obs > /dev/null || { echo "OBS ist nicht installiert."; exit 1; }
|
||||
|
||||
# Ohne laufenden MediaMTX bricht OBS mit "is offline" ab — das steht dann
|
||||
# nur im OBS-Log und man sucht lange.
|
||||
if ! timeout 3 bash -c "</dev/tcp/${ZIEL}/1935" 2>/dev/null; then
|
||||
echo "Auf $ZIEL:1935 antwortet nichts."
|
||||
[ "$ZIEL" = "127.0.0.1" ] && echo "Starte MediaMTX mit: make mtx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$P" "$BASIS/scenes"
|
||||
|
||||
# Sendeziel jedes Mal neu schreiben — es hängt am Argument
|
||||
cat > "$P/service.json" <<JSON
|
||||
{
|
||||
"settings": {
|
||||
"bwtest": false,
|
||||
"key": "filmabend?user=sender&pass=$MTX_SENDEPASSWORT",
|
||||
"server": "rtmp://$ZIEL:1935/",
|
||||
"use_auth": false
|
||||
},
|
||||
"type": "rtmp_custom"
|
||||
}
|
||||
JSON
|
||||
|
||||
# Einstellungen nur anlegen, nicht überschreiben — du willst Bitrate und
|
||||
# Auflösung vielleicht anpassen
|
||||
if [ ! -f "$P/basic.ini" ]; then
|
||||
cat > "$P/basic.ini" <<'INI'
|
||||
[General]
|
||||
Name=filmabend
|
||||
|
||||
[Video]
|
||||
BaseCX=1920
|
||||
BaseCY=1080
|
||||
OutputCX=1920
|
||||
OutputCY=1080
|
||||
FPSType=0
|
||||
FPSCommon=30
|
||||
|
||||
[Output]
|
||||
Mode=Simple
|
||||
|
||||
[SimpleOutput]
|
||||
VBitrate=6000
|
||||
ABitrate=160
|
||||
StreamEncoder=x264
|
||||
Preset=veryfast
|
||||
|
||||
[Audio]
|
||||
SampleRate=48000
|
||||
ChannelSetup=Stereo
|
||||
INI
|
||||
fi
|
||||
|
||||
# Szene ebenfalls nur beim ersten Mal. Die PipeWire-Quelle fragt beim Start,
|
||||
# welches Fenster du senden willst.
|
||||
if [ ! -f "$BASIS/scenes/$PROFIL.json" ]; then
|
||||
cat > "$BASIS/scenes/$PROFIL.json" <<'JSON'
|
||||
{
|
||||
"current_scene": "Filmabend",
|
||||
"current_program_scene": "Filmabend",
|
||||
"name": "filmabend",
|
||||
"scene_order": [{"name": "Filmabend"}],
|
||||
"sources": [
|
||||
{
|
||||
"id": "pipewire-window-capture-source",
|
||||
"name": "Fenster",
|
||||
"settings": {"cursor": false},
|
||||
"versioned_id": "pipewire-window-capture-source"
|
||||
},
|
||||
{
|
||||
"id": "pulse_output_capture",
|
||||
"name": "Ton",
|
||||
"settings": {},
|
||||
"versioned_id": "pulse_output_capture"
|
||||
},
|
||||
{
|
||||
"id": "scene",
|
||||
"name": "Filmabend",
|
||||
"settings": {
|
||||
"items": [
|
||||
{"name": "Fenster", "visible": true, "pos": {"x": 0.0, "y": 0.0},
|
||||
"scale": {"x": 1.0, "y": 1.0}, "align": 5, "bounds_type": 2,
|
||||
"bounds": {"x": 1920.0, "y": 1080.0}},
|
||||
{"name": "Ton", "visible": true}
|
||||
]
|
||||
},
|
||||
"versioned_id": "scene"
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
fi
|
||||
|
||||
echo "Sende an rtmp://$ZIEL:1935/filmabend"
|
||||
echo "OBS fragt gleich, welches Fenster übertragen werden soll."
|
||||
echo "Beenden: OBS-Fenster schließen oder 'make stream-stop'."
|
||||
|
||||
# Ohne --minimize-to-tray bleibt das Fenster sichtbar und lässt sich normal
|
||||
# schließen. Mit Tray-Symbol ignoriert OBS auch Strg-C im Terminal.
|
||||
exec obs --profile "$PROFIL" --collection "$PROFIL" \
|
||||
--startstreaming --disable-updater
|
||||
Reference in New Issue
Block a user