// backend\record_stream_cb.go package main import ( "context" "encoding/json" "errors" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" ) // RecordStream für Chaturbate: // bewusst WIEDER mit ffmpeg-Input auf die HLS-Playlist. // Wichtig: // - Seite laden // - HLS URL parsen // - beste Playlist bestimmen // - wenn separate Audio-Group vorhanden ist, Master-Playlist an ffmpeg geben // - ffmpeg übernimmt Video + beste verfügbare Audio-Spur func RecordStream( ctx context.Context, hc *HTTPClient, domain string, username string, outputPath string, httpCookie string, job *RecordJob, ) error { username = strings.Trim(strings.TrimSpace(username), "/") if username == "" { return errors.New("leerer username") } base := strings.TrimRight(domain, "/") pageURL := base + "/" + username + "/" loadFreshHLS := func() (*selectedHLSStream, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return nil, appErrorf("seite laden: %w", err) } hlsURL, err := ParseStream(body) if err != nil { return nil, appErrorf("stream-parsing: %w", err) } hlsURL = strings.TrimSpace(hlsURL) if hlsURL == "" { return nil, errors.New("leere hls url") } stream, err := getWantedResolutionPlaylistWithHeaders( ctx, hlsURL, httpCookie, hc.userAgent, pageURL, ) if err != nil { return nil, appErrorf("variant-playlist: %w", err) } if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return nil, errors.New("leere final video url") } return stream, nil } stream, err := loadFreshHLS() if err != nil { return err } if job != nil { assetID := assetIDForJob(job) if assetID != "" { _, _ = ensureGeneratedDir(assetID) } jobsMu.Lock() previewURL := strings.TrimSpace(stream.VideoURL) if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { previewURL = strings.TrimSpace(stream.MasterURL) } job.PreviewM3U8 = previewURL job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() } const maxPlaylistRefreshes = 12 refreshes := 0 attempt := 0 useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts") for { attempt++ attemptOut := outputPath mergeAfterRun := false // Nur ab dem ZWEITEN Versuch in Retry-Chunk schreiben. // Der erste Versuch muss direkt in outputPath schreiben, // damit der laufende Download eine echte Hauptdatei hat. if useChunkMerge && attempt > 1 { attemptOut = hlsRetryChunkPath(outputPath, attempt) _ = os.Remove(attemptOut) mergeAfterRun = true } err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL) if mergeAfterRun && fileExistsNonEmpty(attemptOut) { if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil { return appErrorf("retry-chunk merge failed: %w", aerr) } } if err == nil { return nil } if ctx.Err() != nil { return ctx.Err() } msg := strings.ToLower(strings.TrimSpace(err.Error())) shouldRefresh := strings.Contains(msg, "403") || strings.Contains(msg, "401") || strings.Contains(msg, "invalid data found") || strings.Contains(msg, "error opening input") || strings.Contains(msg, "stream-parsing") || strings.Contains(msg, "kein hls-quell-url") || strings.Contains(msg, "http ") if !shouldRefresh { return err } refreshes++ if refreshes > maxPlaylistRefreshes { return appErrorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(1500 * time.Millisecond): } newStream, rerr := loadFreshHLS() if rerr != nil { select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } stream = newStream if job != nil { jobsMu.Lock() previewURL := strings.TrimSpace(stream.VideoURL) if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { previewURL = strings.TrimSpace(stream.MasterURL) } job.PreviewM3U8 = previewURL job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() } } } // ParseStream entspricht der DVR-Variante (roomDossier → hls_source) func ParseStream(html string) (string, error) { matches := roomDossierRegexp.FindStringSubmatch(html) if len(matches) == 0 { return "", errors.New("room dossier nicht gefunden") } decoded, err := strconv.Unquote( strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), ) if err != nil { return "", appErrorf("Unicode-decode failed: %w", err) } var rd struct { HLSSource string `json:"hls_source"` } if err := json.Unmarshal([]byte(decoded), &rd); err != nil { return "", appErrorf("JSON-parse failed: %w", err) } if rd.HLSSource == "" { return "", errors.New("kein HLS-Quell-URL im JSON") } return rd.HLSSource, nil } // Cookie-Hilfsfunktion func addCookiesFromString(req *http.Request, cookieStr string) { if cookieStr == "" { return } pairs := strings.Split(cookieStr, ";") for _, pair := range pairs { parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) if len(parts) != 2 { continue } name := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if name == "" { continue } req.AddCookie(&http.Cookie{ Name: name, Value: value, }) } } // helper func extractUsername(input string) string { s := strings.TrimSpace(input) if s == "" { return "" } if u, err := url.Parse(s); err == nil && u != nil { host := strings.ToLower(strings.TrimSpace(u.Hostname())) if strings.Contains(host, "chaturbate.com") { p := strings.Trim(u.Path, "/") if p == "" { return "" } parts := strings.Split(p, "/") return strings.TrimSpace(parts[0]) } } s = strings.TrimPrefix(s, "https://") s = strings.TrimPrefix(s, "http://") s = strings.TrimPrefix(s, "www.") if strings.HasPrefix(strings.ToLower(s), "chaturbate.com/") { s = s[len("chaturbate.com/"):] } if idx := strings.IndexAny(s, "/?#"); idx != -1 { s = s[:idx] } return strings.Trim(s, "/\\") } func hasChaturbateCookies(cookieStr string) bool { m := parseCookieString(cookieStr) _, hasCF := m["cf_clearance"] _, hasSessID := m["session_id"] _, hasSessIdAlt := m["sessionid"] return hasCF && (hasSessID || hasSessIdAlt) } func parseCookieString(cookieStr string) map[string]string { out := map[string]string{} for _, pair := range strings.Split(cookieStr, ";") { parts := strings.SplitN(strings.TrimSpace(pair), "=", 2) if len(parts) != 2 { continue } name := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) if name == "" { continue } out[strings.ToLower(name)] = value } return out } func detectProvider(raw string) string { s := strings.ToLower(raw) if strings.Contains(s, "chaturbate.com") { return "chaturbate" } if strings.Contains(s, "myfreecams.com") { return "mfc" } return "unknown" }