fixed cb stream error

This commit is contained in:
Linrador 2026-03-27 07:29:30 +01:00
parent 80b9f69597
commit 013f616303
5 changed files with 631 additions and 149 deletions

View File

@ -12,6 +12,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@ -1294,7 +1295,7 @@ func renameJobsOutputBasename(oldFile, newFile string) {
// nimmt jetzt *HTTPClient entgegen
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
if hlsSource == "" {
if strings.TrimSpace(hlsSource) == "" {
return nil, errors.New("HLS-URL leer")
}
@ -1309,51 +1310,122 @@ func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie st
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("master playlist bad status: %d %s", resp.StatusCode, hlsSource)
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
if err != nil || listType != m3u8.MASTER {
if err != nil {
return nil, fmt.Errorf("master playlist decode failed: %w", err)
}
if listType != m3u8.MASTER {
return nil, errors.New("keine gültige Master-Playlist")
}
master := playlist.(*m3u8.MasterPlaylist)
var bestURI string
master, ok := playlist.(*m3u8.MasterPlaylist)
if !ok || master == nil {
return nil, errors.New("master playlist parse fehlgeschlagen")
}
baseURL, err := url.Parse(hlsSource)
if err != nil {
return nil, fmt.Errorf("master url parse failed: %w", err)
}
var bestVariant *m3u8.Variant
var bestWidth int
var bestFramerate int
for _, variant := range master.Variants {
if variant == nil || variant.Resolution == "" {
continue
parseFramerate := func(v *m3u8.Variant) int {
if v == nil {
return 0
}
parts := strings.Split(variant.Resolution, "x")
// 1) Falls BANDWIDTH/FRAME-RATE Infos im Namen kodiert sind
name := strings.ToUpper(strings.TrimSpace(v.Name))
switch {
case strings.Contains(name, "FPS:60"), strings.Contains(name, "FPS=60"), strings.Contains(name, "60FPS"):
return 60
case strings.Contains(name, "FPS:50"), strings.Contains(name, "FPS=50"), strings.Contains(name, "50FPS"):
return 50
case strings.Contains(name, "FPS:30"), strings.Contains(name, "FPS=30"), strings.Contains(name, "30FPS"):
return 30
}
// 2) Fallback Standard
return 30
}
parseWidth := func(v *m3u8.Variant) int {
if v == nil {
return 0
}
res := strings.TrimSpace(v.Resolution)
if res == "" {
return 0
}
parts := strings.Split(res, "x")
if len(parts) != 2 {
continue
return 0
}
width, err := strconv.Atoi(parts[0])
w, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0
}
return w
}
for _, variant := range master.Variants {
if variant == nil || strings.TrimSpace(variant.URI) == "" {
continue
}
fr := 30
if strings.Contains(variant.Name, "FPS:60.0") {
fr = 60
}
width := parseWidth(variant)
fr := parseFramerate(variant)
if width > bestWidth || (width == bestWidth && fr > bestFramerate) {
// auch Varianten ohne Resolution zulassen, aber niedriger priorisieren
if bestVariant == nil ||
width > bestWidth ||
(width == bestWidth && fr > bestFramerate) {
bestVariant = variant
bestWidth = width
bestFramerate = fr
bestURI = variant.URI
}
}
if bestURI == "" {
return nil, errors.New("keine gültige Auflösung gefunden")
if bestVariant == nil {
return nil, errors.New("keine gültige Variante gefunden")
}
variantRef, err := url.Parse(strings.TrimSpace(bestVariant.URI))
if err != nil {
return nil, fmt.Errorf("variant uri parse failed: %w", err)
}
playlistURL := baseURL.ResolveReference(variantRef).String()
playlistParsed, err := url.Parse(playlistURL)
if err != nil {
return nil, fmt.Errorf("playlist url parse failed: %w", err)
}
rootParsed := *playlistParsed
rootParsed.RawQuery = ""
rootParsed.Fragment = ""
lastSlash := strings.LastIndex(rootParsed.Path, "/")
if lastSlash >= 0 {
rootParsed.Path = rootParsed.Path[:lastSlash+1]
} else {
rootParsed.Path = "/"
}
root := hlsSource[:strings.LastIndex(hlsSource, "/")+1]
return &Playlist{
PlaylistURL: root + bestURI,
RootURL: root,
PlaylistURL: playlistURL,
RootURL: rootParsed.String(),
Resolution: bestWidth,
Framerate: bestFramerate,
}, nil

View File

@ -1323,51 +1323,121 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er
}
func latestPreviewSegment(previewDir string) (string, error) {
entries, err := os.ReadDir(previewDir)
segDir := previewSegmentsDir(previewDir)
entries, err := os.ReadDir(segDir)
if err != nil {
return "", err
}
var best string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "seg_low_") && !strings.HasPrefix(name, "seg_hq_") {
name := strings.ToLower(e.Name())
if !strings.HasPrefix(name, "seg_preview_") {
continue
}
if best == "" || name > best {
best = name
best = e.Name()
}
}
if best == "" {
return "", fmt.Errorf("kein Preview-Segment in %s", previewDir)
return "", fmt.Errorf("kein Preview-Segment in %s", segDir)
}
return filepath.Join(previewDir, best), nil
return filepath.Join(segDir, best), nil
}
func buildPreviewInputFromDir(previewDir string) (string, func(), error) {
segDir := previewSegmentsDir(previewDir)
if strings.TrimSpace(segDir) == "" {
return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir)
}
segPath, err := latestPreviewSegment(previewDir)
if err != nil {
return "", nil, err
}
ext := strings.ToLower(filepath.Ext(segPath))
// TS kann direkt gelesen werden
if ext == ".ts" {
return segPath, func() {}, nil
}
// fMP4: init.mp4 + segment.m4s zu temp zusammensetzen
initPath := filepath.Join(segDir, "init.mp4")
if _, err := os.Stat(initPath); err != nil {
return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir)
}
initBytes, err := os.ReadFile(initPath)
if err != nil {
return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err)
}
segBytes, err := os.ReadFile(segPath)
if err != nil {
return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err)
}
tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4")
if err != nil {
return "", nil, err
}
tmpPath := tmp.Name()
cleanup := func() {
_ = tmp.Close()
_ = os.Remove(tmpPath)
}
if _, err := tmp.Write(initBytes); err != nil {
cleanup()
return "", nil, err
}
if _, err := tmp.Write(segBytes); err != nil {
cleanup()
return "", nil, err
}
if err := tmp.Close(); err != nil {
cleanup()
return "", nil, err
}
return tmpPath, cleanup, nil
}
func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPGScaled(seg, 320, 70)
defer cleanup()
img, err := extractLastFrameJPGScaled(inputPath, 320, 70)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(seg, 320, 70)
return extractFirstFrameJPGScaled(inputPath, 320, 70)
}
func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) {
seg, err := latestPreviewSegment(previewDir)
inputPath, cleanup, err := buildPreviewInputFromDir(previewDir)
if err != nil {
return nil, err
}
img, err := extractLastFrameJPG(seg)
if err != nil {
return extractFirstFrameJPGScaled(seg, 720, 75)
}
defer cleanup()
img, err := extractLastFrameJPG(inputPath)
if err == nil && len(img) > 0 {
return img, nil
}
return extractFirstFrameJPGScaled(inputPath, 720, 75)
}
func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) {
@ -1552,23 +1622,8 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
previewDir := strings.TrimSpace(j.PreviewDir)
if previewDir != "" {
img, genErr = extractLastFrameFromPreviewDirJPG(previewDir)
}
if genErr != nil || len(img) == 0 {
outPath := strings.TrimSpace(j.Output)
if outPath != "" {
outPath = filepath.Clean(outPath)
if !filepath.IsAbs(outPath) {
if abs, err := resolvePathRelativeToApp(outPath); err == nil {
outPath = abs
}
}
if fi, err := os.Stat(outPath); err == nil && !fi.IsDir() && fi.Size() > 0 {
img, genErr = extractLastFrameJPG(outPath)
if genErr != nil {
img, _ = extractFirstFrameJPGScaled(outPath, 720, 75)
}
}
fmt.Println("preview from previewDir failed:", previewDir, genErr)
}
}
@ -1602,8 +1657,7 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri
return
}
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
servePreviewStatusSVG(w, "Preview", http.StatusOK)
return
}

View File

@ -9,7 +9,10 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@ -17,6 +20,25 @@ import (
"github.com/grafov/m3u8"
)
func previewSegmentsDir(previewDir string) string {
previewDir = strings.TrimSpace(previewDir)
if previewDir == "" {
return ""
}
return filepath.Join(previewDir, "segments")
}
func ensurePreviewSegmentsDir(previewDir string) (string, error) {
dir := previewSegmentsDir(previewDir)
if strings.TrimSpace(dir) == "" {
return "", fmt.Errorf("previewDir leer")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
// --- DVR-ähnlicher Recorder-Ablauf ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream(
@ -28,8 +50,6 @@ func RecordStream(
httpCookie string,
job *RecordJob,
) error {
// 1) Seite laden
// Domain sauber zusammenbauen (mit/ohne Slash)
base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username
@ -38,58 +58,159 @@ func RecordStream(
return fmt.Errorf("seite laden: %w", err)
}
// 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
hlsURL, err := ParseStream(body)
if err != nil {
return fmt.Errorf("stream-parsing: %w", err)
}
// 3) Playlist holen (wie stream.GetPlaylist im DVR)
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist abrufen: %w", err)
}
// ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input
var previewDir string
var segmentsDir string
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
if dir, derr := ensureGeneratedDir(assetID); derr == nil {
previewDir = strings.TrimSpace(dir)
if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil {
segmentsDir = strings.TrimSpace(sdir)
} else {
fmt.Println("preview ensurePreviewSegmentsDir failed:", assetID, serr)
}
} else {
fmt.Println("preview ensureGeneratedDir failed:", assetID, derr)
}
}
jobsMu.Lock()
job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL)
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
if previewDir != "" {
job.PreviewDir = previewDir
}
jobsMu.Unlock()
if previewDir != "" {
startLiveThumbJPGLoop(ctx, job)
}
}
if segmentsDir != "" {
defer func() {
if err := os.RemoveAll(segmentsDir); err != nil {
//fmt.Println("preview segments cleanup failed:", segmentsDir, err)
}
}()
}
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
// live size tracking (für UI)
var written int64
var lastPush time.Time
var lastBytes int64
published := false
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben
trimPreviewSegments := func(dir string, keep int) {
if strings.TrimSpace(dir) == "" || keep <= 0 {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type item struct {
name string
mod time.Time
}
items := make([]item, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
lower := strings.ToLower(name)
if lower == "init.mp4" {
continue
}
if !strings.HasPrefix(lower, "seg_preview_") {
continue
}
info, err := e.Info()
if err != nil {
continue
}
items = append(items, item{
name: name,
mod: info.ModTime(),
})
}
if len(items) <= keep {
return
}
sort.Slice(items, func(i, j int) bool {
return items[i].mod.Before(items[j].mod)
})
for i := 0; i < len(items)-keep; i++ {
_ = os.Remove(filepath.Join(dir, items[i].name))
}
}
err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error {
if len(b) == 0 {
return nil
}
if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err)
}
// ✅ erst sichtbar machen, wenn wirklich Bytes geschrieben wurden
if err := file.Sync(); err != nil {
//fmt.Println("output sync failed:", outputPath, err)
}
if segmentsDir != "" {
ext := ".m4s"
if len(b) > 0 && b[0] == 0x47 {
ext = ".ts"
}
segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext)
segPath := filepath.Join(segmentsDir, segName)
if err := os.WriteFile(segPath, b, 0o644); err != nil {
fmt.Println("preview segment write failed:", segPath, err)
} else {
trimPreviewSegments(segmentsDir, 12)
}
}
if job != nil && !published {
published = true
_ = publishJob(job.ID)
}
// ✅ live size (UI) throttled
written += int64(len(b))
if job != nil {
now := time.Now()
@ -103,8 +224,8 @@ func RecordStream(
}
}
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
_ = duration // aktuell unbenutzt
_ = duration
_ = segmentURL
return nil
})
if err != nil {
@ -159,11 +280,75 @@ func (p *Playlist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
previewDir string,
handler func(segmentURL string, b []byte, duration float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 60 // statt 5
const maxEmptyRounds = 60
playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL))
if err != nil || playlistBaseURL == nil {
return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL)
}
var lastMapURL string
writeInitIfNeeded := func(mapURL string) {
if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" {
return
}
segDir, err := ensurePreviewSegmentsDir(previewDir)
if err != nil {
fmt.Println("ensurePreviewSegmentsDir failed:", previewDir, err)
return
}
initPath := filepath.Join(segDir, "init.mp4")
// schon vorhanden und gleiches URL-Mapping -> nichts tun
if mapURL == lastMapURL {
if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 {
return
}
}
req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie)
if err != nil {
fmt.Println("init request build failed:", mapURL, err)
return
}
resp, err := hc.client.Do(req)
if err != nil {
fmt.Println("init download failed:", mapURL, err)
return
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
fmt.Println("init bad status:", resp.StatusCode, mapURL)
_, _ = io.Copy(io.Discard, resp.Body)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Println("init read failed:", mapURL, err)
return
}
if len(b) == 0 {
fmt.Println("init empty:", mapURL)
return
}
if err := os.WriteFile(initPath, b, 0o644); err != nil {
fmt.Println("init write failed:", initPath, err)
return
}
lastMapURL = mapURL
}
for {
select {
@ -172,41 +357,122 @@ func (p *Playlist) WatchSegments(
default:
}
// Playlist holen
if strings.TrimSpace(p.PlaylistURL) == "" {
return errors.New("playlist url fehlt")
}
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err)
}
resp, err := hc.client.Do(req)
if err != nil {
emptyRounds++
fmt.Println("playlist download failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Playlist nicht mehr erreichbar Stream vermutlich offline")
}
time.Sleep(2 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
//fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL)
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
emptyRounds++
if emptyRounds >= maxEmptyRounds {
return fmt.Errorf("❌ playlist liefert status %d stream vermutlich offline", resp.StatusCode)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
playlist, listType, err := m3u8.DecodeFrom(resp.Body, true)
resp.Body.Close()
if err != nil || listType != m3u8.MEDIA {
if err != nil {
emptyRounds++
fmt.Println("playlist decode failed:", p.PlaylistURL, err)
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
time.Sleep(2 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
media := playlist.(*m3u8.MediaPlaylist)
if listType != m3u8.MEDIA {
emptyRounds++
fmt.Println("playlist is not media playlist:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
media, ok := playlist.(*m3u8.MediaPlaylist)
if !ok || media == nil {
emptyRounds++
fmt.Println("playlist cast to media failed:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds {
return errors.New("❌ Fehlerhafte Media-Playlist möglicherweise offline")
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
continue
}
if media.Map != nil {
mapURI := strings.TrimSpace(media.Map.URI)
if mapURI != "" {
mapRef, err := url.Parse(mapURI)
if err != nil {
//fmt.Println("map uri parse failed:", mapURI, err)
} else {
mapURL := playlistBaseURL.ResolveReference(mapRef).String()
writeInitIfNeeded(mapURL)
}
}
}
newSegment := false
successfulSegment := false
for _, segment := range media.Segments {
if segment == nil {
continue
}
if int64(segment.SeqId) <= lastSeq {
continue
}
@ -214,31 +480,66 @@ func (p *Playlist) WatchSegments(
lastSeq = int64(segment.SeqId)
newSegment = true
segmentURL := p.RootURL + segment.URI
segURI := strings.TrimSpace(segment.URI)
if segURI == "" {
//fmt.Println("segment uri empty in playlist:", p.PlaylistURL)
continue
}
segRef, err := url.Parse(segURI)
if err != nil {
//fmt.Println("segment uri parse failed:", segURI, err)
continue
}
segmentURL := playlistBaseURL.ResolveReference(segRef).String()
segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
if err != nil {
//fmt.Println("segment request build failed:", segmentURL, err)
continue
}
segResp, err := hc.client.Do(segReq)
if err != nil {
//fmt.Println("segment download failed:", segmentURL, err)
continue
}
if segResp.StatusCode < 200 || segResp.StatusCode >= 300 {
//fmt.Println("segment bad status:", segResp.StatusCode, segmentURL)
_, _ = io.Copy(io.Discard, segResp.Body)
segResp.Body.Close()
continue
}
data, err := io.ReadAll(segResp.Body)
segResp.Body.Close()
if err != nil || len(data) == 0 {
if err != nil {
//fmt.Println("segment read failed:", segmentURL, err)
continue
}
if len(data) == 0 {
//fmt.Println("segment empty:", segmentURL)
continue
}
if err := handler(data, segment.Duration); err != nil {
if err := handler(segmentURL, data, segment.Duration); err != nil {
return err
}
successfulSegment = true
}
if newSegment {
if successfulSegment {
emptyRounds = 0
} else if newSegment {
emptyRounds++
//fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL)
if emptyRounds >= maxEmptyRounds {
return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden Stream vermutlich geschützt, fehlerhaft oder offline.")
}
} else {
emptyRounds++
if emptyRounds >= maxEmptyRounds {
@ -246,7 +547,11 @@ func (p *Playlist) WatchSegments(
}
}
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}

View File

@ -543,6 +543,22 @@ export default function App() {
const modelEventNamesRef = useRef<Set<string>>(new Set())
const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null)
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1)
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
const selectSourceUrl = useCallback(() => {
const el = sourceUrlInputRef.current
if (!el) return
@ -753,6 +769,43 @@ export default function App() {
}
}, [])
const loadDonePage = useCallback(async (pageToLoad = donePage, sortToLoad = doneSort) => {
try {
const key = makePrefetchKey(pageToLoad, sortToLoad)
const prefetched = donePrefetchRef.current
if (prefetched?.key === key && Array.isArray(prefetched.items)) {
setDoneJobs(prefetched.items)
setLastHeaderUpdateAtMs(Date.now())
donePrefetchRef.current = null
void prefetchDonePage(pageToLoad + 1)
return
}
const res = await fetch(
`/api/record/done?page=${pageToLoad}&pageSize=${DONE_PAGE_SIZE}&sort=${encodeURIComponent(sortToLoad)}`,
{ cache: 'no-store' as any }
)
if (!res.ok) return
const data = await res.json().catch(() => null)
const items = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
setDoneJobs(items)
setLastHeaderUpdateAtMs(Date.now())
void prefetchDonePage(pageToLoad + 1)
} catch {
// ignore
}
}, [donePage, doneSort, prefetchDonePage])
const requestFinishedReload = useCallback((reason = 'unknown') => {
const now = Date.now()
const cooldownMs = 700 // 👈 wichtig gegen Event-Stürme
@ -838,20 +891,10 @@ export default function App() {
void loadDoneCount()
}, [authed, loadDoneCount])
const [playerModelKey, setPlayerModelKey] = useState<string | null>(null)
const [sourceUrl, setSourceUrl] = useState('')
const [jobs, setJobs] = useState<RecordJob[]>([])
const [doneJobs, setDoneJobs] = useState<RecordJob[]>([])
const [donePage, setDonePage] = useState(1)
const [doneCount, setDoneCount] = useState<number>(0)
const [modelsCount, setModelsCount] = useState(0)
const [roomStatusByModelKey, setRoomStatusByModelKey] = useState<Record<string, string>>({})
const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState<number>(() => Date.now())
const [nowMs, setNowMs] = useState<number>(() => Date.now())
const [modelsByKey, setModelsByKey] = useState<Record<string, StoredModel>>({})
useEffect(() => {
if (!authed) return
void loadDonePage(donePage, doneSort)
}, [authed, donePage, doneSort, loadDonePage])
useEffect(() => {
setModelsCount(Object.keys(modelsByKey).length)

View File

@ -215,6 +215,65 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const data = await res.json().catch(() => null)
if (!alive || !data) return
const assets = data.generateAssets
if (assets?.running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: true,
fading: false,
}))
} else if (assets?.error) {
const assetsError = String(assets.error || '').trim()
const cancelled =
assetsError.toLowerCase() === 'abgebrochen' ||
assetsError.toLowerCase() === 'abbruch' ||
assetsError.toLowerCase() === 'cancelled' ||
assetsError.toLowerCase() === 'canceled'
setAssetsTask((t) => ({
...t,
status: cancelled ? 'cancelled' : 'error',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: cancelled ? undefined : assetsError,
cancellable: false,
fading: false,
}))
} else if (assets?.text || assets?.finishedAt) {
setAssetsTask((t) => ({
...t,
status: 'done',
title: 'Assets generieren',
text: shortTaskFilename(String(assets.currentFile || assets.text || '')),
done: Number(assets.done ?? 0),
total: Number(assets.total ?? 0),
err: undefined,
cancellable: false,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
cancellable: true,
fading: false,
}))
}
const regen = data.regenerateAssets
if (regen?.running) {
setRegenerateAssetsTask((t) => ({
@ -356,57 +415,6 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}, [])
useEffect(() => {
let alive = true
const loadTaskStatus = async () => {
try {
const res = await fetch('/api/tasks/generate-assets', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!alive || !data) return
const running = Boolean(data.running)
const done = Number(data.done ?? 0)
const total = Number(data.total ?? 0)
const currentFile = shortTaskFilename(data.currentFile)
if (running) {
setAssetsTask((t) => ({
...t,
status: 'running',
title: 'Assets generieren',
text: currentFile || '',
done,
total,
err: undefined,
fading: false,
}))
} else {
setAssetsTask((t) => ({
...t,
status: 'idle',
title: 'Assets generieren',
text: '',
done: 0,
total: 0,
err: undefined,
fading: false,
}))
}
} catch {
// ignorieren
}
}
void loadTaskStatus()
return () => {
alive = false
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)