// backend\record_stream_cb.go package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "time" "github.com/grafov/m3u8" ) // RecordStream für Chaturbate: // bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist. // Stattdessen wie in der alten, funktionierenden Version: // - Seite laden // - HLS URL parsen // - beste Variant-Playlist auswählen // - Segmente selbst laden und in .ts schreiben 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 + "/" loadFreshPlaylist := func() (*cbPlaylist, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return nil, fmt.Errorf("seite laden: %w", err) } hlsURL, err := ParseStream(body) if err != nil { return nil, fmt.Errorf("stream-parsing: %w", err) } hlsURL = strings.TrimSpace(hlsURL) if hlsURL == "" { return nil, errors.New("leere hls url") } finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders( ctx, hlsURL, httpCookie, hc.userAgent, pageURL, ) if err != nil { return nil, fmt.Errorf("variant-playlist: %w", err) } finalPlaylistURL = strings.TrimSpace(finalPlaylistURL) if finalPlaylistURL == "" { return nil, errors.New("leere final playlist url") } return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie) } playlist, err := loadFreshPlaylist() if err != nil { return err } if job != nil { assetID := assetIDForJob(job) if assetID != "" { _, _ = ensureGeneratedDir(assetID) } jobsMu.Lock() job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() if assetID != "" { startLiveThumbJPGLoop(ctx, job) } } file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("datei erstellen: %w", err) } defer func() { _ = file.Close() }() var written int64 var lastPush time.Time var lastBytes int64 published := false handleSegment := func(b []byte, duration float64) error { if len(b) == 0 { return nil } if _, err := file.Write(b); err != nil { return fmt.Errorf("schreibe segment: %w", err) } _ = file.Sync() if job != nil && !published { published = true _ = publishJob(job.ID) } written += int64(len(b)) if job != nil { now := time.Now() if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 { jobsMu.Lock() job.SizeBytes = written jobsMu.Unlock() _ = publishJob(job.ID) lastPush = now lastBytes = written } } _ = duration return nil } const maxPlaylistRefreshes = 12 refreshes := 0 for { err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment) if err == nil { break } 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, "playlist nicht mehr erreichbar") || strings.Contains(msg, "fehlerhafte playlist") || strings.Contains(msg, "keine neuen hls-segmente") || strings.Contains(msg, "stream-parsing") || strings.Contains(msg, "http ") if !shouldRefresh { return fmt.Errorf("watch segments: %w", err) } refreshes++ if refreshes > maxPlaylistRefreshes { return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) } fmt.Println("🔄 [cb] refresh playlist:", "user=", username, "try=", refreshes, "reason=", err, ) select { case <-ctx.Done(): return ctx.Err() case <-time.After(1500 * time.Millisecond): } newPlaylist, rerr := loadFreshPlaylist() if rerr != nil { fmt.Println("⚠️ [cb] refresh failed:", "user=", username, "try=", refreshes, "err=", rerr, ) select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } playlist = newPlaylist if job != nil { jobsMu.Lock() job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() } } if job != nil { jobsMu.Lock() job.SizeBytes = written jobsMu.Unlock() _ = publishJob(job.ID) } return nil } // 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 "", fmt.Errorf("Unicode-decode failed: %w", err) } var rd struct { HLSSource string `json:"hls_source"` } if err := json.Unmarshal([]byte(decoded), &rd); err != nil { return "", fmt.Errorf("JSON-parse failed: %w", err) } if rd.HLSSource == "" { return "", errors.New("kein HLS-Quell-URL im JSON") } return rd.HLSSource, nil } // eigener Typ nur für Chaturbate, damit es keinen Konflikt mit main.go gibt type cbPlaylist struct { PlaylistURL string RootURL string Resolution int Framerate int } func cbFetchPlaylist(ctx context.Context, hc *HTTPClient, playlistURL string, httpCookie string) (*cbPlaylist, error) { req, err := hc.NewRequest(ctx, http.MethodGet, playlistURL, httpCookie) if err != nil { return nil, fmt.Errorf("playlist request erzeugen: %w", err) } resp, err := hc.client.Do(req) if err != nil { return nil, fmt.Errorf("playlist request senden: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { return nil, fmt.Errorf("m3u8 parse: %w", err) } if listType == m3u8.MEDIA { root := playlistURL if idx := strings.LastIndex(root, "/"); idx >= 0 { root = root[:idx+1] } return &cbPlaylist{ PlaylistURL: playlistURL, RootURL: root, }, nil } master, ok := playlist.(*m3u8.MasterPlaylist) if !ok || master == nil { return nil, errors.New("unerwarteter playlist-typ") } var bestURI string var bestHeight int var bestFramerate float64 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 } if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) { bestURI = strings.TrimSpace(v.URI) bestHeight = h bestFramerate = fr } } if bestURI == "" { return nil, errors.New("Master-Playlist ohne gültige Varianten") } baseURL, err := url.Parse(playlistURL) if err != nil { return nil, err } refURL, err := url.Parse(bestURI) if err != nil { return nil, err } finalURL := baseURL.ResolveReference(refURL).String() root := finalURL if idx := strings.LastIndex(root, "/"); idx >= 0 { root = root[:idx+1] } return &cbPlaylist{ PlaylistURL: finalURL, RootURL: root, Resolution: bestHeight, Framerate: int(bestFramerate), }, nil } func (p *cbPlaylist) WatchSegments( ctx context.Context, hc *HTTPClient, httpCookie string, handler func([]byte, float64) error, ) error { var lastSeq int64 = -1 emptyRounds := 0 const maxEmptyRounds = 60 for { select { case <-ctx.Done(): return ctx.Err() default: } req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) if err != nil { return fmt.Errorf("playlist request erzeugen: %w", err) } resp, err := hc.client.Do(req) if err != nil { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("playlist nicht mehr erreichbar – stream vermutlich offline") } time.Sleep(2 * time.Second) continue } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) resp.Body.Close() if err != nil || listType != m3u8.MEDIA { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("fehlerhafte playlist – möglicherweise offline") } time.Sleep(2 * time.Second) continue } media := playlist.(*m3u8.MediaPlaylist) newSegment := false for _, segment := range media.Segments { if segment == nil { continue } if int64(segment.SeqId) <= lastSeq { continue } lastSeq = int64(segment.SeqId) newSegment = true segURL := segment.URI if !strings.HasPrefix(segURL, "http://") && !strings.HasPrefix(segURL, "https://") { segURL = p.RootURL + strings.TrimLeft(segment.URI, "/") } segReq, err := hc.NewRequest(ctx, http.MethodGet, segURL, httpCookie) if err != nil { continue } segResp, err := hc.client.Do(segReq) if err != nil { continue } if segResp.StatusCode != http.StatusOK { segResp.Body.Close() continue } data, err := io.ReadAll(segResp.Body) segResp.Body.Close() if err != nil || len(data) == 0 { continue } if err := handler(data, segment.Duration); err != nil { return err } } if newSegment { emptyRounds = 0 } else { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("keine neuen HLS-segmente empfangen – stream vermutlich beendet oder offline") } } time.Sleep(1 * time.Second) } } // 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" }