// backend\record_stream_hls.go package main import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "syscall" "time" "github.com/grafov/m3u8" ) type selectedHLSStream struct { MasterURL string VideoURL string AudioURL string HasSeparateAudio bool Height int FPS float64 } type hlsAltMedia struct { Type string GroupID string Name string URI string } func parseHLSAttributeList(s string) map[string]string { out := map[string]string{} s = strings.TrimSpace(s) for len(s) > 0 { eq := strings.IndexByte(s, '=') if eq <= 0 { break } key := strings.TrimSpace(s[:eq]) rest := s[eq+1:] var val string if strings.HasPrefix(rest, `"`) { rest = rest[1:] end := strings.IndexByte(rest, '"') if end == -1 { val = rest s = "" } else { val = rest[:end] rest = rest[end+1:] if strings.HasPrefix(rest, ",") { rest = rest[1:] } s = rest } } else { comma := strings.IndexByte(rest, ',') if comma == -1 { val = rest s = "" } else { val = rest[:comma] s = rest[comma+1:] } } key = strings.TrimSpace(key) val = strings.TrimSpace(val) if key != "" { out[key] = val } } return out } func parseAltAudioFromMaster(masterText string) []hlsAltMedia { lines := strings.Split(masterText, "\n") out := make([]hlsAltMedia, 0, 8) for _, line := range lines { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "#EXT-X-MEDIA:") { continue } attrText := strings.TrimPrefix(line, "#EXT-X-MEDIA:") attrs := parseHLSAttributeList(attrText) if !strings.EqualFold(strings.TrimSpace(attrs["TYPE"]), "AUDIO") { continue } out = append(out, hlsAltMedia{ Type: strings.TrimSpace(attrs["TYPE"]), GroupID: strings.TrimSpace(attrs["GROUP-ID"]), Name: strings.TrimSpace(attrs["NAME"]), URI: strings.TrimSpace(attrs["URI"]), }) } return out } func getWantedResolutionPlaylistWithHeaders( ctx context.Context, playlistURL string, httpCookie string, userAgent string, referer string, ) (*selectedHLSStream, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil) if err != nil { return nil, err } if strings.TrimSpace(userAgent) != "" { req.Header.Set("User-Agent", strings.TrimSpace(userAgent)) } if strings.TrimSpace(httpCookie) != "" { req.Header.Set("Cookie", strings.TrimSpace(httpCookie)) } if strings.TrimSpace(referer) != "" { ref := strings.TrimSpace(referer) req.Header.Set("Referer", ref) if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { req.Header.Set("Origin", ru.Scheme+"://"+ru.Host) } } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } rawBody, err := io.ReadAll(resp.Body) if err != nil { return nil, appErrorf("m3u8 read: %w", err) } masterText := string(rawBody) playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true) if err != nil { return nil, appErrorf("m3u8 parse: %w", err) } // Schon Media-Playlist if listType == m3u8.MEDIA { return &selectedHLSStream{ MasterURL: playlistURL, VideoURL: playlistURL, AudioURL: "", HasSeparateAudio: false, Height: 0, FPS: 0, }, nil } master, ok := playlist.(*m3u8.MasterPlaylist) if !ok || master == nil { return nil, errors.New("unerwarteter playlist-typ: keine master-playlist") } altAudio := parseAltAudioFromMaster(masterText) baseURL, err := url.Parse(playlistURL) if err != nil { return nil, err } var bestURI string var bestHeight int var bestFramerate float64 var bestAudioGroup string for _, v := range master.Variants { if v == nil || strings.TrimSpace(v.URI) == "" { continue } h := 0 if v.Resolution != "" { parts := strings.Split(v.Resolution, "x") if len(parts) == 2 { if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { h = hh } } } fr := 30.0 if v.FrameRate > 0 { fr = v.FrameRate } audioGroup := strings.TrimSpace(v.Audio) shouldTake := false switch { case bestURI == "": shouldTake = true case h > bestHeight: shouldTake = true case h == bestHeight && fr > bestFramerate: shouldTake = true } if shouldTake { bestURI = strings.TrimSpace(v.URI) bestHeight = h bestFramerate = fr bestAudioGroup = audioGroup } } if bestURI == "" { return nil, errors.New("Master-Playlist ohne gültige Varianten") } refURL, err := url.Parse(bestURI) if err != nil { return nil, err } videoURL := baseURL.ResolveReference(refURL).String() audioURL := "" hasSeparateAudio := false if bestAudioGroup != "" { hasSeparateAudio = true var bestAudioURI string var bestAudioName string for _, a := range altAudio { if strings.TrimSpace(a.GroupID) != bestAudioGroup { continue } if strings.TrimSpace(a.URI) == "" { continue } name := strings.ToLower(strings.TrimSpace(a.Name)) uri := strings.TrimSpace(a.URI) if bestAudioURI == "" { bestAudioURI = uri bestAudioName = name continue } score := func(s string) int { n := strings.ToLower(strings.TrimSpace(s)) points := 0 if strings.Contains(n, "128") { points += 100 } if strings.Contains(n, "high") { points += 20 } if strings.Contains(n, "aac") { points += 10 } return points } if score(name) > score(bestAudioName) { bestAudioURI = uri bestAudioName = name } } if bestAudioURI == "" { hasSeparateAudio = false } else { audioRef, err := url.Parse(bestAudioURI) if err != nil { return nil, err } audioURL = baseURL.ResolveReference(audioRef).String() } } return &selectedHLSStream{ MasterURL: playlistURL, VideoURL: videoURL, AudioURL: audioURL, HasSeparateAudio: hasSeparateAudio, Height: bestHeight, FPS: bestFramerate, }, nil } func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { lines := make([]string, 0, 6) if strings.TrimSpace(httpCookie) != "" { lines = append(lines, fmt.Sprintf("Cookie: %s", strings.TrimSpace(httpCookie))) } if strings.TrimSpace(referer) != "" { ref := strings.TrimSpace(referer) lines = append(lines, fmt.Sprintf("Referer: %s", ref)) if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { lines = append(lines, fmt.Sprintf("Origin: %s://%s", ru.Scheme, ru.Host)) } } if strings.TrimSpace(userAgent) != "" { lines = append(lines, fmt.Sprintf("User-Agent: %s", strings.TrimSpace(userAgent))) } if len(lines) == 0 { return "" } return strings.Join(lines, "\r\n") + "\r\n" } func hlsRetryChunkPath(outFile string, attempt int) string { dir := filepath.Dir(outFile) ext := filepath.Ext(outFile) base := strings.TrimSuffix(filepath.Base(outFile), ext) canonical := canonicalAssetIDFromName(base) if canonical == "" { canonical = base } ts := time.Now().Format("20060102_150405_000") return filepath.Join(dir, fmt.Sprintf(".%s.retry.%02d.%s%s", canonical, attempt, ts, ext)) } func appendFileAndRemove(dstPath, srcPath string) error { srcPath = strings.TrimSpace(srcPath) dstPath = strings.TrimSpace(dstPath) if srcPath == "" || dstPath == "" { return appErrorf("append path leer") } if filepath.Clean(srcPath) == filepath.Clean(dstPath) { return nil } sfi, err := os.Stat(srcPath) if err != nil { if os.IsNotExist(err) { return nil } return err } if sfi == nil || sfi.IsDir() || sfi.Size() <= 0 { _ = removeWithRetry(srcPath) return nil } src, err := os.Open(srcPath) if err != nil { return err } dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { _ = src.Close() return err } _, copyErr := io.Copy(dst, src) closeSrcErr := src.Close() closeDstErr := dst.Close() if copyErr != nil { return copyErr } if closeSrcErr != nil { return closeSrcErr } if closeDstErr != nil { return closeDstErr } if err := removeWithRetry(srcPath); err != nil && !os.IsNotExist(err) { return err } return nil } func hlsAttrValue(line string, key string) string { line = strings.TrimSpace(line) key = strings.TrimSpace(key) if line == "" || key == "" { return "" } needle := key + "=" idx := strings.Index(line, needle) if idx < 0 { return "" } rest := strings.TrimSpace(line[idx+len(needle):]) if rest == "" { return "" } if strings.HasPrefix(rest, `"`) { rest = rest[1:] end := strings.IndexByte(rest, '"') if end < 0 { return strings.TrimSpace(rest) } return strings.TrimSpace(rest[:end]) } if comma := strings.IndexByte(rest, ','); comma >= 0 { rest = rest[:comma] } return strings.Trim(strings.TrimSpace(rest), `"`) } func hlsExtXMapURI(playlistText string) string { for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#EXT-X-MAP:") { return hlsAttrValue(line, "URI") } } return "" } func hlsPartURIsFromText(playlistText string) []string { out := make([]string, 0, 16) for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "#EXT-X-PART:") { continue } uri := hlsAttrValue(line, "URI") if uri != "" { out = append(out, uri) } } return out } func hlsMediaSegmentURIsFromText(playlistText string) []string { out := make([]string, 0, 16) for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if line == "" { continue } if strings.HasPrefix(line, "#") { continue } out = append(out, line) } return out } func hlsRefreshDelay(playlistText string) time.Duration { for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "#EXT-X-PART-INF:") { raw := hlsAttrValue(line, "PART-TARGET") if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 { d := time.Duration(f * float64(time.Second)) if d < 500*time.Millisecond { d = 500 * time.Millisecond } if d > 2*time.Second { d = 2 * time.Second } return d } } if strings.HasPrefix(line, "#EXT-X-TARGETDURATION:") { raw := strings.TrimSpace(strings.TrimPrefix(line, "#EXT-X-TARGETDURATION:")) if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 { d := time.Duration(f * float64(time.Second)) if d < 1*time.Second { d = 1 * time.Second } if d > 3*time.Second { d = 3 * time.Second } return d } } } return 1 * time.Second } func hlsResolveURI(baseRaw string, refRaw string) (string, error) { baseRaw = strings.TrimSpace(baseRaw) refRaw = strings.TrimSpace(refRaw) if refRaw == "" { return "", appErrorf("empty hls uri") } ref, err := url.Parse(refRaw) if err != nil { return "", err } if ref.IsAbs() { return ref.String(), nil } base, err := url.Parse(baseRaw) if err != nil { return "", err } return base.ResolveReference(ref).String(), nil } func hlsGetBytesWithHeaders( ctx context.Context, rawURL string, httpCookie string, userAgent string, referer string, ) ([]byte, error) { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return nil, appErrorf("empty hls url") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } if strings.TrimSpace(userAgent) != "" { req.Header.Set("User-Agent", strings.TrimSpace(userAgent)) } if strings.TrimSpace(httpCookie) != "" { req.Header.Set("Cookie", strings.TrimSpace(httpCookie)) } if strings.TrimSpace(referer) != "" { ref := strings.TrimSpace(referer) req.Header.Set("Referer", ref) if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { req.Header.Set("Origin", ru.Scheme+"://"+ru.Host) } } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, appErrorf("HTTP %d: %s", resp.StatusCode, rawURL) } return io.ReadAll(resp.Body) } func hlsGetBytesWithHeadersRetry( ctx context.Context, rawURL string, httpCookie string, userAgent string, referer string, attempts int, ) ([]byte, error) { if attempts < 1 { attempts = 1 } var lastErr error for i := 1; i <= attempts; i++ { b, err := hlsGetBytesWithHeaders(ctx, rawURL, httpCookie, userAgent, referer) if err == nil { return b, nil } lastErr = err if ctx.Err() != nil { return nil, ctx.Err() } if i < attempts { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(600 * time.Millisecond): } } } return nil, lastErr } func writeStreamBytesAndPublish(f *os.File, job *RecordJob, b []byte) error { if len(b) == 0 { return nil } if _, err := f.Write(b); err != nil { return err } if job != nil { if fi, err := f.Stat(); err == nil && fi != nil && !fi.IsDir() { jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() _ = publishJobSnapshot(job.ID) } } return nil } func streamLogPrefix(job *RecordJob, referer string, outFile string) string { if s := strings.TrimSpace(hlsErrorModelName(job, referer)); s != "" { return "[" + s + "]" } outFile = strings.TrimSpace(outFile) if outFile != "" { stem := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile)) stem = stripHotPrefix(stem) if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" { return "[" + s + "]" } if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" { return "[" + s + "]" } } return "[stream]" } func recordHLSPlaylistSegments( ctx context.Context, stream *selectedHLSStream, outFile string, job *RecordJob, httpCookie string, userAgent string, referer string, ) error { if stream == nil { return errors.New("stream config nil") } logPrefix := streamLogPrefix(job, referer, outFile) mediaURL := strings.TrimSpace(stream.VideoURL) if mediaURL == "" { return appErrorf("%s media playlist url empty", logPrefix) } outFile = strings.TrimSpace(outFile) if outFile == "" { return appErrorf("%s output path empty", logPrefix) } if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { return appErrorf("%s mkdir failed: %w", logPrefix, err) } f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return appErrorf("%s open output failed: %w", logPrefix, err) } defer f.Close() seen := map[string]struct{}{} writtenMaps := map[string]struct{}{} lastNewSegmentAt := time.Now() if verboseLogs() { appLogln("🎥", logPrefix, "recording media playlist:", mediaURL) } for { if err := ctx.Err(); err != nil { return err } body, err := hlsGetBytesWithHeaders(ctx, mediaURL, httpCookie, userAgent, referer) if err != nil { return appErrorf("%s get playlist: %w", logPrefix, err) } playlistText := string(body) if strings.Contains(playlistText, "#EXT-X-STREAM-INF") { return appErrorf("%s expected media playlist, got master playlist", logPrefix) } segmentURIs := hlsMediaSegmentURIsFromText(playlistText) if len(segmentURIs) == 0 { segmentURIs = hlsPartURIsFromText(playlistText) } wroteAny := false if len(segmentURIs) > 0 { mapURI := hlsExtXMapURI(playlistText) if mapURI != "" { mapURL, err := hlsResolveURI(mediaURL, mapURI) if err != nil { return appErrorf("%s resolve map uri: %w", logPrefix, err) } if _, ok := writtenMaps[mapURL]; !ok { initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3) if err != nil { appLogln("⚠️", logPrefix, "init segment failed:", err) select { case <-ctx.Done(): return ctx.Err() case <-time.After(hlsRefreshDelay(playlistText)): } continue } if err := writeStreamBytesAndPublish(f, job, initBytes); err != nil { return appErrorf("%s write init: %w", logPrefix, err) } writtenMaps[mapURL] = struct{}{} wroteAny = true } } } for _, uri := range segmentURIs { uri = strings.TrimSpace(uri) if uri == "" { continue } segmentURL, err := hlsResolveURI(mediaURL, uri) if err != nil { appLogln("⚠️", logPrefix, "resolve segment failed:", uri, err) continue } if _, ok := seen[segmentURL]; ok { continue } segBytes, err := hlsGetBytesWithHeadersRetry(ctx, segmentURL, httpCookie, userAgent, referer, 3) if err != nil { appLogln("⚠️", logPrefix, "segment failed:", filepath.Base(uri), err) continue } if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil { return appErrorf("%s write segment: %w", logPrefix, err) } seen[segmentURL] = struct{}{} wroteAny = true } if wroteAny { lastNewSegmentAt = time.Now() } else if time.Since(lastNewSegmentAt) > 75*time.Second { return io.EOF } select { case <-ctx.Done(): return ctx.Err() case <-time.After(hlsRefreshDelay(playlistText)): } } } func isExitStatus(err error, code int) bool { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return exitErr.ExitCode() == code } return false } func handleM3U8Mode( ctx context.Context, stream *selectedHLSStream, outFile string, job *RecordJob, httpCookie string, userAgent string, referer string, ) error { if stream == nil { return errors.New("stream config nil") } videoURL := strings.TrimSpace(stream.VideoURL) audioURL := strings.TrimSpace(stream.AudioURL) isChaturbate := strings.Contains(strings.ToLower(strings.TrimSpace(referer)), "chaturbate.com") // Wichtig: // Bei Chaturbate die MasterURL NICHT nochmal an ffmpeg geben. // Die Master-Playlist wurde vorher schon in Go geöffnet/parst. // Das erneute Öffnen der llhls.m3u8 führt bei dir häufig zu 403. inputURL := videoURL useSeparateAudioInput := false if isChaturbate { // Stabilitäts-Test: // Chaturbate erstmal NICHT mit separatem Audio-Input öffnen. // Wenn danach sofort .ts-Dateien entstehen, war der separate Audio-Input der Blocker. useSeparateAudioInput = false } else if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { inputURL = strings.TrimSpace(stream.MasterURL) } u, err := url.Parse(inputURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return appErrorf("ungültige input URL: %q", inputURL) } if useSeparateAudioInput { au, err := url.Parse(audioURL) if err != nil || (au.Scheme != "http" && au.Scheme != "https") { return appErrorf("ungültige audio URL: %q", audioURL) } } outFile = strings.TrimSpace(outFile) if outFile == "" { return errors.New("output file path leer") } ext := strings.ToLower(filepath.Ext(outFile)) if ext != ".ts" && ext != ".mp4" { return appErrorf("nicht unterstützte output-endung: %q", ext) } // Wichtig: // handleM3U8Mode schreibt immer in den übergebenen outFile. // Wenn der Caller einen Retry-Chunk möchte, muss er bereits // einen Retry-Pfad als outFile übergeben. args := []string{ "-y", "-hide_banner", "-nostats", "-loglevel", "error", "-protocol_whitelist", "file,http,https,tcp,tls,crypto", "-http_persistent", "false", } ua := strings.TrimSpace(userAgent) ref := strings.TrimSpace(referer) ck := strings.TrimSpace(httpCookie) appendInputOptions := func(args []string) []string { args = append(args, // 30s statt 15s: CB/CDN hat gelegentlich kurze Hänger. "-rw_timeout", "30000000", "-reconnect", "1", "-reconnect_at_eof", "1", "-reconnect_streamed", "1", "-reconnect_on_network_error", "1", "-reconnect_on_http_error", "403,404,408,429,500,502,503,504", "-reconnect_delay_max", "10", ) if ua != "" { args = append(args, "-user_agent", ua) } if ref != "" { args = append(args, "-referer", ref) } if ck != "" { args = append(args, "-cookies", ck) } if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" { args = append(args, "-headers", hdr) } return args } if useSeparateAudioInput { args = appendInputOptions(args) args = append(args, "-i", videoURL) args = appendInputOptions(args) args = append(args, "-i", audioURL) args = append(args, "-map", "0:v:0", "-map", "1:a:0?", "-dn", "-ignore_unknown", "-c", "copy", ) } else { args = appendInputOptions(args) args = append(args, "-i", inputURL, "-map", "0:v:0", "-map", "0:a:0?", "-dn", "-ignore_unknown", "-c", "copy", ) } switch ext { case ".ts": args = append(args, "-f", "mpegts", // Hilft, wenn Retry-Chunks später byteweise an die Haupt-TS angehängt werden. // PAT/PMT Header werden öfter erneut geschrieben. "-mpegts_flags", "+resend_headers", outFile, ) case ".mp4": args = append(args, "-movflags", "+faststart", outFile, ) } cmd := exec.CommandContext(ctx, ffmpegPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } var stderr bytes.Buffer cmd.Stdout = io.Discard cmd.Stderr = &stderr stopStat := make(chan struct{}) if job != nil { go func() { t := time.NewTicker(1 * time.Second) defer t.Stop() var last int64 for { select { case <-ctx.Done(): return case <-stopStat: return case <-t.C: sz := safeFileSize(outFile) if sz > 0 && sz != last { jobsMu.Lock() job.SizeBytes = sz jobsMu.Unlock() last = sz _ = publishJobSnapshot(job.ID) } } } }() } err = cmd.Run() close(stopStat) if err != nil { // Wenn der User den Recording-Prozess stoppt, beendet ffmpeg HLS unter Windows // oft mit "exit status 1". Das ist in diesem Fall kein echter Fehler. if ctx.Err() != nil && isExitStatus(err, 1) { return nil } return hlsFFmpegFailedError(job, referer, err, stderr.String()) } if job != nil { if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() { jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() _ = publishJobSnapshot(job.ID) } } return nil } func hlsErrorModelName(job *RecordJob, referer string) string { if job != nil { // 1) Beste Quelle bei dir: Output-Dateiname // Beispiel: aurora_natsuki_05_08_2026__12-34-56.mp4 -> aurora_natsuki if out := strings.TrimSpace(job.Output); out != "" { stem := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out)) stem = stripHotPrefix(stem) if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" { return s } if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" { return s } } // 2) Fallback: SourceURL if s := modelNameFromURL(strings.TrimSpace(job.SourceURL)); s != "" { return s } // 3) Fallback: Job-ID if s := strings.TrimSpace(job.ID); s != "" { return s } } // 4) Fallback: Referer if s := modelNameFromURL(strings.TrimSpace(referer)); s != "" { return s } return "" } func modelNameFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil { return "" } if frag := strings.Trim(strings.TrimSpace(u.Fragment), "/"); frag != "" { parts := strings.Split(frag, "/") return strings.TrimSpace(parts[len(parts)-1]) } path := strings.Trim(u.Path, "/") if path == "" { return "" } parts := strings.Split(path, "/") return strings.TrimSpace(parts[len(parts)-1]) } func hlsFFmpegFailedError(job *RecordJob, referer string, err error, stderr string) error { model := hlsErrorModelName(job, referer) msg := strings.TrimSpace(stderr) prefix := "ffmpeg m3u8 failed" if model != "" { prefix = "[" + model + "] " + prefix } if msg != "" { return appErrorf("%s: %w: %s", prefix, err, msg) } return appErrorf("%s: %w", prefix, err) }