Compare commits

...

3 Commits

Author SHA1 Message Date
5a46814039 bugfixes 2026-06-04 23:56:39 +02:00
baa66cabcf added real mfc preview 2026-06-04 23:55:56 +02:00
125a3018ae fixing mfc download 2026-06-04 23:50:51 +02:00
11 changed files with 310 additions and 142 deletions

View File

@ -1,8 +1,8 @@
package main
import (
"io"
"encoding/json"
"io"
"net/http"
)

View File

@ -1,3 +1,5 @@
// backend\cookies_crypto.go
package main
import (

View File

@ -1,3 +1,5 @@
// backend\cors.go
package main
import (

View File

@ -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()

View File

@ -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)

View File

@ -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,34 @@ 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 {
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 {
@ -70,20 +84,128 @@ func RecordStreamMFC(
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 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
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 {
@ -97,145 +219,204 @@ 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(
var lastErr error
tried := make([]string, 0, len(candidates))
for _, candidate := range candidates {
tried = append(tried, candidate)
stream, err := getWantedResolutionPlaylistWithHeaders(
ctx,
m3u8URL,
"", // MFC normalerweise ohne Cookies
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
}
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)
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 == 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 <div class="campreview" ...> Attribute auslesen
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(bodyBytes))
if err != nil {
return StatusUnknown, err
}
m.RawUserID = data.Result.User.ID
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 != "" {
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
}
// campreview vorhanden, aber keine playable url -> „PRIVATE“
return StatusPrivate, 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
}

View File

@ -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="
}

View File

@ -94,28 +94,10 @@ const countryOfPending = (
).trim()
}
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): boolean => {
const anyJ = job as any
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
return (
src.includes('chaturbate.com') ||
chat.includes('chaturbate.com') ||
img.includes('highwebmedia') ||
img.includes('chaturbate')
)
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
if (!isChaturbateJob(job, modelsByKey)) return ''
return onlineImageUrlOfJob(job, modelsByKey)
}
@ -192,6 +174,9 @@ const onlineImageUrlOfJob = (
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.avatar ??
anyJ?.previewImageUrl ??
anyJ?.imageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()

View File

@ -130,28 +130,10 @@ const modelKeyFromJob = (job: RecordJob): string => {
return String(modelNameFromOutput(j.output || '')).trim().toLowerCase()
}
const isChaturbateJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): boolean => {
const anyJ = job as any
const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase()
const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase()
const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase()
return (
src.includes('chaturbate.com') ||
chat.includes('chaturbate.com') ||
img.includes('highwebmedia') ||
img.includes('chaturbate')
)
}
const previewInitialSrcOfJob = (
job: RecordJob,
modelsByKey?: Record<string, { imageUrl?: string }>
): string => {
if (!isChaturbateJob(job, modelsByKey)) return ''
return onlineImageUrlOfJob(job, modelsByKey)
}
@ -164,6 +146,9 @@ const onlineImageUrlOfJob = (
const direct =
String(
anyJ?.modelImageUrl ??
anyJ?.avatar ??
anyJ?.previewImageUrl ??
anyJ?.imageUrl ??
anyJ?.model?.imageUrl ??
''
).trim()

View File

@ -197,6 +197,11 @@ export type RecordJob = {
exitCode?: number
error?: string
logTail?: string
modelImageUrl?: string
avatar?: string
previewImageUrl?: string
imageUrl?: string
}
export type ParsedModel = {