diff --git a/backend/assets_generate.go b/backend/assets_generate.go index d3c889d..8e0fbde 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -163,9 +163,10 @@ type EnsureAssetsResult struct { } type EnsurePrimaryAssetsResult struct { - Skipped bool - ThumbGenerated bool - MetaOK bool + Skipped bool + ThumbGenerated bool + PreviewGenerated bool + MetaOK bool } // Public wrappers (kompatibel zu deinem bisherigen API) @@ -229,7 +230,7 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return out, nil } - _, thumbPath, _, _, metaPath, perr := assetPathsForID(id) + _, thumbPath, previewPath, _, metaPath, perr := assetPathsForID(id) if perr != nil { return out, perr } @@ -254,17 +255,26 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( return false }() - // Basis-Meta nur für Dauer/Props sicherstellen + previewBefore := func() bool { + if pfi, err := os.Stat(previewPath); err == nil && !pfi.IsDir() && pfi.Size() > 0 { + return true + } + return false + }() + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) out.MetaOK = meta.ok - if thumbBefore { + if thumbBefore && previewBefore { out.Skipped = true progress(1) return out, nil } - const thumbsW = 1.0 + const ( + thumbsW = 0.25 + previewW = 0.75 + ) progress(0) @@ -318,8 +328,96 @@ func ensurePrimaryAssetsForVideoWithProgressCtx( progress(thumbsW) } - _ = sourceInputInvalid - progress(1) + // ---------------- + // Preview / Teaser + // ---------------- + const ( + previewClipLenSec = 0.75 + previewMaxClips = 12 + ) + + var computedPreviewClips []previewClip + + if previewBefore { + progress(1) + } else { + func() { + genCtx, cancel := context.WithTimeout(ctx, 3*time.Minute) + defer cancel() + + progress(thumbsW + 0.02) + + if err := genSem.Acquire(genCtx); err != nil { + return + } + defer genSem.Release() + + progress(thumbsW + 0.05) + + if err := generateTeaserClipsMP4WithProgress( + genCtx, + videoPath, + previewPath, + previewClipLenSec, + previewMaxClips, + func(r float64) { + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + progress(thumbsW + r*previewW) + }, + ); err != nil { + if isFFmpegInputInvalidError(err) { + sourceInputInvalid = true + return + } + return + } + + out.PreviewGenerated = true + + if !(meta.durSec > 0) { + return + } + + opts := TeaserPreviewOptions{ + Segments: previewMaxClips, + SegmentDuration: previewClipLenSec, + } + + starts, segDur, _ := computeTeaserStarts(meta.durSec, opts) + + clips := make([]previewClip, 0, len(starts)) + for _, s := range starts { + clips = append(clips, previewClip{ + StartSeconds: math.Round(s*1000) / 1000, + DurationSeconds: math.Round(segDur*1000) / 1000, + }) + } + computedPreviewClips = clips + }() + + progress(1) + } + + // Meta mit Preview-Clips aktualisieren (best effort) + if meta.durSec > 0 && !sourceInputInvalid { + _ = writeVideoMetaWithPreviewClipsAndSprite( + metaPath, + fi, + meta.durSec, + meta.vw, + meta.vh, + meta.fps, + meta.sourceURL, + computedPreviewClips, + nil, + ) + } + return out, nil } @@ -356,6 +454,82 @@ func ensureMetaForVideoCtx( return meta.ok, nil } +func ensureThumbForVideoCtx( + ctx context.Context, + videoPath string, + sourceURL string, +) (bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return false, nil + } + + fi, err := os.Stat(videoPath) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return false, nil + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return false, nil + } + + _, thumbPath, _, _, metaPath, perr := assetPathsForID(id) + if perr != nil { + return false, perr + } + + // Schon vorhanden -> nichts zu tun + if tfi, err := os.Stat(thumbPath); err == nil && tfi != nil && !tfi.IsDir() && tfi.Size() > 0 { + return false, nil + } + + // Meta best effort sicherstellen, damit wir Duration für den "kurz vor Ende"-Fallback haben + meta, _ := ensureVideoMeta(ctx, videoPath, metaPath, sourceURL, fi) + + genCtx, cancel := context.WithTimeout(ctx, 45*time.Second) + defer cancel() + + if err := thumbSem.Acquire(genCtx); err != nil { + return false, err + } + defer thumbSem.Release() + + // Immer letztes Frame bevorzugen + img, e1 := extractLastFrameJPG(videoPath) + if e1 != nil || len(img) == 0 { + // Fallback: kurz vor Ende + if meta.durSec > 0 { + t := meta.durSec - 0.25 + if t < 0 { + t = 0 + } + img, e1 = extractFrameAtTimeJPG(videoPath, t) + } + + // Letzter Fallback: erstes Frame + if e1 != nil || len(img) == 0 { + img, e1 = extractFirstFrameJPGScaled(videoPath, 720, 75) + } + } + + if e1 != nil { + if isFFmpegInputInvalidError(e1) { + return false, nil + } + return false, e1 + } + if len(img) == 0 { + return false, nil + } + + if err := atomicWriteFile(thumbPath, img); err != nil { + return false, err + } + + return true, nil +} + func ensureTeaserForVideoCtx( ctx context.Context, videoPath string, @@ -409,9 +583,14 @@ func ensureTeaserForVideoCtx( nil, ); err != nil { if isFFmpegInputInvalidError(err) { - return false, nil + return false, fmt.Errorf("teaser ffmpeg input ungültig für %s: %w", videoPath, err) } - return false, err + return false, fmt.Errorf("teaser generation fehlgeschlagen für %s: %w", videoPath, err) + } + + // Zusätzliche Sicherheitsprüfung: Datei muss nach der Generierung wirklich existieren + if pfi, err := os.Stat(previewPath); err != nil || pfi == nil || pfi.IsDir() || pfi.Size() <= 0 { + return false, fmt.Errorf("teaser wurde ohne gültige preview.mp4 beendet: %s", previewPath) } var computedPreviewClips []previewClip diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index 49ec31b..9c502e6 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -5,6 +5,7 @@ package main import ( "fmt" "net/url" + "os" "sort" "strings" "time" @@ -89,6 +90,96 @@ func resolveChaturbateURL(m WatchedModelLite) string { return fmt.Sprintf("https://chaturbate.com/%s/", key) } +func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { + deadline := time.Now().Add(maxWait) + + for time.Now().Before(deadline) { + jobsMu.RLock() + job := jobs[jobID] + status := JobStatus("") + out := "" + hidden := false + + if job != nil { + status = job.Status + out = strings.TrimSpace(job.Output) + hidden = job.Hidden + } + jobsMu.RUnlock() + + // Job schon weg oder nicht mehr running -> nichts tun + if job == nil || status != JobRunning { + return + } + + // echte Output-Datei vorhanden? + if out != "" { + if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 { + // hidden blind-try wird jetzt sichtbar gemacht + if hidden { + jobsMu.Lock() + j := jobs[jobID] + if j != nil { + j.Hidden = false + } + jobsMu.Unlock() + } + + publishJob(jobID) + return + } + } + + time.Sleep(900 * time.Millisecond) + } + + // nach Wartezeit immer noch keine Datei => stoppen + löschen + jobsMu.Lock() + job := jobs[jobID] + if job == nil || job.Status != JobRunning { + jobsMu.Unlock() + return + } + + pc := job.previewCmd + job.previewCmd = nil + + previewCancel := job.previewCancel + job.previewCancel = nil + + recordCancel := job.cancel + out := strings.TrimSpace(job.Output) + wasVisible := !job.Hidden + + jobsMu.Unlock() + + if previewCancel != nil { + previewCancel() + } + if pc != nil && pc.Process != nil { + _ = pc.Process.Kill() + } + if recordCancel != nil { + recordCancel() + } + + // 0-Byte Datei ggf. löschen + if out != "" { + if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 { + _ = os.Remove(out) + } + } + + jobsMu.Lock() + j := jobs[jobID] + delete(jobs, jobID) + jobsMu.Unlock() + + if wasVisible && j != nil { + publishJobRemove(j) + } +} + // Startet watched+online(public) automatisch – unabhängig vom Frontend func startChaturbateAutoStartWorker(store *ModelStore) { if store == nil { @@ -99,26 +190,31 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } const pollInterval = 5 * time.Second - const startGap = 5 * time.Second + const startGap = 1500 * time.Millisecond + + // normaler Retry für API-public const retryCooldown = 25 * time.Second + // aggressiver vermeiden für "nicht in API, aber watched" Blind-Try + const blindRetryCooldown = 2 * time.Minute + + // wie lange wir warten, ob eine echte Datei entsteht + const outputProbeMax = 20 * time.Second + queue := make([]autoStartItem, 0, 64) queued := map[string]bool{} lastTry := map[string]time.Time{} + lastBlindTry := map[string]time.Time{} + var lastStart time.Time for { if isAutostartPaused() { - // optional: Queue behalten oder leeren – ich würde sie behalten. time.Sleep(2 * time.Second) continue } s := getSettings() - - // Backend-Autostart läuft, wenn - // - Chaturbate API aktiviert ist - // - Autostart NICHT pausiert ist (wird oben schon via isAutostartPaused() geprüft) if !s.UseChaturbateAPI { queue = queue[:0] queued = map[string]bool{} @@ -127,7 +223,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } cookieHdr := cookieHeaderFromSettings(s) - // ohne cf_clearance + session_* keine Autostarts (gleiches Kriterium wie runJob) if !hasChaturbateCookies(cookieHdr) { time.Sleep(5 * time.Second) continue @@ -139,11 +234,18 @@ func startChaturbateAutoStartWorker(store *ModelStore) { cbMu.RUnlock() showByUser := map[string]string{} + seenInAPI := map[string]bool{} + for _, r := range rooms { - showByUser[normUser(r.Username)] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) + u := normUser(r.Username) + if u == "" { + continue + } + seenInAPI[u] = true + showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) } - // running users (damit wir nicht doppelt starten) + // laufende Jobs sammeln running := map[string]bool{} jobsMu.RLock() for _, j := range jobs { @@ -178,20 +280,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if running[it.userKey] { continue } + it.url = resolveChaturbateURL(m) if it.url == "" { continue } - // Queue behalten, auch wenn der Snapshot in diesem Tick leer/unvollständig ist nextQueue = append(nextQueue, it) nextQueued[it.userKey] = true } queue = nextQueue queued = nextQueued - // enqueue new public watched now := time.Now() + + // ------------------------------------------------------------ + // 1) watched + API sagt public => normal enqueue + // ------------------------------------------------------------ for user, m := range watchedByUser { if showByUser[user] != "public" { continue @@ -210,18 +315,78 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if u == "" { continue } - queue = append(queue, autoStartItem{userKey: user, url: u}) + + queue = append(queue, autoStartItem{ + userKey: user, + url: u, + }) queued[user] = true } - // starte max. einen Job pro Loop (mit Abstand) + // ------------------------------------------------------------ + // 2) watched + NICHT in API => blind best-effort try wie MFC + // aber nur wenn: + // - nicht schon running + // - nicht schon queued + // - nicht kürzlich versucht + // ------------------------------------------------------------ + for user, m := range watchedByUser { + if seenInAPI[user] { + continue + } + if running[user] { + continue + } + if queued[user] { + continue + } + if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { + continue + } + + u := resolveChaturbateURL(m) + if u == "" { + continue + } + + // direkt "blind" starten, nicht erst in queue hängen, + // damit das Verhalten wie MFC ist + if !lastStart.IsZero() && time.Since(lastStart) < startGap { + continue + } + + lastBlindTry[user] = time.Now() + + job, err := startRecordingInternal(RecordRequest{ + URL: u, + Cookie: cookieHdr, + Hidden: true, + }) + if err != nil || job == nil { + if verboseLogs() { + fmt.Println("❌ [autostart] blind start failed:", u, err) + } + continue + } + + if verboseLogs() { + fmt.Println("▶️ [autostart] blind try started:", u) + } + + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) + lastStart = time.Now() + } + + // ------------------------------------------------------------ + // 3) normale API-public queue abarbeiten + // ------------------------------------------------------------ if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) { it := queue[0] queue = queue[1:] delete(queued, it.userKey) lastTry[it.userKey] = time.Now() - _, err := startRecordingInternal(RecordRequest{ + job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: cookieHdr, }) @@ -233,6 +398,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if verboseLogs() { fmt.Println("▶️ [autostart] started:", it.url) } + + // optional auch hier absichern; schadet nicht + if job != nil { + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) + } + lastStart = time.Now() } } diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index f13367c..ccbc7e4 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -9,7 +9,6 @@ import ( "fmt" "io" "net/http" - "path/filepath" "sort" "strconv" "strings" @@ -92,25 +91,6 @@ var ( cbRefreshInFlight bool ) -// --- HLS refresh throttling (damit /online nicht zu teuer wird) --- -var cbHlsRefreshMu sync.Mutex -var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time - -func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { - if userLower == "" { - return false - } - cbHlsRefreshMu.Lock() - defer cbHlsRefreshMu.Unlock() - - last := cbHlsRefreshAt[userLower] - if !last.IsZero() && time.Since(last) < minInterval { - return false - } - cbHlsRefreshAt[userLower] = time.Now() - return true -} - func normalizeList(in []string) []string { seen := map[string]bool{} out := make([]string, 0, len(in)) @@ -367,167 +347,6 @@ 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 { @@ -850,140 +669,6 @@ func hashKey(parts ...string) string { return hex.EncodeToString(h.Sum(nil)) } -// jobMatchesUser prüft, ob ein laufender Job zu diesem Username gehört. -// (wir matchen über SourceURL und Output-Pfad – robust genug ohne modelNameFromFilename Abhängigkeit) -func jobMatchesUser(j *RecordJob, userLower string) bool { - if j == nil { - return false - } - u := strings.ToLower(strings.TrimSpace(userLower)) - if u == "" { - return false - } - - // 1) SourceURL enthält meist / - if s := strings.ToLower(strings.TrimSpace(j.SourceURL)); s != "" { - if strings.Contains(s, "/"+u) || strings.HasSuffix(s, "/"+u) || strings.HasSuffix(s, u) { - return true - } - } - - // 2) Output-Pfad enthält bei dir häufig den modelKey im Dateinamen/Ordner - if out := strings.ToLower(strings.TrimSpace(j.Output)); out != "" { - base := strings.ToLower(strings.TrimSpace(filepath.Base(out))) - if strings.Contains(base, u) { - return true - } - dir := strings.ToLower(strings.TrimSpace(filepath.Base(filepath.Dir(out)))) - if dir == u { - return true - } - } - - return false -} - -// fetchCurrentBestHLS lädt die Room-Seite, parsed hls_source und wählt die beste Variant-Playlist. -func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) { - u := strings.TrimSpace(username) - if u == "" { - return "", fmt.Errorf("empty username") - } - - hc := NewHTTPClient(userAgent) - pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/" - - body, err := hc.FetchPage(ctx, pageURL, cookie) - if err != nil { - return "", err - } - - master, err := ParseStream(body) // hls_source - if err != nil { - return "", err - } - - return strings.TrimSpace(master), nil -} - -// refreshRunningJobsHLS aktualisiert PreviewM3U8 (+Cookie/UA) für passende laufende Jobs. -// Wenn die URL rotiert hat: stopPreview(job) damit ffmpeg neu startet. -func refreshRunningJobsHLS(userLower string, newHls string, cookie string, ua string) { - if strings.TrimSpace(userLower) == "" || strings.TrimSpace(newHls) == "" { - return - } - - toStop := make([]*RecordJob, 0, 4) - - jobsMu.Lock() - for _, j := range jobs { - if j == nil || j.Status != JobRunning { - continue - } - if !jobMatchesUser(j, userLower) { - continue - } - - old := strings.TrimSpace(j.PreviewM3U8) - - j.PreviewM3U8 = newHls - j.PreviewCookie = cookie - j.PreviewUA = ua - - if old != "" && old != newHls { - j.PreviewState = "" - j.PreviewStateAt = "" - j.PreviewStateMsg = "" - toStop = append(toStop, j) - } - } - jobsMu.Unlock() - - for _, j := range toStop { - stopPreview(j) - } -} - -func refreshRunningJobsHLSAsync(users []string, liteByUser map[string]ChaturbateOnlineRoomLite, cookieHeader string, reqUA string) { - if len(users) == 0 || liteByUser == nil { - return - } - - const hlsMinInterval = 12 * time.Second - - liteCopy := make(map[string]ChaturbateOnlineRoomLite, len(liteByUser)) - for k, v := range liteByUser { - liteCopy[k] = v - } - - go func(usersCopy []string, rooms map[string]ChaturbateOnlineRoomLite, cookie string, ua string) { - for _, u := range usersCopy { - rm, ok := rooms[u] - if !ok { - continue - } - - show := strings.ToLower(strings.TrimSpace(rm.CurrentShow)) - if show == "" || show == "offline" { - continue - } - - if !shouldRefreshHLS(u, hlsMinInterval) { - continue - } - - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - newHls, err := fetchCurrentBestHLS(ctx, rm.Username, cookie, ua) - cancel() - if err != nil || strings.TrimSpace(newHls) == "" { - continue - } - - refreshRunningJobsHLS(u, newHls, cookie, ua) - } - }(append([]string(nil), users...), liteCopy, cookieHeader, reqUA) -} - func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodPost { http.Error(w, "Nur GET/POST erlaubt", http.StatusMethodNotAllowed) @@ -992,9 +677,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { enabled := getSettings().UseChaturbateAPI - // Optional: Cookie vom Frontend (für Cloudflare/session – best effort) - cookieHeader := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")) - // UA vom Client (oder fallback) reqUA := strings.TrimSpace(r.Header.Get("User-Agent")) if reqUA == "" { @@ -1263,15 +945,6 @@ func chaturbateOnlineHandler(w http.ResponseWriter, r *http.Request) { needBootstrap = fetchedAt.IsZero() - // --------------------------- - // ✅ HLS URL Refresh für laufende Jobs (best effort) - // Trigger nur, wenn explizite Users angefragt werden (dein Frontend macht das so) - // und nur wenn User gerade online ist. - // --------------------------- - if onlySpecificUsers && liteByUser != nil { - refreshRunningJobsHLSAsync(users, liteByUser, cookieHeader, reqUA) - } - // --------------------------- // Persist "last seen online/offline" für explizit angefragte User // --------------------------- diff --git a/backend/live.go b/backend/live.go index ff3b47a..13633b6 100644 --- a/backend/live.go +++ b/backend/live.go @@ -5,6 +5,7 @@ import ( "bufio" "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -15,6 +16,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" ) @@ -39,6 +41,59 @@ import ( // Allowed files that may be served out of PreviewDir. var previewFileRe = regexp.MustCompile(`^(index(_hq)?\.m3u8|seg_(low|hq)_\d+\.ts|seg_\d+\.ts|init\.m4s|\w+\.m4s)$`) +var cbHlsRefreshMu sync.Mutex +var cbHlsRefreshAt = map[string]time.Time{} // key=userLower -> last refresh time + +func shouldRefreshHLS(userLower string, minInterval time.Duration) bool { + userLower = strings.ToLower(strings.TrimSpace(userLower)) + if userLower == "" { + return false + } + + cbHlsRefreshMu.Lock() + defer cbHlsRefreshMu.Unlock() + + last := cbHlsRefreshAt[userLower] + if !last.IsZero() && time.Since(last) < minInterval { + return false + } + + cbHlsRefreshAt[userLower] = time.Now() + return true +} + +// fetchCurrentBestHLS lädt die Room-Seite und parsed hls_source. +func fetchCurrentBestHLS(ctx context.Context, username string, cookie string, userAgent string) (string, error) { + u := strings.TrimSpace(username) + if u == "" { + return "", fmt.Errorf("empty username") + } + + if strings.TrimSpace(userAgent) == "" { + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" + } + + hc := NewHTTPClient(userAgent) + pageURL := "https://chaturbate.com/" + strings.Trim(u, "/") + "/" + + body, err := hc.FetchPage(ctx, pageURL, cookie) + if err != nil { + return "", err + } + + master, err := ParseStream(body) + if err != nil { + return "", err + } + + master = strings.TrimSpace(master) + if master == "" { + return "", fmt.Errorf("empty parsed hls url") + } + + return master, nil +} + func serveLiveNotReady(w http.ResponseWriter, r *http.Request) { // ✅ Für HLS-Clients (hls.js) ist 204 beim Manifest "ein Fehler" -> aggressive Retries. // Deshalb: IMMER 200 + gültige (aber leere) m3u8 zurückgeben. @@ -103,16 +158,130 @@ func stopPreview(job *RecordJob) { } } +func refreshPreviewLiveSourceForJob(r *http.Request, job *RecordJob) error { + if job == nil { + return fmt.Errorf("job nil") + } + + jobsMu.RLock() + sourceURL := strings.TrimSpace(job.SourceURL) + cookie := strings.TrimSpace(job.PreviewCookie) + ua := strings.TrimSpace(job.PreviewUA) + jobsMu.RUnlock() + + if detectProvider(sourceURL) != "chaturbate" { + return nil + } + + // Optional: Cookie vom aktuellen Request bevorzugen + if reqCookie := strings.TrimSpace(r.Header.Get("X-Chaturbate-Cookie")); reqCookie != "" { + cookie = reqCookie + } + + // UA vom Request bevorzugen + if reqUA := strings.TrimSpace(r.Header.Get("User-Agent")); reqUA != "" { + ua = reqUA + } + if ua == "" { + ua = "Mozilla/5.0" + } + + username := strings.TrimSpace(extractUsername(sourceURL)) + if username == "" { + return fmt.Errorf("username not found") + } + + // ✅ wichtig: doppelten Refresh verhindern + if !shouldRefreshHLS(strings.ToLower(username), 10*time.Second) { + return nil + } + + ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second) + defer cancelRefresh() + + newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua) + if err != nil { + return err + } + newHls = strings.TrimSpace(newHls) + if newHls == "" { + return fmt.Errorf("empty hls") + } + + var oldHls string + + jobsMu.Lock() + oldHls = strings.TrimSpace(job.PreviewM3U8) + job.PreviewM3U8 = newHls + job.PreviewCookie = cookie + job.PreviewUA = ua + job.PreviewState = "" + job.PreviewStateAt = "" + job.PreviewStateMsg = "" + jobsMu.Unlock() + + if oldHls != "" && oldHls != newHls { + stopPreview(job) + } + + return nil +} + func recordPreviewLive(w http.ResponseWriter, r *http.Request) { + prepare := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("prepare"))) + if prepare == "1" || prepare == "true" || prepare == "yes" { + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + jobsMu.RLock() + job, ok := jobs[id] + jobsMu.RUnlock() + + if !ok || job == nil { + http.Error(w, "job nicht gefunden", http.StatusNotFound) + return + } + + err := refreshPreviewLiveSourceForJob(r, job) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + + type resp struct { + OK bool `json:"ok"` + Prepared bool `json:"prepared"` + FetchedAt time.Time `json:"fetchedAt"` + LastError string `json:"lastError,omitempty"` + } + + if err != nil { + _ = json.NewEncoder(w).Encode(resp{ + OK: false, + Prepared: false, + FetchedAt: time.Now(), + LastError: err.Error(), + }) + return + } + + _ = json.NewEncoder(w).Encode(resp{ + OK: true, + Prepared: true, + FetchedAt: time.Now(), + }) + return + } + // ✅ Route bleibt /api/preview/live - // Wenn kein "file" Parameter da ist, liefern wir den neuen Single-Request Stream (fMP4). file := strings.TrimSpace(r.URL.Query().Get("file")) if file == "" { recordPreviewLiveFMP4(w, r) return } - // Legacy: HLS file serving + m3u8 rewrite (falls du es noch irgendwo brauchst) id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { http.Error(w, "id fehlt", http.StatusBadRequest) @@ -579,10 +748,8 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) { jobsMu.RLock() job, ok := jobs[id] state := "" - sourceURL := "" if ok && job != nil { state = strings.TrimSpace(job.PreviewState) - sourceURL = strings.TrimSpace(job.SourceURL) } jobsMu.RUnlock() @@ -591,39 +758,7 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) { return } - username := extractUsername(sourceURL) - if strings.TrimSpace(username) != "" { - jobsMu.RLock() - cookie := strings.TrimSpace(job.PreviewCookie) - ua := strings.TrimSpace(job.PreviewUA) - jobsMu.RUnlock() - - if ua == "" { - ua = "Mozilla/5.0" - } - - ctxRefresh, cancelRefresh := context.WithTimeout(r.Context(), 8*time.Second) - newHls, err := fetchCurrentBestHLS(ctxRefresh, username, cookie, ua) - cancelRefresh() - - if err == nil && strings.TrimSpace(newHls) != "" { - var oldHls string - - jobsMu.Lock() - oldHls = strings.TrimSpace(job.PreviewM3U8) - job.PreviewM3U8 = strings.TrimSpace(newHls) - job.PreviewCookie = cookie - job.PreviewUA = ua - job.PreviewState = "" - job.PreviewStateAt = "" - job.PreviewStateMsg = "" - jobsMu.Unlock() - - if oldHls != "" && oldHls != strings.TrimSpace(newHls) { - stopPreview(job) - } - } - } + _ = refreshPreviewLiveSourceForJob(r, job) // Nach möglichem Refresh alles nochmal sauber lesen jobsMu.RLock() diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 7c52dd8..04a11bd 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -7,15 +7,24 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/url" + "os" "strconv" "strings" "time" + + "github.com/grafov/m3u8" ) -// --- DVR-ähnlicher Recorder-Ablauf --- -// Entspricht grob dem RecordStream aus dem Channel-Snippet: +// RecordStream für Chaturbate: +// bewusst OHNE ffmpeg-Input auf die CB/MMCDN-Playlist. +// Stattdessen wie in der alten, funktionierenden Version: +// - Seite laden +// - HLS URL parsen +// - beste Variant-Playlist auswählen +// - Segmente selbst laden und in .ts schreiben func RecordStream( ctx context.Context, hc *HTTPClient, @@ -26,26 +35,30 @@ func RecordStream( job *RecordJob, ) error { username = strings.Trim(strings.TrimSpace(username), "/") + if username == "" { + return errors.New("leerer username") + } + base := strings.TrimRight(domain, "/") pageURL := base + "/" + username + "/" - loadFreshHLS := func() (string, error) { + loadFreshPlaylist := func() (*cbPlaylist, error) { body, err := hc.FetchPage(ctx, pageURL, httpCookie) if err != nil { - return "", fmt.Errorf("seite laden: %w", err) + return nil, fmt.Errorf("seite laden: %w", err) } hlsURL, err := ParseStream(body) if err != nil { - return "", fmt.Errorf("stream-parsing: %w", err) + return nil, fmt.Errorf("stream-parsing: %w", err) } hlsURL = strings.TrimSpace(hlsURL) if hlsURL == "" { - return "", errors.New("leere hls url") + return nil, errors.New("leere hls url") } - finalURL, err := getWantedResolutionPlaylistWithHeaders( + finalPlaylistURL, err := getWantedResolutionPlaylistWithHeaders( ctx, hlsURL, httpCookie, @@ -53,13 +66,18 @@ func RecordStream( pageURL, ) if err != nil { - return "", fmt.Errorf("variant-playlist: %w", err) + return nil, fmt.Errorf("variant-playlist: %w", err) } - return strings.TrimSpace(finalURL), nil + finalPlaylistURL = strings.TrimSpace(finalPlaylistURL) + if finalPlaylistURL == "" { + return nil, errors.New("leere final playlist url") + } + + return cbFetchPlaylist(ctx, hc, finalPlaylistURL, httpCookie) } - hlsURL, err := loadFreshHLS() + playlist, err := loadFreshPlaylist() if err != nil { return err } @@ -71,7 +89,7 @@ func RecordStream( } jobsMu.Lock() - job.PreviewM3U8 = hlsURL + job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() @@ -81,13 +99,62 @@ func RecordStream( } } + 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 + + handleSegment := func(b []byte, duration float64) error { + if len(b) == 0 { + return nil + } + + if _, err := file.Write(b); err != nil { + return fmt.Errorf("schreibe segment: %w", err) + } + + _ = file.Sync() + + if job != nil && !published { + published = true + _ = publishJob(job.ID) + } + + 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() + + _ = publishJob(job.ID) + + lastPush = now + lastBytes = written + } + } + + _ = duration + return nil + } + const maxPlaylistRefreshes = 12 refreshes := 0 for { - err = handleM3U8Mode(ctx, hlsURL, outputPath, job, httpCookie, hc.userAgent, pageURL) + err = playlist.WatchSegments(ctx, hc, httpCookie, handleSegment) if err == nil { - return nil + break } if ctx.Err() != nil { @@ -98,14 +165,14 @@ func RecordStream( shouldRefresh := strings.Contains(msg, "403") || strings.Contains(msg, "401") || - strings.Contains(msg, "invalid data found") || - strings.Contains(msg, "error opening input") || + strings.Contains(msg, "playlist nicht mehr erreichbar") || + strings.Contains(msg, "fehlerhafte playlist") || + strings.Contains(msg, "keine neuen hls-segmente") || strings.Contains(msg, "stream-parsing") || - strings.Contains(msg, "kein hls-quell-url") || strings.Contains(msg, "http ") if !shouldRefresh { - return err + return fmt.Errorf("watch segments: %w", err) } refreshes++ @@ -125,7 +192,7 @@ func RecordStream( case <-time.After(1500 * time.Millisecond): } - newHLS, rerr := loadFreshHLS() + newPlaylist, rerr := loadFreshPlaylist() if rerr != nil { fmt.Println("⚠️ [cb] refresh failed:", "user=", username, @@ -141,16 +208,25 @@ func RecordStream( continue } - hlsURL = newHLS + playlist = newPlaylist if job != nil { jobsMu.Lock() - job.PreviewM3U8 = hlsURL + job.PreviewM3U8 = strings.TrimSpace(playlist.PlaylistURL) job.PreviewCookie = httpCookie job.PreviewUA = hc.userAgent jobsMu.Unlock() } } + + if job != nil { + jobsMu.Lock() + job.SizeBytes = written + jobsMu.Unlock() + _ = publishJob(job.ID) + } + + return nil } // ParseStream entspricht der DVR-Variante (roomDossier → hls_source) @@ -160,7 +236,6 @@ func ParseStream(html string) (string, error) { return "", errors.New("room dossier nicht gefunden") } - // DVR-Style Unicode-Decode decoded, err := strconv.Unquote( strings.Replace(strconv.Quote(matches[1]), `\\u`, `\u`, -1), ) @@ -180,7 +255,213 @@ func ParseStream(html string) (string, error) { return rd.HLSSource, nil } -// Cookie-Hilfsfunktion (wie ParseCookies + AddCookie im DVR) +// eigener Typ nur für Chaturbate, damit es keinen Konflikt mit main.go gibt +type cbPlaylist struct { + PlaylistURL string + RootURL string + Resolution int + Framerate int +} + +func cbFetchPlaylist(ctx context.Context, hc *HTTPClient, playlistURL string, httpCookie string) (*cbPlaylist, error) { + req, err := hc.NewRequest(ctx, http.MethodGet, playlistURL, httpCookie) + if err != nil { + return nil, fmt.Errorf("playlist request erzeugen: %w", err) + } + + resp, err := hc.client.Do(req) + if err != nil { + return nil, fmt.Errorf("playlist request senden: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d beim Abruf der m3u8", resp.StatusCode) + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + if err != nil { + return nil, fmt.Errorf("m3u8 parse: %w", err) + } + + if listType == m3u8.MEDIA { + root := playlistURL + if idx := strings.LastIndex(root, "/"); idx >= 0 { + root = root[:idx+1] + } + return &cbPlaylist{ + PlaylistURL: playlistURL, + RootURL: root, + }, nil + } + + master, ok := playlist.(*m3u8.MasterPlaylist) + if !ok || master == nil { + return nil, errors.New("unerwarteter playlist-typ") + } + + 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(strings.TrimSpace(parts[1])); err == nil { + h = hh + } + } + } + + fr := 30.0 + if v.FrameRate > 0 { + fr = v.FrameRate + } + + if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) { + bestURI = strings.TrimSpace(v.URI) + bestHeight = h + bestFramerate = fr + } + } + + if bestURI == "" { + return nil, errors.New("Master-Playlist ohne gültige Varianten") + } + + baseURL, err := url.Parse(playlistURL) + if err != nil { + return nil, err + } + + refURL, err := url.Parse(bestURI) + if err != nil { + return nil, err + } + + finalURL := baseURL.ResolveReference(refURL).String() + root := finalURL + if idx := strings.LastIndex(root, "/"); idx >= 0 { + root = root[:idx+1] + } + + return &cbPlaylist{ + PlaylistURL: finalURL, + RootURL: root, + Resolution: bestHeight, + Framerate: int(bestFramerate), + }, nil +} + +func (p *cbPlaylist) WatchSegments( + ctx context.Context, + hc *HTTPClient, + httpCookie string, + handler func([]byte, float64) error, +) error { + var lastSeq int64 = -1 + emptyRounds := 0 + const maxEmptyRounds = 60 + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + req, err := hc.NewRequest(ctx, http.MethodGet, p.PlaylistURL, httpCookie) + if err != nil { + return fmt.Errorf("playlist request erzeugen: %w", err) + } + + resp, err := hc.client.Do(req) + if err != nil { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("playlist nicht mehr erreichbar – stream vermutlich offline") + } + time.Sleep(2 * time.Second) + continue + } + + playlist, listType, err := m3u8.DecodeFrom(resp.Body, true) + resp.Body.Close() + + if err != nil || listType != m3u8.MEDIA { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("fehlerhafte playlist – möglicherweise offline") + } + time.Sleep(2 * time.Second) + continue + } + + media := playlist.(*m3u8.MediaPlaylist) + newSegment := false + + for _, segment := range media.Segments { + if segment == nil { + continue + } + if int64(segment.SeqId) <= lastSeq { + continue + } + + lastSeq = int64(segment.SeqId) + newSegment = true + + segURL := segment.URI + if !strings.HasPrefix(segURL, "http://") && !strings.HasPrefix(segURL, "https://") { + segURL = p.RootURL + strings.TrimLeft(segment.URI, "/") + } + + segReq, err := hc.NewRequest(ctx, http.MethodGet, segURL, httpCookie) + if err != nil { + continue + } + + segResp, err := hc.client.Do(segReq) + if err != nil { + continue + } + + if segResp.StatusCode != http.StatusOK { + segResp.Body.Close() + continue + } + + data, err := io.ReadAll(segResp.Body) + segResp.Body.Close() + if err != nil || len(data) == 0 { + continue + } + + if err := handler(data, segment.Duration); err != nil { + return err + } + } + + if newSegment { + emptyRounds = 0 + } else { + emptyRounds++ + if emptyRounds >= maxEmptyRounds { + return errors.New("keine neuen HLS-segmente empfangen – stream vermutlich beendet oder offline") + } + } + + time.Sleep(1 * time.Second) + } +} + +// Cookie-Hilfsfunktion func addCookiesFromString(req *http.Request, cookieStr string) { if cookieStr == "" { return @@ -203,14 +484,13 @@ func addCookiesFromString(req *http.Request, cookieStr string) { } } -// --- helper --- +// helper func extractUsername(input string) string { s := strings.TrimSpace(input) if s == "" { return "" } - // Falls volle URL if u, err := url.Parse(s); err == nil && u != nil { host := strings.ToLower(strings.TrimSpace(u.Hostname())) if strings.Contains(host, "chaturbate.com") { @@ -223,7 +503,6 @@ func extractUsername(input string) string { } } - // Fallback: rohe Eingabe s = strings.TrimPrefix(s, "https://") s = strings.TrimPrefix(s, "http://") s = strings.TrimPrefix(s, "www.") @@ -239,9 +518,8 @@ func extractUsername(input string) string { func hasChaturbateCookies(cookieStr string) bool { m := parseCookieString(cookieStr) _, hasCF := m["cf_clearance"] - // akzeptiere session_id ODER sessionid ODER sessionid/sessionId Varianten (case-insensitive durch ToLower) _, hasSessID := m["session_id"] - _, hasSessIdAlt := m["sessionid"] // falls es ohne underscore kommt + _, hasSessIdAlt := m["sessionid"] return hasCF && (hasSessID || hasSessIdAlt) } diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index 9418bef..c00cdb8 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -12,6 +12,7 @@ import ( "net/url" "os" "os/exec" + "path/filepath" "strconv" "strings" "time" @@ -64,7 +65,10 @@ func getWantedResolutionPlaylistWithHeaders( return playlistURL, nil } - master := playlist.(*m3u8.MasterPlaylist) + master, ok := playlist.(*m3u8.MasterPlaylist) + if !ok || master == nil { + return "", errors.New("unerwarteter playlist-typ: keine master-playlist") + } var bestURI string var bestHeight int @@ -79,7 +83,7 @@ func getWantedResolutionPlaylistWithHeaders( if v.Resolution != "" { parts := strings.Split(v.Resolution, "x") if len(parts) == 2 { - if hh, err := strconv.Atoi(parts[1]); err == nil { + if hh, err := strconv.Atoi(strings.TrimSpace(parts[1])); err == nil { h = hh } } @@ -90,10 +94,10 @@ func getWantedResolutionPlaylistWithHeaders( fr = v.FrameRate } - if h > bestHeight || (h == bestHeight && fr > bestFramerate) { + if bestURI == "" || h > bestHeight || (h == bestHeight && fr > bestFramerate) { + bestURI = strings.TrimSpace(v.URI) bestHeight = h bestFramerate = fr - bestURI = strings.TrimSpace(v.URI) } } @@ -105,12 +109,15 @@ func getWantedResolutionPlaylistWithHeaders( if err != nil { return "", err } + refURL, err := url.Parse(bestURI) if err != nil { return "", err } - return baseURL.ResolveReference(refURL).String(), nil + finalURL := baseURL.ResolveReference(refURL).String() + + return finalURL, nil } func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { @@ -153,34 +160,6 @@ func handleM3U8Mode( 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") } @@ -214,13 +193,34 @@ func handleM3U8Mode( args = append(args, "-headers", hdr) } + ext := strings.ToLower(filepath.Ext(strings.TrimSpace(outFile))) + args = append(args, "-i", m3u8URL, + "-map", "0:v:0", + "-map", "0:a:0?", + "-dn", + "-ignore_unknown", "-c", "copy", - "-movflags", "+faststart", - outFile, ) + switch ext { + case ".ts": + args = append(args, + "-f", "mpegts", + outFile, + ) + case ".mp4": + args = append(args, + "-movflags", "+faststart", + outFile, + ) + default: + return fmt.Errorf("nicht unterstützte output-endung: %q", ext) + } + + fmt.Println("🎬 ffmpeg hls args:", strings.Join(args, " ")) + cmd := exec.CommandContext(ctx, ffmpegPath, args...) var stderr bytes.Buffer @@ -246,6 +246,7 @@ func handleM3U8Mode( if err != nil || fi == nil || fi.IsDir() { continue } + sz := fi.Size() if sz > 0 && sz != last { jobsMu.Lock() @@ -270,5 +271,14 @@ func handleM3U8Mode( return fmt.Errorf("ffmpeg m3u8 failed: %w", err) } + if job != nil { + if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() { + jobsMu.Lock() + job.SizeBytes = fi.Size() + jobsMu.Unlock() + _ = publishJob(job.ID) + } + } + return nil } diff --git a/backend/recorder.go b/backend/recorder.go index 3e30cee..068906d 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -483,7 +483,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { username = "unknown" } - filename := fmt.Sprintf("%s_%s.mp4", username, startedAt.Format("01_02_2006__15-04-05")) + // ✅ Erst in TS aufnehmen + filename := fmt.Sprintf("%s_%s.ts", username, startedAt.Format("01_02_2006__15-04-05")) recordDirAbs, _ := resolvePathRelativeToApp(s.RecordDir) recordDir := strings.TrimSpace(recordDirAbs) @@ -554,7 +555,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { _ = os.MkdirAll(recordDirAbs, 0o755) username := extractUsername(req.URL) - filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) jobsMu.Lock() existingOut := strings.TrimSpace(job.Output) @@ -584,7 +585,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { _ = os.MkdirAll(recordDirAbs, 0o755) username := extractMFCUsername(req.URL) - filename := fmt.Sprintf("%s_%s.mp4", username, now.Format("01_02_2006__15-04-05")) + filename := fmt.Sprintf("%s_%s.ts", username, now.Format("01_02_2006__15-04-05")) outPath := filepath.Join(recordDirAbs, filename) jobsMu.Lock() @@ -642,9 +643,8 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } // ------------------------------------------------------------ - // 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 + // HARTE SCHRANKE 2: Output prüfen, aber gestoppte Downloads + // nicht aggressiv wegwerfen // ------------------------------------------------------------ { var ( @@ -653,12 +653,21 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { ) if target == JobStopped { - fi, serr = waitForUsableOutput(out, 2*time.Second) + fi, serr = waitForUsableOutput(out, 3*time.Second) } else { fi, serr = os.Stat(out) } if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + // Bei STOP nicht sofort wegwerfen – nur wenn wirklich nichts da ist + if target == JobStopped && shouldKeepStoppedJobForInspection(target, out) { + jobsMu.Lock() + job.Phase = "" + job.Progress = 100 + jobsMu.Unlock() + return + } + _ = removeWithRetry(out) purgeDurationCacheForPath(out) @@ -808,6 +817,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { }) } + // 1) Erst nach done verschieben setPhase("moving", 10) moved, err2 := moveToDoneDir(out) @@ -819,12 +829,27 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } else { job.Error = "moveToDoneDir returned empty path" } + job.Output = out job.Phase = "" job.Progress = 100 job.PostWorkKey = "" job.PostWork = nil jobsMu.Unlock() + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "moving", + Label: "Verschieben nach done fehlgeschlagen", + }) + return nil + } + jobsMu.Lock() delete(jobs, job.ID) jobsMu.Unlock() @@ -847,7 +872,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Unlock() notifyDoneChanged() - // 3) Datei nach move trotzdem kaputt + // 2) Nach move prüfen { fi, serr := os.Stat(out) if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { @@ -872,8 +897,108 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } } + // 3) ✅ TS -> MP4 Remux in den Nacharbeiten + if strings.EqualFold(filepath.Ext(out), ".ts") { + setPhase("remuxing", 35) + + remuxed, rerr := maybeRemuxTS(out) + if rerr != nil || strings.TrimSpace(remuxed) == "" { + jobsMu.Lock() + job.Status = JobFailed + if rerr != nil { + job.Error = "TS remux failed: " + rerr.Error() + } else { + job.Error = "TS remux returned empty path" + } + job.Output = out // originale .ts behalten + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() + + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "remuxing", + Label: "TS Remux fehlgeschlagen", + }) + return nil + } + + 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: "remuxing", + Label: "TS Remux fehlgeschlagen", + }) + return nil + } + + out = strings.TrimSpace(remuxed) + + fi, serr := os.Stat(out) + if serr != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 || !strings.EqualFold(filepath.Ext(out), ".mp4") { + jobsMu.Lock() + job.Status = JobFailed + job.Error = "TS remux result invalid" + job.Phase = "" + job.Progress = 100 + job.PostWorkKey = "" + job.PostWork = nil + jobsMu.Unlock() + + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: filepath.Base(out), + AssetID: assetIDFromVideoPath(out), + Queue: "postwork", + State: "error", + Phase: "remuxing", + Label: "Remux-Ergebnis ungültig", + }) + return nil + } + + 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: "remuxing", + Label: "Remux-Ergebnis ungültig", + }) + return nil + } + + jobsMu.Lock() + job.Output = out + job.SizeBytes = fi.Size() + jobsMu.Unlock() + notifyDoneChanged() + } + // 4) DURATION - setPhase("probe", 35) + setPhase("probe", 60) { dctx, cancel := context.WithTimeout(ctx, 6*time.Second) if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 { @@ -885,7 +1010,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } // 5) VIDEO PROPS - setPhase("probe", 75) + setPhase("probe", 85) { pctx, cancel := context.WithTimeout(ctx, 6*time.Second) w, h, fps, perr := probeVideoProps(pctx, out) @@ -950,11 +1075,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.PostWork = nil jobsMu.Unlock() - jobsMu.Lock() - delete(jobs, job.ID) - jobsMu.Unlock() + if postTarget == JobStopped { + _ = publishJob(job.ID) + } else { + jobsMu.Lock() + delete(jobs, job.ID) + jobsMu.Unlock() - publishJobRemove(job) + publishJobRemove(job) + } publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ File: finalFile, @@ -1006,8 +1135,24 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { job.Progress = 100 job.PostWorkKey = "" job.PostWork = nil + job.Error = "Nachbearbeitung konnte nicht eingeplant werden" jobsMu.Unlock() + // Gestoppte Downloads mit existierender Datei nicht sofort aus der UI werfen + if shouldKeepStoppedJobForInspection(postTarget, out) { + _ = publishJob(job.ID) + + publishFinishedPostworkStatusForJob(job, finishedPostworkEvent{ + File: postFile, + AssetID: postAssetID, + Queue: "postwork", + State: "error", + Phase: "postwork", + Label: "Nachbearbeitung konnte nicht eingeplant werden", + }) + return + } + jobsMu.Lock() delete(jobs, job.ID) jobsMu.Unlock() diff --git a/backend/server.go b/backend/server.go index be87a80..4ee6cbf 100644 --- a/backend/server.go +++ b/backend/server.go @@ -45,7 +45,6 @@ func main() { store := registerRoutes(mux, auth) go startChaturbateOnlinePoller(store) - go startChaturbateBioContextPoller() go startChaturbateAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store) go startDiskSpaceGuard() diff --git a/backend/sse.go b/backend/sse.go index 77b82cb..c9bd33b 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -63,6 +63,7 @@ type finishedPostworkEvent struct { State string `json:"state"` // "queued" | "running" | "done" | "error" | "missing" Phase string `json:"phase,omitempty"` Label string `json:"label,omitempty"` + Error string `json:"error,omitempty"` Position int `json:"position,omitempty"` Waiting int `json:"waiting,omitempty"` Running int `json:"running,omitempty"` diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 20a9083..80fe74d 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -156,6 +156,7 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s phase, state, label, + "", ) } diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index 4e9e81e..4ab4221 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "os" "path/filepath" @@ -60,6 +61,7 @@ func publishFinishedPostworkPhase( phase string, state string, label string, + errMsg string, ) { setRegenerateAssetsJobPhase(file, queue, phase, state, label) @@ -71,6 +73,7 @@ func publishFinishedPostworkPhase( State: state, Phase: phase, Label: label, + Error: strings.TrimSpace(errMsg), TS: time.Now().UnixMilli(), } if b, err := json.Marshal(ev); err == nil { @@ -320,18 +323,24 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") setRegenerateAssetsTaskError(file, "Abgebrochen") - publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "") + publishFinishedPostworkPhase(file, id, "postwork", "meta", "missing", "", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "missing", "", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "missing", "", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "missing", "", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "missing", "", "") removeRegenerateAssetsJobLater(file, 2*time.Second) } failJob := func(queue, phase, msg string) { + msg = strings.TrimSpace(msg) + + if msg != "" { + fmt.Printf("❌ regenerate-assets error file=%s queue=%s phase=%s error=%s\n", file, queue, phase, msg) + } + if queue != "" && phase != "" { - publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler") + publishFinishedPostworkPhase(file, id, queue, phase, "error", "Fehler", msg) } finishRegenerateAssetsJob(file, "error", msg) @@ -345,11 +354,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { } // Initialstatus - publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") + publishFinishedPostworkPhase(file, id, "postwork", "meta", "running", "Meta", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") // Alte Assets löschen if id != "" { @@ -376,12 +385,12 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta") + publishFinishedPostworkPhase(file, id, "postwork", "meta", "done", "Meta", "") // 2) Thumb - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "running", "Vorschaubild", "") - if _, err := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, videoPath, "", nil); err != nil { + if _, err := ensureThumbForVideoCtx(ctx, videoPath, ""); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { cancelJob() return @@ -395,10 +404,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "done", "Vorschaubild", "") // 3) Teaser - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "running", "Teaser", "") if _, err := ensureTeaserForVideoCtx(ctx, videoPath, ""); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { @@ -414,10 +423,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "done", "Teaser", "") // 4) Sprites - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "running", "Sprites", "") if _, err := ensureSpritesForVideoCtx(ctx, videoPath, ""); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { @@ -433,10 +442,10 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "") // 5) KI-Analyse - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "") if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { @@ -452,7 +461,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { return } - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "") finishRegenerateAssetsJob(file, "done", "") setRegenerateAssetsTaskDone(file, "Fertig") @@ -590,11 +599,11 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { enqueueRegenerateAssetsJob(job) - publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta") - publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild") - publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser") - publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse") + publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "") + publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") + publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") + publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e729383..13a5910 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,6 +93,7 @@ type JobEvent = { modelImageUrl?: string modelChatRoomUrl?: string ts?: number + error?: string postWorkKey?: string postWork?: { @@ -124,18 +125,6 @@ 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', @@ -213,39 +202,6 @@ 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() @@ -1628,18 +1584,7 @@ export default function App() { // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen if (ok && next.pendingKeyLower) { - const kLower = next.pendingKeyLower - - setPendingAutoStartByKey((prev) => { - const copy = { ...(prev || {}) } - delete copy[kLower] - pendingAutoStartByKeyRef.current = copy - return copy - }) - - setPendingWatchedRooms((prev) => - prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== kLower) - ) + removePendingAutoStart(next.pendingKeyLower) } } finally { // dedupe wieder freigeben @@ -1772,6 +1717,53 @@ export default function App() { return () => window.removeEventListener('models-changed', onChanged as any) }, [loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) + const queuePendingAutoStart = useCallback((keyLower: string, url: string, show?: string, imageUrl?: string) => { + if (!keyLower) return + + setPendingAutoStartByKey((prev) => { + const next = { ...(prev || {}), [keyLower]: url } + pendingAutoStartByKeyRef.current = next + return next + }) + + setPendingWatchedRooms((prev) => { + const nextItem: PendingWatchedRoom = { + id: keyLower, + modelKey: keyLower, + url, + currentShow: normalizePendingShow(show), + imageUrl: imageUrl || undefined, + } + + const idx = prev.findIndex( + (x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower + ) + + if (idx >= 0) { + const copy = [...prev] + copy[idx] = { ...copy[idx], ...nextItem } + return copy + } + + return [nextItem, ...prev] + }) + }, []) + + const removePendingAutoStart = useCallback((keyLower: string) => { + if (!keyLower) return + + setPendingAutoStartByKey((prev) => { + const next = { ...(prev || {}) } + delete next[keyLower] + pendingAutoStartByKeyRef.current = next + return next + }) + + setPendingWatchedRooms((prev) => + prev.filter((x) => String(x.modelKey ?? '').trim().toLowerCase() !== keyLower) + ) + }, []) + const selectedTabRef = useRef(selectedTab) const applyPendingRoomSnapshot = useCallback( @@ -1908,12 +1900,6 @@ export default function App() { }), }) - if (!res.ok) return - - const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null - - const rooms = Array.isArray(data?.rooms) ? data!.rooms : [] - const byKey: Record< string, { @@ -1923,20 +1909,30 @@ export default function App() { } > = {} - for (const room of rooms) { - const key = String(room?.username ?? '').trim().toLowerCase() - if (!key) continue + if (res.ok) { + const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null + const rooms = Array.isArray(data?.rooms) ? data.rooms : [] - byKey[key] = { - show: normalizePendingShow(room?.current_show), - imageUrl: String(room?.image_url ?? '').trim() || undefined, - chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined, + for (const room of rooms) { + const key = String(room?.username ?? '').trim().toLowerCase() + if (!key) continue + + byKey[key] = { + show: normalizePendingShow(room?.current_show), + imageUrl: String(room?.image_url ?? '').trim() || undefined, + chatRoomUrl: String(room?.chat_room_url ?? '').trim() || undefined, + } } } - // Nicht gelieferte Keys => offline for (const key of keys) { - const snap = byKey[key] ?? { show: 'offline' as const } + const snap = + byKey[key] ?? + { + show: 'unknown' as const, + imageUrl: String(modelsByKeyRef.current[key]?.imageUrl ?? '').trim() || undefined, + chatRoomUrl: String(modelsByKeyRef.current[key]?.chatRoomUrl ?? '').trim() || undefined, + } maybeNotifyWatchedModelStatusChange(key, snap.show, { imageUrl: snap.imageUrl, @@ -1944,16 +1940,24 @@ export default function App() { applyPendingRoomSnapshot(key, snap) - if (snap.show === 'public') { - const url = String((pendingMap as any)?.[key] ?? '').trim() - if (!url) continue + const url = String((pendingMap as any)?.[key] ?? '').trim() + if (!url) continue + if (snap.show === 'public') { enqueueStart({ url, silent: true, pendingKeyLower: key, }) + continue } + + if (snap.show === 'offline') { + removePendingAutoStart(key) + continue + } + + // private / hidden / away / unknown bleiben in der Warteschlange } } catch { // ignore @@ -1976,7 +1980,13 @@ export default function App() { cancelled = true if (timer != null) window.clearTimeout(timer) } - }, [recSettings.useChaturbateApi, applyPendingRoomSnapshot, enqueueStart]) + }, [ + recSettings.useChaturbateApi, + applyPendingRoomSnapshot, + enqueueStart, + maybeNotifyWatchedModelStatusChange, + removePendingAutoStart, + ]) const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower) @@ -2104,39 +2114,16 @@ export default function App() { const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() if (mkLower) { - // silent bleibt günstig wie bisher + // silent: direkt in Queue legen, Status kommt später über den Poller 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: 'unknown', - } - - const idx = prev.findIndex( - (x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower - ) - - if (idx >= 0) { - const copy = [...prev] - copy[idx] = { ...copy[idx], ...nextItem } - return copy - } - - return [nextItem, ...prev] - }) - + queuePendingAutoStart(mkLower, norm, 'unknown') return true } - // manuell: erst /online + let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown' + let resolvedImageUrl: string | undefined + let resolvedChatRoomUrl: string | undefined + try { const onlineRes = await fetch('/api/chaturbate/online', { method: 'POST', @@ -2147,67 +2134,51 @@ export default function App() { body: JSON.stringify({ q: [mkLower] }), }) - 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) + ? 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 + resolvedShow = normalizePendingShow(room.current_show) + resolvedImageUrl = String(room.image_url ?? '').trim() || undefined + resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined } } - - if (!handled) { - const bio = await fetchChaturbateBioContextStatus(mkLower, currentCookies) - applyPendingRoomSnapshot(mkLower, { - show: bio.show, - imageUrl: bio.imageUrl, - chatRoomUrl: bio.chatRoomUrl, - }) - } } catch { // best effort } + + applyPendingRoomSnapshot(mkLower, { + show: resolvedShow, + imageUrl: resolvedImageUrl, + chatRoomUrl: resolvedChatRoomUrl, + }) + + if ( + resolvedShow === 'private' || + resolvedShow === 'hidden' || + resolvedShow === 'away' || + resolvedShow === 'unknown' + ) { + queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl) + return true + } + + if (resolvedShow === 'offline') { + removePendingAutoStart(mkLower) + setError('Model ist aktuell offline und wurde nicht zur Warteschlange hinzugefügt.') + return false + } + + // nur bei public unten normal starten } } catch { if (silent) { const mkLower = providerKeyLowerFromUrl(norm) if (mkLower) { - setPendingAutoStartByKey((prev) => { - const next = { ...(prev || {}), [mkLower]: norm } - pendingAutoStartByKeyRef.current = next - return next - }) - - setPendingWatchedRooms((prev) => { - const nextItem: PendingWatchedRoom = { - id: mkLower, - modelKey: mkLower, - url: norm, - currentShow: 'unknown', - } - - const idx = prev.findIndex( - (x) => String(x.modelKey ?? '').trim().toLowerCase() === mkLower - ) - - if (idx >= 0) { - const copy = [...prev] - copy[idx] = { ...copy[idx], ...nextItem } - return copy - } - - return [nextItem, ...prev] - }) - + queuePendingAutoStart(mkLower, norm, 'unknown') return true } } @@ -2510,6 +2481,7 @@ export default function App() { state, phase: String(msg?.phase ?? '').trim() || undefined, label: String(msg?.label ?? '').trim() || undefined, + error: String(msg?.error ?? '').trim() || undefined, position: typeof msg?.position === 'number' && Number.isFinite(msg.position) ? msg.position : undefined, waiting: typeof msg?.waiting === 'number' && Number.isFinite(msg.waiting) ? msg.waiting : undefined, running: typeof msg?.running === 'number' && Number.isFinite(msg.running) ? msg.running : undefined, @@ -2785,34 +2757,19 @@ export default function App() { const handleRemovePendingWatchedRoom = useCallback(async (pending: PendingWatchedRoom) => { const keyLower = String(pending?.modelKey ?? '').trim().toLowerCase() - const url = String(pending?.url ?? '').trim() - // 1) Aus pendingAutoStartByKey entfernen if (keyLower) { - setPendingAutoStartByKey((prev) => { - const next = { ...(prev || {}) } - delete next[keyLower] - pendingAutoStartByKeyRef.current = next - return next - }) + removePendingAutoStart(keyLower) + return } - // 2) Aus sichtbarer Warteliste entfernen + const url = String(pending?.url ?? '').trim() + if (!url) return + setPendingWatchedRooms((prev) => - prev.filter((x) => { - const xKey = String(x?.modelKey ?? '').trim().toLowerCase() - const xUrl = String(x?.url ?? '').trim() - - // bevorzugt über modelKey entfernen - if (keyLower && xKey === keyLower) return false - - // Fallback über URL - if (!keyLower && url && xUrl === url) return false - - return true - }) + prev.filter((x) => String(x?.url ?? '').trim() !== url) ) - }, []) + }, [removePendingAutoStart]) type FinishedFileActionKind = 'delete' | 'keep' | 'rename' diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 424b0c7..6529dce 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -34,6 +34,7 @@ import { XMarkIcon, ArchiveBoxIcon, TrashIcon, + ClipboardDocumentIcon, } from '@heroicons/react/24/outline' import { type SwipeCardHandle } from './SwipeCard' import { flushSync } from 'react-dom' @@ -245,6 +246,85 @@ function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)) } +async function copyTextToClipboard(text: string): Promise { + const value = String(text ?? '').trim() + if (!value) return false + + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(value) + return true + } + } catch { + // fallback below + } + + try { + const ta = document.createElement('textarea') + ta.value = value + ta.setAttribute('readonly', '') + ta.style.position = 'fixed' + ta.style.opacity = '0' + ta.style.pointerEvents = 'none' + document.body.appendChild(ta) + ta.focus() + ta.select() + const ok = document.execCommand('copy') + document.body.removeChild(ta) + return ok + } catch { + return false + } +} + +function CopyErrorButton({ text }: { text: string }) { + const [copied, setCopied] = React.useState(false) + const timeoutRef = React.useRef(null) + + React.useEffect(() => { + return () => { + if (timeoutRef.current != null) { + window.clearTimeout(timeoutRef.current) + } + } + }, []) + + const handleClick = async (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + + const ok = await copyTextToClipboard(text || 'Unbekannter Fehler') + if (!ok) return + + setCopied(true) + + if (timeoutRef.current != null) { + window.clearTimeout(timeoutRef.current) + } + + timeoutRef.current = window.setTimeout(() => { + setCopied(false) + timeoutRef.current = null + }, 1200) + } + + return ( + + ) +} + function errorTextOf(err: unknown): string { if (err instanceof Error) return err.message || String(err) return String(err ?? '') @@ -905,6 +985,7 @@ function summarizePostworkSteps( phase, label: labelForStep(queue, phase), state: badge.state, + error: String(badge.error ?? '').trim() || undefined, ts: badge.ts, position: badge.position, waiting: badge.waiting, @@ -1035,7 +1116,9 @@ function mergePostworkSummaries( step.state === 'queued' || step.state === 'error' ) { - stepMap.set(step.key, step) + if (!prev || (step.ts || 0) >= (prev.ts || 0)) { + stepMap.set(step.key, step) + } continue } @@ -1227,10 +1310,18 @@ export function renderFinishedPostworkBadge( Fertig ) : step.state === 'error' ? ( - <> +
- Fehler - + + + Fehler + + + {step.error ? : null} +
) : step.state === 'missing' ? ( <> @@ -2586,6 +2677,11 @@ export default function FinishedDownloads({ return true } catch (e: any) { + console.error('regenerate-assets failed', { + file: cleanFile, + error: e?.message ? String(e.message) : String(e), + }) + setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ @@ -3188,6 +3284,7 @@ export default function FinishedDownloads({ String(detail?.label ?? '').trim() === file ? undefined : detail?.label, + error: String(detail?.error ?? '').trim() || undefined, } const cur = Array.isArray(prev[file]) ? prev[file] : [] diff --git a/frontend/src/components/ui/HoverPopover.tsx b/frontend/src/components/ui/HoverPopover.tsx index 3f9eefe..e0f1aef 100644 --- a/frontend/src/components/ui/HoverPopover.tsx +++ b/frontend/src/components/ui/HoverPopover.tsx @@ -1,3 +1,5 @@ +// frontend\src\components\ui\HoverPopover.tsx + 'use client' import { @@ -16,20 +18,25 @@ type Pos = { left: number; top: number } type HoverPopoverAPI = { close: () => void } type HoverPopoverProps = PropsWithChildren<{ - // Entweder direkt ein ReactNode - // oder eine Renderfunktion, die den Open-Status bekommt - // (2. Param erlaubt z.B. Close-Button im Popover) content: ReactNode | ((open: boolean, api: HoverPopoverAPI) => ReactNode) + onOpenChange?: (open: boolean) => void }> -export default function HoverPopover({ children, content }: HoverPopoverProps) { +export default function HoverPopover({ children, content, onOpenChange }: HoverPopoverProps) { const triggerRef = useRef(null) const popoverRef = useRef(null) const [open, setOpen] = useState(false) const [pos, setPos] = useState(null) - // Timeout-Ref für verzögertes Schließen + const setOpenState = (next: boolean) => { + setOpen((prev) => (prev === next ? prev : next)) + } + + useEffect(() => { + onOpenChange?.(open) + }, [open, onOpenChange]) + const closeTimeoutRef = useRef(null) const clearCloseTimeout = () => { @@ -42,14 +49,14 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { const scheduleClose = () => { clearCloseTimeout() closeTimeoutRef.current = window.setTimeout(() => { - setOpen(false) + setOpenState(false) closeTimeoutRef.current = null - }, 150) // 150ms „Gnadenzeit“ zum Rüberfahren + }, 150) } const handleEnter = () => { clearCloseTimeout() - setOpen(true) + setOpenState(true) } const handleLeave = () => { @@ -58,7 +65,7 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { const close = () => { clearCloseTimeout() - setOpen(false) + setOpenState(false) } const computePos = () => { @@ -67,25 +74,19 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { if (!trigger || !pop) return const gap = 8 - const pad = 8 // Abstand zum Viewport-Rand + const pad = 8 const r = trigger.getBoundingClientRect() const pr = pop.getBoundingClientRect() - // Prefer: unten let top = r.bottom + gap const bottomOverflow = top + pr.height > window.innerHeight - pad if (bottomOverflow) { - // Versuch: oben const above = r.top - pr.height - gap if (above >= pad) top = above - else { - // Notfalls clamp ins Viewport - top = Math.max(pad, window.innerHeight - pr.height - pad) - } + else top = Math.max(pad, window.innerHeight - pr.height - pad) } - // Left clamp let left = r.left if (left + pr.width > window.innerWidth - pad) { left = window.innerWidth - pr.width - pad @@ -95,15 +96,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { setPos({ left, top }) } - // Beim Öffnen: erst rendern, dann messen/positionieren useLayoutEffect(() => { if (!open) return const id = requestAnimationFrame(() => computePos()) return () => cancelAnimationFrame(id) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - // Reposition bei Scroll/Resize solange offen useEffect(() => { if (!open) return const onMove = () => requestAnimationFrame(() => computePos()) @@ -113,15 +111,12 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { window.removeEventListener('resize', onMove) window.removeEventListener('scroll', onMove, true) } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) - // Cleanup für Timeout useEffect(() => { return () => clearCloseTimeout() }, []) - // Hilfsfunktion: content normalisieren const renderContent = () => typeof content === 'function' ? (content as any)(open, { close }) @@ -162,4 +157,4 @@ export default function HoverPopover({ children, content }: HoverPopoverProps) { )} ) -} +} \ No newline at end of file diff --git a/frontend/src/components/ui/LiveVideo.tsx b/frontend/src/components/ui/LiveVideo.tsx index 5ca2474..da450bb 100644 --- a/frontend/src/components/ui/LiveVideo.tsx +++ b/frontend/src/components/ui/LiveVideo.tsx @@ -12,6 +12,8 @@ export default function LiveVideo({ className, roomStatus, onVolumeChange, + onLoadingStart, + onReady, }: { src: string muted?: boolean @@ -19,6 +21,8 @@ export default function LiveVideo({ className?: string roomStatus?: string onVolumeChange?: (volume: number, muted: boolean) => void + onLoadingStart?: () => void + onReady?: () => void }) { const ref = useRef(null) const [broken, setBroken] = useState(false) @@ -56,8 +60,8 @@ export default function LiveVideo({ } useEffect(() => { - let cancelled = false let watchdogTimer: number | null = null + let readySent = false const video = ref.current if (!video) return @@ -77,28 +81,58 @@ export default function LiveVideo({ } catch {} } - // 1) src setzen (fMP4/MP4 stream, single HTTP response) hardReset() + + if (!src) { + setBroken(false) + setBrokenReason(null) + return + } + + onLoadingStart?.() + + const markReady = () => { + if (readySent) return + readySent = true + onReady?.() + } + video.src = src video.load() video.play().catch(() => {}) - // 2) leichter Watchdog: wenn gar kein Fortschritt -> einmal neu setzen let lastT = Date.now() const onTime = () => { lastT = Date.now() + markReady() } + + const onLoadedData = () => { + markReady() + } + + const onCanPlay = () => { + markReady() + } + + const onPlaying = () => { + markReady() + } + video.addEventListener('timeupdate', onTime) + video.addEventListener('loadeddata', onLoadedData) + video.addEventListener('canplay', onCanPlay) + video.addEventListener('playing', onPlaying) let stalledRetries = 0 watchdogTimer = window.setInterval(() => { - if (cancelled) return - - // wenn nicht paused, aber 12s kein Fortschritt -> reconnect versuchen if (!video.paused && Date.now() - lastT > 12_000) { if (stalledRetries < 1) { stalledRetries += 1 + readySent = false + onLoadingStart?.() + hardReset() video.src = src video.load() @@ -118,30 +152,30 @@ export default function LiveVideo({ } }, 4_000) - // 3) HTTP-Fehler (403/404) erkennen ist bei