468 lines
11 KiB
Go
468 lines
11 KiB
Go
// 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
|
||
}
|