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
Attribute auslesen - doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes)) - if err != nil { - return StatusUnknown, err - } + m.AvatarURL = strings.TrimSpace(data.Result.User.Avatar) - params := doc.Find(".campreview").First() - if params.Length() == 0 { - // keine campreview -> offline + if len(data.Result.User.Sessions) == 0 { return StatusOffline, nil } - attrs := map[string]string{} - params.Each(func(_ int, s *goquery.Selection) { - for _, a := range []string{ - "data-cam-preview-server-id-value", - "data-cam-preview-model-id-value", - "data-cam-preview-is-wzobs-value", - } { - if v, ok := s.Attr(a); ok { - attrs[a] = v - } - } - }) - m.Attrs = attrs + sess := data.Result.User.Sessions[0] - // 4) Versuchen, VideoURL (Preview-HLS) zu ermitteln - uStr, err := m.GetVideoURL(true) - if err != nil { - return StatusUnknown, err + serverID := mfcDigitsOnly(sess.ServerName) + if serverID == "" { + return StatusUnknown, appErrorf("mfc: server_name ohne server-id: %q", sess.ServerName) } - if uStr != "" { - return StatusPublic, nil + + modelID := data.Result.User.ID + if modelID < 100000000 { + modelID += 100000000 } - // campreview vorhanden, aber keine playable url -> „PRIVATE“ - return StatusPrivate, nil + + 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() + st, err := mfc.GetStatus(ctx, "") if err != nil { return err } diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 36b4f31..6525a4c 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -1,8 +1,8 @@ { "databaseUrl": "postgres://postgres@127.0.0.1:5432/nsfwapp?sslmode=disable", "encryptedDbPassword": "vkNvHM+/AgEkRPW0X+rDDys4bPb5Q7ZPGqPSADCluIJ6GCDGNGlDy3s=", - "recordDir": "C:\\Users\\Rother\\Desktop\\Privat\\records", - "doneDir": "C:\\Users\\Rother\\Desktop\\Privat\\records\\done", + "recordDir": "C:\\Users\\Chris\\Desktop\\privat\\records", + "doneDir": "C:\\Users\\Chris\\Desktop\\privat\\records\\done", "ffmpegPath": "", "autoAddToDownloadList": true, "enableConcurrentDownloadsLimit": true, @@ -13,7 +13,7 @@ "autoDeleteSmallDownloadsBelowMB": 300, "autoDeleteSmallDownloadsKeepFavorites": false, "lowDiskPauseBelowGB": 5, - "blurPreviews": true, + "blurPreviews": false, "teaserPlayback": "all", "teaserAudio": true, "enableNotifications": true, @@ -25,5 +25,5 @@ "enrichPostworkEnabled": false, "trainingRecognitionEnabled": true, "trainingDetectorEpochs": 60, - "encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A==" + "encryptedCookies": "p0aRfigWn3+TfypNAV7vPky13RW+gwhTlhNOGKKAED0FSMf0kdGV6LgIizzTeDMD5Ck+9SGGf6EfVg+RPnEr3ZjkkkjOTVGOJ9XNzpuczHqzi0ifmt0l9/lnNTaqh8oTzrk09uQC9YYe0CtsY6gjpYFAdVEPGimCPdULqn+8Jln1CSDiBGRGF0uZGxfmAZ1T/YQym/wjiCCdIRfcjTF4f+sz6PMBQOOnTA74if1OFNA6PxpGoXfHS7xvMeV6RX3J/qcTyMmFWr+uD2h1UKs7N9w4Y1sfw4heB10jzy/VL+9gWg2o4AKE5tGqOmlVYpv8KculMWfJHNxNpRonPNizPR4jO2jZYdr3AikuXTTeILyN7hb4MtO4fXblJ+9of1ABR6LvDe1dAXmbkNdqEqRo0k1WS6jaCdNkrleP8vGuH56H7jZMnEeJAcQxS99kVjFo0qMAEJ2ai8HPkdYURC3dWSu9uIbGmiqi6hgva2WM6jPG7TqZDyqUS7hjJwAmPa3aLPnsEWHqkcE=" } diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index f8ea84c..9c3937d 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -115,7 +115,6 @@ const previewInitialSrcOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { - if (!isChaturbateJob(job, modelsByKey)) return '' return onlineImageUrlOfJob(job, modelsByKey) } @@ -192,6 +191,9 @@ const onlineImageUrlOfJob = ( const direct = String( anyJ?.modelImageUrl ?? + anyJ?.avatar ?? + anyJ?.previewImageUrl ?? + anyJ?.imageUrl ?? anyJ?.model?.imageUrl ?? '' ).trim() diff --git a/frontend/src/components/ui/DownloadsCardRow.tsx b/frontend/src/components/ui/DownloadsCardRow.tsx index ff9efc2..90ac438 100644 --- a/frontend/src/components/ui/DownloadsCardRow.tsx +++ b/frontend/src/components/ui/DownloadsCardRow.tsx @@ -151,7 +151,6 @@ const previewInitialSrcOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { - if (!isChaturbateJob(job, modelsByKey)) return '' return onlineImageUrlOfJob(job, modelsByKey) } @@ -164,6 +163,9 @@ const onlineImageUrlOfJob = ( const direct = String( anyJ?.modelImageUrl ?? + anyJ?.avatar ?? + anyJ?.previewImageUrl ?? + anyJ?.imageUrl ?? anyJ?.model?.imageUrl ?? '' ).trim() diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a4f8551..e05ecd7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -197,6 +197,11 @@ export type RecordJob = { exitCode?: number error?: string logTail?: string + + modelImageUrl?: string + avatar?: string + previewImageUrl?: string + imageUrl?: string } export type ParsedModel = {