diff --git a/backend/app/main.py b/backend/app/main.py index 8c9a07e..7625988 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,29 +13,42 @@ 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") +# Ein einzelner Zuschauer erzeugt gut zwanzig Stream-Anfragen je Sekunde. +# Jede davon zu protokollieren füllt bei fünfzehn Leuten die Platte und +# sagt über den Betrieb nichts aus. Fehler kommen weiterhin durch. +logging.getLogger("httpx").setLevel(logging.WARNING) + + +class OhneStreamZugriffe(logging.Filter): + def filter(self, eintrag): + return "/stream/" not in eintrag.getMessage() + + +logging.getLogger("uvicorn.access").addFilter(OhneStreamZugriffe()) log = logging.getLogger("app") app = FastAPI(title="Netflix Horror DE") scheduler = AsyncIOScheduler() sync_lock = asyncio.Lock() -class GzipOhneLivekanal(GZipMiddleware): - """Kompression für alles außer den Ereignisstrom. +# Der Ereignisstrom darf nicht durch die Kompression, und Video auch nicht: +# GZipMiddleware sammelt die Antwort, bevor sie ausgeht. Bei Server-Sent +# Events kommt dadurch gar nichts an, solange der Puffer nicht voll ist — +# und der wird bei kurzen Ereignissen nie voll. Videosegmente sind bereits +# komprimiert; sie noch einmal durch gzip zu schicken kostet nur Rechenzeit. +OHNE_KOMPRESSION = ("/api/events", "/stream") - 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. - """ +class GzipMitAusnahmen(GZipMiddleware): async def __call__(self, scope, receive, send): - if scope["type"] == "http" and scope["path"].startswith("/api/events"): + if scope["type"] == "http" and scope["path"].startswith(OHNE_KOMPRESSION): 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. -app.add_middleware(GzipOhneLivekanal, minimum_size=1024) +app.add_middleware(GzipMitAusnahmen, minimum_size=1024) app.add_middleware( CORSMiddleware, @@ -65,6 +78,11 @@ async def startup(): asyncio.create_task(guarded_sync()) +@app.on_event("shutdown") +async def shutdown(): + await streamproxy.schliessen() + + @app.get("/api/health") def health(): return {"ok": True} diff --git a/backend/app/streamproxy.py b/backend/app/streamproxy.py index 445fb95..170c616 100644 --- a/backend/app/streamproxy.py +++ b/backend/app/streamproxy.py @@ -1,14 +1,18 @@ """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. +Ursprünge, also CORS und eine zweite Route nach außen. + +Low-Latency-HLS fragt viel und oft: je Zuschauer rund zwanzig Anfragen pro +Sekunde, für Bild und Ton getrennt. Deshalb hält dieses Modul einen +gemeinsamen Client mit offenen Verbindungen und reicht die Daten stückweise +weiter, statt jedes Segment erst vollständig in den Speicher zu holen. """ import httpx from fastapi import HTTPException, Request from fastapi.responses import Response, StreamingResponse +from starlette.background import BackgroundTask from .config import MEDIAMTX_URL @@ -23,26 +27,45 @@ DURCHREICHEN = { "etag", } +# Ein Client für alle Anfragen. Ein neuer je Segment hieße: bei fünfzehn +# Zuschauern dreihundert Verbindungsaufbauten pro Sekunde. +# +# Der lange Timeout gehört zu Low-Latency-HLS: Der Player fragt nach einem +# Teil, das es noch nicht gibt, und MediaMTX hält die Antwort zurück, bis +# es fertig ist. +_klient = httpx.AsyncClient( + timeout=httpx.Timeout(30.0, connect=5.0), + limits=httpx.Limits(max_connections=200, max_keepalive_connections=100), + follow_redirects=True, +) + + +async def schliessen(): + await _klient.aclose() + async def weiterleiten(request: Request, pfad: str): - ziel = f"{MEDIAMTX_URL}/{pfad}" - frage = dict(request.query_params) + anfrage = _klient.build_request( + "GET", f"{MEDIAMTX_URL}/{pfad}", params=dict(request.query_params) + ) + try: + antwort = await _klient.send(anfrage, stream=True) + except httpx.HTTPError: + raise HTTPException(status_code=502, detail="Stream nicht erreichbar") - 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") + if antwort.status_code >= 400: + await antwort.aclose() + if antwort.status_code == 404: + raise HTTPException(status_code=404, detail="Kein Stream") + raise HTTPException(status_code=antwort.status_code, detail="Stream-Fehler") 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, + return StreamingResponse( + antwort.aiter_raw(), status_code=antwort.status_code, headers=kopf, + background=BackgroundTask(antwort.aclose), ) diff --git a/frontend/src/components/StreamTab.vue b/frontend/src/components/StreamTab.vue index f0f5b0d..ea25595 100644 --- a/frontend/src/components/StreamTab.vue +++ b/frontend/src/components/StreamTab.vue @@ -36,8 +36,10 @@ function verbinden() { // 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, + // Ziel-Latenz zu halten. 1,5 wäre deutlich hörbar, wenn der Player sie + // ausreizt; 1,1 reicht für normale Schwankungen und fällt nicht auf. + // Bleibt der Rückstand größer, springt liveMaxLatencyDuration ohnehin. + maxLiveSyncPlaybackRate: 1.1, backBufferLength: 30, }) diff --git a/mediamtx/mediamtx.yml b/mediamtx/mediamtx.yml index bdfa0e0..b359b93 100644 --- a/mediamtx/mediamtx.yml +++ b/mediamtx/mediamtx.yml @@ -9,7 +9,13 @@ hlsSegmentCount: 7 hlsSegmentDuration: 1s hlsPartDuration: 200ms # Die Seite reicht den Stream unter eigenem Ursprung durch -hlsAllowOrigin: '*' +hlsAllowOrigins: ['*'] + +# Ohne das baut MediaMTX den Muxer erst auf, wenn der erste Zuschauer +# fragt — und reißt ihn 60s nach dem letzten wieder ab. Jeder Neuaufbau +# setzt die Zeitbasis zurück; wer dazukommt, während gerade keiner schaut, +# wartet auf den Aufbau. Durchlaufend sehen alle dieselbe Stelle. +hlsAlwaysRemux: yes rtmp: yes rtmpAddress: :1935