diff --git a/backend/.env b/backend/.env index e33764f..c1ed77b 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,2 @@ -HTTPS_ENABLED=1 +HTTPS_ENABLED=0 AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 7369f49..ab1fdea 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/cookies_api.go b/backend/cookies_api.go index ac1b16e..6a67770 100644 --- a/backend/cookies_api.go +++ b/backend/cookies_api.go @@ -1,8 +1,8 @@ package main import ( - "io" "encoding/json" + "io" "net/http" ) diff --git a/backend/cookies_crypto.go b/backend/cookies_crypto.go index a01c94f..cf7331c 100644 --- a/backend/cookies_crypto.go +++ b/backend/cookies_crypto.go @@ -1,3 +1,5 @@ +// backend\cookies_crypto.go + package main import ( diff --git a/backend/cors.go b/backend/cors.go index 52a7cf2..e779c7b 100644 --- a/backend/cors.go +++ b/backend/cors.go @@ -1,3 +1,5 @@ +// backend\cors.go + package main import ( diff --git a/backend/main.go b/backend/main.go index e45638d..ed3dc50 100644 --- a/backend/main.go +++ b/backend/main.go @@ -65,6 +65,8 @@ type RecordJob struct { VideoHeight int `json:"videoHeight,omitempty"` FPS float64 `json:"fps,omitempty"` Meta *videoMeta `json:"meta,omitempty"` + ModelImageURL string `json:"modelImageUrl,omitempty"` + Avatar string `json:"avatar,omitempty"` Hidden bool `json:"-"` @@ -119,6 +121,8 @@ type jobEventSnapshot struct { PreviewState string PostWorkKey string PostWork *PostWorkKeyStatus + ModelImageURL string + Avatar string } type dummyResponseWriter struct { @@ -178,6 +182,8 @@ func publishJobUpsertSnapshot(s jobEventSnapshot) { "previewState": s.PreviewState, "postWorkKey": s.PostWorkKey, "ts": time.Now().UnixMilli(), + "modelImageUrl": s.ModelImageURL, + "avatar": s.Avatar, } if s.PostWork != nil { @@ -232,6 +238,8 @@ func publishJobSnapshot(jobID string) bool { PreviewState: job.PreviewState, PostWorkKey: job.PostWorkKey, PostWork: pw, + ModelImageURL: job.ModelImageURL, + Avatar: job.Avatar, } jobsMu.RUnlock() diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index 1cef6a1..faefa46 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -147,7 +147,7 @@ func getWantedResolutionPlaylistWithHeaders( defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, appErrorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + return nil, appErrorf("HTTP %d beim Abruf der m3u8: %s", resp.StatusCode, playlistURL) } rawBody, err := io.ReadAll(resp.Body) diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index a949836..f546fa3 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -3,18 +3,16 @@ package main import ( - "bytes" "context" + "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strconv" "strings" "time" - "github.com/PuerkitoBio/goquery" "github.com/grafov/m3u8" ) @@ -38,18 +36,31 @@ func RecordStreamMFC( return err } - st, err := mfc.GetStatus() + 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 { @@ -70,20 +81,78 @@ func RecordStreamMFC( 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 handleM3U8Mode(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) + 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 { @@ -97,145 +166,197 @@ 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 && m.VideoURL != "" { + if !refresh && strings.TrimSpace(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 "", appErrorf("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) + candidates, err := m.GetVideoURLCandidates() if err != nil { return "", err } - m.VideoURL = u + 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) { - m3u8URL, err := m.GetVideoURL(refresh) + candidates, err := m.GetVideoURLCandidates() if err != nil { return nil, err } - if strings.TrimSpace(m3u8URL) == "" { - return nil, errors.New("keine m3u8 URL gefunden") + if len(candidates) == 0 { + return nil, errors.New("keine mfc m3u8 kandidaten gefunden") } - return getWantedResolutionPlaylistWithHeaders( - ctx, - m3u8URL, - "", // MFC normalerweise ohne Cookies - userAgent, - m.GetWebsiteURL(), - ) + 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() (Status, error) { - // 1) share-Seite prüfen (existiert/nicht existiert) - shareURL := "https://share.myfreecams.com/" + m.Username - resp, err := http.Get(shareURL) +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 == 404 { + if resp.StatusCode == http.StatusNotFound { return StatusNotExist, nil } - if resp.StatusCode != 200 { - return StatusUnknown, appErrorf("HTTP %d", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + return StatusUnknown, appErrorf("mfc usernameLookup HTTP %d: %s", resp.StatusCode, lookupURL) } - // 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 + var data mfcUsernameLookupResp + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return StatusUnknown, appErrorf("mfc usernameLookup json: %w", 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, appErrorf("tracking url invalid: %w", err) - } - qs := u.Query() - if qs.Get("model_id") == "" { + if data.Result.User.ID <= 0 { return StatusNotExist, nil } - // 3) HTML parsen und