nsfwapp/backend/record_stream_mfc.go
2026-06-04 23:55:56 +02:00

528 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}