// 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 && strings.TrimSpace(mfc.AvatarURL) != "" { jobsMu.Lock() job.ModelImageURL = strings.TrimSpace(mfc.AvatarURL) job.Avatar = strings.TrimSpace(mfc.AvatarURL) 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 strings.TrimSpace(mfc.AvatarURL) != "" { job.ModelImageURL = strings.TrimSpace(mfc.AvatarURL) job.Avatar = strings.TrimSpace(mfc.AvatarURL) 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 } 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"` } `json:"sessions"` } `json:"user"` } `json:"result"` } 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.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) } 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 }