first release

This commit is contained in:
Linrador 2025-08-06 07:20:18 +02:00
commit 100e5e6b8b
5 changed files with 317 additions and 0 deletions

1
build.bat Normal file
View File

@ -0,0 +1 @@
go build -o recorder.exe

9
go.mod Normal file
View File

@ -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
)

6
go.sum Normal file
View File

@ -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=

301
main.go Normal file
View File

@ -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 <Chaturbate-URL oder Benutzername> [-o <Pfad/zieldatei.ts>]")
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)
}
}

BIN
recorder.exe Normal file

Binary file not shown.