From 7298e4c91f20b77c81f8688ee434ea187cd22894 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sat, 4 Apr 2026 13:27:52 +0200 Subject: [PATCH] updated --- backend/chaturbate_biocontext.go | 134 +++++++- backend/chaturbate_online.go | 203 +++++++++++- backend/main.go | 66 +--- backend/preview.go | 223 ++++--------- backend/record_stream_cb.go | 537 +++++-------------------------- backend/record_stream_hls.go | 274 ++++++++++++++++ backend/record_stream_mfc.go | 98 +----- backend/recorder.go | 314 ++++++++++++------ backend/recorder_settings.json | 2 +- backend/server.go | 1 + frontend/src/App.tsx | 238 ++++++-------- 11 files changed, 1083 insertions(+), 1007 deletions(-) create mode 100644 backend/record_stream_hls.go diff --git a/backend/chaturbate_biocontext.go b/backend/chaturbate_biocontext.go index 0690775..326099d 100644 --- a/backend/chaturbate_biocontext.go +++ b/backend/chaturbate_biocontext.go @@ -23,6 +23,94 @@ type BioContextResp struct { LastError string `json:"lastError,omitempty"` Model string `json:"model"` Bio any `json:"bio,omitempty"` + + // neu + RoomStatus string `json:"roomStatus,omitempty"` // public/private/hidden/away/offline/unknown + IsOnline bool `json:"isOnline,omitempty"` + ChatRoomURL string `json:"chatRoomUrl,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` +} + +func normalizeCBShow(v string) string { + s := strings.ToLower(strings.TrimSpace(v)) + switch s { + case "public", "private", "hidden", "away", "offline": + return s + case "online", "live": + return "public" + default: + return "unknown" + } +} + +func asMap(v any) map[string]any { + m, _ := v.(map[string]any) + return m +} + +func firstString(m map[string]any, keys ...string) string { + for _, k := range keys { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) + } + } + } + return "" +} + +func inferRoomStatusFromBio(bio any) (show string, chatRoomURL string, imageURL string, ok bool) { + root := asMap(bio) + if root == nil { + return "unknown", "", "", false + } + + // häufige Kandidaten + candidates := []map[string]any{ + root, + asMap(root["room_status"]), + asMap(root["room"]), + asMap(root["status"]), + asMap(root["user"]), + asMap(root["broadcaster"]), + } + + for _, m := range candidates { + if m == nil { + continue + } + + raw := firstString( + m, + "current_show", + "room_status", + "status", + "show", + "chat_status", + ) + + if raw != "" { + show = normalizeCBShow(raw) + chatRoomURL = firstString(m, "chat_room_url", "room_url", "url") + imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url") + return show, chatRoomURL, imageURL, true + } + } + + // Fallback: rekursiv ein paar bekannte Unterobjekte durchsuchen + for _, key := range []string{"room", "user", "broadcaster", "data"} { + if m := asMap(root[key]); m != nil { + raw := firstString(m, "current_show", "room_status", "status", "show", "chat_status") + if raw != "" { + show = normalizeCBShow(raw) + chatRoomURL = firstString(m, "chat_room_url", "room_url", "url") + imageURL = firstString(m, "image_url", "image_url_360x270", "avatar_url", "profile_image_url") + return show, chatRoomURL, imageURL, true + } + } + } + + return "unknown", "", "", false } func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) { @@ -65,14 +153,21 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) { // 2) Wenn Cache frisch ist und kein Force-Refresh, geben wir ihn sofort zurück if hasCache && !forceRefresh && !cachedAt.IsZero() && time.Since(cachedAt) < bioCacheMaxAge { + roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(cachedBio) + isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown" + w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(BioContextResp{ - Enabled: enabled, - FetchedAt: cachedAt, - LastError: "", - Model: model, - Bio: cachedBio, + Enabled: enabled, + FetchedAt: cachedAt, + LastError: "", + Model: model, + Bio: cachedBio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, }) return } @@ -208,18 +303,37 @@ func chaturbateBioContextHandler(w http.ResponseWriter, r *http.Request) { fetchedAt := time.Now().UTC() + roomStatus, chatRoomURL, imageURL, hasStatus := inferRoomStatusFromBio(bio) + isOnline := hasStatus && roomStatus != "offline" && roomStatus != "unknown" + // 5) Persistieren if cbModelStore != nil { _ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano)) + + if hasStatus { + _ = cbModelStore.SetChaturbateRoomState( + "chaturbate.com", + model, + roomStatus, + isOnline, + chatRoomURL, + imageURL, + fetchedAt, + ) + } } w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(BioContextResp{ - Enabled: enabled, - FetchedAt: fetchedAt, - LastError: "", - Model: model, - Bio: bio, + Enabled: enabled, + FetchedAt: fetchedAt, + LastError: "", + Model: model, + Bio: bio, + RoomStatus: roomStatus, + IsOnline: isOnline, + ChatRoomURL: chatRoomURL, + ImageURL: imageURL, }) } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index b82c37d..f13367c 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -271,6 +271,37 @@ func indexLiteByUser(rooms []ChaturbateRoom) map[string]ChaturbateOnlineRoomLite return m } +func hasActiveRecordingJobForModelKey(modelKey string) bool { + mk := strings.ToLower(strings.TrimSpace(modelKey)) + if mk == "" { + return false + } + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + + if !isActiveRecordingJob(j) { + continue + } + + if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" { + continue + } + + u := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL))) + if u == mk { + return true + } + } + + return false +} + func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) { if store == nil { return @@ -319,6 +350,11 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate continue } + // Solange ein aktiver Recorder für das Model läuft, NICHT auf offline kippen. + if hasActiveRecordingJobForModelKey(modelKey) { + continue + } + _ = store.SetChaturbateRoomState( "chaturbate.com", modelKey, @@ -331,6 +367,167 @@ func syncChaturbateRoomStateIntoModelStore(store *ModelStore, rooms []Chaturbate } } +var cbBioRefreshMu sync.Mutex +var cbBioRefreshAt = map[string]time.Time{} // key=userLower -> last biocontext refresh + +func shouldRefreshBio(userLower string, minInterval time.Duration) bool { + if userLower == "" { + return false + } + + cbBioRefreshMu.Lock() + defer cbBioRefreshMu.Unlock() + + last := cbBioRefreshAt[userLower] + if !last.IsZero() && time.Since(last) < minInterval { + return false + } + cbBioRefreshAt[userLower] = time.Now() + return true +} + +func activeChaturbateJobsMissingFromOnlineSnapshot() map[string]string { + out := map[string]string{} + + cbMu.RLock() + lite := cb.LiteByUser + cbMu.RUnlock() + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + if !isActiveRecordingJob(j) { + continue + } + if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" { + continue + } + + userLower := strings.ToLower(strings.TrimSpace(extractUsername(j.SourceURL))) + if userLower == "" { + continue + } + + if lite != nil { + if _, ok := lite[userLower]; ok { + continue // schon im /online Snapshot + } + } + + cookie := strings.TrimSpace(j.PreviewCookie) + out[userLower] = cookie + } + + return out +} + +func refreshChaturbateStatusFromBioContext(ctx context.Context, model string, cookieHeader string) error { + model = sanitizeModelKey(model) + if model == "" { + return fmt.Errorf("empty model") + } + + u := fmt.Sprintf(chaturbateBioContextURLFmt, model) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64)") + req.Header.Set("Accept", "application/json, text/plain, */*") + req.Header.Set("Referer", "https://chaturbate.com/"+model+"/") + + if ck := strings.TrimSpace(cookieHeader); ck != "" { + req.Header.Set("Cookie", ck) + } + + resp, err := cbHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + return fmt.Errorf("biocontext HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return err + } + + var bio any + if err := json.Unmarshal(raw, &bio); err != nil { + return err + } + + fetchedAt := time.Now().UTC() + + if cbModelStore != nil { + _ = cbModelStore.SetBioContext("chaturbate.com", model, string(raw), fetchedAt.Format(time.RFC3339Nano)) + } + + show, chatRoomURL, imageURL, ok := inferRoomStatusFromBio(bio) + if !ok { + return nil + } + + isOnline := show != "offline" && show != "unknown" + + if cbModelStore != nil { + _ = cbModelStore.SetChaturbateRoomState( + "chaturbate.com", + model, + show, + isOnline, + chatRoomURL, + imageURL, + fetchedAt, + ) + } + + return nil +} + +func startChaturbateBioContextPoller() { + const scanEvery = 15 * time.Second + const minPerModel = 1 * time.Minute + + ticker := time.NewTicker(scanEvery) + defer ticker.Stop() + + for range ticker.C { + if !getSettings().UseChaturbateAPI { + continue + } + + missing := activeChaturbateJobsMissingFromOnlineSnapshot() + if len(missing) == 0 { + continue + } + + for userLower, cookieHeader := range missing { + if !shouldRefreshBio(userLower, minPerModel) { + continue + } + + go func(model, cookie string) { + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + + if err := refreshChaturbateStatusFromBioContext(ctx, model, cookie); err != nil { + fmt.Println("⚠️ [chaturbate] biocontext refresh failed:", model, err) + } + }(userLower, cookieHeader) + } + } +} + // --- Profilbild Download + Persist (online -> offline) --- func selectBestRoomImageURL(rm ChaturbateRoom) string { @@ -400,6 +597,10 @@ func persistOfflineTransitions(prevRoomsByUser, newRoomsByUser map[string]Chatur continue } + if hasActiveRecordingJobForModelKey(userLower) { + continue + } + username := strings.TrimSpace(prevRm.Username) if username == "" { username = strings.TrimSpace(userLower) @@ -690,7 +891,7 @@ func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, us } hc := NewHTTPClient(userAgent) - pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/" body, err := hc.FetchPage(ctx, pageURL, cookie) if err != nil { diff --git a/backend/main.go b/backend/main.go index 6a1b006..66dbfaf 100644 --- a/backend/main.go +++ b/backend/main.go @@ -29,6 +29,13 @@ var roomDossierRegexp = regexp.MustCompile(`window\.initialRoomDossier = "(.*?)" type JobStatus string +type Playlist struct { + PlaylistURL string + RootURL string + Resolution int + Framerate int +} + const ( JobRunning JobStatus = "running" JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch @@ -57,10 +64,6 @@ type RecordJob struct { Error string `json:"error,omitempty"` - PreviewDir string `json:"-"` - previewCmd *exec.Cmd `json:"-"` - LiveThumbStarted bool `json:"-"` - // ✅ Preview-Status (z.B. private/offline anhand ffmpeg HTTP Fehler) PreviewState string `json:"previewState,omitempty"` // "", "private", "offline", "error" PreviewStateAt string `json:"previewStateAt,omitempty"` // RFC3339Nano @@ -72,14 +75,17 @@ type RecordJob struct { previewJPGAt time.Time `json:"-"` previewGen bool `json:"-"` + LiveThumbStarted bool `json:"-"` + PreviewDir string `json:"-"` + previewCmd *exec.Cmd `json:"-"` + previewCancel context.CancelFunc `json:"-"` + previewLastHit time.Time `json:"-"` + previewStartMu sync.Mutex `json:"-"` + PreviewM3U8 string `json:"-"` // HLS url, die ffmpeg inputt PreviewCookie string `json:"-"` // Cookie header (falls nötig) PreviewUA string `json:"-"` // user-agent - previewCancel context.CancelFunc `json:"-"` - previewLastHit time.Time `json:"-"` - previewStartMu sync.Mutex `json:"-"` - // ✅ Frontend Progress beim Stop/Finalize Phase string `json:"phase,omitempty"` // stopping | remuxing | moving Progress int `json:"progress,omitempty"` // 0..100 @@ -264,43 +270,8 @@ var ( jobsMu = sync.RWMutex{} ) -func startPreviewIdleKiller() { - t := time.NewTicker(5 * time.Second) - go func() { - defer t.Stop() - for range t.C { - jobsMu.RLock() - list := make([]*RecordJob, 0, len(jobs)) - for _, j := range jobs { - if j != nil { - list = append(list, j) - } - } - jobsMu.RUnlock() - - for _, j := range list { - jobsMu.RLock() - cmdRunning := j.previewCmd != nil - last := j.previewLastHit - st := j.Status - jobsMu.RUnlock() - - if !cmdRunning { - continue - } - // wenn Job nicht mehr läuft oder Hover weg - if st != JobRunning || (!last.IsZero() && time.Since(last) > 10*time.Minute) { - stopPreview(j) - } - } - } - }() -} - func init() { initFFmpegLimiters() - startPreviewIdleKiller() - initSSE() } @@ -602,27 +573,18 @@ func removeGeneratedForID(id string) { return } - // falls jemand "file.mp4" übergibt id = strings.TrimSuffix(id, filepath.Ext(id)) - - // HOT Prefix weg id = stripHotPrefix(id) - // wichtig: exakt gleiche Normalisierung wie überall sonst (Ordnernamen!) var err error id, err = sanitizeID(id) if err != nil || id == "" { return } - // 1) NEU: generated/meta// ... if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" { _ = os.RemoveAll(filepath.Join(root, id)) } - - // 2) Temp Preview Segmente (HLS) wegräumen - // (%TEMP%/rec_preview/) - _ = os.RemoveAll(filepath.Join(os.TempDir(), "rec_preview", id)) } func purgeDurationCacheForPath(p string) { diff --git a/backend/preview.go b/backend/preview.go index a2a5e13..123318c 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -368,7 +368,7 @@ func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath return false } ext := strings.ToLower(filepath.Ext(name)) - return ext == ".mp4" || ext == ".ts" + return ext == ".mp4" } for _, m := range cands { @@ -1326,175 +1326,56 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er return b, nil } -func latestPreviewSegments(previewDir string, maxCount int) ([]string, error) { - segDir := previewSegmentsDir(previewDir) - entries, err := os.ReadDir(segDir) - if err != nil { - return nil, err +func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, userAgent string) ([]byte, error) { + m3u8URL = strings.TrimSpace(m3u8URL) + if m3u8URL == "" { + return nil, fmt.Errorf("m3u8 leer") } - if maxCount <= 0 { - maxCount = 4 + if strings.TrimSpace(userAgent) == "" { + userAgent = "Mozilla/5.0" } - names := make([]string, 0, len(entries)) - for _, e := range entries { - if e.IsDir() { - continue - } - - name := strings.ToLower(e.Name()) - if !strings.HasPrefix(name, "seg_preview_") { - continue - } - - names = append(names, e.Name()) + args := []string{ + "-hide_banner", + "-loglevel", "error", } - if len(names) == 0 { - return nil, fmt.Errorf("kein Preview-Segment in %s", segDir) + if strings.TrimSpace(userAgent) != "" { + args = append(args, "-user_agent", strings.TrimSpace(userAgent)) } - sort.Strings(names) - - if len(names) > maxCount { - names = names[len(names)-maxCount:] + if strings.TrimSpace(cookie) != "" { + args = append(args, "-headers", fmt.Sprintf("Cookie: %s\r\n", strings.TrimSpace(cookie))) } - out := make([]string, 0, len(names)) - for _, name := range names { - out = append(out, filepath.Join(segDir, name)) + args = append(args, + "-i", m3u8URL, + "-frames:v", "1", + "-vf", "scale=320:-2", + "-vcodec", "mjpeg", + "-q:v", "6", + "-f", "image2pipe", + "pipe:1", + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + + var out bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("ffmpeg live m3u8 jpg failed: %w (%s)", err, strings.TrimSpace(stderr.String())) } - return out, nil -} - -func buildPreviewInputFromDir(previewDir string) (string, func(), error) { - segDir := previewSegmentsDir(previewDir) - if strings.TrimSpace(segDir) == "" { - return "", nil, fmt.Errorf("segments dir fehlt für %s", previewDir) + b := out.Bytes() + if len(b) == 0 { + return nil, fmt.Errorf("ffmpeg live m3u8 jpg: empty output") } - segPaths, err := latestPreviewSegments(previewDir, 4) - if err != nil { - return "", nil, err - } - - ext := strings.ToLower(filepath.Ext(segPaths[len(segPaths)-1])) - - // Bei TS: ebenfalls lieber die letzten paar Segmente zusammenkleben, - // nicht nur das letzte einzelne Segment. - if ext == ".ts" { - tmp, err := os.CreateTemp(segDir, "preview_input_*.ts") - if err != nil { - return "", nil, err - } - tmpPath := tmp.Name() - - cleanup := func() { - _ = tmp.Close() - _ = os.Remove(tmpPath) - } - - for _, segPath := range segPaths { - b, err := os.ReadFile(segPath) - if err != nil { - cleanup() - return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) - } - if len(b) == 0 { - continue - } - if _, err := tmp.Write(b); err != nil { - cleanup() - return "", nil, err - } - } - - if err := tmp.Close(); err != nil { - cleanup() - return "", nil, err - } - - return tmpPath, cleanup, nil - } - - // fMP4/CMAF: init.mp4 + die letzten N Segmente - initPath := filepath.Join(segDir, "init.mp4") - if _, err := os.Stat(initPath); err != nil { - return "", nil, fmt.Errorf("init.mp4 fehlt in %s", segDir) - } - - initBytes, err := os.ReadFile(initPath) - if err != nil { - return "", nil, fmt.Errorf("init lesen fehlgeschlagen: %w", err) - } - - tmp, err := os.CreateTemp(segDir, "preview_input_*.mp4") - if err != nil { - return "", nil, err - } - tmpPath := tmp.Name() - - cleanup := func() { - _ = tmp.Close() - _ = os.Remove(tmpPath) - } - - if _, err := tmp.Write(initBytes); err != nil { - cleanup() - return "", nil, err - } - - for _, segPath := range segPaths { - segBytes, err := os.ReadFile(segPath) - if err != nil { - cleanup() - return "", nil, fmt.Errorf("segment lesen fehlgeschlagen: %w", err) - } - if len(segBytes) == 0 { - continue - } - if _, err := tmp.Write(segBytes); err != nil { - cleanup() - return "", nil, err - } - } - - if err := tmp.Close(); err != nil { - cleanup() - return "", nil, err - } - - return tmpPath, cleanup, nil -} - -func extractLastFrameFromPreviewDirThumbJPG(previewDir string) ([]byte, error) { - inputPath, cleanup, err := buildPreviewInputFromDir(previewDir) - if err != nil { - return nil, err - } - defer cleanup() - - img, err := extractLastFrameJPGScaled(inputPath, 320, 70) - if err == nil && len(img) > 0 { - return img, nil - } - return extractFirstFrameJPGScaled(inputPath, 320, 70) -} - -func extractLastFrameFromPreviewDirJPG(previewDir string) ([]byte, error) { - inputPath, cleanup, err := buildPreviewInputFromDir(previewDir) - if err != nil { - return nil, err - } - defer cleanup() - - img, err := extractLastFrameJPG(inputPath) - if err == nil && len(img) > 0 { - return img, nil - } - return extractFirstFrameJPGScaled(inputPath, 720, 75) + return b, nil } func serveLivePreviewJPGFile(w http.ResponseWriter, r *http.Request, path string) { @@ -1796,11 +1677,21 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri var img []byte var genErr error - previewDir := strings.TrimSpace(j.PreviewDir) - if previewDir != "" { - img, genErr = extractLastFrameFromPreviewDirJPG(previewDir) + previewM3U8 := strings.TrimSpace(j.PreviewM3U8) + previewCookie := strings.TrimSpace(j.PreviewCookie) + previewUA := strings.TrimSpace(j.PreviewUA) + + if previewM3U8 != "" { + img, genErr = extractLiveFrameFromM3U8ThumbJPG(context.Background(), previewM3U8, previewCookie, previewUA) if genErr != nil { - //fmt.Println("preview from previewDir failed:", previewDir, genErr) + // optional loggen + } + } + + if len(img) == 0 { + outPath := strings.TrimSpace(j.Output) + if outPath != "" { + img, _ = extractLastFrameJPGScaled(outPath, 720, 75) } } @@ -1845,7 +1736,9 @@ func recordPreviewWithBase(w http.ResponseWriter, r *http.Request, basePath stri func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { jobsMu.RLock() status := job.Status - previewDir := job.PreviewDir + previewM3U8 := strings.TrimSpace(job.PreviewM3U8) + previewCookie := strings.TrimSpace(job.PreviewCookie) + previewUA := strings.TrimSpace(job.PreviewUA) out := job.Output jobsMu.RUnlock() @@ -1876,19 +1769,23 @@ func updateLiveThumbJPGOnce(ctx context.Context, job *RecordJob) { } var img []byte - if previewDir != "" { - if b, err := extractLastFrameFromPreviewDirThumbJPG(previewDir); err == nil && len(b) > 0 { + + if previewM3U8 != "" { + if b, err := extractLiveFrameFromM3U8ThumbJPG(ctx, previewM3U8, previewCookie, previewUA); err == nil && len(b) > 0 { img = b } } + if len(img) == 0 && out != "" { if b, err := extractLastFrameJPGScaled(out, 320, 70); err == nil && len(b) > 0 { img = b } } + if len(img) == 0 { return } + _ = atomicWriteFile(thumbPath, img) } diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index d7448ab..7c52dd8 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -7,38 +7,13 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" - "os" - "path/filepath" - "sort" "strconv" "strings" "time" - - "github.com/grafov/m3u8" ) -func previewSegmentsDir(previewDir string) string { - previewDir = strings.TrimSpace(previewDir) - if previewDir == "" { - return "" - } - return filepath.Join(previewDir, "segments") -} - -func ensurePreviewSegmentsDir(previewDir string) (string, error) { - dir := previewSegmentsDir(previewDir) - if strings.TrimSpace(dir) == "" { - return "", fmt.Errorf("previewDir leer") - } - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - return dir, nil -} - // --- DVR-ähnlicher Recorder-Ablauf --- // Entspricht grob dem RecordStream aus dem Channel-Snippet: func RecordStream( @@ -50,185 +25,132 @@ func RecordStream( httpCookie string, job *RecordJob, ) error { + username = strings.Trim(strings.TrimSpace(username), "/") base := strings.TrimRight(domain, "/") - pageURL := base + "/" + username + pageURL := base + "/" + username + "/" - body, err := hc.FetchPage(ctx, pageURL, httpCookie) - if err != nil { - return fmt.Errorf("seite laden: %w", err) + loadFreshHLS := func() (string, error) { + body, err := hc.FetchPage(ctx, pageURL, httpCookie) + if err != nil { + return "", fmt.Errorf("seite laden: %w", err) + } + + hlsURL, err := ParseStream(body) + if err != nil { + return "", fmt.Errorf("stream-parsing: %w", err) + } + + hlsURL = strings.TrimSpace(hlsURL) + if hlsURL == "" { + return "", errors.New("leere hls url") + } + + finalURL, err := getWantedResolutionPlaylistWithHeaders( + ctx, + hlsURL, + httpCookie, + hc.userAgent, + pageURL, + ) + if err != nil { + return "", fmt.Errorf("variant-playlist: %w", err) + } + + return strings.TrimSpace(finalURL), nil } - hlsURL, err := ParseStream(body) + hlsURL, err := loadFreshHLS() if err != nil { - return fmt.Errorf("stream-parsing: %w", err) + return err } - playlist, err := FetchPlaylist(ctx, hc, hlsURL, httpCookie) - if err != nil { - return fmt.Errorf("playlist abrufen: %w", err) - } - - var previewDir string - var segmentsDir string - if job != nil { assetID := assetIDForJob(job) if assetID != "" { - if dir, derr := ensureGeneratedDir(assetID); derr == nil { - previewDir = strings.TrimSpace(dir) - - if sdir, serr := ensurePreviewSegmentsDir(previewDir); serr == nil { - segmentsDir = strings.TrimSpace(sdir) - } - } + _, _ = ensureGeneratedDir(assetID) } jobsMu.Lock() - job.PreviewM3U8 = strings.TrimSpace(hlsURL) + job.PreviewM3U8 = hlsURL job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent - if previewDir != "" { - job.PreviewDir = previewDir - } jobsMu.Unlock() - if previewDir != "" { + if assetID != "" { startLiveThumbJPGLoop(ctx, job) } } - if segmentsDir != "" { - defer func() { - if err := os.RemoveAll(segmentsDir); err != nil { - //fmt.Println("preview segments cleanup failed:", segmentsDir, err) - } - }() - } + const maxPlaylistRefreshes = 12 + refreshes := 0 - file, err := os.Create(outputPath) - if err != nil { - return fmt.Errorf("datei erstellen: %w", err) - } - defer func() { - _ = file.Close() - }() - - var written int64 - var lastPush time.Time - var lastBytes int64 - published := false - - trimPreviewSegments := func(dir string, keep int) { - if strings.TrimSpace(dir) == "" || keep <= 0 { - return - } - - entries, err := os.ReadDir(dir) - if err != nil { - return - } - - type item struct { - name string - mod time.Time - } - - items := make([]item, 0, len(entries)) - for _, e := range entries { - if e.IsDir() { - continue - } - - name := e.Name() - lower := strings.ToLower(name) - - if lower == "init.mp4" { - continue - } - if !strings.HasPrefix(lower, "seg_preview_") { - continue - } - - info, err := e.Info() - if err != nil { - continue - } - - items = append(items, item{ - name: name, - mod: info.ModTime(), - }) - } - - if len(items) <= keep { - return - } - - sort.Slice(items, func(i, j int) bool { - return items[i].mod.Before(items[j].mod) - }) - - for i := 0; i < len(items)-keep; i++ { - _ = os.Remove(filepath.Join(dir, items[i].name)) - } - } - - err = playlist.WatchSegments(ctx, hc, httpCookie, previewDir, func(segmentURL string, b []byte, duration float64) error { - if len(b) == 0 { + for { + err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL) + if err == nil { return nil } - if _, err := file.Write(b); err != nil { - return fmt.Errorf("schreibe segment: %w", err) + if ctx.Err() != nil { + return ctx.Err() } - if err := file.Sync(); err != nil { - //fmt.Println("output sync failed:", outputPath, err) + msg := strings.ToLower(strings.TrimSpace(err.Error())) + shouldRefresh := + strings.Contains(msg, "403") || + strings.Contains(msg, "401") || + strings.Contains(msg, "invalid data found") || + strings.Contains(msg, "error opening input") || + strings.Contains(msg, "stream-parsing") || + strings.Contains(msg, "kein hls-quell-url") || + strings.Contains(msg, "http ") + + if !shouldRefresh { + return err } - if segmentsDir != "" { - if err := os.MkdirAll(segmentsDir, 0o755); err == nil { - ext := ".m4s" - if len(b) > 0 && b[0] == 0x47 { - ext = ".ts" - } + refreshes++ + if refreshes > maxPlaylistRefreshes { + return fmt.Errorf("cb refresh-limit erreicht nach %d versuchen: %w", refreshes-1, err) + } - segName := fmt.Sprintf("seg_preview_%020d%s", time.Now().UnixNano(), ext) - segPath := filepath.Join(segmentsDir, segName) + fmt.Println("🔄 [cb] refresh playlist:", + "user=", username, + "try=", refreshes, + "reason=", err, + ) - if err := os.WriteFile(segPath, b, 0o644); err == nil { - trimPreviewSegments(segmentsDir, 12) - } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(1500 * time.Millisecond): + } + + newHLS, rerr := loadFreshHLS() + if rerr != nil { + fmt.Println("⚠️ [cb] refresh failed:", + "user=", username, + "try=", refreshes, + "err=", rerr, + ) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): } + continue } - if job != nil && !published { - published = true - _ = publishJob(job.ID) - } + hlsURL = newHLS - written += int64(len(b)) if job != nil { - now := time.Now() - if lastPush.IsZero() || now.Sub(lastPush) >= 750*time.Millisecond || (written-lastBytes) >= 2*1024*1024 { - jobsMu.Lock() - job.SizeBytes = written - jobsMu.Unlock() - - lastPush = now - lastBytes = written - } + jobsMu.Lock() + job.PreviewM3U8 = hlsURL + job.PreviewCookie = httpCookie + job.PreviewUA = hc.userAgent + jobsMu.Unlock() } - - _ = duration - _ = segmentURL - return nil - }) - if err != nil { - return fmt.Errorf("watch segments: %w", err) } - - return nil } // ParseStream entspricht der DVR-Variante (roomDossier → hls_source) @@ -258,289 +180,6 @@ func ParseStream(html string) (string, error) { return rd.HLSSource, nil } -// --- Playlist/WatchSegments wie gehabt --- -type Playlist struct { - PlaylistURL string - RootURL string - Resolution int - Framerate int -} - -type Resolution struct { - Framerate map[int]string - Width int -} - -// nutzt ebenfalls *HTTPClient -func (p *Playlist) WatchSegments( - ctx context.Context, - hc *HTTPClient, - httpCookie string, - previewDir string, - handler func(segmentURL string, b []byte, duration float64) error, -) error { - var lastSeq int64 = -1 - emptyRounds := 0 - const maxEmptyRounds = 60 - - playlistBaseURL, err := url.Parse(strings.TrimSpace(p.PlaylistURL)) - if err != nil || playlistBaseURL == nil { - return fmt.Errorf("ungültige playlist url: %q", p.PlaylistURL) - } - - var lastMapURL string - - writeInitIfNeeded := func(mapURL string) { - if strings.TrimSpace(previewDir) == "" || strings.TrimSpace(mapURL) == "" { - return - } - - segDir, err := ensurePreviewSegmentsDir(previewDir) - if err != nil { - return - } - initPath := filepath.Join(segDir, "init.mp4") - - // schon vorhanden und gleiches URL-Mapping -> nichts tun - if mapURL == lastMapURL { - if st, err := os.Stat(initPath); err == nil && !st.IsDir() && st.Size() > 0 { - return - } - } - - req, err := hc.NewRequest(ctx, http.MethodGet, mapURL, httpCookie) - if err != nil { - return - } - - resp, err := hc.client.Do(req) - if err != nil { - return - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - _, _ = io.Copy(io.Discard, resp.Body) - return - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - return - } - if len(b) == 0 { - return - } - - if err := os.WriteFile(initPath, b, 0o644); err != nil { - return - } - - lastMapURL = mapURL - } - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - - if strings.TrimSpace(p.PlaylistURL) == "" { - return errors.New("playlist url fehlt") - } - - req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) - if err != nil { - return fmt.Errorf("fehler beim erstellen der playlist-request: %w", err) - } - - resp, err := hc.client.Do(req) - if err != nil { - emptyRounds++ - //fmt.Println("playlist download failed:", p.PlaylistURL, err) - - if emptyRounds >= maxEmptyRounds { - return errors.New("❌ Playlist nicht mehr erreichbar – Stream vermutlich offline") - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - //fmt.Println("playlist bad status:", resp.StatusCode, p.PlaylistURL) - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - - emptyRounds++ - if emptyRounds >= maxEmptyRounds { - return fmt.Errorf("❌ playlist liefert status %d – stream vermutlich offline", resp.StatusCode) - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) - resp.Body.Close() - if err != nil { - emptyRounds++ - - if emptyRounds >= maxEmptyRounds { - return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - if listType != m3u8.MEDIA { - emptyRounds++ - - if emptyRounds >= maxEmptyRounds { - return errors.New("❌ Fehlerhafte Playlist – möglicherweise offline") - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - media, ok := playlist.(*m3u8.MediaPlaylist) - if !ok || media == nil { - emptyRounds++ - - if emptyRounds >= maxEmptyRounds { - return errors.New("❌ Fehlerhafte Media-Playlist – möglicherweise offline") - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(2 * time.Second): - } - continue - } - - if media.Map != nil { - mapURI := strings.TrimSpace(media.Map.URI) - if mapURI != "" { - mapRef, err := url.Parse(mapURI) - if err != nil { - //fmt.Println("map uri parse failed:", mapURI, err) - } else { - mapURL := playlistBaseURL.ResolveReference(mapRef).String() - writeInitIfNeeded(mapURL) - } - } - } - - newSegment := false - successfulSegment := false - - for _, segment := range media.Segments { - if segment == nil { - continue - } - - if int64(segment.SeqId) <= lastSeq { - continue - } - - lastSeq = int64(segment.SeqId) - newSegment = true - - segURI := strings.TrimSpace(segment.URI) - if segURI == "" { - //fmt.Println("segment uri empty in playlist:", p.PlaylistURL) - continue - } - - segRef, err := url.Parse(segURI) - if err != nil { - //fmt.Println("segment uri parse failed:", segURI, err) - continue - } - - segmentURL := playlistBaseURL.ResolveReference(segRef).String() - - segReq, err := hc.NewRequest(ctx, http.MethodGet, segmentURL, httpCookie) - if err != nil { - //fmt.Println("segment request build failed:", segmentURL, err) - continue - } - - segResp, err := hc.client.Do(segReq) - if err != nil { - //fmt.Println("segment download failed:", segmentURL, err) - continue - } - - if segResp.StatusCode < 200 || segResp.StatusCode >= 300 { - //fmt.Println("segment bad status:", segResp.StatusCode, segmentURL) - _, _ = io.Copy(io.Discard, segResp.Body) - segResp.Body.Close() - continue - } - - data, err := io.ReadAll(segResp.Body) - segResp.Body.Close() - if err != nil { - //fmt.Println("segment read failed:", segmentURL, err) - continue - } - if len(data) == 0 { - //fmt.Println("segment empty:", segmentURL) - continue - } - - if err := handler(segmentURL, data, segment.Duration); err != nil { - return err - } - - successfulSegment = true - } - - if successfulSegment { - emptyRounds = 0 - } else if newSegment { - emptyRounds++ - //fmt.Println("playlist had new segment ids but none could be downloaded:", p.PlaylistURL) - - if emptyRounds >= maxEmptyRounds { - return errors.New("🛑 Neue HLS-Segmente gefunden, aber keines konnte geladen werden – Stream vermutlich geschützt, fehlerhaft oder offline.") - } - } else { - emptyRounds++ - if emptyRounds >= maxEmptyRounds { - return errors.New("🛑 Keine neuen HLS-Segmente empfangen – Stream vermutlich beendet oder offline.") - } - } - - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(1 * time.Second): - } - } -} - // Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) func addCookiesFromString(req *http.Request, cookieStr string) { if cookieStr == "" { diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go new file mode 100644 index 0000000..9418bef --- /dev/null +++ b/backend/record_stream_hls.go @@ -0,0 +1,274 @@ +// backend\record_stream_hls.go + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/grafov/m3u8" +) + +func getWantedResolutionPlaylistWithHeaders( + ctx context.Context, + playlistURL string, + httpCookie string, + userAgent string, + referer string, +) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, playlistURL, nil) + if err != nil { + return "", err + } + + if strings.TrimSpace(userAgent) != "" { + req.Header.Set("User-Agent", strings.TrimSpace(userAgent)) + } + if strings.TrimSpace(httpCookie) != "" { + req.Header.Set("Cookie", strings.TrimSpace(httpCookie)) + } + if strings.TrimSpace(referer) != "" { + ref := strings.TrimSpace(referer) + req.Header.Set("Referer", ref) + if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { + req.Header.Set("Origin", ru.Scheme+"://"+ru.Host) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + if err != nil { + return "", fmt.Errorf("m3u8 parse: %w", err) + } + + if listType == m3u8.MEDIA { + return playlistURL, nil + } + + master := playlist.(*m3u8.MasterPlaylist) + + var bestURI string + var bestHeight int + var bestFramerate float64 + + for _, v := range master.Variants { + if v == nil || strings.TrimSpace(v.URI) == "" { + continue + } + + h := 0 + if v.Resolution != "" { + parts := strings.Split(v.Resolution, "x") + if len(parts) == 2 { + if hh, err := strconv.Atoi(parts[1]); err == nil { + h = hh + } + } + } + + fr := 30.0 + if v.FrameRate > 0 { + fr = v.FrameRate + } + + if h > bestHeight || (h == bestHeight && fr > bestFramerate) { + bestHeight = h + bestFramerate = fr + bestURI = strings.TrimSpace(v.URI) + } + } + + if bestURI == "" { + return "", errors.New("Master-Playlist ohne gültige Varianten") + } + + baseURL, err := url.Parse(playlistURL) + if err != nil { + return "", err + } + refURL, err := url.Parse(bestURI) + if err != nil { + return "", err + } + + return baseURL.ResolveReference(refURL).String(), nil +} + +func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { + lines := make([]string, 0, 6) + + if strings.TrimSpace(httpCookie) != "" { + lines = append(lines, fmt.Sprintf("Cookie: %s", strings.TrimSpace(httpCookie))) + } + + if strings.TrimSpace(referer) != "" { + ref := strings.TrimSpace(referer) + lines = append(lines, fmt.Sprintf("Referer: %s", ref)) + + if ru, err := url.Parse(ref); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { + lines = append(lines, fmt.Sprintf("Origin: %s://%s", ru.Scheme, ru.Host)) + } + } + + if strings.TrimSpace(userAgent) != "" { + lines = append(lines, fmt.Sprintf("User-Agent: %s", strings.TrimSpace(userAgent))) + } + + if len(lines) == 0 { + return "" + } + return strings.Join(lines, "\r\n") + "\r\n" +} + +func handleM3U8Mode( + ctx context.Context, + m3u8URL string, + outFile string, + job *RecordJob, + httpCookie string, + userAgent string, + referer string, +) error { + u, err := url.Parse(m3u8URL) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") { + return fmt.Errorf("ungültige URL: %q", m3u8URL) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m3u8URL, nil) + if err != nil { + return err + } + if strings.TrimSpace(userAgent) != "" { + req.Header.Set("User-Agent", strings.TrimSpace(userAgent)) + } + if strings.TrimSpace(httpCookie) != "" { + req.Header.Set("Cookie", strings.TrimSpace(httpCookie)) + } + if strings.TrimSpace(referer) != "" { + req.Header.Set("Referer", strings.TrimSpace(referer)) + if ru, err := url.Parse(strings.TrimSpace(referer)); err == nil && ru != nil && ru.Scheme != "" && ru.Host != "" { + req.Header.Set("Origin", ru.Scheme+"://"+ru.Host) + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + if strings.TrimSpace(outFile) == "" { + return errors.New("output file path leer") + } + + args := []string{ + "-y", + "-hide_banner", + "-nostats", + "-loglevel", "warning", + "-protocol_whitelist", "file,http,https,tcp,tls,crypto", + "-http_persistent", "false", + } + + ua := strings.TrimSpace(userAgent) + ref := strings.TrimSpace(referer) + ck := strings.TrimSpace(httpCookie) + + if ua != "" { + args = append(args, "-user_agent", ua) + } + + if ref != "" { + args = append(args, "-referer", ref) + } + + if ck != "" { + args = append(args, "-cookies", ck) + } + + if hdr := buildFFmpegInputHeaders(httpCookie, userAgent, referer); hdr != "" { + args = append(args, "-headers", hdr) + } + + args = append(args, + "-i", m3u8URL, + "-c", "copy", + "-movflags", "+faststart", + outFile, + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + + var stderr bytes.Buffer + cmd.Stdout = io.Discard + cmd.Stderr = &stderr + + stopStat := make(chan struct{}) + + if job != nil { + go func() { + t := time.NewTicker(1 * time.Second) + defer t.Stop() + + var last int64 + for { + select { + case <-ctx.Done(): + return + case <-stopStat: + return + case <-t.C: + fi, err := os.Stat(outFile) + if err != nil || fi == nil || fi.IsDir() { + continue + } + sz := fi.Size() + if sz > 0 && sz != last { + jobsMu.Lock() + job.SizeBytes = sz + jobsMu.Unlock() + last = sz + _ = publishJob(job.ID) + } + } + } + }() + } + + err = cmd.Run() + close(stopStat) + + if err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg != "" { + return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) + } + return fmt.Errorf("ffmpeg m3u8 failed: %w", err) + } + + return nil +} diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index 6cdaa72..fef19f1 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -10,8 +10,6 @@ import ( "io" "net/http" "net/url" - "os" - "os/exec" "strconv" "strings" "time" @@ -89,7 +87,7 @@ func RecordStreamMFC( } // Aufnahme starten - return handleM3U8Mode(ctx, m3u8URL, outputPath, job) + return handleM3U8Mode(ctx, m3u8URL, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL()) } type MyFreeCams struct { @@ -245,7 +243,7 @@ func runMFC(ctx context.Context, username string, outArg string) error { return errors.New("keine m3u8 URL gefunden") } - return handleM3U8Mode(ctx, m3u8URL, outArg, nil) + return handleM3U8Mode(ctx, m3u8URL, outArg, nil, "", "", mfc.GetWebsiteURL()) } func getWantedResolutionPlaylist(playlistURL string) (string, error) { @@ -310,98 +308,6 @@ func getWantedResolutionPlaylist(playlistURL string) (string, error) { return root + bestURI, nil } -func handleM3U8Mode(ctx context.Context, m3u8URL, outFile string, job *RecordJob) error { - // Validierung - u, err := url.Parse(m3u8URL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") { - return fmt.Errorf("ungültige URL: %q", m3u8URL) - } - - // HTTP-Check MIT Context - req, err := http.NewRequestWithContext(ctx, "GET", m3u8URL, nil) - if err != nil { - return err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) - } - - if strings.TrimSpace(outFile) == "" { - return errors.New("output file path leer") - } - - // ffmpeg mit Context (STOP FUNKTIONIERT HIER!) - cmd := exec.CommandContext( - ctx, - ffmpegPath, - "-y", - "-hide_banner", - "-nostats", - "-loglevel", "warning", - "-i", m3u8URL, - "-c", "copy", - outFile, - ) - - var stderr bytes.Buffer - cmd.Stdout = io.Discard - cmd.Stderr = &stderr - - // ✅ live size polling während ffmpeg läuft - stopStat := make(chan struct{}) - - if job != nil { - go func() { - t := time.NewTicker(1 * time.Second) - defer t.Stop() - - var last int64 - for { - select { - case <-ctx.Done(): - return - case <-stopStat: - return - case <-t.C: - fi, err := os.Stat(outFile) - if err != nil { - continue - } - sz := fi.Size() - if sz > 0 && sz != last { - jobsMu.Lock() - job.SizeBytes = sz - jobsMu.Unlock() - last = sz - } - } - } - }() - } - - // ✅ WICHTIG: ffmpeg wirklich laufen lassen - err = cmd.Run() - - close(stopStat) - - if err != nil { - msg := strings.TrimSpace(stderr.String()) - if msg != "" { - return fmt.Errorf("ffmpeg m3u8 failed: %w: %s", err, msg) - } - return fmt.Errorf("ffmpeg m3u8 failed: %w", err) - } - - return nil -} - /* ─────────────────────────────── Kleine Helper für MFC ─────────────────────────────── */ diff --git a/backend/recorder.go b/backend/recorder.go index f3f861a..3e30cee 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -262,6 +262,127 @@ func recordPreviewSprite(w http.ResponseWriter, r *http.Request) { // ---------------- Start + run job ---------------- +func markModelPublicWhileRecording(job *RecordJob) { + if job == nil { + return + } + + rawURL := strings.TrimSpace(job.SourceURL) + if rawURL == "" { + return + } + + if detectProvider(rawURL) != "chaturbate" { + return + } + + modelKey := strings.TrimSpace(extractUsername(rawURL)) + if modelKey == "" { + return + } + + store := getModelStore() + if store == nil { + return + } + + now := time.Now().UTC() + + _ = store.SetChaturbateRoomState( + "chaturbate.com", + modelKey, + "public", + true, + rawURL, + "", + now, + ) +} + +func safeFileSize(path string) int64 { + path = strings.TrimSpace(path) + if path == "" { + return 0 + } + + fi, err := os.Stat(path) + if err != nil || fi == nil || fi.IsDir() { + return 0 + } + return fi.Size() +} + +func statOutputForLog(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "path-empty" + } + + fi, err := os.Stat(path) + if err != nil { + return "stat-error=" + err.Error() + } + if fi == nil { + return "stat-nil" + } + if fi.IsDir() { + return "is-dir" + } + + return fmt.Sprintf("exists size=%d mod=%s", fi.Size(), fi.ModTime().Format(time.RFC3339Nano)) +} + +func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error) { + path = strings.TrimSpace(path) + if path == "" { + return nil, fmt.Errorf("path empty") + } + + deadline := time.Now().Add(timeout) + var lastErr error + + for { + fi, err := os.Stat(path) + if err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 { + return fi, nil + } + + if err != nil { + lastErr = err + } else if fi == nil { + lastErr = fmt.Errorf("stat returned nil") + } else if fi.IsDir() { + lastErr = fmt.Errorf("path is dir") + } else { + lastErr = fmt.Errorf("file size is 0") + } + + if time.Now().After(deadline) { + break + } + time.Sleep(150 * time.Millisecond) + } + + if lastErr == nil { + lastErr = fmt.Errorf("usable output not ready in time") + } + return nil, lastErr +} + +func shouldKeepStoppedJobForInspection(target JobStatus, out string) bool { + if target != JobStopped { + return false + } + + fi, err := os.Stat(strings.TrimSpace(out)) + if err != nil || fi == nil || fi.IsDir() { + return false + } + + // auch kleine Fragmente sichtbar behalten, damit du sie debuggen kannst + return fi.Size() > 0 +} + func isActiveRecordingJob(job *RecordJob) bool { if job == nil { return false @@ -310,6 +431,11 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { return nil, errors.New("url fehlt") } + provider := detectProvider(url) + if provider != "chaturbate" && provider != "mfc" { + return nil, fmt.Errorf("unsupported provider: %q", url) + } + jobsMu.Lock() defer jobsMu.Unlock() @@ -345,7 +471,6 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } startedAt := time.Now() - provider := detectProvider(url) username := "" switch provider { @@ -358,7 +483,7 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { username = "unknown" } - filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05")) + filename := fmt.Sprintf("%s_%s.mp4", username, startedAt.Format("01_02_2006__15-04-05")) recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDir := strings.TrimSpace(recordDirAbs) @@ -382,8 +507,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { } jobs[jobID] = job - go runJob(ctx, job, req) + return job, nil } @@ -429,7 +554,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { _ = os.MkdirAll(recordDirAbs, 0o755) username := extractUsername(req.URL) - filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) jobsMu.Lock() existingOut := strings.TrimSpace(job.Output) @@ -446,6 +571,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() } + markModelPublicWhileRecording(job) err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) case "mfc": @@ -458,7 +584,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { _ = os.MkdirAll(recordDirAbs, 0o755) username := extractMFCUsername(req.URL) - filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) + filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) outPath := filepath.Join(recordDirAbs, filename) jobsMu.Lock() @@ -489,6 +615,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.EndedAt = &end job.EndedAtMs = end.UnixMilli() + job.Status = target if errText != "" { job.Error = errText } @@ -496,22 +623,41 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { out := strings.TrimSpace(job.Output) jobsMu.Unlock() + // ------------------------------------------------------------ + // HARTE SCHRANKE 1: gar kein Output-Pfad + // ------------------------------------------------------------ if out == "" { jobsMu.Lock() - job.Status = target job.Phase = "" job.Progress = 100 job.PostWorkKey = "" job.PostWork = nil jobsMu.Unlock() + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() publishJobRemove(job) return } - // harte Schranke: leere / ungültige Output-Dateien nie in den Postwork schicken + // ------------------------------------------------------------ + // HARTE SCHRANKE 2: Output ungültig / leer vor Postwork + // WICHTIG: bei JobStopped kurz warten, damit Windows/File-Flush + // nach context canceled noch fertig werden kann + // ------------------------------------------------------------ { - fi, serr := os.Stat(out) + var ( + fi os.FileInfo + serr error + ) + + if target == JobStopped { + fi, serr = waitForUsableOutput(out, 2*time.Second) + } else { + fi, serr = os.Stat(out) + } + if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { _ = removeWithRetry(out) purgeDurationCacheForPath(out) @@ -521,21 +667,13 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - - if shouldLogRecordInfo(req) { - if serr != nil { - fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(stat error:", serr, ")") - } else if fi == nil || fi.IsDir() { - fmt.Println("🧹 removed invalid output before postwork:", filepath.Base(out), "(not a regular file)") - } else { - fmt.Println("🧹 removed empty output before postwork:", filepath.Base(out), "(0 bytes)") - } - } return } } - // pre-queue gate: nur in die Nachbearbeitung, wenn Datei behalten werden soll + // ------------------------------------------------------------ + // AUTO DELETE SMALL DOWNLOADS + // ------------------------------------------------------------ { fi, serr := os.Stat(out) if serr == nil && fi != nil && !fi.IsDir() { @@ -563,10 +701,6 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() publishJobRemove(job) - - if shouldLogRecordInfo(req) { - fmt.Println("🧹 auto-deleted before enqueue:", base, "(size: "+formatBytesSI(fi.Size())+", threshold: "+formatBytesSI(threshold)+")") - } return } @@ -576,7 +710,9 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } } - // postwork queue + // ------------------------------------------------------------ + // POSTWORK QUEUE + // ------------------------------------------------------------ postOut := out postTarget := target postKey := "postwork:" + job.ID @@ -619,24 +755,20 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } out := strings.TrimSpace(postOut) + if out == "" { jobsMu.Lock() + job.Status = postTarget job.Phase = "" job.Progress = 100 - job.Status = postTarget job.PostWorkKey = "" job.PostWork = nil jobsMu.Unlock() + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: postFile, - AssetID: postAssetID, - Queue: "postwork", - State: "done", - Label: "Primäre Nachbearbeitung fertig", - }) return nil } @@ -676,52 +808,46 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { }) } - // 1) Remux - // 1) Remux - if strings.EqualFold(filepath.Ext(out), ".ts") { - setPhase("remuxing", 10) - - newOut, err2 := maybeRemuxTSForJob(job, out) - if err2 != nil || strings.TrimSpace(newOut) == "" { - finalFile := filepath.Base(out) - finalAssetID := assetIDFromVideoPath(out) - - jobsMu.Lock() - job.Status = JobFailed - job.Error = "TS->MP4 Remux fehlgeschlagen" - if err2 != nil { - job.Error = "TS->MP4 Remux fehlgeschlagen: " + err2.Error() - } - job.Phase = "" - job.Progress = 100 - job.PostWorkKey = "" - job.PostWork = nil - jobsMu.Unlock() - - publishJobRemove(job) - - publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ - File: finalFile, - AssetID: finalAssetID, - Queue: "postwork", - State: "error", - Phase: "remuxing", - Label: "Remux fehlgeschlagen", - }) - - return nil - } - - out = strings.TrimSpace(newOut) - jobsMu.Lock() - job.Output = out - jobsMu.Unlock() - } - - // 2) Move to done setPhase("moving", 10) - // auch nach Remux nochmal hart prüfen: keine 0-Byte-Dateien nach done verschieben + moved, err2 := moveToDoneDir(out) + if err2 != nil || strings.TrimSpace(moved) == "" { + jobsMu.Lock() + job.Status = JobFailed + if err2 != nil { + job.Error = "moveToDoneDir failed: " + err2.Error() + } else { + job.Error = "moveToDoneDir returned empty path" + } + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() + + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() + publishJobRemove(job) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "moving", + Label: "Verschieben nach done fehlgeschlagen", + }) + return nil + } + + out = strings.TrimSpace(moved) + jobsMu.Lock() + job.Output = out + jobsMu.Unlock() + notifyDoneChanged() + + // 3) Datei nach move trotzdem kaputt { fi, serr := os.Stat(out) if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { @@ -742,29 +868,11 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { Phase: "moving", Label: "Nachbearbeitung fehlgeschlagen", }) - - if shouldLogRecordInfo(req) { - if serr != nil { - fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(stat error:", serr, ")") - } else if fi == nil || fi.IsDir() { - fmt.Println("🧹 removed invalid post-remux output:", filepath.Base(out), "(not a regular file)") - } else { - fmt.Println("🧹 removed empty post-remux output:", filepath.Base(out), "(0 bytes)") - } - } return nil } } - if moved, err2 := moveToDoneDir(out); err2 == nil && strings.TrimSpace(moved) != "" { - out = strings.TrimSpace(moved) - jobsMu.Lock() - job.Output = out - jobsMu.Unlock() - notifyDoneChanged() - } - - // 3) Duration + // 4) DURATION setPhase("probe", 35) { dctx, cancel := context.WithTimeout(ctx, 6*time.Second) @@ -776,7 +884,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { cancel() } - // 4) Video props + // 5) VIDEO PROPS setPhase("probe", 75) { pctx, cancel := context.WithTimeout(ctx, 6*time.Second) @@ -791,7 +899,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } } - // 5) Nur primäre Assets synchron: preview.jpg + preview.mp4 + // 6) PRIMARY ASSETS setPhase("assets", 0) lastPct := -1 @@ -830,7 +938,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } setPhase("assets", 100) - // Finalize primary postwork + // FINALIZE PRIMARY POSTWORK finalFile := filepath.Base(out) finalAssetID := assetIDFromVideoPath(out) @@ -842,6 +950,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = nil jobsMu.Unlock() + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() + publishJobRemove(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ @@ -896,6 +1008,10 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = nil jobsMu.Unlock() + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() + publishJobRemove(job) publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index cb6f73b..44d5b99 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -9,7 +9,7 @@ "maxConcurrentDownloads": 80, "useChaturbateApi": true, "useMyFreeCamsWatcher": true, - "autoDeleteSmallDownloads": true, + "autoDeleteSmallDownloads": false, "autoDeleteSmallDownloadsBelowMB": 100, "lowDiskPauseBelowGB": 5, "blurPreviews": false, diff --git a/backend/server.go b/backend/server.go index 4ee6cbf..be87a80 100644 --- a/backend/server.go +++ b/backend/server.go @@ -45,6 +45,7 @@ func main() { store := registerRoutes(mux, auth) go startChaturbateOnlinePoller(store) + go startChaturbateBioContextPoller() go startChaturbateAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store) go startDiskSpaceGuard() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4942875..898577e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -124,6 +124,18 @@ type TaskStateEvent = { ts?: number } +type ChaturbateBioContextResponse = { + enabled?: boolean + fetchedAt?: string + lastError?: string + model?: string + roomStatus?: string + isOnline?: boolean + chatRoomUrl?: string + imageUrl?: string + bio?: unknown +} + const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', @@ -201,6 +213,39 @@ type AutostartState = { pausedByDisk?: boolean } +async function fetchChaturbateBioContextStatus( + modelKey: string, + cookiesObj: Record +): Promise<{ + show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' + imageUrl?: string + chatRoomUrl?: string +}> { + const cookieHeader = Object.entries(cookiesObj) + .map(([k, v]) => `${k}=${v}`) + .join('; ') + + const res = await fetch(`/api/chaturbate/biocontext?model=${encodeURIComponent(modelKey)}&refresh=1`, { + method: 'GET', + cache: 'no-store', + headers: cookieHeader + ? { 'X-Chaturbate-Cookie': cookieHeader } + : undefined, + }) + + if (!res.ok) { + return { show: 'unknown' } + } + + const data = (await res.json().catch(() => null)) as ChaturbateBioContextResponse | null + + return { + show: normalizePendingShow(data?.roomStatus), + imageUrl: String(data?.imageUrl ?? '').trim() || undefined, + chatRoomUrl: String(data?.chatRoomUrl ?? '').trim() || undefined, + } +} + function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { const s = String(v ?? '').trim().toLowerCase() @@ -1939,64 +1984,6 @@ export default function App() { chaturbateStoreKeysLowerRef.current = chaturbateStoreKeysLower }, [chaturbateStoreKeysLower]) - const fetchChaturbateCurrentShow = useCallback(async (modelKey: string) => { - const keyLower = String(modelKey ?? '').trim().toLowerCase() - if (!keyLower) { - return { - show: 'unknown' as const, - imageUrl: '', - chatRoomUrl: '', - } - } - - try { - const res = await fetch('/api/chaturbate/online', { - method: 'POST', - cache: 'no-store', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - q: [keyLower], - }), - }) - - if (!res.ok) { - return { - show: 'unknown' as const, - imageUrl: '', - chatRoomUrl: '', - } - } - - const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null - - const room = Array.isArray(data?.rooms) - ? data!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === keyLower) - : null - - if (!room) { - return { - show: 'offline' as const, - imageUrl: '', - chatRoomUrl: '', - } - } - - return { - show: normalizePendingShow(room.current_show), - imageUrl: String(room.image_url ?? '').trim(), - chatRoomUrl: String(room.chat_room_url ?? '').trim(), - } - } catch { - return { - show: 'unknown' as const, - imageUrl: '', - chatRoomUrl: '', - } - } - }, []) - useEffect(() => { selectedTabRef.current = selectedTab }, [selectedTab]) @@ -2070,17 +2057,14 @@ export default function App() { } }, [authed, recSettings.useChaturbateApi]) - function shouldQueueForRoomStatus( - show: string - ): boolean { + function shouldAlwaysQueueForRoomStatus(show: string): boolean { const s = String(show || '').trim().toLowerCase() - return ( - s === 'private' || - s === 'hidden' || - s === 'away' || - s === 'offline' || - s === 'unknown' - ) + return s === 'private' || s === 'hidden' || s === 'away' + } + + function shouldQueueForOfflineLikeStatus(show: string): boolean { + const s = String(show || '').trim().toLowerCase() + return s === 'offline' || s === 'unknown' } // ✅ StartURL (hier habe ich den alten Online-Fetch entfernt und nur Snapshot genutzt) @@ -2118,7 +2102,7 @@ export default function App() { }) if (alreadyRunning) return true - // ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status + // ✅ Chaturbate: parse modelKey + queue-logic über aktuellen room_status if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { try { const parsed = await apiJSON('/api/models/parse', { @@ -2130,26 +2114,20 @@ export default function App() { const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() if (mkLower) { - const upsertPendingRow = (opts?: { - show?: unknown - imageUrl?: string - chatRoomUrl?: string - }) => { - const model = modelsByKeyRef.current[mkLower] as any - const show = normalizePendingShow(opts?.show ?? model?.roomStatus) - - const imageUrl = - String(opts?.imageUrl ?? '').trim() || - String(model?.imageUrl ?? '').trim() || - undefined + // silent bleibt günstig wie bisher + if (silent) { + setPendingAutoStartByKey((prev) => { + const next = { ...(prev || {}), [mkLower]: norm } + pendingAutoStartByKeyRef.current = next + return next + }) setPendingWatchedRooms((prev) => { const nextItem: PendingWatchedRoom = { id: mkLower, modelKey: mkLower, url: norm, - currentShow: show, - imageUrl, + currentShow: 'unknown', } const idx = prev.findIndex( @@ -2164,64 +2142,54 @@ export default function App() { return [nextItem, ...prev] }) - } - const enqueuePending = (opts?: { - show?: unknown - imageUrl?: string - chatRoomUrl?: string - }) => { - setPendingAutoStartByKey((prev) => { - const next = { ...(prev || {}), [mkLower]: norm } - pendingAutoStartByKeyRef.current = next - return next - }) - - upsertPendingRow(opts) - - applyPendingRoomSnapshot(mkLower, { - show: normalizePendingShow(opts?.show), - imageUrl: String(opts?.imageUrl ?? '').trim() || undefined, - chatRoomUrl: String(opts?.chatRoomUrl ?? '').trim() || undefined, - }) - } - - // Wenn gerade andere Starts laufen -> direkt in Warteschlange - if (busyRef.current) { - enqueuePending({ show: 'unknown' }) return true } - // Live current_show prüfen - const live = await fetchChaturbateCurrentShow(mkLower) - const liveShow = normalizePendingShow(live?.show) - - if (shouldQueueForRoomStatus(liveShow)) { - enqueuePending({ - show: liveShow, - imageUrl: live?.imageUrl, - chatRoomUrl: live?.chatRoomUrl, + // manuell: erst /online + try { + const onlineRes = await fetch('/api/chaturbate/online', { + method: 'POST', + cache: 'no-store', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ q: [mkLower] }), }) - return true - } - // public -> Snapshot aktualisieren und normal starten - applyPendingRoomSnapshot(mkLower, { - show: liveShow, - imageUrl: live?.imageUrl, - chatRoomUrl: live?.chatRoomUrl, - }) + let handled = false + + if (onlineRes.ok) { + const onlineData = (await onlineRes.json().catch(() => null)) as ChaturbateOnlineResponse | null + const room = Array.isArray(onlineData?.rooms) + ? onlineData!.rooms.find((x) => String(x?.username ?? '').trim().toLowerCase() === mkLower) + : null + + if (room) { + applyPendingRoomSnapshot(mkLower, { + show: normalizePendingShow(room.current_show), + imageUrl: String(room.image_url ?? '').trim() || undefined, + chatRoomUrl: String(room.chat_room_url ?? '').trim() || undefined, + }) + handled = true + } + } + + if (!handled) { + const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies) + applyPendingRoomSnapshot(mkLower, { + show: bio.show, + imageUrl: bio.imageUrl, + chatRoomUrl: bio.chatRoomUrl, + }) + } + } catch { + // best effort + } } } catch { - // Wenn Live-Check fehlschlägt: lieber in Warteschlange statt blind starten - try { - const parsed = await apiJSON('/api/models/parse', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: norm }), - }) - - const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() + if (silent) { + const mkLower = providerKeyLowerFromUrl(norm) if (mkLower) { setPendingAutoStartByKey((prev) => { const next = { ...(prev || {}), [mkLower]: norm } @@ -2252,8 +2220,6 @@ export default function App() { return true } - } catch { - // parse fail -> normal starten } } } @@ -2298,7 +2264,7 @@ export default function App() { setBusy(false) busyRef.current = false } - }, [applyPendingRoomSnapshot, fetchChaturbateCurrentShow]) + }, []) // ✅ settings: initial + nach Save-Event + nur beim echten Wechsel hidden -> visible useEffect(() => {