424 lines
9.1 KiB
Go
424 lines
9.1 KiB
Go
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)
|
||
}
|
||
}
|
||
|