// backend\record_stream_mfc.go package main import ( "bytes" "context" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) // 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, username string, outputPath string, job *RecordJob, ) error { mfc := NewMyFreeCams(username) // ✅ Statt sofort zu failen: kurz auf PUBLIC warten const waitPublicMax = 2 * time.Minute deadline := time.Now().Add(waitPublicMax) var lastSt *Status for { // Context cancel / stop if err := ctx.Err(); err != nil { return err } st, err := mfc.GetStatus() if err == nil { tmp := st lastSt = &tmp if st == StatusPublic { break } } if time.Now().After(deadline) { if lastSt == nil { return fmt.Errorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax) } return fmt.Errorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt) } time.Sleep(5 * time.Second) } // ✅ erst jetzt die Video URL holen (weil public) m3u8URL, err := mfc.GetVideoURL(false) if err != nil { return fmt.Errorf("mfc get video url: %w", err) } if strings.TrimSpace(m3u8URL) == "" { return fmt.Errorf("mfc: keine m3u8 URL gefunden") } // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input if job != nil { jobsMu.Lock() job.PreviewM3U8 = strings.TrimSpace(m3u8URL) job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen job.PreviewUA = hc.userAgent jobsMu.Unlock() } // ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar) if job != nil { _ = publishJob(job.ID) } // Aufnahme starten return handleM3U8Mode(ctx, m3u8URL, outputPath, job) } 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(ctx context.Context, username string, outArg string) error { mfc := NewMyFreeCams(username) st, err := mfc.GetStatus() if err != nil { return err } 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(ctx, m3u8URL, outArg, nil) } 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(ctx context.Context, m3u8URL, outFile string, job *RecordJob) error { // Validierung u, err := url.Parse(m3u8URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { return fmt.Errorf("ungültige URL: %q", m3u8URL) } // HTTP-Check MIT Context req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return 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) } if strings.TrimSpace(outFile) == "" { return errors.New("output file path leer") } // ffmpeg mit Context (STOP FUNKTIONIERT HIER!) cmd := exec.CommandContext( ctx, ffmpegPath, "-y", "-hide_banner", "-nostats", "-loglevel", "warning", "-i", m3u8URL, "-c", "copy", outFile, ) var stderr bytes.Buffer cmd.Stdout = io.Discard cmd.Stderr = &stderr // ✅ live size polling während ffmpeg läuft stopStat := make(chan struct{}) if job != nil { go func() { t := time.NewTicker(1 * time.Second) defer t.Stop() var last int64 for { select { case <-ctx.Done(): return case <-stopStat: return case <-t.C: fi, err := os.Stat(outFile) if err != nil { continue } sz := fi.Size() if sz > 0 && sz != last { jobsMu.Lock() job.SizeBytes = sz jobsMu.Unlock() publishJobUpsert(job) last = sz } } } }() } // ✅ WICHTIG: ffmpeg wirklich laufen lassen err = cmd.Run() close(stopStat) if err != nil { msg := strings.TrimSpace(stderr.String()) if msg != "" { return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) } return fmt.Errorf("ffmpeg m3u8 failed: %w", err) } return nil } /* ─────────────────────────────── Kleine Helper für MFC ─────────────────────────────── */ func extractMFCUsername(input string) string { s := strings.TrimSpace(input) if s == "" { return "" } // 1) URL mit Fragment (#username) if u, err := url.Parse(s); err == nil && u.Fragment != "" { return strings.Trim(strings.TrimSpace(u.Fragment), "/") } // 2) URL Pfad: letztes Segment nehmen if u, err := url.Parse(s); err == nil && u.Host != "" { p := strings.Trim(u.Path, "/") if p == "" { return "" } parts := strings.Split(p, "/") return strings.TrimSpace(parts[len(parts)-1]) } // 3) Fallback: raw return s }