// backend\record_stream_hls.go package main import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "strconv" "strings" "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: %s", resp.StatusCode, playlistURL) } 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 hlsWithoutBlockingReloadParams(rawURL string) string { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return "" } u, err := url.Parse(rawURL) if err != nil { return rawURL } q := u.Query() q.Del("_HLS_msn") q.Del("_HLS_part") u.RawQuery = q.Encode() return u.String() } func hlsWithBlockingReloadParams(rawURL string, msn int, part int) string { rawURL = strings.TrimSpace(rawURL) if rawURL == "" || msn < 0 { return rawURL } u, err := url.Parse(rawURL) if err != nil { return rawURL } q := u.Query() q.Set("_HLS_msn", strconv.Itoa(msn)) if part >= 0 { q.Set("_HLS_part", strconv.Itoa(part)) } else { q.Del("_HLS_part") } u.RawQuery = q.Encode() return u.String() } func hlsURLPathKey(rawURL string) string { rawURL = strings.TrimSpace(rawURL) if rawURL == "" { return "" } u, err := url.Parse(rawURL) if err != nil || u == nil { return rawURL } return strings.TrimSpace(u.Path) } func hlsSamePlaylistPath(a string, b string) bool { ak := hlsURLPathKey(a) bk := hlsURLPathKey(b) return ak != "" && bk != "" && ak == bk } func hlsParseChaturbateMediaSeqPart(uri string) (seq int, part int, isPart bool, ok bool) { uri = strings.TrimSpace(uri) if uri == "" { return 0, 0, false, false } if before, _, found := strings.Cut(uri, "?"); found { uri = before } if idx := strings.LastIndex(uri, "/"); idx >= 0 { uri = uri[idx+1:] } pieces := strings.Split(uri, "_") for i, p := range pieces { switch p { case "seg": // seg_6_4240_audio_... if i+2 >= len(pieces) { continue } n, err := strconv.Atoi(strings.TrimSpace(pieces[i+2])) if err != nil { continue } return n, -1, false, true case "part": // part_6_4241_0_audio_... if i+3 >= len(pieces) { continue } n, err1 := strconv.Atoi(strings.TrimSpace(pieces[i+2])) pn, err2 := strconv.Atoi(strings.TrimSpace(pieces[i+3])) if err1 != nil || err2 != nil { continue } return n, pn, true, true } } return 0, 0, false, false } func hlsNextBlockingReloadParams(playlistText string) (msn int, part int, ok bool) { maxSeg := -1 maxPartSeq := -1 maxPart := -1 for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if line == "" { continue } if strings.HasPrefix(line, "#EXT-X-PART:") { uri := hlsAttrValue(line, "URI") if seq, pn, isPart, ok := hlsParseChaturbateMediaSeqPart(uri); ok && isPart { if seq > maxPartSeq || (seq == maxPartSeq && pn > maxPart) { maxPartSeq = seq maxPart = pn } } continue } if strings.HasPrefix(line, "#") { continue } if seq, _, isPart, ok := hlsParseChaturbateMediaSeqPart(line); ok && !isPart { if seq > maxSeg { maxSeg = seq } } } // Wenn Parts sichtbar sind, beim nächsten Request nach dem nächsten Part fragen. if maxPartSeq >= 0 && maxPart >= 0 { return maxPartSeq, maxPart + 1, true } // Sonst beim nächsten Media Sequence warten. if maxSeg >= 0 { return maxSeg + 1, 0, true } return -1, -1, false } func hlsRenditionReportPosition( playlistText string, basePlaylistURL string, targetPlaylistURL string, ) (msn int, part int, ok bool) { targetPlaylistURL = hlsWithoutBlockingReloadParams(targetPlaylistURL) for _, line := range strings.Split(playlistText, "\n") { line = strings.TrimSpace(line) if !strings.HasPrefix(line, "#EXT-X-RENDITION-REPORT:") { continue } attrText := strings.TrimPrefix(line, "#EXT-X-RENDITION-REPORT:") attrs := parseHLSAttributeList(attrText) reportURI := strings.TrimSpace(attrs["URI"]) if reportURI == "" { continue } reportURL, err := hlsResolveURI(basePlaylistURL, reportURI) if err != nil { continue } reportURL = hlsWithoutBlockingReloadParams(reportURL) if !hlsSamePlaylistPath(reportURL, targetPlaylistURL) { continue } rawMSN := strings.TrimSpace(attrs["LAST-MSN"]) if rawMSN == "" { continue } n, err := strconv.Atoi(rawMSN) if err != nil { continue } pn := 0 if rawPart := strings.TrimSpace(attrs["LAST-PART"]); rawPart != "" { if v, err := strconv.Atoi(rawPart); err == nil { pn = v } } return n, pn, true } return -1, -1, false } func hlsPrimeRenditionURLFromPlaylist( ctx context.Context, sourcePlaylistURL string, targetPlaylistURL string, httpCookie string, userAgent string, referer string, logPrefix string, kind string, ) string { sourcePlaylistURL = strings.TrimSpace(sourcePlaylistURL) targetPlaylistURL = strings.TrimSpace(targetPlaylistURL) if sourcePlaylistURL == "" || targetPlaylistURL == "" { return targetPlaylistURL } body, err := hlsGetBytesWithHeadersRetry( ctx, sourcePlaylistURL, httpCookie, userAgent, referer, 3, ) if err != nil { if verboseLogs() { appLogln("⚠️", logPrefix, "unable to prime", kind, "playlist:", err) } return targetPlaylistURL } msn, part, ok := hlsRenditionReportPosition( string(body), sourcePlaylistURL, targetPlaylistURL, ) if !ok { return targetPlaylistURL } primed := hlsWithBlockingReloadParams(targetPlaylistURL, msn, part) if verboseLogs() { appLogln("🔁", logPrefix, "primed", kind, "playlist:", primed) } return primed } 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() } msg := strings.ToLower(strings.TrimSpace(err.Error())) // 401/403/404/410 sind bei CB meistens kein kurzer Netzwerkhänger, // sondern stale/ungültige Playlist-URL. Nicht 3x denselben URL hämmern, // sondern direkt nach außen geben, damit RecordStream frische HLS-URLs holt. if strings.Contains(msg, "http 401") || strings.Contains(msg, "http 403") || strings.Contains(msg, "http 404") || strings.Contains(msg, "http 410") { return nil, 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 streamSidecarPath(outFile string, suffix string, ext string) string { outFile = strings.TrimSpace(outFile) suffix = strings.Trim(strings.TrimSpace(suffix), ".") ext = strings.TrimSpace(ext) if outFile == "" || suffix == "" { return "" } if ext == "" { ext = filepath.Ext(outFile) } if !strings.HasPrefix(ext, ".") { ext = "." + ext } dir := filepath.Dir(outFile) base := strings.TrimSuffix(filepath.Base(outFile), filepath.Ext(outFile)) canonical := canonicalAssetIDFromName(base) if canonical == "" { canonical = base } return filepath.Join(dir, "."+canonical+"."+suffix+ext) } func muxAudioVideoSegmentFiles( ctx context.Context, videoPath string, audioPath string, outFile string, logPrefix string, ) error { videoPath = strings.TrimSpace(videoPath) audioPath = strings.TrimSpace(audioPath) outFile = strings.TrimSpace(outFile) if videoPath == "" || audioPath == "" || outFile == "" { return appErrorf("%s mux paths empty", logPrefix) } if !fileExistsNonEmpty(videoPath) { return appErrorf("%s mux video missing or empty", logPrefix) } if !fileExistsNonEmpty(audioPath) { return appErrorf("%s mux audio missing or empty", logPrefix) } ext := strings.ToLower(filepath.Ext(outFile)) tmp := streamSidecarPath(outFile, "muxing", ext) backup := streamSidecarPath(outFile, "video-original", ".bak") _ = removeWithRetry(tmp) _ = removeWithRetry(backup) args := []string{ "-y", "-hide_banner", "-loglevel", "error", "-fflags", "+genpts+discardcorrupt", "-err_detect", "ignore_err", "-i", videoPath, "-i", audioPath, "-map", "0:v:0", "-map", "1:a:0", "-dn", "-ignore_unknown", "-c", "copy", } switch ext { case ".ts": args = append(args, "-f", "mpegts", tmp) case ".mp4": args = append(args, "-movflags", "+faststart", tmp) default: args = append(args, "-f", "mpegts", tmp) } cmd := exec.CommandContext(ctx, ffmpegPath, args...) hideCommandWindow(cmd) var stderr bytes.Buffer cmd.Stdout = io.Discard cmd.Stderr = &stderr if err := cmd.Run(); err != nil { _ = removeWithRetry(tmp) return appErrorf("%s mux failed: %w (%s)", logPrefix, err, strings.TrimSpace(stderr.String())) } if err := requireNonEmptyRegularFile(tmp, "mux result"); err != nil { _ = removeWithRetry(tmp) return err } sameOutputAsVideo := filepath.Clean(videoPath) == filepath.Clean(outFile) if sameOutputAsVideo { // Recording-Fall: // outFile ist die laufende Video-Datei selbst, also sicher ersetzen. if err := os.Rename(outFile, backup); err != nil { _ = removeWithRetry(tmp) return appErrorf("%s mux backup rename failed: %w", logPrefix, err) } if err := os.Rename(tmp, outFile); err != nil { _ = os.Rename(backup, outFile) _ = removeWithRetry(tmp) return appErrorf("%s mux final rename failed: %w", logPrefix, err) } _ = removeWithRetry(backup) } else { // Postwork-Fall: // videoPath ist z.B. records/foo.ts, outFile ist eine neue temp/foo.mp4. // Hier darf outFile noch nicht existieren. _ = removeWithRetry(outFile) if err := os.Rename(tmp, outFile); err != nil { _ = removeWithRetry(tmp) return appErrorf("%s mux final rename failed: %w", logPrefix, err) } } _ = removeWithRetry(audioPath) return nil } 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) videoURL := strings.TrimSpace(stream.VideoURL) audioURL := strings.TrimSpace(stream.AudioURL) if videoURL == "" { return appErrorf("%s video media playlist url empty", logPrefix) } // Chaturbate LL-HLS: // Die Website lädt Audio/Video-Playlists oft mit _HLS_msn/_HLS_part. // Die rohen chunklist_...m3u8 URLs ohne diese Parameter liefern bei dir häufig 403. // Deshalb primen wir die Audio-URL aus den RENDITION-REPORTs der Video-Playlist. if audioURL != "" { audioURL = hlsPrimeRenditionURLFromPlaylist( ctx, videoURL, audioURL, httpCookie, userAgent, referer, logPrefix, "audio", ) } // Kein separates Audio vorhanden: bisheriges Verhalten. if audioURL == "" { return recordHLSMediaPlaylistToFile( ctx, videoURL, outFile, job, httpCookie, userAgent, referer, logPrefix, "video", ) } // Separates Audio vorhanden: Video in Hauptdatei, Audio in Sidecar-Datei. audioOut := streamSidecarPath(outFile, "audio", ".m4s") if audioOut == "" { return appErrorf("%s audio sidecar path empty", logPrefix) } if verboseLogs() { appLogln("🎥", logPrefix, "recording separate video/audio playlists") appLogln("🎥", logPrefix, "video playlist:", videoURL) appLogln("🎧", logPrefix, "audio playlist:", audioURL) } recCtx, cancel := context.WithCancel(ctx) defer cancel() errCh := make(chan error, 2) go func() { errCh <- recordHLSMediaPlaylistToFile( recCtx, videoURL, outFile, job, httpCookie, userAgent, referer, logPrefix, "video", ) }() go func() { errCh <- recordHLSMediaPlaylistToFile( recCtx, audioURL, audioOut, nil, httpCookie, userAgent, referer, logPrefix, "audio", ) }() firstErr := <-errCh cancel() secondErr := <-errCh parentCanceled := ctx.Err() != nil endOfStream := errors.Is(firstErr, io.EOF) || errors.Is(secondErr, io.EOF) // Wenn einer der beiden Streams wegen Fehler endet, canceln wir den anderen. // Dessen "context canceled" ist dann nur Folgefehler und soll nicht das Log aufblasen. finalErr := firstErr if errors.Is(finalErr, context.Canceled) && !parentCanceled { finalErr = secondErr } if errors.Is(secondErr, context.Canceled) && !parentCanceled { secondErr = nil } // Falls firstErr nil/context-canceled ist, aber secondErr der echte Fehler war. if finalErr == nil || (errors.Is(finalErr, context.Canceled) && !parentCanceled) { finalErr = secondErr } shouldFinalize := parentCanceled || endOfStream || finalErr == nil if parentCanceled { if verboseLogs() { appLogln("⏹️", logPrefix, "stop requested; deferring audio/video mux to postwork") } return ctx.Err() } if shouldFinalize { if !fileExistsNonEmpty(outFile) { if finalErr != nil { return finalErr } return appErrorf("%s video output missing or empty", logPrefix) } if !fileExistsNonEmpty(audioOut) { if finalErr != nil { return appErrorf("%s audio missing/empty; refusing video-only file: %w", logPrefix, finalErr) } return appErrorf("%s audio missing/empty; refusing video-only file", logPrefix) } muxCtx, muxCancel := context.WithTimeout(context.Background(), 5*time.Minute) muxErr := muxAudioVideoSegmentFiles(muxCtx, outFile, audioOut, outFile, logPrefix) muxCancel() if muxErr != nil { return appErrorf("%s audio/video mux failed; refusing video-only file: %w", logPrefix, muxErr) } // Sicherheitscheck: Nach dem Mux muss wirklich Audio vorhanden sein. if !videoHasAudioStream(context.Background(), outFile) { return appErrorf("%s mux result has no audio stream; refusing video-only file", logPrefix) } 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) } } if verboseLogs() { appLogln("✅", logPrefix, "audio/video mux succeeded:", filepath.Base(outFile)) } if parentCanceled { return ctx.Err() } if endOfStream { return nil } } return finalErr } func recordHLSMediaPlaylistToFile( ctx context.Context, mediaURL string, outFile string, job *RecordJob, httpCookie string, userAgent string, referer string, logPrefix string, kind string, ) error { mediaURL = strings.TrimSpace(mediaURL) outFile = strings.TrimSpace(outFile) kind = strings.TrimSpace(kind) if mediaURL == "" { return appErrorf("%s %s playlist url empty", logPrefix, kind) } if outFile == "" { return appErrorf("%s %s output path empty", logPrefix, kind) } baseMediaURL := hlsWithoutBlockingReloadParams(mediaURL) requestURL := mediaURL if err := os.MkdirAll(filepath.Dir(outFile), 0o755); err != nil { return appErrorf("%s %s mkdir failed: %w", logPrefix, kind, err) } f, err := os.OpenFile(outFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return appErrorf("%s %s open output failed: %w", logPrefix, kind, err) } defer f.Close() seen := map[string]struct{}{} writtenMaps := map[string]struct{}{} lastNewSegmentAt := time.Now() if verboseLogs() { appLogln("🎥", logPrefix, "recording", kind, "media playlist:", mediaURL) } for { if err := ctx.Err(); err != nil { return err } body, err := hlsGetBytesWithHeadersRetry(ctx, requestURL, httpCookie, userAgent, referer, 3) if err != nil { return appErrorf("%s %s get playlist: %w", logPrefix, kind, err) } playlistText := string(body) if nextMSN, nextPart, ok := hlsNextBlockingReloadParams(playlistText); ok { requestURL = hlsWithBlockingReloadParams(baseMediaURL, nextMSN, nextPart) } else { requestURL = baseMediaURL } if strings.Contains(playlistText, "#EXT-X-STREAM-INF") { return appErrorf("%s %s expected media playlist, got master playlist", logPrefix, kind) } 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 %s resolve map uri: %w", logPrefix, kind, err) } if _, ok := writtenMaps[mapURL]; !ok { initBytes, err := hlsGetBytesWithHeadersRetry(ctx, mapURL, httpCookie, userAgent, referer, 3) if err != nil { appLogln("⚠️", logPrefix, kind, "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 %s write init: %w", logPrefix, kind, 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, kind, "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, kind, "segment failed:", filepath.Base(uri), err) continue } if err := writeStreamBytesAndPublish(f, job, segBytes); err != nil { return appErrorf("%s %s write segment: %w", logPrefix, kind, 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", ) if isChaturbate && stream.HasSeparateAudio { // Bei CB mit separater Audio-Playlist darf keine stumme Datei entstehen. args = append(args, "-map", "0:a:0") } else { args = append(args, "-map", "0:a:0?") } args = append(args, "-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...) hideCommandWindow(cmd) 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) }