From d578d4e6aa7f1163d96a5c02da3da1c2ee11ee32 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Tue, 3 Mar 2026 22:28:26 +0100 Subject: [PATCH] updated live video --- backend/live.go | 81 +++++- backend/preview.go | 11 +- backend/record_stream_cb.go | 9 + backend/record_stream_mfc.go | 9 + frontend/src/components/ui/LiveHlsVideo.tsx | 262 -------------------- frontend/src/components/ui/LiveVideo.tsx | 111 +++++++++ frontend/src/components/ui/ModelDetails.tsx | 6 +- frontend/src/components/ui/ModelPreview.tsx | 6 +- frontend/src/components/ui/Player.tsx | 4 +- 9 files changed, 223 insertions(+), 276 deletions(-) delete mode 100644 frontend/src/components/ui/LiveHlsVideo.tsx create mode 100644 frontend/src/components/ui/LiveVideo.tsx diff --git a/backend/live.go b/backend/live.go index f801f93..1bf6120 100644 --- a/backend/live.go +++ b/backend/live.go @@ -61,6 +61,37 @@ func serveLiveNotReady(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(body)) } +func maybeBlockHLSOnPreview(w http.ResponseWriter, r *http.Request, basePath, file string) bool { + // Nur /api/preview (nicht /api/preview/live) + if strings.TrimSpace(basePath) != "/api/preview" { + return false + } + + low := strings.ToLower(strings.TrimSpace(file)) + if low == "" { + return false + } + + // Nur echte HLS-Dateien blocken (Manifest/Segmente) + if strings.HasSuffix(low, ".m3u8") || strings.HasSuffix(low, ".ts") || strings.HasSuffix(low, ".m4s") { + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Preview-HLS-Disabled", "1") + + // Optionales Debug (hilft dir, den Auslöser zu finden) + fmt.Printf("[HLS-BLOCK] %s file=%q referer=%q ua=%q\n", + r.URL.String(), + file, + r.Referer(), + r.Header.Get("User-Agent"), + ) + + http.Error(w, "HLS disabled on /api/preview; use /api/preview/live", http.StatusGone) // 410 + return true + } + + return false +} + // stopPreview stops the running ffmpeg HLS preview process for a job and resets state. func stopPreview(job *RecordJob) { jobsMu.Lock() @@ -81,8 +112,54 @@ func stopPreview(job *RecordJob) { } func recordPreviewLive(w http.ResponseWriter, r *http.Request) { - // identisch zu /api/preview, aber m3u8 rewriting soll auf /api/preview/live zeigen - recordPreviewWithBase(w, r, "/api/preview/live") + // ✅ Route bleibt /api/preview/live + // Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4). + file := strings.TrimSpace(r.URL.Query().Get("file")) + if file == "" { + recordPreviewLiveFMP4(w, r) + return + } + + // Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst) + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + servePreviewHLSFileWithBase(w, r, id, file, "/api/preview/live") +} + +// recordPreviewFile serves ONLY the HLS file requests for /api/preview?file=... +// preview.webp bleibt in preview.go (servePreviewWebPAlias). +func recordPreviewFile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + id = strings.TrimSpace(r.URL.Query().Get("name")) + } + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + file := strings.TrimSpace(r.URL.Query().Get("file")) + if file == "" { + http.Error(w, "file fehlt", http.StatusBadRequest) + return + } + + // ✅ HLS auf /api/preview blocken (Manifest/Segmente), um Polling-Storm zu verhindern. + // Wenn du es NICHT blocken willst, diese if-Zeile entfernen. + if maybeBlockHLSOnPreview(w, r, "/api/preview", file) { + return + } + + // Alles andere (falls doch erlaubt) über die gemeinsame Serving-Funktion + servePreviewHLSFileWithBase(w, r, id, file, "/api/preview") } // servePreviewHLSFileWithBase serves a single HLS file (index/segment) for a job. diff --git a/backend/preview.go b/backend/preview.go index cda4a7b..44032f8 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -1493,15 +1493,18 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri return } - // HLS / file serving + // file serving if file := strings.TrimSpace(r.URL.Query().Get("file")); file != "" { - low := strings.ToLower(file) + low := strings.ToLower(strings.TrimSpace(file)) + + // ✅ preview.webp weiterhin hier behandeln if low == "preview.webp" { servePreviewWebPAlias(w, r, id) return } - // ✅ Wichtig: HLS rewrite soll auf basePath zeigen (/api/preview oder /api/preview/live) - servePreviewHLSFileWithBase(w, r, id, file, basePath) + + // ✅ alles andere (m3u8/ts/m4s/...) liegt jetzt in live.go + recordPreviewFile(w, r) return } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 40d3f6b..c3167c7 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -51,6 +51,15 @@ func RecordStream( return fmt.Errorf("playlist abrufen: %w", err) } + // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input + if job != nil { + jobsMu.Lock() + job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) + job.PreviewCookie = httpCookie + job.PreviewUA = hc.userAgent + jobsMu.Unlock() + } + if job != nil && strings.TrimSpace(job.PreviewDir) == "" { assetID := assetIDForJob(job) if strings.TrimSpace(assetID) == "" { diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index fd73f69..2d8b939 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -75,6 +75,15 @@ func RecordStreamMFC( return fmt.Errorf("mfc: keine m3u8 URL gefunden") } + // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input + if job != nil { + jobsMu.Lock() + job.PreviewM3U8 = strings.TrimSpace(m3u8URL) + job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen + job.PreviewUA = hc.userAgent + jobsMu.Unlock() + } + // ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar) if job != nil { _ = publishJob(job.ID) diff --git a/frontend/src/components/ui/LiveHlsVideo.tsx b/frontend/src/components/ui/LiveHlsVideo.tsx deleted file mode 100644 index 38bcd70..0000000 --- a/frontend/src/components/ui/LiveHlsVideo.tsx +++ /dev/null @@ -1,262 +0,0 @@ -// frontend\src\components\ui\LiveHlsVideo.tsx - -'use client' - -import { useEffect, useMemo, useRef, useState } from 'react' -import Hls from 'hls.js' -import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy' - -function withNonce(url: string, nonce: number) { - const sep = url.includes('?') ? '&' : '?' - return `${url}${sep}v=${nonce}` -} - -export default function LiveHlsVideo({ - src, - muted = DEFAULT_INLINE_MUTED, - className, -}: { - src: string - muted?: boolean - className?: string -}) { - const ref = useRef(null) - const [broken, setBroken] = useState(false) - const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null) - - // ✅ Nur für "harte Reloads" (triggert Effect neu) - const [reloadKey, setReloadKey] = useState(1) - - // ✅ manifestUrl ist stabil pro reloadKey - const manifestUrl = useMemo(() => withNonce(src, reloadKey), [src, reloadKey]) - - const lastReloadAtRef = useRef(0) - - useEffect(() => { - let cancelled = false - let hls: Hls | null = null - let stallTimer: number | null = null - let watchdogTimer: number | null = null - - const videoEl = ref.current - if (!videoEl) return - const video: HTMLVideoElement = videoEl - - setBroken(false) - setBrokenReason(null) - - applyInlineVideoPolicy(video, { muted }) - - const cleanupTimers = () => { - if (stallTimer) window.clearTimeout(stallTimer) - if (watchdogTimer) window.clearInterval(watchdogTimer) - stallTimer = null - watchdogTimer = null - } - - const hardReload = () => { - if (cancelled) return - - const now = Date.now() - // ✅ verhindert Reload-Stürme (z.B. wenn hls.js kurz zickt) - if (now - lastReloadAtRef.current < 4000) return - lastReloadAtRef.current = now - - cleanupTimers() - setReloadKey((x) => x + 1) - } - - async function waitForManifestWithSegments(): Promise<{ ok: boolean; reason?: 'private' | 'offline' }> { - const started = Date.now() - - while (!cancelled && Date.now() - started < 90_000) { - try { - // ✅ immer frisch pollen (Cache umgehen) - const url = withNonce(src, Date.now()) - const r = await fetch(url, { cache: 'no-store' }) - - if (r.status === 403) return { ok: false, reason: 'private' } - if (r.status === 404) return { ok: false, reason: 'offline' } - - if (r.ok) { - const txt = await r.text() - if (txt.includes('#EXTINF')) return { ok: true } - } - } catch {} - await new Promise((res) => setTimeout(res, 1200)) - } - - // kein reason => "noch nicht ready" - return { ok: false } - } - - async function start() { - const res = await waitForManifestWithSegments() - if (cancelled) return - - if (!res.ok) { - // ✅ Nur echte Endzustände dauerhaft anzeigen - if (res.reason === 'private' || res.reason === 'offline') { - setBrokenReason(res.reason) - setBroken(true) - return - } - - // ✅ Sonst: retry per reloadKey (ohne broken) - window.setTimeout(() => { - if (!cancelled) hardReload() - }, 800) - return - } - - // ✅ Safari / iOS: Native HLS - if (video.canPlayType('application/vnd.apple.mpegurl')) { - video.pause() - video.removeAttribute('src') - video.load() - - video.src = manifestUrl - video.load() - video.play().catch((e) => console.debug('[LiveHlsVideo] play() failed', e)) - - // ---- Stall Handling (native) ---- - let lastProgressTs = Date.now() - let lastTime = -1 - - const onTimeUpdate = () => { - if (video.currentTime > lastTime + 0.01) { - lastTime = video.currentTime - lastProgressTs = Date.now() - } - } - - const scheduleStallReload = () => { - if (stallTimer) return - stallTimer = window.setTimeout(() => { - stallTimer = null - if (!cancelled && Date.now() - lastProgressTs > 3500) hardReload() - }, 800) - } - - video.addEventListener('timeupdate', onTimeUpdate) - video.addEventListener('waiting', scheduleStallReload) - video.addEventListener('stalled', scheduleStallReload) - video.addEventListener('error', scheduleStallReload) - - watchdogTimer = window.setInterval(() => { - if (cancelled) return - if (!video.paused && Date.now() - lastProgressTs > 6000) hardReload() - }, 2000) - - return () => { - video.removeEventListener('timeupdate', onTimeUpdate) - video.removeEventListener('waiting', scheduleStallReload) - video.removeEventListener('stalled', scheduleStallReload) - video.removeEventListener('error', scheduleStallReload) - } - } - - // ✅ Nicht-Safari: hls.js - if (!Hls.isSupported()) { - setBroken(true) - return - } - - hls = new Hls({ - lowLatencyMode: false, - - // ✅ Live: nicht super-aggressiv hinterherlaufen - liveSyncDurationCount: 3, - liveMaxLatencyDurationCount: 10, - - // Buffer - maxBufferLength: 12, - backBufferLength: 30, - - // ✅ Netzwerk-Retry-Backoff (verhindert Request-Stürme) - manifestLoadingTimeOut: 8000, - manifestLoadingMaxRetry: 6, - manifestLoadingRetryDelay: 1000, - manifestLoadingMaxRetryTimeout: 8000, - - levelLoadingTimeOut: 8000, - levelLoadingMaxRetry: 6, - levelLoadingRetryDelay: 1000, - levelLoadingMaxRetryTimeout: 8000, - - fragLoadingTimeOut: 8000, - fragLoadingMaxRetry: 6, - fragLoadingRetryDelay: 1000, - fragLoadingMaxRetryTimeout: 8000, - }) - - hls.on(Hls.Events.ERROR, (_evt, data) => { - if (!hls) return - - if (data.fatal) { - if (data.type === Hls.ErrorTypes.NETWORK_ERROR) { - hls.startLoad() - return - } - if (data.type === Hls.ErrorTypes.MEDIA_ERROR) { - hls.recoverMediaError() - return - } - // ✅ statt "für immer kaputt": einmal hart reloaden - hardReload() - } - }) - - hls.loadSource(manifestUrl) - hls.attachMedia(video) - - hls.on(Hls.Events.MANIFEST_PARSED, () => { - video.play().catch((e) => console.debug('[LiveHlsVideo] play() failed', e)) - }) - } - - let nativeCleanup: void | (() => void) = undefined - void (async () => { - const maybeCleanup = await start() - if (typeof maybeCleanup === 'function') nativeCleanup = maybeCleanup - })() - - return () => { - cancelled = true - cleanupTimers() - try { - nativeCleanup?.() - } catch {} - try { - hls?.destroy() - } catch {} - } - }, [src, muted, manifestUrl]) // <- manifestUrl ist OK, weil reloadKey NICHT im Effect gesetzt wird - - if (broken) { - return ( -
- {brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : '–'} -
- ) - } - - return ( -