package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "strconv" "strings" "time" "github.com/grafov/m3u8" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) // --- main --- func main() { if len(os.Args) < 2 { fmt.Println("Verwendung: recorder.exe [--http-cookie \"\"] [best] [-o ] [-f]") os.Exit(1) } var ( httpCookie string outputPath string urlArg string ) 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 "-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) } username := extractUsername(urlArg) // Default-Ausgabedatei, falls kein -o if outputPath == "" { outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405")) } ctx := context.Background() // Seite laden (inkl. Cookie, falls gesetzt) body, err := fetchPage("https://chaturbate.com/"+username, httpCookie) if err != nil { fmt.Println("❌ Fehler beim Laden der Seite:", err) os.Exit(1) } hlsURL, err := ParseStream(body) if err != nil { fmt.Println("❌ Stream nicht gefunden oder offline:", err) os.Exit(1) } playlist, err := FetchPlaylist(ctx, hlsURL, httpCookie) if err != nil { fmt.Println("❌ Fehler beim Abrufen der Playlist:", err) os.Exit(1) } file, err := os.Create(outputPath) if err != nil { fmt.Println("❌ Datei konnte nicht erstellt werden:", err) os.Exit(1) } defer file.Close() fmt.Println("📡 Aufnahme gestartet:", outputPath) err = playlist.WatchSegments(ctx, httpCookie, func(b []byte, _ float64) error { _, err := file.Write(b) if err != nil { fmt.Println("❌ Fehler beim Schreiben in Datei:", err) } return err }) if err != nil { fmt.Println("❌ Aufnahmefehler:", err) } // TS -> MP4 remuxen (wie gehabt) 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) } } // --- helper --- func extractUsername(input string) string { input = strings.TrimPrefix(input, "https://") input = strings.TrimPrefix(input, "http://") input = strings.TrimPrefix(input, "www.") if strings.HasPrefix(input, "chaturbate.com/") { return strings.TrimPrefix(input, "chaturbate.com/") } return strings.TrimSpace(input) } 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, }) } } func fetchPage(url, httpCookie string) (string, error) { client := http.Client{Timeout: 10 * time.Second} req, err := http.NewRequest("GET", url, nil) if err != nil { return "", err } req.Header.Set("User-Agent", "Mozilla/5.0") if httpCookie != "" { // kompletter Inhalt von --http-cookie, z.B. "session=XYZ; foo=bar" addCookiesFromString(req, httpCookie) } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != 200 { return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, url) } data, err := io.ReadAll(resp.Body) if err != nil { return "", err } return string(data), nil } func ParseStream(html string) (string, error) { matches := roomDossierRegexp.FindStringSubmatch(html) if len(matches) < 2 { return "", errors.New("room dossier nicht gefunden") } encoded := matches[1] decoded, err := strconv.Unquote(`"` + strings.ReplaceAll(encoded, `\\u`, `\u`) + `"`) 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 } type Playlist struct { PlaylistURL string RootURL string Resolution int Framerate int } type Resolution struct { Framerate map[int]string Width int } func FetchPlaylist(ctx context.Context, hlsSource, httpCookie string) (*Playlist, error) { if hlsSource == "" { return nil, errors.New("HLS-URL leer") } client := http.Client{Timeout: 10 * time.Second} req, err := http.NewRequestWithContext(ctx, "GET", hlsSource, nil) if err != nil { return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0") if httpCookie != "" { req.Header.Set("Cookie", httpCookie) } resp, err := 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 } func (p *Playlist) WatchSegments( ctx context.Context, httpCookie string, handler func([]byte, float64) error, ) error { var lastSeq int64 = -1 client := http.Client{Timeout: 10 * time.Second} emptyRounds := 0 const maxEmptyRounds = 5 for { select { case <-ctx.Done(): return ctx.Err() default: } // Playlist holen req, err := http.NewRequestWithContext(ctx, "GET", p.PlaylistURL, nil) if err != nil { return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0") if httpCookie != "" { req.Header.Set("Cookie", httpCookie) } resp, err := 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 := http.NewRequestWithContext(ctx, "GET", segmentURL, nil) if err != nil { continue } segReq.Header.Set("User-Agent", "Mozilla/5.0") if httpCookie != "" { segReq.Header.Set("Cookie", httpCookie) } segResp, err := 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) } }