package main import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) // --- Gemeinsame Status-Werte für MFC --- type Status int const ( StatusUnknown Status = iota StatusPublic StatusPrivate StatusOffline StatusNotExist ) func (s Status) String() string { switch s { case StatusPublic: return "PUBLIC" case StatusPrivate: return "PRIVATE" case StatusOffline: return "OFFLINE" case StatusNotExist: return "NOTEXIST" default: return "UNKNOWN" } } // HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR) type HTTPClient struct { client *http.Client userAgent string } // gemeinsamen HTTP-Client erzeugen func NewHTTPClient(userAgent string) *HTTPClient { if userAgent == "" { // Default, falls kein UA übergeben wird userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" } return &HTTPClient{ client: &http.Client{ Timeout: 10 * time.Second, }, userAgent: userAgent, } } // Request-Erstellung mit User-Agent + Cookies func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, method, url, nil) if err != nil { return nil, err } // Basis-Header, die immer gesetzt werden if h.userAgent != "" { req.Header.Set("User-Agent", h.userAgent) } else { req.Header.Set("User-Agent", "Mozilla/5.0") } req.Header.Set("Accept", "*/*") // Cookie-String wie "name=value; foo=bar" addCookiesFromString(req, cookieStr) return req, nil } // Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate) func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) { req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr) if err != nil { return "", err } resp, err := h.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return "", err } body := string(data) // Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden" if strings.Contains(body, "Just a moment...") { return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") – kein Room-HTML") } if strings.Contains(body, "Verify your age") { return "", errors.New("Altersverifikationsseite erhalten – kein Room-HTML") } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url) } return body, nil } // --- main --- func main() { if len(os.Args) < 2 { fmt.Println("Verwendung: recorder.exe [--http-cookie \"\"] [--user-agent \"\"] [--domain \"https://chaturbate.com/\"] [best] [-o ] [-f]") os.Exit(1) } var ( httpCookie string outputPath string urlArg string userAgent string domain = "https://chaturbate.com/" // Standard wie beim DVR ) args := os.Args[1:] for i := 0; i < len(args); i++ { a := args[i] switch a { case "--http-cookie": if i+1 >= len(args) { fmt.Println("Fehlender Wert nach --http-cookie") os.Exit(1) } httpCookie = args[i+1] i++ case "--user-agent": if i+1 >= len(args) { fmt.Println("Fehlender Wert nach --user-agent") os.Exit(1) } userAgent = args[i+1] i++ case "--domain": if i+1 >= len(args) { fmt.Println("Fehlender Wert nach --domain") os.Exit(1) } domain = args[i+1] i++ case "-o": if i+1 >= len(args) { fmt.Println("Fehlender Wert nach -o") os.Exit(1) } outputPath = args[i+1] i++ case "-f": // nur für Kompatibilität, wird ignoriert case "best": // wird ignoriert, wir wählen sowieso beste Qualität default: // erste „normale“ Angabe ist die URL / der Benutzername if urlArg == "" { urlArg = a } } } if urlArg == "" { fmt.Println("Keine URL / kein Benutzername angegeben.") os.Exit(1) } // Provider anhand der URL erkennen provider := detectProvider(urlArg) ctx := context.Background() hc := NewHTTPClient(userAgent) switch provider { case "chaturbate": // wie bisher username := extractUsername(urlArg) username = strings.Trim(username, "/\\") // trailing Slash entfernen if outputPath == "" { outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405")) } if err := RecordStream(ctx, hc, domain, username, outputPath, httpCookie); err != nil { fmt.Println("❌ Aufnahmefehler (CB):", err) os.Exit(1) } case "mfc": if outputPath == "" { outputPath = fmt.Sprintf("mfc_%s.mp4", time.Now().Format("20060102_150405")) } if err := RecordStreamMFC(ctx, hc, urlArg, outputPath, httpCookie); err != nil { fmt.Println("❌ Aufnahmefehler (MFC):", err) os.Exit(1) } default: fmt.Println("Unbekannter oder nicht unterstützter Provider für URL:", urlArg) os.Exit(1) } // TS -> MP4 remuxen nur für Chaturbate if provider == "chaturbate" { mp4Out := outputPath ext := filepath.Ext(outputPath) if ext != ".mp4" { mp4Out = strings.TrimSuffix(outputPath, ext) + ".mp4" } if err := exec.Command( "ffmpeg", "-y", "-i", outputPath, "-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-movflags", "+faststart", mp4Out, ).Run(); err != nil { fmt.Println("⚠️ Fehler bei Umwandlung in MP4:", err) } else { fmt.Println("✅ Umwandlung abgeschlossen (web-optimiert):", mp4Out) } } } // --- 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, ) error { // 1) Seite laden // Domain sauber zusammenbauen (mit/ohne Slash) base := strings.TrimRight(domain, "/") pageURL := base + "/" + username body, err := hc.FetchPage(ctx, pageURL, httpCookie) // 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) } fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate) // 4) Datei öffnen file, err := os.Create(outputPath) if err != nil { return fmt.Errorf("datei erstellen: %w", err) } defer func() { _ = file.Close() }() fmt.Println("📡 Aufnahme gestartet:", outputPath) // 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) } // 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 } // RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf. // In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren. // RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC). func RecordStreamMFC( ctx context.Context, hc *HTTPClient, pageURL string, outputPath string, httpCookie string, ) error { _ = ctx // wird im aktuellen MFC-Flow nicht benötigt _ = hc // ebenfalls nicht benötigt _ = httpCookie // MFC-Flow nutzt aktuell keine Cookies username := extractMFCUsername(pageURL) if username == "" { return fmt.Errorf("konnte MFC-Username aus URL %q nicht ermitteln", pageURL) } return runMFC(username, outputPath) } 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" } // --- 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, "/\\") } // 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, }) } } // 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 } // nimmt jetzt *HTTPClient entgegen func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) { if hlsSource == "" { return nil, errors.New("HLS-URL leer") } req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie) if err != nil { return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) } resp, err := hc.client.Do(req) if err != nil { return nil, fmt.Errorf("Fehler beim Laden der Playlist: %w", err) } defer resp.Body.Close() playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil || listType != m3u8.MASTER { return nil, errors.New("keine gültige Master-Playlist") } master := playlist.(*m3u8.MasterPlaylist) var bestURI string var bestWidth int var bestFramerate int for _, variant := range master.Variants { if variant == nil || variant.Resolution == "" { continue } parts := strings.Split(variant.Resolution, "x") if len(parts) != 2 { continue } width, err := strconv.Atoi(parts[1]) if err != nil { continue } fr := 30 if strings.Contains(variant.Name, "FPS:60.0") { fr = 60 } if width > bestWidth || (width == bestWidth && fr > bestFramerate) { bestWidth = width bestFramerate = fr bestURI = variant.URI } } if bestURI == "" { return nil, errors.New("keine gültige Auflösung gefunden") } root := hlsSource[:strings.LastIndex(hlsSource, "/")+1] return &Playlist{ PlaylistURL: root + bestURI, RootURL: root, Resolution: bestWidth, Framerate: bestFramerate, }, nil } // 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 = 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) } } /* ─────────────────────────────── MyFreeCams (übernommener Flow) ─────────────────────────────── */ type MyFreeCams struct { Username string Attrs map[string]string VideoURL string } func NewMyFreeCams(username string) *MyFreeCams { return &MyFreeCams{ Username: username, Attrs: map[string]string{}, } } func (m *MyFreeCams) GetWebsiteURL() string { return "https://www.myfreecams.com/#" + m.Username } func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { if !refresh && m.VideoURL != "" { return m.VideoURL, nil } // Prüfen, ob alle benötigten Attribute vorhanden sind if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok { return "", nil } sid := m.Attrs["data-cam-preview-server-id-value"] midBase := m.Attrs["data-cam-preview-model-id-value"] isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true" midInt, err := strconv.Atoi(midBase) if err != nil { return "", fmt.Errorf("model-id parse error: %w", err) } mid := 100000000 + midInt a := "" if isWzobs { a = "a_" } playlistURL := fmt.Sprintf( "https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8", sid, a, mid, ) // Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken u, err := getWantedResolutionPlaylist(playlistURL) if err != nil { return "", err } m.VideoURL = u return m.VideoURL, nil } func (m *MyFreeCams) GetStatus() (Status, error) { // 1) share-Seite prüfen (existiert/nicht existiert) shareURL := "https://share.myfreecams.com/" + m.Username resp, err := http.Get(shareURL) if err != nil { return StatusUnknown, err } defer resp.Body.Close() if resp.StatusCode == 404 { return StatusNotExist, nil } if resp.StatusCode != 200 { return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) } // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return StatusUnknown, err } // 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?")) if start == -1 { // ohne tracking Parameter -> behandeln wie nicht existent return StatusNotExist, nil } end := bytes.IndexByte(bodyBytes[start:], '"') if end == -1 { return StatusUnknown, errors.New("tracking url parse failed") } raw := string(bodyBytes[start : start+end]) u, err := url.Parse(raw) if err != nil { return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) } qs := u.Query() if qs.Get("model_id") == "" { return StatusNotExist, nil } // 3) HTML parsen und
Attribute auslesen doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) if err != nil { return StatusUnknown, err } params := doc.Find(".campreview").First() if params.Length() == 0 { // keine campreview -> offline return StatusOffline, nil } attrs := map[string]string{} params.Each(func(_ int, s *goquery.Selection) { for _, a := range []string{ "data-cam-preview-server-id-value", "data-cam-preview-model-id-value", "data-cam-preview-is-wzobs-value", } { if v, ok := s.Attr(a); ok { attrs[a] = v } } }) m.Attrs = attrs // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln uStr, err := m.GetVideoURL(true) if err != nil { return StatusUnknown, err } if uStr != "" { return StatusPublic, nil } // campreview vorhanden, aber keine playable url -> „PRIVATE“ return StatusPrivate, nil } func runMFC(username string, outArg string) error { mfc := NewMyFreeCams(username) st, err := mfc.GetStatus() if err != nil { return err } fmt.Println("MFC Status:", st) if st != StatusPublic { return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) } m3u8URL, err := mfc.GetVideoURL(false) if err != nil { return err } if m3u8URL == "" { return errors.New("keine m3u8 URL gefunden") } return handleM3U8Mode(m3u8URL, outArg) } /* ─────────────────────────────── Gemeinsame HLS/M3U8-Helper (MFC) ─────────────────────────────── */ func getWantedResolutionPlaylist(playlistURL string) (string, error) { // Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück. resp, err := http.Get(playlistURL) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { return "", fmt.Errorf("m3u8 parse: %w", err) } if listType == m3u8.MEDIA { return playlistURL, nil } master := playlist.(*m3u8.MasterPlaylist) var bestURI string var bestWidth int var bestFramerate float64 for _, v := range master.Variants { if v == nil { continue } // Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert. w := 0 if v.Resolution != "" { parts := strings.Split(v.Resolution, "x") if len(parts) == 2 { if ww, err := strconv.Atoi(parts[1]); err == nil { w = ww } } } fr := 30.0 if v.FrameRate > 0 { fr = v.FrameRate } else if strings.Contains(v.Name, "FPS:60") { fr = 60 } if w > bestWidth || (w == bestWidth && fr > bestFramerate) { bestWidth = w bestFramerate = fr bestURI = v.URI } } if bestURI == "" { return "", errors.New("Master-Playlist ohne gültige Varianten") } // Absolutieren root := playlistURL[:strings.LastIndex(playlistURL, "/")+1] if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") { return bestURI, nil } return root + bestURI, nil } func handleM3U8Mode(m3u8URL, outArg string) error { // Minimalvalidierung u, err := url.Parse(m3u8URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("ungültige URL: %q", m3u8URL) } // kleinen Check machen, ob abrufbar resp, err := http.Get(m3u8URL) if err != nil { return fmt.Errorf("Abruf fehlgeschlagen: %w", err) } io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } // Ausgabedatei outFile := outArg if strings.TrimSpace(outFile) == "" { def := "mfc_" + time.Now().Format("20060102_150405") + ".mp4" fmt.Printf("Name der MP4-Datei (Enter für %s): ", def) outFile = readLine() if strings.TrimSpace(outFile) == "" { outFile = def } } // Überschreiben? if fileExists(outFile) { fmt.Print("Die Datei existiert bereits. Überschreiben? [y/N] ") a := strings.ToLower(strings.TrimSpace(readLine())) if a != "y" && a != "j" { return errors.New("abgebrochen") } } // ffmpeg copy-download fmt.Println("📦 Starte Download mit ffmpeg:", outFile) cmd := exec.Command( "ffmpeg", "-i", m3u8URL, "-c", "copy", outFile, ) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err) } fmt.Println("✅ Download abgeschlossen:", outFile) return nil } /* ─────────────────────────────── Kleine Helper für MFC ─────────────────────────────── */ func extractMFCUsername(input string) string { if strings.Contains(input, "myfreecams.com/#") { i := strings.Index(input, "#") if i >= 0 && i < len(input)-1 { return strings.TrimSpace(input[i+1:]) } return "" } return strings.TrimSpace(input) } func readLine() string { r := bufio.NewReader(os.Stdin) s, _ := r.ReadString('\n') return strings.TrimRight(s, "\r\n") } func fileExists(path string) bool { _, err := os.Stat(path) return err == nil }