package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/grafov/m3u8" ) // --- 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 { // 1) Seite laden // Domain sauber zusammenbauen (mit/ohne Slash) base := strings.TrimRight(domain, "/") pageURL := base + "/" + username body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { return fmt.Errorf("seite laden: %w", err) } // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream) hlsURL, err := ParseStream(body) if err != nil { return fmt.Errorf("stream-parsing: %w", err) } // 3) Playlist holen (wie stream.GetPlaylist im DVR) playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) if err != nil { return fmt.Errorf("playlist abrufen: %w", err) } // ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar) if job != nil { _ = publishJob(job.ID) } if job != nil && strings.TrimSpace(job.PreviewDir) == "" { assetID := assetIDForJob(job) if strings.TrimSpace(assetID) == "" { assetID = job.ID } previewDir := filepath.Join(os.TempDir(), "rec_preview", assetID) jobsMu.Lock() job.PreviewDir = previewDir jobsMu.Unlock() if err := startPreviewHLS(ctx, job, playlist.PlaylistURL, previewDir, httpCookie, hc.userAgent); err != nil { fmt.Println("⚠️ preview start fehlgeschlagen:", err) } } // 4) Datei öffnen file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("datei erstellen: %w", err) } if job != nil { _ = publishJob(job.ID) } defer func() { _ = file.Close() }() // live size tracking (für UI) var written int64 var lastPush time.Time var lastBytes int64 // 5) Segmente „watchen“ – analog zu WatchSegments + HandleSegment im DVR err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error { // Hier wäre im DVR ch.HandleSegment – bei dir einfach in eine Datei schreiben if _, err := file.Write(b); err != nil { return fmt.Errorf("schreibe segment: %w", err) } // ✅ live size (UI) – throttled 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() notifyJobsChanged() lastPush = now lastBytes = written } } // Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest _ = duration // aktuell unbenutzt 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, handler func([]byte, float64) error, ) error { var lastSeq int64 = -1 emptyRounds := 0 const maxEmptyRounds = 60 // statt 5 for { select { case <-ctx.Done(): return ctx.Err() default: } // Playlist holen 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++ 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 segmentURL := p.RootURL + segment.URI segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) if err != nil { continue } segResp, err := hc.client.Do(segReq) if err != nil { 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 (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" }