From e7216138c70dfb59008dfbf3ad79004b601b5dd9 Mon Sep 17 00:00:00 2001 From: team3 Date: Mon, 31 Aug 2026 11:54:20 +0200 Subject: [PATCH] update --- .env.example | 1 + Makefile | 33 +- README.md | 82 ++++- backend/app/config.py | 4 + backend/app/main.py | 114 ++++++- backend/app/streamproxy.py | 48 +++ backend/app/suggestions.py | 6 + backend/app/watched.py | 169 ++++++++++ docker-compose.yml | 24 +- frontend/package-lock.json | 7 + frontend/package.json | 1 + frontend/src/App.vue | 89 ++++- frontend/src/components/MovieDetail.vue | 65 +++- frontend/src/components/SearchTab.vue | 5 - frontend/src/components/StreamTab.vue | 180 ++++++++++ frontend/src/components/WatchedTab.vue | 425 ++++++++++++++++++++++++ frontend/src/components/WheelTab.vue | 168 ++++++---- frontend/src/live.js | 18 + frontend/vite.config.js | 4 + mediamtx/mediamtx.yml | 38 +++ scripts/stream.sh | 123 +++++++ 21 files changed, 1517 insertions(+), 87 deletions(-) create mode 100644 backend/app/streamproxy.py create mode 100644 backend/app/watched.py create mode 100644 frontend/src/components/StreamTab.vue create mode 100644 frontend/src/components/WatchedTab.vue create mode 100644 frontend/src/live.js create mode 100644 mediamtx/mediamtx.yml create mode 100755 scripts/stream.sh diff --git a/.env.example b/.env.example index 9042f87..3d1f945 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ TMDB_TOKEN= MINIMAX_KEY= +MTX_SENDEPASSWORT= diff --git a/Makefile b/Makefile index 39f3dff..2465cd0 100644 --- a/Makefile +++ b/Makefile @@ -1,15 +1,30 @@ -.PHONY: install dev stop sync build prod logs down push pull +.PHONY: install dev stop sync build prod logs down push pull stream stream-stop mtx mtx-stop install: cd backend && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt cd frontend && npm install -dev: +# MediaMTX nimmt den Stream entgegen. Ohne ihn läuft der Stream-Reiter ins +# Leere und OBS findet keinen Server. +mtx: + @docker ps --filter name=horror-mtx-dev --format '{{.Names}}' | grep -q . || \ + docker run -d --name horror-mtx-dev --network host \ + --env-file .env \ + -e MTX_AUTHINTERNALUSERS_0_PASS="$$(grep '^MTX_SENDEPASSWORT=' .env | cut -d= -f2-)" \ + -v "$(PWD)/mediamtx/mediamtx.yml:/mediamtx.yml:ro" \ + bluenviron/mediamtx:latest > /dev/null + @echo "MediaMTX läuft: RTMP auf :1935, HLS auf :8890" + +mtx-stop: + -@docker rm -f horror-mtx-dev 2>/dev/null > /dev/null + @echo "MediaMTX gestoppt." + +dev: mtx @echo "Backend: http://localhost:8000 Frontend: http://localhost:5173" @cd backend && .venv/bin/uvicorn app.main:app --reload --port 8000 & @cd frontend && npx vite --port 5173 -stop: +stop: mtx-stop stream-stop -@pkill -f "uvicorn app.main:app" 2>/dev/null -@pkill -f "vite --port 5173" 2>/dev/null @echo "gestoppt." @@ -32,6 +47,18 @@ logs: down: docker compose down +# ── Senden ─────────────────────────────────────────────────────────────────── +# Ohne Argument an den lokalen MediaMTX, sonst an den Server: +# make stream ZIEL=horror.marha.de +ZIEL ?= 127.0.0.1 + +stream: mtx + @scripts/stream.sh $(ZIEL) + +stream-stop: + -@pkill -f "obs --profile filmabend" 2>/dev/null + @echo "OBS beendet." + # ── Datenbank hin und her (lokal ausführen) ─────────────────────────────────── SERVER = root@178.104.67.87 REMOTE = /var/www/horror diff --git a/README.md b/README.md index 111e24b..aedde13 100644 --- a/README.md +++ b/README.md @@ -33,11 +33,15 @@ Im Betrieb läuft der Sync täglich um 4:30 von selbst. ## Entwickeln ```bash -make dev # Backend :8000, Frontend :5173 -make stop +make dev # MediaMTX, Backend :8000, Frontend :5173 +make stop # alles wieder aus ``` -Der Vite-Dev-Server leitet `/api` an den Backend-Port weiter. +`make dev` startet MediaMTX als Container mit. Ohne ihn liefe der +Stream-Reiter ins Leere und OBS fände keinen Server. Einzeln geht es über +`make mtx` und `make mtx-stop`. + +Der Vite-Dev-Server leitet `/api` ans Backend und `/stream` an MediaMTX. ## Produktiv @@ -113,6 +117,78 @@ Im Raster stehen Reihen als eine Karte, die durch ihre Teile blättert. Aus Die Karte startet beim bestbewerteten Teil, danach richtet sich auch ihr Platz im Raster. +## Der Stream-Reiter + +Du sendest von deinem Rechner per OBS an den Server, alle schauen im Browser +zu. Voice läuft weiter über Discord. + +``` +OBS ──RTMP──► MediaMTX ──LL-HLS──► FastAPI ──► Browser (hls.js) +``` + +**Senden:** + +```bash +make stream # an den lokalen MediaMTX +make stream ZIEL=horror.marha.de # an den Server +make stream-stop # OBS beenden +``` + +Strg-C im Terminal beendet OBS nicht zuverlässig — deshalb `make stream-stop` +oder einfach das OBS-Fenster schließen. + +Das Skript legt ein eigenes OBS-Profil `filmabend` an und startet OBS damit. +Deine normale OBS-Konfiguration bleibt unberührt. Beim Start fragt der +Desktop, welches Fenster übertragen werden soll — erst nach dieser Auswahl +fließen Bilder. + +Bitrate und Auflösung stehen in +`~/.config/obs-studio/basic/profiles/filmabend/basic.ini` und werden nur +beim ersten Mal geschrieben. Das Sendeziel aktualisiert das Skript jedes Mal. + +Von Hand geht es auch: + +| Feld | Wert | +| --- | --- | +| Server | `rtmp://horror.marha.de:1935/` | +| Streamschlüssel | `filmabend?user=sender&pass=` | + +Ohne Passwort weist MediaMTX die Verbindung ab. Zuschauen braucht keins. + +**Ton:** OBS nimmt den Desktop-Ton über PulseAudio mit. Im Browser ginge +das nicht — dort liefert `getDisplayMedia` unter Linux nur Tab-Ton, und +Firefox gar keinen. + +**Warum alle dasselbe Bild sehen** + +Jeder Player hält 4 Sekunden Abstand zur Live-Kante (`liveSyncDuration`). +Wer zurückfällt, spielt bis zu 1,5-fach schneller ab, bis er wieder auf +Ziellatenz ist — das regelt hls.js über `maxLiveSyncPlaybackRate`. Ab 7 +Sekunden Rückstand springt es stattdessen. + +Gemessen mit zwei unabhängigen Playern am selben Stream: + +| Fall | Ergebnis | +| --- | --- | +| Dauerlauf, 45 s | Latenz konstant 4,05 s, keine Regelung nötig | +| Zwei Player | **11 ms** Bildversatz | +| Nach 8 s Aussetzer | zurück auf 12 ms in 9 Sekunden | + +Die Anzeige rechts zeigt Verzögerung, Abweichung, Tempo und die Sendezeit +aus dem Manifest. Zwei Zuschauer mit gleicher Sendezeit sehen exakt dasselbe +Bild — damit lässt sich der Gleichlauf im Betrieb prüfen. + +**Grenzen** + +- Dein Upload muss stabil 6–8 Mbit/s halten. Reicht er nicht, wird es für + alle schlechter. +- Der Server schiebt pro Zuschauer die volle Bitrate raus. Bei 15 Leuten + und 6 Mbit/s sind das 90 Mbit/s und rund 80 GB je Filmabend. Hetzner + liefert 10 Gbit und 20 TB im Monat. +- Der HLS-Proxy in FastAPI reicht jedes Byte durch Python. Für einen + Filmabend genügt das; bei mehr Last gehört eine Traefik-Route direkt auf + MediaMTX davor. + ## Vorschläge und Rad Im Reiter **Rad** landen die Filmvorschläge. Jede Sitzung darf einen Film diff --git a/backend/app/config.py b/backend/app/config.py index c7c92c4..a837e7b 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -16,6 +16,10 @@ MINIMAX_URL = "https://api.minimax.io/v1/chat/completions" MINIMAX_MODEL = "MiniMax-M3" PROVIDER_NETFLIX = 8 + +# MediaMTX liefert den Live-Stream; die Adresse zeigt im Container auf den +# Dienstnamen, lokal auf localhost +MEDIAMTX_URL = os.getenv("MEDIAMTX_URL", "http://127.0.0.1:8890") REGION = "DE" LANGUAGE = "de-DE" diff --git a/backend/app/main.py b/backend/app/main.py index c180f37..8c9a07e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -9,7 +9,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles -from . import db, events, search, suggestions, sync +from . import db, events, search, streamproxy, suggestions, sync, watched from .config import IMAGE_BASE, STATIC_DIR logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") @@ -19,9 +19,23 @@ app = FastAPI(title="Netflix Horror DE") scheduler = AsyncIOScheduler() sync_lock = asyncio.Lock() +class GzipOhneLivekanal(GZipMiddleware): + """Kompression für alles außer den Ereignisstrom. + + GZipMiddleware sammelt die Antwort, bevor sie ausgeht. Bei Server-Sent + Events heißt das: Es kommt gar nichts an, solange der Puffer nicht voll + ist — und der wird bei kurzen Ereignissen nie voll. + """ + + async def __call__(self, scope, receive, send): + if scope["type"] == "http" and scope["path"].startswith("/api/events"): + await self.app(scope, receive, send) + return + await super().__call__(scope, receive, send) + + # Der Katalog ist reines JSON und schrumpft dabei auf etwa ein Fünftel. -# Der Live-Kanal bleibt außen vor: gepufferte Ereignisse kämen zu spät. -app.add_middleware(GZipMiddleware, minimum_size=1024) +app.add_middleware(GzipOhneLivekanal, minimum_size=1024) app.add_middleware( CORSMiddleware, @@ -43,6 +57,7 @@ async def guarded_sync(): async def startup(): db.init() suggestions.init() + watched.init() scheduler.add_job(guarded_sync, "cron", hour=4, minute=30, id="daily-sync") scheduler.start() if not db.get_meta("last_sync"): @@ -198,14 +213,7 @@ async def vorschlagen( filmabend_sid: str | None = Cookie(None), ): sid = sitzung(response, filmabend_sid) - - movie = db.get_movie(movie_id) - if not movie: - try: - movie = await search.detail(movie_id) - except Exception: - raise HTTPException(status_code=404, detail="Film nicht gefunden") - + movie = await film_holen(movie_id) # Ein zweiter Vorschlag ersetzt den ersten, statt abgelehnt zu werden eintrag = suggestions.ersetzen(sid, movie, name) events.senden("suggestions") @@ -221,6 +229,90 @@ def vorschlag_loeschen(response: Response, filmabend_sid: str | None = Cookie(No return {"removed": entfernt} +async def film_holen(movie_id: int): + """Erst der Katalog, dann TMDB — Suchtreffer stehen nicht in der DB.""" + movie = db.get_movie(movie_id) + if movie: + return movie + try: + return await search.detail(movie_id) + except Exception: + raise HTTPException(status_code=404, detail="Film nicht gefunden") + + +@app.get("/api/watched") +def liste_gesehen(response: Response, filmabend_sid: str | None = Cookie(None)): + sid = sitzung(response, filmabend_sid) + return watched.alle(sid) + + +@app.post("/api/watched") +async def als_gesehen(movie_id: int = Body(..., embed=True)): + """Der Host trägt ein, was am Abend lief.""" + eintrag = watched.markieren(await film_holen(movie_id)) + events.senden("watched") + return eintrag + + +@app.delete("/api/watched/{watched_id}") +def gesehen_entfernen(watched_id: int): + entfernt = watched.entfernen(watched_id) + if entfernt: + events.senden("watched") + return {"removed": entfernt} + + +@app.patch("/api/watched/{watched_id}") +def gesehen_aendern( + watched_id: int, + watched_at: str | None = Body(None, embed=True), + hidden: bool | None = Body(None, embed=True), +): + """Der Host rückt das Datum nach oder blendet einen Film aus.""" + geaendert = 0 + if watched_at: + geaendert += watched.datum_setzen(watched_id, watched_at) + if hidden is not None: + geaendert += watched.sichtbarkeit(watched_id, hidden) + if not geaendert: + raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") + events.senden("watched") + return {"ok": True} + + +@app.post("/api/watched/{watched_id}/rating") +def bewerten( + watched_id: int, + response: Response, + stars: int = Body(..., embed=True), + name: str | None = Body(None, embed=True), + filmabend_sid: str | None = Cookie(None), +): + sid = sitzung(response, filmabend_sid) + stars = max(1, min(watched.MAX_STERNE, stars)) + eintrag = watched.bewerten(watched_id, sid, stars, name) + if not eintrag: + raise HTTPException(status_code=404, detail="Film steht nicht in der Liste") + events.senden("watched") + return eintrag + + +@app.delete("/api/suggestions/alle") +def runde_zuruecksetzen(): + """Host leert die Runde: Vorschläge weg, Rad wieder leer.""" + entfernt = suggestions.alles_loeschen() + letzter_dreh["ergebnis"] = None + letzter_dreh["start"] = 0.0 + events.senden("reset") + return {"removed": entfernt} + + +@app.get("/stream/{pfad:path}") +async def stream(request: Request, pfad: str): + """HLS von MediaMTX unter eigenem Ursprung ausliefern.""" + return await streamproxy.weiterleiten(request, pfad) + + @app.post("/api/sync") async def trigger_sync(): if sync_lock.locked(): diff --git a/backend/app/streamproxy.py b/backend/app/streamproxy.py new file mode 100644 index 0000000..445fb95 --- /dev/null +++ b/backend/app/streamproxy.py @@ -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, + ) diff --git a/backend/app/suggestions.py b/backend/app/suggestions.py index 74dbc54..ea62593 100644 --- a/backend/app/suggestions.py +++ b/backend/app/suggestions.py @@ -126,3 +126,9 @@ def loeschen(session_id): return conn.execute( "DELETE FROM suggestions WHERE session_id = ?", (session_id,) ).rowcount + + +def alles_loeschen(): + """Host setzt die Runde zurück.""" + with connect() as conn: + return conn.execute("DELETE FROM suggestions").rowcount diff --git a/backend/app/watched.py b/backend/app/watched.py new file mode 100644 index 0000000..6e27de7 --- /dev/null +++ b/backend/app/watched.py @@ -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")] diff --git a/docker-compose.yml b/docker-compose.yml index a8d2422..00b9c74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,6 @@ services: container_name: horror restart: unless-stopped env_file: .env - environment: - DB_PATH: /data/horror.db healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health', timeout=5)"] interval: 30s @@ -20,6 +18,11 @@ services: - web volumes: - ./data:/data + environment: + DB_PATH: /data/horror.db + MEDIAMTX_URL: http://mediamtx:8890 + depends_on: + - mediamtx labels: - "traefik.enable=true" - "traefik.http.routers.horrorapp.rule=Host(`horror.marha.de`)" @@ -27,6 +30,23 @@ services: - "traefik.http.routers.horrorapp.tls.certresolver=letsencrypt" - "traefik.http.services.horrorapp.loadbalancer.server.port=8000" + mediamtx: + image: bluenviron/mediamtx:latest + container_name: horror-mediamtx + restart: unless-stopped + env_file: .env + environment: + # MediaMTX liest Konfiguration auch aus der Umgebung. Damit steht das + # Sendepasswort nicht in der Datei, die im Git liegt. + MTX_AUTHINTERNALUSERS_0_PASS: ${MTX_SENDEPASSWORT} + volumes: + - ./mediamtx/mediamtx.yml:/mediamtx.yml:ro + # RTMP nach außen für OBS; HLS bleibt intern und läuft über die App + ports: + - "1935:1935" + networks: + - web + networks: web: external: true diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 06397e2..9d25de9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "horror-frontend", "dependencies": { + "hls.js": "^1.7.1", "vue": "^3.5.13" }, "devDependencies": { @@ -1134,6 +1135,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/hls.js": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.1.tgz", + "integrity": "sha512-DlzIkeBAS9IIQ432k3BUf3HlwbsR0+trB1i2lDdN2gUkNkrehFurh0/48M5c1/EjlDkdGng1gwZIpwyPxvdZ/g==", + "license": "Apache-2.0" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/frontend/package.json b/frontend/package.json index b73e6da..d5d36e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,7 @@ "preview": "vite preview" }, "dependencies": { + "hls.js": "^1.7.1", "vue": "^3.5.13" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 817074f..d0df7f1 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,9 +1,11 @@ + +
+ + Steht unter „Gesehen“. + + + + Hat nicht geklappt. + +
@@ -348,6 +385,32 @@ h2 { cursor: default; } +.gesehen { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin-top: 0.6rem; +} + +.gesehen button { + padding: 0.45rem 1rem; + font-size: 0.85rem; + font-weight: 600; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 7px; +} + +.gesehen button:hover:not(:disabled) { + border-color: var(--accent); +} + +.gesehen button:disabled { + color: var(--muted); + cursor: default; +} + .hinweis { flex-basis: 100%; color: var(--muted); diff --git a/frontend/src/components/SearchTab.vue b/frontend/src/components/SearchTab.vue index 5d5b493..0caa4e1 100644 --- a/frontend/src/components/SearchTab.vue +++ b/frontend/src/components/SearchTab.vue @@ -63,11 +63,6 @@ async function open(movie) {

Nichts gefunden für „{{ query }}“.

-

- Tippe einen Titel ein. Gesucht wird der ganze TMDB-Bestand, nicht nur - der Katalog. -

-
+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 +}) + + + + + + diff --git a/frontend/src/components/WatchedTab.vue b/frontend/src/components/WatchedTab.vue new file mode 100644 index 0000000..8063e64 --- /dev/null +++ b/frontend/src/components/WatchedTab.vue @@ -0,0 +1,425 @@ + + + + + diff --git a/frontend/src/components/WheelTab.vue b/frontend/src/components/WheelTab.vue index 9bafdf0..77cce40 100644 --- a/frontend/src/components/WheelTab.vue +++ b/frontend/src/components/WheelTab.vue @@ -1,8 +1,10 @@