fixed cb stream error
This commit is contained in:
parent
80b9f69597
commit
013f616303
114
backend/main.go
114
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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -542,6 +542,22 @@ export default function App() {
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user