From 013f6163037ffaadaddefc6f6a6f3ff657caa5dd Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 27 Mar 2026 07:29:30 +0100 Subject: [PATCH] fixed cb stream error --- backend/main.go | 114 +++++- backend/preview.go | 120 ++++-- backend/record_stream_cb.go | 365 ++++++++++++++++-- frontend/src/App.tsx | 71 +++- .../src/components/ui/RecorderSettings.tsx | 110 +++--- 5 files changed, 631 insertions(+), 149 deletions(-) diff --git a/backend/main.go b/backend/main.go index 65525bc..9ce907b 100644 --- a/backend/main.go +++ b/backend/main.go @@ -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 diff --git a/backend/preview.go b/backend/preview.go index 548c32d..4d46d86 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -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 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) - } - } + if genErr != nil { + 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 } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 805c47f..870585d 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -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): + } } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 54add8f..320df57 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -542,6 +542,22 @@ export default function App() { const eventSourceRef = useRef(null) const modelEventNamesRef = useRef>(new Set()) const onModelJobEventRef = useRef<((ev: MessageEvent) => void) | null>(null) + + + const [playerModelKey, setPlayerModelKey] = useState(null) + const [sourceUrl, setSourceUrl] = useState('') + const [jobs, setJobs] = useState([]) + const [doneJobs, setDoneJobs] = useState([]) + const [donePage, setDonePage] = useState(1) + const [doneCount, setDoneCount] = useState(0) + const [modelsCount, setModelsCount] = useState(0) + + const [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) + + const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) + const [nowMs, setNowMs] = useState(() => Date.now()) + + const [modelsByKey, setModelsByKey] = useState>({}) const selectSourceUrl = useCallback(() => { const el = sourceUrlInputRef.current @@ -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(null) - const [sourceUrl, setSourceUrl] = useState('') - const [jobs, setJobs] = useState([]) - const [doneJobs, setDoneJobs] = useState([]) - const [donePage, setDonePage] = useState(1) - const [doneCount, setDoneCount] = useState(0) - const [modelsCount, setModelsCount] = useState(0) - - const [roomStatusByModelKey, setRoomStatusByModelKey] = useState>({}) - - const [lastHeaderUpdateAtMs, setLastHeaderUpdateAtMs] = useState(() => Date.now()) - const [nowMs, setNowMs] = useState(() => Date.now()) - - const [modelsByKey, setModelsByKey] = useState>({}) + useEffect(() => { + if (!authed) return + void loadDonePage(donePage, doneSort) + }, [authed, donePage, doneSort, loadDonePage]) useEffect(() => { setModelsCount(Object.keys(modelsByKey).length) diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 0ef9d5f..a9a6db0 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -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)