This commit is contained in:
team3
2026-08-31 11:54:20 +02:00
parent f29a4106bb
commit e7216138c7
21 changed files with 1517 additions and 87 deletions

View File

@@ -1,2 +1,3 @@
TMDB_TOKEN= TMDB_TOKEN=
MINIMAX_KEY= MINIMAX_KEY=
MTX_SENDEPASSWORT=

View File

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

View File

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

View File

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

View File

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

View 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,
)

View File

@@ -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
View 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")]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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