// backend\record_stream_mfc.go package main import ( "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "time" "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) const waitPublicMax = 90 * time.Second deadline := time.Now().Add(waitPublicMax) for { if err := ctx.Err(); err != nil { return err } st, err := mfc.GetStatus(ctx, hc.userAgent) if err == nil && st == StatusPublic { break } if time.Now().After(deadline) { if err != nil { return appErrorf("mfc status: %w", err) } return context.DeadlineExceeded } time.Sleep(5 * time.Second) } if job != nil { img := strings.TrimSpace(mfc.PreviewImageURL()) if img != "" { jobsMu.Lock() job.ModelImageURL = img job.Avatar = img job.PreviewState = "public" jobsMu.Unlock() _ = publishJobSnapshot(job.ID) } } // ✅ erst jetzt die Video URL holen (weil public) stream, err := mfc.GetSelectedStream(ctx, false, hc.userAgent) if err != nil { return appErrorf("mfc get video url: %w", err) } if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return appErrorf("mfc: keine m3u8 URL gefunden") } // ✅ WICHTIG: fMP4 live preview (/api/preview/live) braucht job.PreviewM3U8 als Input if job != nil { previewURL := strings.TrimSpace(stream.VideoURL) if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" { previewURL = strings.TrimSpace(stream.MasterURL) } jobsMu.Lock() job.PreviewM3U8 = previewURL job.PreviewCookie = "" job.PreviewUA = hc.userAgent if img := strings.TrimSpace(mfc.PreviewImageURL()); img != "" { job.ModelImageURL = img job.Avatar = img job.PreviewState = "public" } jobsMu.Unlock() _ = publishJobSnapshot(job.ID) } if job != nil { _ = publishJob(job.ID) } return recordHLSPlaylistSegments(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) } type MyFreeCams struct { Username string Attrs map[string]string VideoURL string ModelID string ServerID string Phase string AvatarURL string RawUserID int SnapURL string } type mfcUsernameLookupResp struct { Result struct { User struct { ID int `json:"id"` Username string `json:"username"` Avatar string `json:"avatar"` Sessions []struct { ServerName string `json:"server_name"` Phase string `json:"phase"` SnapURL string `json:"snap_url"` } `json:"sessions"` } `json:"user"` } `json:"result"` } func normalizeMFCSnapURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if strings.HasPrefix(raw, "//") { raw = "https:" + raw } else if strings.HasPrefix(raw, "/") { raw = "https://snap.mfcimg.com" + raw } else if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") { raw = "https://snap.mfcimg.com/" + strings.TrimLeft(raw, "/") } u, err := url.Parse(raw) if err != nil || u.Scheme == "" || u.Host == "" { return raw } q := u.Query() q.Set("no-cache", strconv.FormatInt(time.Now().UnixNano(), 10)) u.RawQuery = q.Encode() return u.String() } func buildMFCSnapimgURL(serverID string, rawUserID int) string { serverID = strings.TrimSpace(serverID) if serverID == "" || rawUserID <= 0 { return "" } return normalizeMFCSnapURL(fmt.Sprintf( "https://snap.mfcimg.com/snapimg/%s/853x480/mfc_%d", serverID, rawUserID, )) } func (m *MyFreeCams) PreviewImageURL() string { if s := strings.TrimSpace(m.SnapURL); s != "" { return s } return strings.TrimSpace(m.AvatarURL) } func mfcDigitsOnly(s string) string { var b strings.Builder for _, r := range strings.TrimSpace(s) { if r >= '0' && r <= '9' { b.WriteRune(r) } } return b.String() } func normalizeMFCPhase(phase string) string { phase = strings.TrimSpace(phase) // API liefert meistens schon "a_" oder "". if phase == "" { return "" } if phase == "a" { return "a_" } if strings.HasPrefix(phase, "a") { return "a_" } if !strings.HasSuffix(phase, "_") { return phase + "_" } return phase } 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 uniqueStrings(in []string) []string { seen := map[string]struct{}{} out := make([]string, 0, len(in)) for _, s := range in { s = strings.TrimSpace(s) if s == "" { continue } if _, ok := seen[s]; ok { continue } seen[s] = struct{}{} out = append(out, s) } return out } func (m *MyFreeCams) GetVideoURLCandidates() ([]string, error) { serverID := strings.TrimSpace(m.ServerID) modelID := strings.TrimSpace(m.ModelID) phase := normalizeMFCPhase(m.Phase) if serverID == "" || modelID == "" { return nil, errors.New("mfc: lookup daten fehlen; GetStatus() wurde nicht erfolgreich ausgeführt") } streamName := phase + modelID candidates := []string{ fmt.Sprintf( "https://edgevideo.myfreecams.com/llhls/NxServer/%s/ngrp:mfc_%s.f4v_cmaf/playlist.m3u8?nc=%d&v=1.97.23", serverID, streamName, time.Now().UnixNano(), ), fmt.Sprintf( "https://edgevideo.myfreecams.com/llhls/NxServer/%s/ngrp:mfc_%s.f4v_cmaf/playlist.m3u8", serverID, streamName, ), } return uniqueStrings(candidates), nil } func (m *MyFreeCams) GetVideoURL(refresh bool) (string, error) { if !refresh && strings.TrimSpace(m.VideoURL) != "" { return m.VideoURL, nil } candidates, err := m.GetVideoURLCandidates() if err != nil { return "", err } if len(candidates) == 0 { return "", nil } m.VideoURL = candidates[0] return m.VideoURL, nil } func (m *MyFreeCams) GetSelectedStream(ctx context.Context, refresh bool, userAgent string) (*selectedHLSStream, error) { candidates, err := m.GetVideoURLCandidates() if err != nil { return nil, err } if len(candidates) == 0 { return nil, errors.New("keine mfc m3u8 kandidaten gefunden") } var lastErr error tried := make([]string, 0, len(candidates)) for _, candidate := range candidates { tried = append(tried, candidate) stream, err := getWantedResolutionPlaylistWithHeaders( ctx, candidate, "", userAgent, m.GetWebsiteURL(), ) if err == nil && stream != nil && strings.TrimSpace(stream.VideoURL) != "" { m.VideoURL = candidate appLogln("✅ mfc playlist ok:", candidate) appLogln("✅ mfc video playlist:", stream.VideoURL) return stream, nil } if err != nil { lastErr = err appLogln("⚠️ mfc playlist candidate failed:", candidate, err) } } if lastErr != nil { return nil, appErrorf( "keine mfc m3u8 abrufbar; letzter fehler: %w; getestete urls: %s", lastErr, strings.Join(tried, " | "), ) } return nil, appErrorf("keine mfc m3u8 abrufbar; getestete urls: %s", strings.Join(tried, " | ")) } func (m *MyFreeCams) GetStatus(ctx context.Context, userAgent string) (Status, error) { username := strings.TrimSpace(m.Username) if username == "" { return StatusUnknown, errors.New("mfc: username fehlt") } lookupURL := "https://api-edge.myfreecams.com/usernameLookup/" + url.PathEscape(username) req, err := http.NewRequestWithContext(ctx, http.MethodGet, lookupURL, nil) if err != nil { return StatusUnknown, err } if ua := strings.TrimSpace(userAgent); ua != "" { req.Header.Set("User-Agent", ua) } req.Header.Set("Accept", "application/json") req.Header.Set("Referer", "https://www.myfreecams.com/#"+username) req.Header.Set("Origin", "https://www.myfreecams.com") resp, err := http.DefaultClient.Do(req) if err != nil { return StatusUnknown, err } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return StatusNotExist, nil } if resp.StatusCode != http.StatusOK { return StatusUnknown, appErrorf("mfc usernameLookup HTTP %d: %s", resp.StatusCode, lookupURL) } var data mfcUsernameLookupResp if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { return StatusUnknown, appErrorf("mfc usernameLookup json: %w", err) } if data.Result.User.ID <= 0 { return StatusNotExist, nil } m.RawUserID = data.Result.User.ID m.AvatarURL = strings.TrimSpace(data.Result.User.Avatar) if len(data.Result.User.Sessions) == 0 { return StatusOffline, nil } sess := data.Result.User.Sessions[0] serverID := mfcDigitsOnly(sess.ServerName) if serverID == "" { return StatusUnknown, appErrorf("mfc: server_name ohne server-id: %q", sess.ServerName) } m.SnapURL = buildMFCSnapimgURL(serverID, data.Result.User.ID) if strings.TrimSpace(m.SnapURL) == "" && strings.TrimSpace(sess.SnapURL) != "" { m.SnapURL = normalizeMFCSnapURL(sess.SnapURL) } modelID := data.Result.User.ID if modelID < 100000000 { modelID += 100000000 } m.ModelID = strconv.Itoa(modelID) m.ServerID = serverID m.Phase = normalizeMFCPhase(sess.Phase) appLogln("✅ mfc lookup:", "user=", username, "modelID=", m.ModelID, "serverID=", m.ServerID, "phase=", m.Phase, "serverName=", sess.ServerName, ) return StatusPublic, nil } func runMFC(ctx context.Context, username string, outArg string) error { mfc := NewMyFreeCams(username) st, err := mfc.GetStatus(ctx, "") if err != nil { return err } if st != StatusPublic { return appErrorf("Stream ist nicht live (Status: %s)", st) } stream, err := mfc.GetSelectedStream(ctx, false, "") if err != nil { return err } if stream == nil || strings.TrimSpace(stream.VideoURL) == "" { return errors.New("keine m3u8 URL gefunden") } return handleM3U8Mode(ctx, stream, outArg, nil, "", "", mfc.GetWebsiteURL()) } 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 "", appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) } playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) if err != nil { return "", appErrorf("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 } /* ─────────────────────────────── 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 }