first release
This commit is contained in:
commit
100e5e6b8b
9
go.mod
Normal file
9
go.mod
Normal 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
6
go.sum
Normal 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
301
main.go
Normal 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
BIN
recorder.exe
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user