update
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
TMDB_TOKEN=
|
TMDB_TOKEN=
|
||||||
MINIMAX_KEY=
|
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:
|
install:
|
||||||
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
|
||||||
cd frontend && npm install
|
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"
|
@echo "Backend: http://localhost:8000 Frontend: http://localhost:5173"
|
||||||
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
|
@cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 &
|
||||||
@cd frontend && npx vite --port 5173
|
@cd frontend && npx vite --port 5173
|
||||||
|
|
||||||
stop:
|
stop: mtx-stop stream-stop
|
||||||
-@pkill -f "uvicorn app.main:app" 2>/dev/null
|
-@pkill -f "uvicorn app.main:app" 2>/dev/null
|
||||||
-@pkill -f "vite --port 5173" 2>/dev/null
|
-@pkill -f "vite --port 5173" 2>/dev/null
|
||||||
@echo "gestoppt."
|
@echo "gestoppt."
|
||||||
@@ -32,6 +47,18 @@ logs:
|
|||||||
down:
|
down:
|
||||||
docker compose 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) ───────────────────────────────────
|
# ── Datenbank hin und her (lokal ausführen) ───────────────────────────────────
|
||||||
SERVER = root@178.104.67.87
|
SERVER = root@178.104.67.87
|
||||||
REMOTE = /var/www/horror
|
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
|
## Entwickeln
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make dev # Backend :8000, Frontend :5173
|
make dev # MediaMTX, Backend :8000, Frontend :5173
|
||||||
make stop
|
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
|
## 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
|
Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr
|
||||||
Platz im Raster.
|
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
|
## 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
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ MINIMAX_URL = "https://api.minimax.io/v1/chat/completions"
|
|||||||
MINIMAX_MODEL = "MiniMax-M3"
|
MINIMAX_MODEL = "MiniMax-M3"
|
||||||
|
|
||||||
PROVIDER_NETFLIX = 8
|
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"
|
REGION = "DE"
|
||||||
LANGUAGE = "de-DE"
|
LANGUAGE = "de-DE"
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 db, events, search, suggestions, sync
|
from . import db, events, search, streamproxy, suggestions, sync, watched
|
||||||
from .config import IMAGE_BASE, STATIC_DIR
|
from .config import IMAGE_BASE, STATIC_DIR
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
@@ -19,9 +19,23 @@ app = FastAPI(title="Netflix Horror DE")
|
|||||||
scheduler = AsyncIOScheduler()
|
scheduler = AsyncIOScheduler()
|
||||||
sync_lock = asyncio.Lock()
|
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 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(GzipOhneLivekanal, minimum_size=1024)
|
||||||
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@@ -43,6 +57,7 @@ async def guarded_sync():
|
|||||||
async def startup():
|
async def startup():
|
||||||
db.init()
|
db.init()
|
||||||
suggestions.init()
|
suggestions.init()
|
||||||
|
watched.init()
|
||||||
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync")
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
if not db.get_meta("last_sync"):
|
if not db.get_meta("last_sync"):
|
||||||
@@ -198,14 +213,7 @@ async def vorschlagen(
|
|||||||
filmabend_sid: str | None = Cookie(None),
|
filmabend_sid: str | None = Cookie(None),
|
||||||
):
|
):
|
||||||
sid = sitzung(response, filmabend_sid)
|
sid = sitzung(response, filmabend_sid)
|
||||||
|
movie = await film_holen(movie_id)
|
||||||
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")
|
|
||||||
|
|
||||||
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
# Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden
|
||||||
eintrag = suggestions.ersetzen(sid, movie, name)
|
eintrag = suggestions.ersetzen(sid, movie, name)
|
||||||
events.senden("suggestions")
|
events.senden("suggestions")
|
||||||
@@ -221,6 +229,90 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No
|
|||||||
return {"removed": entfernt}
|
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")
|
@app.post("/api/sync")
|
||||||
async def trigger_sync():
|
async def trigger_sync():
|
||||||
if sync_lock.locked():
|
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(
|
return conn.execute(
|
||||||
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
|
"DELETE FROM suggestions WHERE session_id = ?", (session_id,)
|
||||||
).rowcount
|
).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
|
container_name: horror
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
|
||||||
DB_PATH: /data/horror.db
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -20,6 +18,11 @@ services:
|
|||||||
- web
|
- web
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
DB_PATH: /data/horror.db
|
||||||
|
MEDIAMTX_URL: http://mediamtx:8890
|
||||||
|
depends_on:
|
||||||
|
- mediamtx
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.horrorapp.rule=Host(`horror.marha.de`)"
|
- "traefik.http.routers.horrorapp.rule=Host(`horror.marha.de`)"
|
||||||
@@ -27,6 +30,23 @@ services:
|
|||||||
- "traefik.http.routers.horrorapp.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.horrorapp.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.horrorapp.loadbalancer.server.port=8000"
|
- "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:
|
networks:
|
||||||
web:
|
web:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@@ -6,6 +6,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "horror-frontend",
|
"name": "horror-frontend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "^1.7.1",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -1134,6 +1135,12 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"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": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hls.js": "^1.7.1",
|
||||||
"vue": "^3.5.13"
|
"vue": "^3.5.13"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import MovieCard from './components/MovieCard.vue'
|
import MovieCard from './components/MovieCard.vue'
|
||||||
import FilterSidebar from './components/FilterSidebar.vue'
|
import FilterSidebar from './components/FilterSidebar.vue'
|
||||||
import MovieDetail from './components/MovieDetail.vue'
|
import MovieDetail from './components/MovieDetail.vue'
|
||||||
import SearchTab from './components/SearchTab.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 WheelTab from './components/WheelTab.vue'
|
||||||
import SeriesCard from './components/SeriesCard.vue'
|
import SeriesCard from './components/SeriesCard.vue'
|
||||||
|
|
||||||
@@ -14,7 +16,13 @@ const error = ref('')
|
|||||||
const search = ref('')
|
const search = ref('')
|
||||||
const sort = ref('rating')
|
const sort = ref('rating')
|
||||||
const selected = ref(null)
|
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 include = ref([])
|
||||||
const exclude = ref([])
|
const exclude = ref([])
|
||||||
|
|
||||||
@@ -187,13 +195,48 @@ const netflixEntries = computed(() => {
|
|||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ id: 'netflix', label: 'Netflix', count: netflixMovies.value.length },
|
{ 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: 'search', label: 'Suche', count: null },
|
||||||
{ id: 'wheel', label: 'Rad', 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)
|
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
|
// Wer schon vorgeschlagen hat, soll das in der Detailansicht sehen
|
||||||
const meinVorschlag = ref(null)
|
const meinVorschlag = ref(null)
|
||||||
|
|
||||||
@@ -326,11 +369,15 @@ async function load() {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
load()
|
load()
|
||||||
ladeVorschlag()
|
ladeVorschlag()
|
||||||
|
ladeGesehen()
|
||||||
|
window.addEventListener('keydown', tastatur)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', tastatur))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page" :class="{ breit: activeTab === 'search' || activeTab === 'wheel' }">
|
<div class="page" :class="{ breit: ['search', 'wheel', 'stream', 'watched'].includes(activeTab) }">
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button
|
<button
|
||||||
v-for="tab in tabs"
|
v-for="tab in tabs"
|
||||||
@@ -342,11 +389,27 @@ onMounted(() => {
|
|||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
<span v-if="tab.count !== null" class="count">{{ tab.count }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span v-if="host" class="host">Host</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<SearchTab v-if="activeTab === 'search'" @open="selected = $event" />
|
<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 v-else class="layout">
|
||||||
<div class="results">
|
<div class="results">
|
||||||
@@ -402,7 +465,10 @@ onMounted(() => {
|
|||||||
@close="selected = null"
|
@close="selected = null"
|
||||||
@open="selected = $event"
|
@open="selected = $event"
|
||||||
:mein-vorschlag="meinVorschlag"
|
:mein-vorschlag="meinVorschlag"
|
||||||
|
:host="host"
|
||||||
|
:gesehen="gesehen.some((e) => e.movie_id === selected.id)"
|
||||||
@suggested="nachVorschlag"
|
@suggested="nachVorschlag"
|
||||||
|
@watched="ladeGesehen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -446,6 +512,19 @@ onMounted(() => {
|
|||||||
border-bottom-color: var(--accent);
|
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 {
|
.tabs .count {
|
||||||
padding: 0.1rem 0.45rem;
|
padding: 0.1rem 0.45rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ const props = defineProps({
|
|||||||
movie: { type: Object, required: true },
|
movie: { type: Object, required: true },
|
||||||
series: { type: Array, default: () => [] },
|
series: { type: Array, default: () => [] },
|
||||||
meinVorschlag: { type: Object, default: null },
|
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: '' })
|
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 poster = computed(() => posterUrl(props.movie.poster_path, 'w342'))
|
||||||
const shots = computed(() =>
|
const shots = computed(() =>
|
||||||
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
(props.movie.images || []).map((path) => backdropUrl(path, 'w780'))
|
||||||
@@ -143,6 +163,23 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -348,6 +385,32 @@ h2 {
|
|||||||
cursor: default;
|
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 {
|
.hinweis {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
@@ -63,11 +63,6 @@ async function open(movie) {
|
|||||||
<p v-else-if="searched && !results.length" class="note">
|
<p v-else-if="searched && !results.length" class="note">
|
||||||
Nichts gefunden für „{{ query }}“.
|
Nichts gefunden für „{{ query }}“.
|
||||||
</p>
|
</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">
|
<div class="grid">
|
||||||
<MovieCard
|
<MovieCard
|
||||||
v-for="movie in results"
|
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>
|
<script setup>
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { posterUrl } from '../images'
|
import { posterUrl } from '../images'
|
||||||
|
import { abonnieren } from '../live'
|
||||||
|
|
||||||
const emit = defineEmits(['open'])
|
defineProps({ host: Boolean })
|
||||||
|
const emit = defineEmits(['open', 'reset'])
|
||||||
|
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const mine = ref(null)
|
const mine = ref(null)
|
||||||
@@ -26,16 +28,14 @@ const FARBEN = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
// Ohne Server-Ziehung sähe jede Seite ein anderes Ergebnis
|
||||||
let quelle = null
|
let abbestellen = null
|
||||||
|
|
||||||
function verbinden() {
|
function verbinden() {
|
||||||
quelle = new EventSource('/api/events')
|
abbestellen = abonnieren((nachricht) => {
|
||||||
quelle.onmessage = (ereignis) => {
|
|
||||||
const nachricht = JSON.parse(ereignis.data)
|
|
||||||
if (nachricht.type === 'suggestions') laden_()
|
if (nachricht.type === 'suggestions') laden_()
|
||||||
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
else if (nachricht.type === 'spin') abspielen(nachricht.data)
|
||||||
}
|
else if (nachricht.type === 'reset') zurueckgesetzt()
|
||||||
// Der Browser verbindet nach einem Abbruch von selbst neu
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function abspielen(ergebnis, uebersprungen = 0) {
|
function abspielen(ergebnis, uebersprungen = 0) {
|
||||||
@@ -101,7 +101,7 @@ onMounted(async () => {
|
|||||||
verbinden()
|
verbinden()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => quelle?.close())
|
onUnmounted(() => abbestellen?.())
|
||||||
|
|
||||||
defineExpose({ neu: laden_ })
|
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) {
|
async function oeffnen(item) {
|
||||||
const resp = await fetch(`/api/search/${item.movie_id}`)
|
const resp = await fetch(`/api/search/${item.movie_id}`)
|
||||||
if (resp.ok) emit('open', await resp.json())
|
if (resp.ok) emit('open', await resp.json())
|
||||||
@@ -174,64 +193,70 @@ async function oeffnen(item) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="wheel-tab">
|
<div class="wheel-tab">
|
||||||
<div class="buehne">
|
<div class="buehne">
|
||||||
<p v-if="laden" class="note">Lade Vorschläge…</p>
|
<p v-if="fehler" class="note error">{{ fehler }}</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>
|
|
||||||
|
|
||||||
<template v-else>
|
<div class="rad-halter">
|
||||||
<div class="rad-halter">
|
<div class="zeiger"></div>
|
||||||
<div class="zeiger"></div>
|
<svg
|
||||||
<svg
|
class="rad"
|
||||||
class="rad"
|
:class="{ dreht }"
|
||||||
:class="{ dreht }"
|
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
||||||
:viewBox="`0 0 ${GROESSE} ${GROESSE}`"
|
:style="{
|
||||||
:style="{
|
transform: `rotate(${winkel}deg)`,
|
||||||
transform: `rotate(${winkel}deg)`,
|
transitionDuration: `${dauer}s`,
|
||||||
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">
|
<g v-for="(seg, i) in segmente" :key="seg.item.id">
|
||||||
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
<path :d="seg.pfad" :fill="seg.farbe" stroke="#0a0a0c" stroke-width="2" />
|
||||||
<text
|
<text
|
||||||
v-if="seg.breit"
|
v-if="seg.breit"
|
||||||
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
:transform="`rotate(${seg.textDreh} ${MITTE} ${MITTE})`"
|
||||||
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
:x="seg.gespiegelt ? MITTE - 40 : MITTE + 40"
|
||||||
:y="MITTE + 4"
|
:y="MITTE + 4"
|
||||||
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
:text-anchor="seg.gespiegelt ? 'end' : 'start'"
|
||||||
class="beschriftung"
|
class="beschriftung"
|
||||||
>
|
>
|
||||||
{{ seg.kurz }}
|
{{ seg.kurz }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
<circle :cx="MITTE" :cy="MITTE" r="26" fill="#0a0a0c" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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' }}
|
{{ dreht ? 'dreht…' : 'Drehen' }}
|
||||||
</button>
|
</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)">
|
<div v-if="gewinner" class="gewinner" @click="oeffnen(gewinner)">
|
||||||
<img
|
<img
|
||||||
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
v-if="posterUrl(gewinner.poster_path, 'w154')"
|
||||||
:src="posterUrl(gewinner.poster_path, 'w154')"
|
:src="posterUrl(gewinner.poster_path, 'w154')"
|
||||||
:alt="gewinner.title"
|
:alt="gewinner.title"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p class="label">Der Film des Abends</p>
|
<p class="label">Der Film des Abends</p>
|
||||||
<h2>{{ gewinner.title }}</h2>
|
<h2>{{ gewinner.title }}</h2>
|
||||||
<p class="jahr">
|
<p class="jahr">
|
||||||
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
{{ gewinner.year }}<span v-if="gewinner.name"> · von {{ gewinner.name }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside v-if="items.length" class="liste">
|
<aside class="liste">
|
||||||
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
<h3>Vorschläge <span class="anzahl">{{ items.length }}</span></h3>
|
||||||
|
<p v-if="!items.length && !laden" class="leer">Noch nichts vorgeschlagen.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="(item, i) in items" :key="item.id">
|
<li v-for="(item, i) in items" :key="item.id">
|
||||||
<button
|
<button
|
||||||
@@ -300,6 +325,35 @@ async function oeffnen(item) {
|
|||||||
border-left: 11px solid transparent;
|
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 {
|
.dreh {
|
||||||
padding: 0.7rem 2.2rem;
|
padding: 0.7rem 2.2rem;
|
||||||
color: #fff;
|
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: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8000',
|
'/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