updated live video

This commit is contained in:
Linrador 2026-03-03 22:28:26 +01:00
parent a0a869c5a5
commit d578d4e6aa
9 changed files with 223 additions and 276 deletions

View File

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

View File

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

View File

@ -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) == "" {

View File

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

View File

@ -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<HTMLVideoElement>(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 (
<div className="text-xs text-gray-400 italic">
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : ''}
</div>
)
}
return (
<video
ref={ref}
className={className}
playsInline
autoPlay
muted={muted}
preload="auto"
crossOrigin="anonymous"
onClick={() => {
const v = ref.current
if (v) {
v.muted = false
v.play().catch(() => {})
}
}}
/>
)
}

View File

@ -0,0 +1,111 @@
// frontend/src/components/ui/LiveVideo.tsx
'use client'
import { useEffect, useRef, useState } from 'react'
import { applyInlineVideoPolicy, DEFAULT_INLINE_MUTED } from './videoPolicy'
export default function LiveVideo({
src,
muted = DEFAULT_INLINE_MUTED,
className,
}: {
src: string
muted?: boolean
className?: string
}) {
const ref = useRef<HTMLVideoElement>(null)
const [broken, setBroken] = useState(false)
const [brokenReason, setBrokenReason] = useState<'private' | 'offline' | null>(null)
useEffect(() => {
let cancelled = false
let watchdogTimer: number | null = null
const video = ref.current
if (!video) return
setBroken(false)
setBrokenReason(null)
applyInlineVideoPolicy(video, { muted })
const hardReset = () => {
try {
video.pause()
video.removeAttribute('src')
video.load()
} catch {}
}
// 1) src setzen (fMP4/MP4 stream, single HTTP response)
hardReset()
video.src = src
video.load()
video.play().catch(() => {})
// 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen
let lastT = Date.now()
const onTime = () => {
lastT = Date.now()
}
video.addEventListener('timeupdate', onTime)
watchdogTimer = window.setInterval(() => {
if (cancelled) return
// wenn nicht paused, aber 12s keine timeupdate => neu verbinden
if (!video.paused && Date.now() - lastT > 12_000) {
hardReset()
video.src = src
video.load()
video.play().catch(() => {})
lastT = Date.now()
}
}, 4_000)
// 3) HTTP-Fehler (403/404) erkennen ist bei <video> nicht sauber möglich.
// Wir machen hier bewusst KEIN aggressives Retry. Broken UI nur, wenn Video "error" signalisiert.
const onError = () => {
// best effort: 403/404 kannst du im Backend zusätzlich über Query/JSON o.ä. signalisieren,
// aber hier: generic broken
setBroken(true)
}
video.addEventListener('error', onError)
return () => {
cancelled = true
if (watchdogTimer) window.clearInterval(watchdogTimer)
watchdogTimer = null
video.removeEventListener('timeupdate', onTime)
video.removeEventListener('error', onError)
hardReset()
}
}, [src, muted])
if (broken) {
return (
<div className="text-xs text-gray-400 italic">
{brokenReason === 'private' ? 'Private' : brokenReason === 'offline' ? 'Offline' : ''}
</div>
)
}
return (
<video
ref={ref}
className={className}
playsInline
autoPlay
muted={muted}
preload="auto"
crossOrigin="anonymous"
onClick={() => {
const v = ref.current
if (v) {
v.muted = false
v.play().catch(() => {})
}
}}
/>
)
}

View File

@ -38,7 +38,7 @@ import TagOverflowRow from './TagOverflowRow'
import PreviewScrubber from './PreviewScrubber'
import { formatResolution } from './formatters'
import Pagination from './Pagination'
import LiveHlsVideo from './LiveHlsVideo'
import LiveVideo from './LiveVideo'
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ')
@ -2735,8 +2735,8 @@ export default function ModelDetails({
{/* Live HLS inline (statt Teaser) */}
{showLive ? (
<div className="absolute inset-0 z-10">
<LiveHlsVideo
src={`/api/preview/live?id=${encodeURIComponent(String((j as any)?.id ?? ''))}&file=index_hq.m3u8&hover=1`}
<LiveVideo
src={`/api/preview/live?id=${encodeURIComponent(String((j as any)?.id ?? ''))}&hover=1`}
className="absolute inset-0 w-full h-full"
muted
/>

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import HoverPopover from './HoverPopover'
import LiveHlsVideo from './LiveHlsVideo'
import LiveVideo from './LiveVideo'
import { XMarkIcon } from '@heroicons/react/24/outline'
type Props = {
@ -237,7 +237,7 @@ export default function ModelPreview({
)
const hq = useMemo(
() => `/api/preview?id=${encodeURIComponent(jobId)}&hover=1&file=index_hq.m3u8`,
() => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1`,
[jobId]
)
@ -269,7 +269,7 @@ export default function ModelPreview({
}}
>
<div className="absolute inset-0">
<LiveHlsVideo src={hq} muted={true} className="w-full h-full object-contain object-bottom relative z-0" />
<LiveVideo src={hq} muted={true} className="w-full h-full object-contain object-bottom relative z-0" />
<div className="absolute left-2 top-2 inline-flex items-center gap-1.5 rounded-full bg-red-600/90 px-2 py-1 text-[11px] font-semibold text-white shadow-sm">
<span className="inline-block size-1.5 rounded-full bg-white animate-pulse" />

View File

@ -17,7 +17,7 @@ import { DEFAULT_PLAYER_START_MUTED } from './videoPolicy'
import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveHlsVideo from './LiveHlsVideo'
import LiveVideo from './LiveVideo'
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
@ -1762,7 +1762,7 @@ export default function Player({
>
{isRunning ? (
<div className="absolute inset-0 bg-black">
<LiveHlsVideo
<LiveVideo
src={liveHlsSrc}
muted={startMuted}
className="w-full h-full object-contain object-bottom"