diff --git a/main.go b/main.go index f752d24..746ec18 100644 --- a/main.go +++ b/main.go @@ -1,15 +1,12 @@ package main import ( - "bufio" - "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" - "net/url" "os" "os/exec" "path/filepath" @@ -18,101 +15,75 @@ import ( "strings" "time" - "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) -/* ─────────────────────────────── - Gemeinsame Typen & Utilities - ─────────────────────────────── */ - var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)"`) -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" - } -} - -/* ─────────────────────────────── - main - ─────────────────────────────── */ - +// --- main --- func main() { if len(os.Args) < 2 { - fmt.Println("Verwendung:") - fmt.Println(" recorder.exe [-o ]") - fmt.Println(" recorder.exe [-o ]") - fmt.Println(" recorder.exe --site mfc [-o ]") + fmt.Println("Verwendung: recorder.exe [--http-cookie \"\"] [best] [-o ] [-f]") os.Exit(1) } - // optionales -o - outputPathArg := "" - site := "" // "", "mfc" + var ( + httpCookie string + outputPath string + urlArg string + ) + args := os.Args[1:] - clean := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { - if args[i] == "-o" && i+1 < len(args) { - outputPathArg = args[i+1] + 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++ - continue - } - if args[i] == "--site" && i+1 < len(args) { - site = strings.ToLower(args[i+1]) + + case "-o": + if i+1 >= len(args) { + fmt.Println("Fehlender Wert nach -o") + os.Exit(1) + } + outputPath = args[i+1] i++ - continue + + 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 + } } - clean = append(clean, args[i]) } - if len(clean) == 0 { - fmt.Println("Fehlendes Ziel (Benutzername oder URL).") + + if urlArg == "" { + fmt.Println("Keine URL / kein Benutzername angegeben.") os.Exit(1) } - arg := clean[0] - // Dispatch: MyFreeCams? - if site == "mfc" || strings.Contains(arg, "myfreecams.com/#") { - username := extractMFCUsername(arg) - if username == "" { - fmt.Println("Konnte MFC-Username nicht erkennen.") - os.Exit(1) - } - if err := runMFC(username, outputPathArg); err != nil { - fmt.Println("❌ MFC-Fehler:", err) - os.Exit(1) - } - return - } + username := extractUsername(urlArg) - // Sonst: Chaturbate (bestehender Flow) :contentReference[oaicite:1]{index=1} - username := extractCBUsername(arg) - outputPath := outputPathArg + // Default-Ausgabedatei, falls kein -o if outputPath == "" { outputPath = fmt.Sprintf("%s_%s.ts", username, time.Now().Format("20060102_150405")) } ctx := context.Background() - body, err := fetchPage("https://chaturbate.com/" + username) + + // 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) @@ -124,7 +95,7 @@ func main() { os.Exit(1) } - playlist, err := FetchPlaylist(ctx, hlsURL) + playlist, err := FetchPlaylist(ctx, hlsURL, httpCookie) if err != nil { fmt.Println("❌ Fehler beim Abrufen der Playlist:", err) os.Exit(1) @@ -139,7 +110,7 @@ func main() { fmt.Println("📡 Aufnahme gestartet:", outputPath) - err = playlist.WatchSegments(ctx, func(b []byte, _ float64) error { + 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) @@ -151,6 +122,7 @@ func main() { fmt.Println("❌ Aufnahmefehler:", err) } + // TS -> MP4 remuxen (wie gehabt) mp4Out := outputPath ext := filepath.Ext(outputPath) if ext != ".mp4" { @@ -173,384 +145,8 @@ func main() { } } -/* ─────────────────────────────── - MyFreeCams (Port von myfreecams.py) - ─────────────────────────────── */ - -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 - ─────────────────────────────── */ - -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" – uns ist Höhe egal, nehmen die zweite Zahl als "Breite" (wie in deiner CB-Logik gemacht). - 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) - } - - // Erreichbarkeit kurz prüfen - 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 bestimmen - outFile := strings.TrimSpace(outArg) - if outFile == "" { - def := "mfc_" + time.Now().Format("20060102_150405") + ".mp4" - fmt.Printf("Name der MP4-Datei (Enter für %s): ", def) - if s := readLine(); s != "" { - outFile = s - } else { - 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") - } - } - - // ── 1) Live-fähiges, fragmentiertes MP4 schreiben (copy) ────────── - // WICHTIG: -bsf:a aac_adtstoasc setzen + explizit mappen - fmt.Println("📦 Starte Download mit ffmpeg:", outFile) - cmd := exec.Command( - "ffmpeg", - "-nostdin", "-y", - "-fflags", "+genpts", - "-i", m3u8URL, - "-map", "0:v:0", "-map", "0:a:0?", - "-c:v", "copy", - "-c:a", "copy", - "-bsf:a", "aac_adtstoasc", - "-movflags", "+frag_keyframe+empty_moov+default_base_moof", - "-f", "mp4", - 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) - - // ── 2) Finales Remux in +faststart (für blitzschnelles Öffnen) ──── - fmt.Println("🔁 Remux in finales MP4 (faststart)…") - if err := remuxMP4Faststart(outFile); err != nil { - fmt.Println("⚠️ Remux-Fehler:", err) - } else { - fmt.Println("✅ Remux abgeschlossen:", outFile) - } - return nil -} - - -// remuxMP4Faststart remuxt ein (ggf. fragmentiertes) MP4 in finales MP4 mit +faststart. -// 1. Versuch: copy/copy. Wenn danach kein Audio vorhanden ist, 2. Versuch mit AAC-Reencode. -func remuxMP4Faststart(inPath string) error { - base := strings.TrimSuffix(inPath, filepath.Ext(inPath)) - tmp := base + ".remux.mp4" - - hadAudioBefore, _ := hasAudioTrack(inPath) // nur zur Info/Fallback - - // 1) Copy-Remux - if err := runCmd("ffmpeg", - "-y", - "-i", inPath, - "-map", "0:v:0", "-map", "0:a:0?", - "-c:v", "copy", - "-c:a", "copy", - "-movflags", "+faststart", - tmp, - ); err != nil { - return fmt.Errorf("ffmpeg remux (copy) fehlgeschlagen: %w", err) - } - - hasAudioAfter, _ := hasAudioTrack(tmp) - if !hasAudioAfter && hadAudioBefore { - // 2) Fallback: remux mit Audio-Reencode (wem auch immer copy schiefging) - _ = os.Remove(tmp) - if err := runCmd("ffmpeg", - "-y", - "-i", inPath, - "-map", "0:v:0", "-map", "0:a:0?", - "-c:v", "copy", - "-c:a", "aac", "-b:a", "192k", - "-movflags", "+faststart", - tmp, - ); err != nil { - return fmt.Errorf("ffmpeg remux (aac-fallback) fehlgeschlagen: %w", err) - } - } - - // Original durch remuxtes ersetzen - if err := os.Remove(inPath); err != nil { - return fmt.Errorf("remux ok, aber konnte Original nicht entfernen: %w", err) - } - if err := os.Rename(tmp, inPath); err != nil { - return fmt.Errorf("remux ok, aber konnte Zieldatei nicht umbenennen: %w", err) - } - return nil -} - -func runCmd(name string, args ...string) error { - cmd := exec.Command(name, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} - -// hasAudioTrack prüft mit ffprobe, ob mind. ein Audiostream vorhanden ist. -func hasAudioTrack(path string) (bool, error) { - // ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 - out := &bytes.Buffer{} - cmd := exec.Command("ffprobe", - "-v", "error", - "-select_streams", "a", - "-show_entries", "stream=index", - "-of", "csv=p=0", - path, - ) - cmd.Stdout = out - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return false, err - } - // Wenn irgendwas zurückkommt, gibt's Audio - return strings.TrimSpace(out.String()) != "", nil -} - -/* ─────────────────────────────── - Chaturbate-Helpers (aus deiner Datei) - ─────────────────────────────── */ - -func extractCBUsername(input string) string { // :contentReference[oaicite:2]{index=2} +// --- helper --- +func extractUsername(input string) string { input = strings.TrimPrefix(input, "https://") input = strings.TrimPrefix(input, "http://") input = strings.TrimPrefix(input, "www.") @@ -560,13 +156,40 @@ func extractCBUsername(input string) string { // :contentReference[oaicite:2]{in return strings.TrimSpace(input) } -func fetchPage(u string) (string, error) { // :contentReference[oaicite:3]{index=3} +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", u, nil) + 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 { @@ -575,7 +198,7 @@ func fetchPage(u string) (string, error) { // :contentReference[oaicite:3]{index defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, u) + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, url) } data, err := io.ReadAll(resp.Body) @@ -586,7 +209,8 @@ func fetchPage(u string) (string, error) { // :contentReference[oaicite:3]{index return string(data), nil } -func ParseStream(html string) (string, error) { // :contentReference[oaicite:4]{index=4} + +func ParseStream(html string) (string, error) { matches := roomDossierRegexp.FindStringSubmatch(html) if len(matches) < 2 { return "", errors.New("room dossier nicht gefunden") @@ -610,19 +234,34 @@ func ParseStream(html string) (string, error) { // :contentReference[oaicite:4]{ return rd.HLSSource, nil } -type Playlist struct { // :contentReference[oaicite:5]{index=5} +type Playlist struct { PlaylistURL string RootURL string Resolution int Framerate int } -func FetchPlaylist(ctx context.Context, hlsSource string) (*Playlist, error) { // :contentReference[oaicite:6]{index=6} +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") } - resp, err := http.Get(hlsSource) + 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) } @@ -678,7 +317,11 @@ func FetchPlaylist(ctx context.Context, hlsSource string) (*Playlist, error) { / }, nil } -func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float64) error) error { // :contentReference[oaicite:7]{index=7} +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} @@ -692,7 +335,17 @@ func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float default: } - resp, err := client.Get(p.PlaylistURL) + // 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 { @@ -701,6 +354,7 @@ func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float time.Sleep(2 * time.Second) continue } + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) resp.Body.Close() @@ -728,7 +382,17 @@ func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float newSegment = true segmentURL := p.RootURL + segment.URI - segResp, err := client.Get(segmentURL) + + 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 } @@ -757,28 +421,3 @@ func (p *Playlist) WatchSegments(ctx context.Context, handler func([]byte, float } } -/* ─────────────────────────────── - Kleine Helper - ─────────────────────────────── */ - -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 dfb1f53..9a2e432 100644 Binary files a/recorder.exe and b/recorder.exe differ