streamrecorder/main.go
2025-12-08 19:07:55 +01:00

516 lines
12 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 = "(.*?)"`)
// HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR)
type HTTPClient struct {
client *http.Client
userAgent string
}
// gemeinsamen HTTP-Client erzeugen
func NewHTTPClient(userAgent string) *HTTPClient {
if userAgent == "" {
// Default, falls kein UA übergeben wird
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
}
return &HTTPClient{
client: &http.Client{
Timeout: 10 * time.Second,
},
userAgent: userAgent,
}
}
// Request-Erstellung mit User-Agent + Cookies
func (h *HTTPClient) NewRequest(ctx context.Context, method, url, cookieStr string) (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, method, url, nil)
if err != nil {
return nil, err
}
// Basis-Header, die immer gesetzt werden
if h.userAgent != "" {
req.Header.Set("User-Agent", h.userAgent)
} else {
req.Header.Set("User-Agent", "Mozilla/5.0")
}
req.Header.Set("Accept", "*/*")
// Cookie-String wie "name=value; foo=bar"
addCookiesFromString(req, cookieStr)
return req, nil
}
// Seite laden + einfache Erkennung von Schutzseiten (Cloudflare / Age-Gate)
func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (string, error) {
req, err := h.NewRequest(ctx, http.MethodGet, url, cookieStr)
if err != nil {
return "", err
}
resp, err := h.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
body := string(data)
// Etwas aussagekräftigere Fehler als nur "room dossier nicht gefunden"
if strings.Contains(body, "<title>Just a moment...</title>") {
return "", errors.New("Schutzseite von Cloudflare erhalten (\"Just a moment...\") kein Room-HTML")
}
if strings.Contains(body, "Verify your age") {
return "", errors.New("Altersverifikationsseite erhalten kein Room-HTML")
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d beim Laden von %s", resp.StatusCode, url)
}
return body, nil
}
// --- main ---
func main() {
if len(os.Args) < 2 {
fmt.Println("Verwendung: recorder.exe [--http-cookie \"<Cookie>\"] [--user-agent \"<UA>\"] [--domain \"https://chaturbate.com/\"] <Chaturbate-URL oder Benutzername> [best] [-o <Pfad/zieldatei.ts>] [-f]")
os.Exit(1)
}
var (
httpCookie string
outputPath string
urlArg string
userAgent string
domain = "https://chaturbate.com/" // Standard wie beim DVR
)
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 "--user-agent":
if i+1 >= len(args) {
fmt.Println("Fehlender Wert nach --user-agent")
os.Exit(1)
}
userAgent = args[i+1]
i++
case "--domain":
if i+1 >= len(args) {
fmt.Println("Fehlender Wert nach --domain")
os.Exit(1)
}
domain = 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)
username = strings.Trim(username, "/\\")
if outputPath == "" {
outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405"))
}
ctx := context.Background()
// HIER User-Agent an den Client durchreichen
hc := NewHTTPClient(userAgent)
if err := RecordStream(ctx, hc, domain, username, outputPath, httpCookie); err != nil {
fmt.Println("❌ Aufnahmefehler:", err)
os.Exit(1)
}
// 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)
}
}
// --- DVR-ähnlicher Recorder-Ablauf ---
// Entspricht grob dem RecordStream aus dem Channel-Snippet:
func RecordStream(
ctx context.Context,
hc *HTTPClient,
domain string,
username string,
outputPath string,
httpCookie string,
) error {
// 1) Seite laden
// Domain sauber zusammenbauen (mit/ohne Slash)
base := strings.TrimRight(domain, "/")
pageURL := base + "/" + username
body, err := hc.FetchPage(ctx, pageURL, httpCookie)
// 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream)
hlsURL, err := ParseStream(body)
if err != nil {
return fmt.Errorf("stream-parsing: %w", err)
}
// 3) Playlist holen (wie stream.GetPlaylist im DVR)
playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie)
if err != nil {
return fmt.Errorf("playlist abrufen: %w", err)
}
fmt.Printf("Stream-Qualität: %dp @ %dfps\n", playlist.Resolution, playlist.Framerate)
// 4) Datei öffnen
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("datei erstellen: %w", err)
}
defer func() {
_ = file.Close()
}()
fmt.Println("📡 Aufnahme gestartet:", outputPath)
// 5) Segmente „watchen“ analog zu WatchSegments + HandleSegment im DVR
err = playlist.WatchSegments(ctx, hc, httpCookie, func(b []byte, duration float64) error {
// Hier wäre im DVR ch.HandleSegment bei dir einfach in eine Datei schreiben
if _, err := file.Write(b); err != nil {
return fmt.Errorf("schreibe segment: %w", err)
}
// Könntest hier z.B. auch Dauer/Größe loggen, wenn du möchtest
_ = duration // aktuell unbenutzt
return nil
})
if err != nil {
return fmt.Errorf("watch segments: %w", err)
}
return nil
}
// --- helper ---
func extractUsername(input string) string {
input = strings.TrimSpace(input)
input = strings.TrimPrefix(input, "https://")
input = strings.TrimPrefix(input, "http://")
input = strings.TrimPrefix(input, "www.")
if strings.HasPrefix(input, "chaturbate.com/") {
input = strings.TrimPrefix(input, "chaturbate.com/")
}
// alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.)
if idx := strings.IndexAny(input, "/?#"); idx != -1 {
input = input[:idx]
}
// zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen
return strings.Trim(input, "/\\")
}
// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR)
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,
})
}
}
// ParseStream entspricht der DVR-Variante (roomDossier → hls_source)
func ParseStream(html string) (string, error) {
matches := roomDossierRegexp.FindStringSubmatch(html)
if len(matches) == 0 {
return "", errors.New("room dossier nicht gefunden")
}
// DVR-Style Unicode-Decode
decoded, err := strconv.Unquote(
strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1),
)
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
}
// --- Playlist/WatchSegments wie gehabt ---
type Playlist struct {
PlaylistURL string
RootURL string
Resolution int
Framerate int
}
type Resolution struct {
Framerate map[int]string
Width int
}
// nimmt jetzt *HTTPClient entgegen
func FetchPlaylist(ctx context.Context, hc *HTTPClient, hlsSource, httpCookie string) (*Playlist, error) {
if hlsSource == "" {
return nil, errors.New("HLS-URL leer")
}
req, err := hc.NewRequest(ctx, http.MethodGet, hlsSource, httpCookie)
if err != nil {
return nil, fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
}
resp, err := hc.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
}
// nutzt ebenfalls *HTTPClient
func (p *Playlist) WatchSegments(
ctx context.Context,
hc *HTTPClient,
httpCookie string,
handler func([]byte, float64) error,
) error {
var lastSeq int64 = -1
emptyRounds := 0
const maxEmptyRounds = 5
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
// Playlist holen
req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie)
if err != nil {
return fmt.Errorf("Fehler beim Erstellen der Playlist-Request: %w", err)
}
resp, err := hc.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 := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie)
if err != nil {
continue
}
segResp, err := hc.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)
}
}