// backend\record_stream_cb.go package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "sort" "strconv" "strings" "time" "github.com/grafov/m3u8" ) func previewSegmentsDir(previewDir string) string { previewDir = strings.TrimSpace(previewDir) if previewDir == "" { return "" } return filepath.Join(previewDir, "segments") } func ensurePreviewSegmentsDir(previewDir string) (string, error) { dir := previewSegmentsDir(previewDir) if strings.TrimSpace(dir) == "" { return "", fmt.Errorf("previewDir leer") } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } return dir, nil } // --- 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 { base := strings.TrimRight(domain, "/") pageURL := base + "/" + username 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) } playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) if err != nil { return fmt.Errorf("playlist abrufen: %w", err) } var previewDir string var segmentsDir string if job != nil { assetID := assetIDForJob(job) if assetID != "" { if dir, derr := ensureGeneratedDir(assetID); derr == nil { previewDir = strings.TrimSpace(dir) if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil { segmentsDir = strings.TrimSpace(sdir) } else { fmt.Println("preview ensurePreviewSegmentsDir failed:", assetID, serr) } } else { fmt.Println("preview ensureGeneratedDir failed:", assetID, derr) } } jobsMu.Lock() job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent if previewDir != "" { job.PreviewDir = previewDir } jobsMu.Unlock() if previewDir != "" { startLiveThumbJPGLoop(ctx, job) } } if segmentsDir != "" { defer func() { if err := os.RemoveAll(segmentsDir); err != nil { //fmt.Println("preview segments cleanup failed:", segmentsDir, err) } }() } 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 trimPreviewSegments := func(dir string, keep int) { if strings.TrimSpace(dir) == "" || keep <= 0 { return } entries, err := os.ReadDir(dir) if err != nil { return } type item struct { name string mod time.Time } items := make([]item, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } name := e.Name() lower := strings.ToLower(name) if lower == "init.mp4" { continue } if !strings.HasPrefix(lower, "seg_preview_") { continue } info, err := e.Info() if err != nil { continue } items = append(items, item{ name: name, mod: info.ModTime(), }) } if len(items) <= keep { return } sort.Slice(items, func(i, j int) bool { return items[i].mod.Before(items[j].mod) }) for i := 0; i < len(items)-keep; i++ { _ = os.Remove(filepath.Join(dir, items[i].name)) } } err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, 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) } if err := file.Sync(); err != nil { //fmt.Println("output sync failed:", outputPath, err) } if segmentsDir != "" { ext := ".m4s" if len(b) > 0 && b[0] == 0x47 { ext = ".ts" } segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext) segPath := filepath.Join(segmentsDir, segName) if err := os.WriteFile(segPath, b, 0o644); err != nil { fmt.Println("preview segment write failed:", segPath, err) } else { trimPreviewSegments(segmentsDir, 12) } } 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() lastPush = now lastBytes = written } } _ = duration _ = segmentURL return nil }) if err != nil { return fmt.Errorf("watch segments: %w", err) } 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") } // 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 } // --- Playlist/WatchSegments wie gehabt --- type Playlist struct { PlaylistURL string RootURL string Resolution int Framerate int } type Resolution struct { Framerate map[int]string Width int } // nutzt ebenfalls *HTTPClient func (p *Playlist) WatchSegments( ctx context.Context, hc *HTTPClient, httpCookie string, previewDir string, handler func(segmentURL string, b []byte, duration float64) error, ) error { var lastSeq int64 = -1 emptyRounds := 0 const maxEmptyRounds = 60 playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL)) if err != nil || playlistBaseURL == nil { return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL) } var lastMapURL string writeInitIfNeeded := func(mapURL string) { if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" { return } segDir, err := ensurePreviewSegmentsDir(previewDir) if err != nil { fmt.Println("ensurePreviewSegmentsDir failed:", previewDir, err) return } initPath := filepath.Join(segDir, "init.mp4") // schon vorhanden und gleiches URL-Mapping -> nichts tun if mapURL == lastMapURL { if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 { return } } req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie) if err != nil { fmt.Println("init request build failed:", mapURL, err) return } resp, err := hc.client.Do(req) if err != nil { fmt.Println("init download failed:", mapURL, err) return } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { fmt.Println("init bad status:", resp.StatusCode, mapURL) _, _ = io.Copy(io.Discard, resp.Body) return } b, err := io.ReadAll(resp.Body) if err != nil { fmt.Println("init read failed:", mapURL, err) return } if len(b) == 0 { fmt.Println("init empty:", mapURL) return } if err := os.WriteFile(initPath, b, 0o644); err != nil { fmt.Println("init write failed:", initPath, err) return } lastMapURL = mapURL } for { select { case <-ctx.Done(): return ctx.Err() default: } if strings.TrimSpace(p.PlaylistURL) == "" { return errors.New("playlist url fehlt") } req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) if err != nil { return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err) } resp, err := hc.client.Do(req) if err != nil { emptyRounds++ fmt.Println("playlist download failed:", p.PlaylistURL, err) if emptyRounds >= maxEmptyRounds { return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } if resp.StatusCode < 200 || resp.StatusCode >= 300 { //fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL) _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() emptyRounds++ if emptyRounds >= maxEmptyRounds { return fmt.Errorf("❌ playlist liefert status %d – stream vermutlich offline", resp.StatusCode) } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) resp.Body.Close() if err != nil { emptyRounds++ fmt.Println("playlist decode failed:", p.PlaylistURL, err) if emptyRounds >= maxEmptyRounds { return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } if listType != m3u8.MEDIA { emptyRounds++ fmt.Println("playlist is not media playlist:", p.PlaylistURL) if emptyRounds >= maxEmptyRounds { return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } media, ok := playlist.(*m3u8.MediaPlaylist) if !ok || media == nil { emptyRounds++ fmt.Println("playlist cast to media failed:", p.PlaylistURL) if emptyRounds >= maxEmptyRounds { return errors.New("❌ Fehlerhafte Media-Playlist – möglicherweise offline") } select { case <-ctx.Done(): return ctx.Err() case <-time.After(2 * time.Second): } continue } if media.Map != nil { mapURI := strings.TrimSpace(media.Map.URI) if mapURI != "" { mapRef, err := url.Parse(mapURI) if err != nil { //fmt.Println("map uri parse failed:", mapURI, err) } else { mapURL := playlistBaseURL.ResolveReference(mapRef).String() writeInitIfNeeded(mapURL) } } } newSegment := false successfulSegment := false for _, segment := range media.Segments { if segment == nil { continue } if int64(segment.SeqId) <= lastSeq { continue } lastSeq = int64(segment.SeqId) newSegment = true segURI := strings.TrimSpace(segment.URI) if segURI == "" { //fmt.Println("segment uri empty in playlist:", p.PlaylistURL) continue } segRef, err := url.Parse(segURI) if err != nil { //fmt.Println("segment uri parse failed:", segURI, err) continue } segmentURL := playlistBaseURL.ResolveReference(segRef).String() segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) if err != nil { //fmt.Println("segment request build failed:", segmentURL, err) continue } segResp, err := hc.client.Do(segReq) if err != nil { //fmt.Println("segment download failed:", segmentURL, err) continue } if segResp.StatusCode < 200 || segResp.StatusCode >= 300 { //fmt.Println("segment bad status:", segResp.StatusCode, segmentURL) _, _ = io.Copy(io.Discard, segResp.Body) segResp.Body.Close() continue } data, err := io.ReadAll(segResp.Body) segResp.Body.Close() if err != nil { //fmt.Println("segment read failed:", segmentURL, err) continue } if len(data) == 0 { //fmt.Println("segment empty:", segmentURL) continue } if err := handler(segmentURL, data, segment.Duration); err != nil { return err } successfulSegment = true } if successfulSegment { emptyRounds = 0 } else if newSegment { emptyRounds++ //fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL) if emptyRounds >= maxEmptyRounds { return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden – Stream vermutlich geschützt, fehlerhaft oder offline.") } } else { emptyRounds++ if emptyRounds >= maxEmptyRounds { return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.") } } select { case <-ctx.Done(): return ctx.Err() case <-time.After(1 * time.Second): } } } // 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 { input = strings.TrimSpace(input) input = strings.TrimPrefix(input, "https://") input = strings.TrimPrefix(input, "http://") input = strings.TrimPrefix(input, "www.") if strings.HasPrefix(input, "chaturbate.com/") { input = strings.TrimPrefix(input, "chaturbate.com/") } // alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.) if idx := strings.IndexAny(input, "/?#"); idx != -1 { input = input[:idx] } // zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen return strings.Trim(input, "/\\") } 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" }