// 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, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } rawBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("m3u8 read: %w", err) } masterText := string(rawBody) playlist, listType, err := m3u8.DecodeFrom(bytes.NewReader(rawBody), true) if err != nil { return nil, fmt.Errorf("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 fmt.Errorf("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 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) u, err := url.Parse(videoURL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("ungültige video URL: %q", videoURL) } if audioURL != "" { au, err := url.Parse(audioURL) if err != nil || (au.Scheme != "http" && au.Scheme != "https") { return fmt.Errorf("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 fmt.Errorf("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", "warning", "-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 { 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 audioURL != "" { 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", videoURL, "-map", "0:v:0", "-map", "0:a:0?", "-dn", "-ignore_unknown", "-c", "copy", ) } switch ext { case ".ts": args = append(args, "-f", "mpegts", 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 _ = publishJob(job.ID) } } } }() } err = cmd.Run() close(stopStat) if err != nil { msg := strings.TrimSpace(stderr.String()) if msg != "" { return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) } return fmt.Errorf("ffmpeg m3u8 failed: %w", err) } if job != nil { if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() { jobsMu.Lock() job.SizeBytes = fi.Size() jobsMu.Unlock() _ = publishJob(job.ID) } } return nil }