streamrecorder/main.go
2025-12-08 17:48:55 +01:00

424 lines
9.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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