diff --git a/go.mod b/go.mod index ca5b1b1..67e68cd 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,15 @@ module streamrecorder -go 1.23.2 +go 1.24.0 require ( github.com/gofrs/uuid/v5 v5.3.2 github.com/gorilla/websocket v1.5.3 github.com/grafov/m3u8 v0.12.1 ) + +require ( + github.com/PuerkitoBio/goquery v1.11.0 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + golang.org/x/net v0.47.0 // indirect +) diff --git a/go.sum b/go.sum index 4c23a96..4c0ed80 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,77 @@ +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/main.go b/main.go index 35443ea..8239d41 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,15 @@ package main import ( + "bufio" + "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -15,55 +18,80 @@ import ( "strings" "time" + "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) +// --- Gemeinsame Status-Werte für MFC --- +type Status int + +const ( + StatusUnknown Status = iota + StatusPublic + StatusPrivate + StatusOffline + StatusNotExist +) + +func (s Status) String() string { + switch s { + case StatusPublic: + return "PUBLIC" + case StatusPrivate: + return "PRIVATE" + case StatusOffline: + return "OFFLINE" + case StatusNotExist: + return "NOTEXIST" + default: + return "UNKNOWN" + } +} + // HTTPClient kapselt http.Client + Header/Cookies (wie internal.Req im DVR) type HTTPClient struct { - client *http.Client - userAgent string + 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)" - } + 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, - } + 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 - } + 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", "*/*") + // 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) + // Cookie-String wie "name=value; foo=bar" + addCookiesFromString(req, cookieStr) - return req, nil + 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) @@ -100,132 +128,153 @@ func (h *HTTPClient) FetchPage(ctx context.Context, url, cookieStr string) (stri // --- main --- func main() { - if len(os.Args) < 2 { - fmt.Println("Verwendung: recorder.exe [--http-cookie \"\"] [--user-agent \"\"] [--domain \"https://chaturbate.com/\"] [best] [-o ] [-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 len(os.Args) < 2 { + fmt.Println("Verwendung: recorder.exe [--http-cookie \"\"] [--user-agent \"\"] [--domain \"https://chaturbate.com/\"] [best] [-o ] [-f]") + os.Exit(1) } - 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) + 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) + } + + // Provider anhand der URL erkennen + provider := detectProvider(urlArg) + + ctx := context.Background() + hc := NewHTTPClient(userAgent) + + switch provider { + + case "chaturbate": + // wie bisher + username := extractUsername(urlArg) + username = strings.Trim(username, "/\\") // trailing Slash entfernen + + if outputPath == "" { + outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405")) + } + + if err := RecordStream(ctx, hc, domain, username, outputPath, httpCookie); err != nil { + fmt.Println("❌ Aufnahmefehler (CB):", err) + os.Exit(1) + } + + case "mfc": + if outputPath == "" { + outputPath = fmt.Sprintf("mfc_%s.mp4", time.Now().Format("20060102_150405")) + } + if err := RecordStreamMFC(ctx, hc, urlArg, outputPath, httpCookie); err != nil { + fmt.Println("❌ Aufnahmefehler (MFC):", err) + os.Exit(1) + } + + default: + fmt.Println("Unbekannter oder nicht unterstützter Provider für URL:", urlArg) + os.Exit(1) + } + + // TS -> MP4 remuxen nur für Chaturbate + if provider == "chaturbate" { + 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, + 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 + // 1) Seite laden + // Domain sauber zusammenbauen (mit/ohne Slash) + base := strings.TrimRight(domain, "/") + pageURL := base + "/" + username - body, err := hc.FetchPage(ctx, pageURL, httpCookie) + body, err := hc.FetchPage(ctx, pageURL, httpCookie) // 2) HLS-URL aus roomDossier extrahieren (wie DVR.ParseStream) hlsURL, err := ParseStream(body) @@ -268,25 +317,58 @@ func RecordStream( 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/") - } +// RecordStreamMFC nimmt vorerst die URL 1:1 und ruft ffmpeg direkt darauf auf. +// In der Praxis musst du hier meist erst eine HLS-URL aus dem HTML extrahieren. +// RecordStreamMFC ist jetzt nur noch ein Wrapper um den bewährten MFC-Flow (runMFC). +func RecordStreamMFC( + ctx context.Context, + hc *HTTPClient, + pageURL string, + outputPath string, + httpCookie string, +) error { + _ = ctx // wird im aktuellen MFC-Flow nicht benötigt + _ = hc // ebenfalls nicht benötigt + _ = httpCookie // MFC-Flow nutzt aktuell keine Cookies - // alles nach dem ersten Slash abschneiden (Pfadteile, /, etc.) - if idx := strings.IndexAny(input, "/?#"); idx != -1 { - input = input[:idx] - } + username := extractMFCUsername(pageURL) + if username == "" { + return fmt.Errorf("konnte MFC-Username aus URL %q nicht ermitteln", pageURL) + } - // zur Sicherheit evtl. übrig gebliebene Slash/Backslash trimmen - return strings.Trim(input, "/\\") + return runMFC(username, outputPath) } +func detectProvider(raw string) string { + s := strings.ToLower(raw) + + if strings.Contains(s, "chaturbate.com") { + return "chaturbate" + } + if strings.Contains(s, "myfreecams.com") { + return "mfc" + } + return "unknown" +} + +// --- 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) { @@ -513,3 +595,312 @@ func (p *Playlist) WatchSegments( time.Sleep(1 * time.Second) } } + +/* ─────────────────────────────── + MyFreeCams (übernommener Flow) + ─────────────────────────────── */ + +type MyFreeCams struct { + Username string + Attrs map[string]string + VideoURL string +} + +func NewMyFreeCams(username string) *MyFreeCams { + return &MyFreeCams{ + Username: username, + Attrs: map[string]string{}, + } +} + +func (m *MyFreeCams) GetWebsiteURL() string { + return "https://www.myfreecams.com/#" + m.Username +} + +func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { + if !refresh && m.VideoURL != "" { + return m.VideoURL, nil + } + + // Prüfen, ob alle benötigten Attribute vorhanden sind + if _, ok := m.Attrs["data-cam-preview-model-id-value"]; !ok { + return "", nil + } + sid := m.Attrs["data-cam-preview-server-id-value"] + midBase := m.Attrs["data-cam-preview-model-id-value"] + isWzobs := strings.ToLower(m.Attrs["data-cam-preview-is-wzobs-value"]) == "true" + + midInt, err := strconv.Atoi(midBase) + if err != nil { + return "", fmt.Errorf("model-id parse error: %w", err) + } + mid := 100000000 + midInt + a := "" + if isWzobs { + a = "a_" + } + + playlistURL := fmt.Sprintf( + "https://previews.myfreecams.com/hls/NxServer/%s/ngrp:mfc_%s%d.f4v_mobile_mhp1080_previewurl/playlist.m3u8", + sid, a, mid, + ) + + // Validieren (HTTP 200) & ggf. auf gewünschte Auflösung verlinken + u, err := getWantedResolutionPlaylist(playlistURL) + if err != nil { + return "", err + } + m.VideoURL = u + return m.VideoURL, nil +} + +func (m *MyFreeCams) GetStatus() (Status, error) { + // 1) share-Seite prüfen (existiert/nicht existiert) + shareURL := "https://share.myfreecams.com/" + m.Username + resp, err := http.Get(shareURL) + if err != nil { + return StatusUnknown, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return StatusNotExist, nil + } + if resp.StatusCode != 200 { + return StatusUnknown, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + // wir brauchen sowohl Bytes (für Suche) als auch Reader (für HTML) + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return StatusUnknown, err + } + + // 2) „tracking.php?“ suchen und prüfen, ob model_id vorhanden ist + start := bytes.Index(bodyBytes, []byte("https://www.myfreecams.com/php/tracking.php?")) + if start == -1 { + // ohne tracking Parameter -> behandeln wie nicht existent + return StatusNotExist, nil + } + end := bytes.IndexByte(bodyBytes[start:], '"') + if end == -1 { + return StatusUnknown, errors.New("tracking url parse failed") + } + raw := string(bodyBytes[start : start+end]) + u, err := url.Parse(raw) + if err != nil { + return StatusUnknown, fmt.Errorf("tracking url invalid: %w", err) + } + qs := u.Query() + if qs.Get("model_id") == "" { + return StatusNotExist, nil + } + + // 3) HTML parsen und
Attribute auslesen + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) + if err != nil { + return StatusUnknown, err + } + + params := doc.Find(".campreview").First() + if params.Length() == 0 { + // keine campreview -> offline + return StatusOffline, nil + } + + attrs := map[string]string{} + params.Each(func(_ int, s *goquery.Selection) { + for _, a := range []string{ + "data-cam-preview-server-id-value", + "data-cam-preview-model-id-value", + "data-cam-preview-is-wzobs-value", + } { + if v, ok := s.Attr(a); ok { + attrs[a] = v + } + } + }) + m.Attrs = attrs + + // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln + uStr, err := m.GetVideoURL(true) + if err != nil { + return StatusUnknown, err + } + if uStr != "" { + return StatusPublic, nil + } + // campreview vorhanden, aber keine playable url -> „PRIVATE“ + return StatusPrivate, nil +} + +func runMFC(username string, outArg string) error { + mfc := NewMyFreeCams(username) + st, err := mfc.GetStatus() + if err != nil { + return err + } + fmt.Println("MFC Status:", st) + + if st != StatusPublic { + return fmt.Errorf("Stream ist nicht öffentlich (Status: %s)", st) + } + m3u8URL, err := mfc.GetVideoURL(false) + if err != nil { + return err + } + if m3u8URL == "" { + return errors.New("keine m3u8 URL gefunden") + } + + return handleM3U8Mode(m3u8URL, outArg) +} + +/* ─────────────────────────────── + Gemeinsame HLS/M3U8-Helper (MFC) + ─────────────────────────────── */ + +func getWantedResolutionPlaylist(playlistURL string) (string, error) { + // Holt eine URL; wenn MASTER, wähle beste Variante; wenn MEDIA, gib die URL zurück. + resp, err := http.Get(playlistURL) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + if err != nil { + return "", fmt.Errorf("m3u8 parse: %w", err) + } + if listType == m3u8.MEDIA { + return playlistURL, nil + } + + master := playlist.(*m3u8.MasterPlaylist) + var bestURI string + var bestWidth int + var bestFramerate float64 + + for _, v := range master.Variants { + if v == nil { + continue + } + // Resolution kommt als "WxH" – wir nutzen die Höhe als Vergleichswert. + w := 0 + if v.Resolution != "" { + parts := strings.Split(v.Resolution, "x") + if len(parts) == 2 { + if ww, err := strconv.Atoi(parts[1]); err == nil { + w = ww + } + } + } + fr := 30.0 + if v.FrameRate > 0 { + fr = v.FrameRate + } else if strings.Contains(v.Name, "FPS:60") { + fr = 60 + } + if w > bestWidth || (w == bestWidth && fr > bestFramerate) { + bestWidth = w + bestFramerate = fr + bestURI = v.URI + } + } + if bestURI == "" { + return "", errors.New("Master-Playlist ohne gültige Varianten") + } + + // Absolutieren + root := playlistURL[:strings.LastIndex(playlistURL, "/")+1] + if strings.HasPrefix(bestURI, "http://") || strings.HasPrefix(bestURI, "https://") { + return bestURI, nil + } + return root + bestURI, nil +} + +func handleM3U8Mode(m3u8URL, outArg string) error { + // Minimalvalidierung + u, err := url.Parse(m3u8URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("ungültige URL: %q", m3u8URL) + } + + // kleinen Check machen, ob abrufbar + resp, err := http.Get(m3u8URL) + if err != nil { + return fmt.Errorf("Abruf fehlgeschlagen: %w", err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + // Ausgabedatei + outFile := outArg + if strings.TrimSpace(outFile) == "" { + def := "mfc_" + time.Now().Format("20060102_150405") + ".mp4" + fmt.Printf("Name der MP4-Datei (Enter für %s): ", def) + outFile = readLine() + if strings.TrimSpace(outFile) == "" { + outFile = def + } + } + + // Überschreiben? + if fileExists(outFile) { + fmt.Print("Die Datei existiert bereits. Überschreiben? [y/N] ") + a := strings.ToLower(strings.TrimSpace(readLine())) + if a != "y" && a != "j" { + return errors.New("abgebrochen") + } + } + + // ffmpeg copy-download + fmt.Println("📦 Starte Download mit ffmpeg:", outFile) + cmd := exec.Command( + "ffmpeg", + "-i", m3u8URL, + "-c", "copy", + outFile, + ) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("ffmpeg fehlgeschlagen: %w", err) + } + + fmt.Println("✅ Download abgeschlossen:", outFile) + return nil +} + +/* ─────────────────────────────── + Kleine Helper für MFC + ─────────────────────────────── */ + +func extractMFCUsername(input string) string { + if strings.Contains(input, "myfreecams.com/#") { + i := strings.Index(input, "#") + if i >= 0 && i < len(input)-1 { + return strings.TrimSpace(input[i+1:]) + } + return "" + } + return strings.TrimSpace(input) +} + +func readLine() string { + r := bufio.NewReader(os.Stdin) + s, _ := r.ReadString('\n') + return strings.TrimRight(s, "\r\n") +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/recorder.exe b/recorder.exe index aa0d7ea..6814f7f 100644 Binary files a/recorder.exe and b/recorder.exe differ