// backend\record_stream_cb.go package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" ) // --- DVR-ähnlicher Recorder-Ablauf --- // Entspricht grob dem RecordStream aus dem Channel-Snippet: func RecordStream( ctx context.Context, hc *HTTPClient, domain string, username string, outputPath string, httpCookie string, job *RecordJob, ) error { username = strings.Trim(strings.TrimSpace(username), "/") base := strings.TrimRight(domain, "/") pageURL := base + "/" + username + "/" loadFreshHLS := func() (string, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return "", fmt.Errorf("seite laden: %w", err) } hlsURL, err := ParseStream(body) if err != nil { return "", fmt.Errorf("stream-parsing: %w", err) } hlsURL = strings.TrimSpace(hlsURL) if hlsURL == "" { return "", errors.New("leere hls url") } finalURL, err := getWantedResolutionPlaylistWithHeaders( ctx, hlsURL, httpCookie, hc.userAgent, pageURL, ) if err != nil { return "", fmt.Errorf("variant-playlist: %w", err) } return strings.TrimSpace(finalURL), nil } hlsURL, err := loadFreshHLS() if err != nil { return err } if job != nil { assetID := assetIDForJob(job) if assetID != "" { _, _ = ensureGeneratedDir(assetID) } jobsMu.Lock() job.PreviewM3U8 = hlsURL job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() if assetID != "" { startLiveThumbJPGLoop(ctx, job) } } const maxPlaylistRefreshes = 12 refreshes := 0 for { err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL) 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 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): } newHLS, rerr := loadFreshHLS() 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 } hlsURL = newHLS if job != nil { jobsMu.Lock() job.PreviewM3U8 = hlsURL 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") } // DVR-Style Unicode-Decode 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 } // Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) 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 "" } // Falls volle URL 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]) } } // Fallback: rohe Eingabe 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"] // akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower) _, hasSessID := m["session_id"] _, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt 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" }