commit 100e5e6b8b511e301b6c957b880e677d67f43f37 Author: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed Aug 6 07:20:18 2025 +0200 first release diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..26197c9 --- /dev/null +++ b/build.bat @@ -0,0 +1 @@ +go build -o recorder.exe \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca5b1b1 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module streamrecorder + +go 1.23.2 + +require ( + github.com/gofrs/uuid/v5 v5.3.2 + github.com/gorilla/websocket v1.5.3 + github.com/grafov/m3u8 v0.12.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c23a96 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= +github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e4b8c3e --- /dev/null +++ b/main.go @@ -0,0 +1,301 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "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 [-o ]") + os.Exit(1) + } + + arg := os.Args[1] + username := extractUsername(arg) + + outputPath := fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405")) + for i := 2; i < len(os.Args); i++ { + if os.Args[i] == "-o" && i+1 < len(os.Args) { + outputPath = os.Args[i+1] + break + } + } + + ctx := context.Background() + body, err := fetchPage("https://chaturbate.com/" + username) + 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) + 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, 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) + } + + // MP4 umwandeln + mp4Out := strings.TrimSuffix(outputPath, ".ts") + ".mp4" + if err := exec.Command("ffmpeg", "-y", "-i", outputPath, "-c", "copy", mp4Out).Run(); err != nil { + fmt.Println("⚠️ Fehler bei Umwandlung in MP4:", err) + } else { + fmt.Println("✅ Umwandlung abgeschlossen:", 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 fetchPage(url 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") + + 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 string) (*Playlist, error) { + if hlsSource == "" { + return nil, errors.New("HLS-URL leer") + } + + resp, err := http.Get(hlsSource) + 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 + } + + // Besser, wenn größere Auflösung oder gleiche Auflösung mit höherem Framerate + 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, handler func([]byte, float64) error) error { + var lastSeq int64 = -1 + client := http.Client{Timeout: 10 * time.Second} + + emptyRounds := 0 + const maxEmptyRounds = 5 // z. B. nach 5 leeren Runden abbrechen + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + resp, err := client.Get(p.PlaylistURL) + 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 + segResp, err := client.Get(segmentURL) + 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) + } +} diff --git a/recorder.exe b/recorder.exe new file mode 100644 index 0000000..8293809 Binary files /dev/null and b/recorder.exe differ