nsfwapp/backend/record_stream_hls.go
2026-06-19 07:05:34 +02:00

1721 lines
37 KiB
Go

// 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: %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...)
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: 0x08000000,
}
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...)
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)
}