diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index d0cddc3..61aa47f 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -131,20 +131,27 @@ func generatePreviewSpriteJPG( var stderr bytes.Buffer cmd.Stderr = &stderr + if err := ctx.Err(); err != nil { + return err + } + err := cmd.Run() if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + msgErr := strings.TrimSpace(stderr.String()) if msgErr == "" { msgErr = "(kein stderr)" } return fmt.Errorf( - "ffmpeg sprite failed for %q -> %q: %w | ctxErr=%v | args=%q | stderr=%s", + "ffmpeg sprite failed for %q -> %q: %w | args=%q | stderr=%s", videoPath, tmpPath, err, - ctx.Err(), strings.Join(cmd.Args, " "), msgErr, ) diff --git a/backend/chaturbate_autostart.go b/backend/chaturbate_autostart.go index fed2075..8845649 100644 --- a/backend/chaturbate_autostart.go +++ b/backend/chaturbate_autostart.go @@ -3,9 +3,11 @@ package main import ( + "errors" "fmt" "net/url" "os" + "path/filepath" "sort" "strings" "time" @@ -14,6 +16,8 @@ import ( type autoStartItem struct { userKey string url string + blind bool + source string // "watched" | "manual" } func normUser(s string) string { @@ -45,14 +49,27 @@ func chaturbateUserFromURL(raw string) string { if raw == "" { return "" } + + lower := strings.ToLower(raw) + if !strings.Contains(lower, "://") { + if strings.HasPrefix(lower, "chaturbate.com/") || strings.HasPrefix(lower, "www.chaturbate.com/") { + raw = "https://" + raw + } else { + return "" + } + } + u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "" } - host := strings.ToLower(u.Hostname()) - if !strings.Contains(host, "chaturbate.com") { + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + host = strings.TrimPrefix(host, "www.") + if host != "chaturbate.com" && !strings.HasSuffix(host, ".chaturbate.com") { return "" } + parts := strings.Split(u.Path, "/") for _, p := range parts { p = strings.TrimSpace(p) @@ -60,6 +77,7 @@ func chaturbateUserFromURL(raw string) string { return normUser(p) } } + return "" } @@ -99,15 +117,11 @@ func cookieHeaderFromSettings(s RecorderSettings) string { } func resolveChaturbateURL(m WatchedModelLite) string { - in := strings.TrimSpace(m.Input) - if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") { - return in + if u := chaturbateUserFromURL(m.Input); u != "" { + return fmt.Sprintf("https://chaturbate.com/%s/", u) } - key := strings.Trim(strings.TrimSpace(m.ModelKey), "/") - if key == "" { - key = watchedChaturbateUserKey(m) - } + key := watchedChaturbateUserKey(m) if key == "" { return "" } @@ -115,7 +129,7 @@ func resolveChaturbateURL(m WatchedModelLite) string { return fmt.Sprintf("https://chaturbate.com/%s/", key) } -func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { +func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func(), onNoOutput func()) { deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { @@ -150,6 +164,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { jobsMu.Unlock() } + if onOutput != nil { + onOutput() + } + publishJob(jobID) return } @@ -195,6 +213,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { } } + if onNoOutput != nil { + onNoOutput() + } + jobsMu.Lock() j := jobs[jobID] delete(jobs, jobID) @@ -205,6 +227,41 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { } } +func clearAllPendingAutoStartOnStartup() error { + pendingAutoStartMu.Lock() + defer pendingAutoStartMu.Unlock() + + dir := filepath.Join("data", "pending-autostart") + + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := strings.TrimSpace(entry.Name()) + if name == "" { + continue + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + continue + } + + if err := os.Remove(filepath.Join(dir, name)); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + } + + return nil +} + // Startet watched+online(public) automatisch – unabhängig vom Frontend func startChaturbateAutoStartWorker(store *ModelStore) { if store == nil { @@ -232,6 +289,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queued := map[string]bool{} lastTry := map[string]time.Time{} lastBlindTry := map[string]time.Time{} + probeCursor := 0 scanTicker := time.NewTicker(scanInterval) startTicker := time.NewTicker(startInterval) @@ -252,6 +310,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queue = queue[:0] queued = map[string]bool{} lastCookieHdr = "" + + pendingAutoStartMu.Lock() + _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nil) + pendingAutoStartMu.Unlock() + continue } @@ -276,20 +339,56 @@ func startChaturbateAutoStartWorker(store *ModelStore) { showByUser := map[string]string{} imageByUser := map[string]string{} - seenInAPI := map[string]bool{} + + pendingAutoStartMu.Lock() + manualItems, err := loadManualPendingAutoStartItemsForProvider("chaturbate") + pendingAutoStartMu.Unlock() + if err != nil { + if verboseLogs() { + fmt.Println("⚠️ [autostart] load manual chaturbate pending failed:", err) + } + continue + } + + manualByUser := map[string]PendingAutoStartItem{} + manualOrder := make([]string, 0, len(manualItems)) + + for _, it := range manualItems { + key := normUser(it.ModelKey) + if key == "" { + key = chaturbateUserFromURL(it.URL) + } + if key == "" { + continue + } + + it.ModelKey = key + it.URL = strings.TrimSpace(it.URL) + if it.URL == "" { + it.URL = fmt.Sprintf("https://chaturbate.com/%s/", key) + } + + if _, exists := manualByUser[key]; exists { + continue + } + + manualByUser[key] = it + manualOrder = append(manualOrder, key) + } for _, r := range rooms { u := normUser(r.Username) if u == "" { continue } - seenInAPI[u] = true showByUser[u] = normalizePendingShowServer(r.CurrentShow) imageByUser[u] = selectBestRoomImageURL(r) } // laufende Jobs sammeln runningByUser := map[string]*RecordJob{} + hiddenProbeUser := "" + jobsMu.RLock() for _, j := range jobs { if j == nil { @@ -301,9 +400,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) { if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" { continue } + u := chaturbateUserFromURL(j.SourceURL) - if u != "" { - runningByUser[u] = j + if u == "" { + continue + } + + runningByUser[u] = j + + if j.Hidden && hiddenProbeUser == "" { + hiddenProbeUser = u } } jobsMu.RUnlock() @@ -311,28 +417,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) { // watched list aus DB // bewusst OHNE host-filter laden, weil Altbestände evtl. keinen sauberen host haben watched := store.ListWatchedLite("") + watchedByUser := map[string]WatchedModelLite{} + watchedOrder := make([]string, 0, len(watched)) + for _, m := range watched { if !m.Watching { continue } - // nur Chaturbate-Einträge berücksichtigen host := strings.ToLower(strings.TrimSpace(m.Host)) host = strings.TrimPrefix(host, "www.") if host != "" && host != "chaturbate.com" { - // Falls Host gesetzt und nicht chaturbate -> ignorieren continue } - // Falls Host leer ist, versuchen wir über Input/ModelKey zu erkennen key := watchedChaturbateUserKey(m) if key == "" { continue } - // Wenn Host leer ist, aber Input eindeutig nicht Chaturbate ist -> skip if host == "" { in := strings.TrimSpace(m.Input) if in != "" { @@ -345,36 +450,84 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } } + if _, exists := watchedByUser[key]; exists { + continue + } + watchedByUser[key] = m + watchedOrder = append(watchedOrder, key) } // queue prune nextQueue := queue[:0] nextQueued := map[string]bool{} + for _, it := range queue { - m, ok := watchedByUser[it.userKey] - if !ok { - continue - } if runningByUser[it.userKey] != nil { continue } - it.url = resolveChaturbateURL(m) - if it.url == "" { - continue + if it.source == "manual" { + m, ok := manualByUser[it.userKey] + if !ok { + continue + } + + it.url = strings.TrimSpace(m.URL) + if it.url == "" { + continue + } + } else { + m, ok := watchedByUser[it.userKey] + if !ok { + continue + } + + it.url = resolveChaturbateURL(m) + if it.url == "" { + continue + } + } + + show := normalizePendingShowServer(showByUser[it.userKey]) + + if it.blind { + if show != "offline" && show != "unknown" { + continue + } + } else { + if show != "public" { + continue + } } nextQueue = append(nextQueue, it) nextQueued[it.userKey] = true } + queue = nextQueue queued = nextQueued - now := time.Now() - nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser)) + blindQueued := false + selectedBlindUser := hiddenProbeUser + + for _, it := range queue { + if it.blind { + blindQueued = true + if selectedBlindUser == "" { + selectedBlindUser = it.userKey + } + break + } + } + + offlineCandidates := make([]autoStartItem, 0, len(watchedOrder)) + nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) + now := time.Now() + + for _, user := range watchedOrder { + m := watchedByUser[user] - for user, m := range watchedByUser { u := resolveChaturbateURL(m) if u == "" { continue @@ -385,7 +538,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) { switch show { case "public": - // public => kein Pending-Eintrag if runningByUser[user] != nil { continue } @@ -399,11 +551,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) { queue = append(queue, autoStartItem{ userKey: user, url: u, + blind: false, + source: "watched", }) queued[user] = true case "private", "hidden", "away": - // laufenden watched-Download beenden und auf public warten nextPending = append(nextPending, PendingAutoStartItem{ ModelKey: user, URL: u, @@ -418,19 +571,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) { } default: - // offline / unknown / nicht in API - nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli() - - nextPending = append(nextPending, PendingAutoStartItem{ - ModelKey: user, - URL: u, - Mode: "probe_retry", - NextProbeAtMs: nextProbeAtMs, - CurrentShow: show, - ImageURL: img, - Source: "watched", - }) - if runningByUser[user] != nil { continue } @@ -441,16 +581,127 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } + offlineCandidates = append(offlineCandidates, autoStartItem{ + userKey: user, + url: u, + blind: true, + source: "watched", + }) + } + } + + for _, user := range manualOrder { + if _, exists := watchedByUser[user]; exists { + // watched gewinnt + continue + } + + itm := manualByUser[user] + u := strings.TrimSpace(itm.URL) + if u == "" { + continue + } + + show := normalizePendingShowServer(showByUser[user]) + + switch show { + case "public": + if runningByUser[user] != nil { + continue + } + if queued[user] { + continue + } + if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown { + continue + } + queue = append(queue, autoStartItem{ userKey: user, url: u, + blind: false, + source: "manual", }) queued[user] = true + + case "private", "hidden", "away": + // manual pending bleibt einfach stehen und wartet auf public + continue + + default: + if runningByUser[user] != nil { + continue + } + if queued[user] { + continue + } + if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { + continue + } + + offlineCandidates = append(offlineCandidates, autoStartItem{ + userKey: user, + url: u, + blind: true, + source: "manual", + }) + } + } + + // Nur EIN Offline-Kandidat gleichzeitig in die Queue + if !blindQueued && !hasHiddenProbeRunningForProvider("chaturbate") && len(offlineCandidates) > 0 { + n := len(offlineCandidates) + start := 0 + if probeCursor > 0 { + start = probeCursor % n + } + + for i := 0; i < n; i++ { + idx := (start + i) % n + it := offlineCandidates[idx] + + if queued[it.userKey] { + continue + } + if runningByUser[it.userKey] != nil { + continue + } + + queue = append(queue, it) + queued[it.userKey] = true + selectedBlindUser = it.userKey + probeCursor = (idx + 1) % n + break + } + } + + if selectedBlindUser != "" { + if m, ok := watchedByUser[selectedBlindUser]; ok { + u := resolveChaturbateURL(m) + if u != "" { + show := normalizePendingShowServer(showByUser[selectedBlindUser]) + img := strings.TrimSpace(imageByUser[selectedBlindUser]) + + nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli() + if t, ok := lastBlindTry[selectedBlindUser]; ok { + nextProbeAtMs = t.Add(blindRetryCooldown).UnixMilli() + } + + nextPending = append(nextPending, PendingAutoStartItem{ + ModelKey: selectedBlindUser, + URL: u, + Mode: "probe_retry", + NextProbeAtMs: nextProbeAtMs, + CurrentShow: show, + ImageURL: img, + Source: "watched", + }) + } } } pendingAutoStartMu.Lock() - _ = saveWatchedPendingAutoStartItems(nextPending) + _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending) pendingAutoStartMu.Unlock() case <-startTicker.C: @@ -471,15 +722,6 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } - it := queue[0] - queue = queue[1:] - delete(queued, it.userKey) - - // prüfen, ob inzwischen schon aktiv - if isJobRunningForURL(it.url) { - continue - } - rooms, _, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge) if !fresh { continue @@ -494,10 +736,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) { showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) } + it := queue[0] show := strings.TrimSpace(showByUser[it.userKey]) isPublic := strings.Contains(show, "public") - if isPublic { + // Nicht-public nur einzeln nacheinander prüfen + if it.blind && hasHiddenProbeRunningForProvider("chaturbate") { + continue + } + + queue = queue[1:] + delete(queued, it.userKey) + + if isJobRunningForURL(it.url) { + continue + } + + if !it.blind && isPublic { lastTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ @@ -511,16 +766,24 @@ func startChaturbateAutoStartWorker(store *ModelStore) { continue } - pendingAutoStartMu.Lock() - _ = removeWatchedPendingAutoStartItem(it.userKey) - pendingAutoStartMu.Unlock() - if verboseLogs() { fmt.Println("▶️ [autostart] started:", it.url) } if job != nil { - go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) + if it.source == "manual" { + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() { + pendingAutoStartMu.Lock() + _ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey) + pendingAutoStartMu.Unlock() + }, nil) + } else { + pendingAutoStartMu.Lock() + _ = removeWatchedPendingAutoStartItemForProvider("chaturbate", it.userKey) + pendingAutoStartMu.Unlock() + + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil) + } } } else { lastBlindTry[it.userKey] = time.Now() @@ -541,7 +804,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) { fmt.Println("▶️ [autostart] blind try started:", it.url) } - go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) + if it.source == "manual" { + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() { + pendingAutoStartMu.Lock() + _ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey) + pendingAutoStartMu.Unlock() + }, nil) + } else { + go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil) + } } } } diff --git a/backend/myfreecams_autostart.go b/backend/myfreecams_autostart.go index d5e293c..b72a012 100644 --- a/backend/myfreecams_autostart.go +++ b/backend/myfreecams_autostart.go @@ -3,11 +3,92 @@ package main import ( + "fmt" + "net/url" "os" "strings" "time" ) +func mfcUserFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + lower := strings.ToLower(raw) + if !strings.Contains(lower, "://") { + if strings.HasPrefix(lower, "myfreecams.com/") || strings.HasPrefix(lower, "www.myfreecams.com/") { + raw = "https://" + raw + } else { + return "" + } + } + + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + return "" + } + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + host = strings.TrimPrefix(host, "www.") + if host != "myfreecams.com" && !strings.HasSuffix(host, ".myfreecams.com") { + return "" + } + + hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/") + if hash != "" { + parts := strings.Split(hash, "/") + for i := len(parts) - 1; i >= 0; i-- { + p := strings.TrimSpace(parts[i]) + if p != "" { + return strings.ToLower(p) + } + } + } + + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + for i := len(parts) - 1; i >= 0; i-- { + p := strings.TrimSpace(parts[i]) + if p != "" { + return strings.ToLower(p) + } + } + + return "" +} + +func watchedMyFreeCamsUserKey(m WatchedModelLite) string { + key := strings.ToLower(strings.TrimSpace(m.ModelKey)) + if key != "" { + return key + } + + in := strings.TrimSpace(m.Input) + if in == "" { + return "" + } + + if u := mfcUserFromURL(in); u != "" { + return u + } + + return strings.ToLower(strings.Trim(in, "/")) +} + +func resolveMyFreeCamsURL(m WatchedModelLite) string { + if u := mfcUserFromURL(m.Input); u != "" { + return fmt.Sprintf("https://www.myfreecams.com/#%s", u) + } + + key := watchedMyFreeCamsUserKey(m) + if key == "" { + return "" + } + + return fmt.Sprintf("https://www.myfreecams.com/#%s", key) +} + // Startet watched MyFreeCams Models (ohne API) "best-effort". // Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt. func startMyFreeCamsAutoStartWorker(store *ModelStore) { @@ -15,85 +96,344 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) { return } - // pro Model: Retry-Cooldown, damit nicht ständig dieselben Models angestoßen werden + const scanInterval = 1 * time.Second + const startInterval = 2 * time.Second const cooldown = 2 * time.Minute - - // wie lange wir nach Start warten, ob eine Output-Datei entsteht const outputProbeMax = 20 * time.Second - // kleine Pause zwischen Starts, damit nicht zu viele Jobs auf einmal anlaufen - const startGap = 1200 * time.Millisecond - + queue := make([]autoStartItem, 0, 64) + queued := map[string]bool{} lastAttempt := map[string]time.Time{} + probeCursor := 0 - tick := time.NewTicker(6 * time.Second) - defer tick.Stop() + scanTicker := time.NewTicker(scanInterval) + startTicker := time.NewTicker(startInterval) + defer scanTicker.Stop() + defer startTicker.Stop() - for range tick.C { - // global früh abbrechen - if isAutostartPaused() { - continue - } - - s := getSettings() - if !s.UseMyFreeCamsWatcher { - continue - } - - // watched Models aus DB - watched := store.ListWatchedLite("myfreecams.com") - if len(watched) == 0 { - continue - } - - // langsam nacheinander starten (keine API -> einzelnes "Anprobieren") - for _, m := range watched { - // während des Loops erneut prüfen, falls User live stoppt + for { + select { + case <-scanTicker.C: if isAutostartPaused() { - break + continue + } + + s := getSettings() + if !s.UseMyFreeCamsWatcher { + queue = queue[:0] + queued = map[string]bool{} + + pendingAutoStartMu.Lock() + _ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil) + pendingAutoStartMu.Unlock() + continue + } + + watched := store.ListWatchedLite("myfreecams.com") + + pendingAutoStartMu.Lock() + manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc") + pendingAutoStartMu.Unlock() + if err != nil { + fmt.Println("⚠️ [mfc autostart] load manual pending failed:", err) + continue + } + + if len(watched) == 0 && len(manualItems) == 0 { + queue = queue[:0] + queued = map[string]bool{} + + pendingAutoStartMu.Lock() + _ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil) + pendingAutoStartMu.Unlock() + continue + } + + watchedByUser := map[string]WatchedModelLite{} + watchedOrder := make([]string, 0, len(watched)) + + manualByUser := map[string]PendingAutoStartItem{} + manualOrder := make([]string, 0, len(manualItems)) + + for _, it := range manualItems { + key := strings.ToLower(strings.TrimSpace(it.ModelKey)) + if key == "" { + key = mfcUserFromURL(it.URL) + } + if key == "" { + continue + } + + it.ModelKey = key + it.URL = strings.TrimSpace(it.URL) + if it.URL == "" { + continue + } + + if _, exists := manualByUser[key]; exists { + continue + } + + manualByUser[key] = it + manualOrder = append(manualOrder, key) + } + + for _, m := range watched { + if !m.Watching { + continue + } + + userKey := watchedMyFreeCamsUserKey(m) + if userKey == "" { + continue + } + + if _, exists := watchedByUser[userKey]; exists { + continue + } + + watchedByUser[userKey] = m + watchedOrder = append(watchedOrder, userKey) + } + + runningByUser := map[string]bool{} + hiddenProbeUser := "" + + jobsMu.RLock() + for _, j := range jobs { + if j == nil { + continue + } + if j.Status != JobRunning { + continue + } + if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != "mfc" { + continue + } + + u := mfcUserFromURL(j.SourceURL) + if u == "" { + continue + } + + runningByUser[u] = true + + if j.Hidden && hiddenProbeUser == "" { + hiddenProbeUser = u + } + } + jobsMu.RUnlock() + + nextQueue := queue[:0] + nextQueued := map[string]bool{} + + for _, it := range queue { + if runningByUser[it.userKey] { + continue + } + + if it.source == "manual" { + m, ok := manualByUser[it.userKey] + if !ok { + continue + } + + it.url = strings.TrimSpace(m.URL) + if it.url == "" { + continue + } + } else { + m, ok := watchedByUser[it.userKey] + if !ok { + continue + } + + it.url = resolveMyFreeCamsURL(m) + if it.url == "" { + continue + } + } + + nextQueue = append(nextQueue, it) + nextQueued[it.userKey] = true + } + + queue = nextQueue + queued = nextQueued + + now := time.Now() + blindQueued := false + + selectedWatchedBlindUser := "" + if _, ok := watchedByUser[hiddenProbeUser]; ok { + selectedWatchedBlindUser = hiddenProbeUser + } + + for _, it := range queue { + if it.blind { + blindQueued = true + if it.source == "watched" && selectedWatchedBlindUser == "" { + selectedWatchedBlindUser = it.userKey + } + break + } + } + + candidates := make([]autoStartItem, 0, len(watchedOrder)+len(manualOrder)) + nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) + + // watched MFC Kandidaten + for _, user := range watchedOrder { + m := watchedByUser[user] + + u := resolveMyFreeCamsURL(m) + if u == "" { + continue + } + + if runningByUser[user] { + continue + } + if queued[user] { + continue + } + if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown { + continue + } + + candidates = append(candidates, autoStartItem{ + userKey: user, + url: u, + blind: true, + source: "watched", + }) + } + + // manuelle MFC Kandidaten, watched gewinnt bei Kollision + for _, user := range manualOrder { + if _, exists := watchedByUser[user]; exists { + continue + } + + itm := manualByUser[user] + u := strings.TrimSpace(itm.URL) + if u == "" { + continue + } + + if runningByUser[user] { + continue + } + if queued[user] { + continue + } + if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown { + continue + } + + candidates = append(candidates, autoStartItem{ + userKey: user, + url: u, + blind: true, + source: "manual", + }) + } + + if !blindQueued && !hasHiddenProbeRunningForProvider("mfc") && len(candidates) > 0 { + n := len(candidates) + start := 0 + if probeCursor > 0 { + start = probeCursor % n + } + + for i := 0; i < n; i++ { + idx := (start + i) % n + it := candidates[idx] + + if queued[it.userKey] || runningByUser[it.userKey] { + continue + } + + queue = append(queue, it) + queued[it.userKey] = true + + if it.source == "watched" { + selectedWatchedBlindUser = it.userKey + } + + probeCursor = (idx + 1) % n + break + } + } + + if selectedWatchedBlindUser != "" { + if m, ok := watchedByUser[selectedWatchedBlindUser]; ok { + u := resolveMyFreeCamsURL(m) + if u != "" { + nextProbeAtMs := now.Add(cooldown).UnixMilli() + if t, ok := lastAttempt[selectedWatchedBlindUser]; ok { + nextProbeAtMs = t.Add(cooldown).UnixMilli() + } + + nextPending = append(nextPending, PendingAutoStartItem{ + ModelKey: selectedWatchedBlindUser, + URL: u, + Mode: "probe_retry", + NextProbeAtMs: nextProbeAtMs, + CurrentShow: "offline", + Source: "watched", + }) + } + } + } + + pendingAutoStartMu.Lock() + _ = saveWatchedPendingAutoStartItemsForProvider("mfc", nextPending) + pendingAutoStartMu.Unlock() + + case <-startTicker.C: + if isAutostartPaused() { + continue } if !getSettings().UseMyFreeCamsWatcher { - break + continue } - - u := strings.TrimSpace(m.Input) - if u == "" { + if len(queue) == 0 { continue } - modelID := strings.TrimSpace(m.ID) - if modelID == "" { - // Fallback - modelID = strings.TrimSpace(m.Host) + ":" + strings.TrimSpace(m.ModelKey) - } - if modelID == "" { + // Nur EIN Hidden-Probe gleichzeitig + if hasHiddenProbeRunningForProvider("mfc") { continue } - // Cooldown - if t, ok := lastAttempt[modelID]; ok && time.Since(t) < cooldown { + it := queue[0] + queue = queue[1:] + delete(queued, it.userKey) + + if isJobRunningForURL(it.url) { continue } - // Bereits aktiv? - if isJobRunningForURL(u) { - continue - } - - lastAttempt[modelID] = time.Now() + lastAttempt[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ - URL: u, + URL: it.url, Hidden: true, }) if err != nil || job == nil { continue } - // Wenn keine echte Output-Datei entsteht -> Job wieder abbrechen/aufräumen - go mfcAbortIfNoOutput(job.ID, outputProbeMax) - - time.Sleep(startGap) + if it.source == "manual" { + go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() { + pendingAutoStartMu.Lock() + _ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey) + pendingAutoStartMu.Unlock() + }) + } else { + go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil) + } } } } @@ -120,7 +460,7 @@ func isJobRunningForURL(u string) bool { // Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen. // Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht. -func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) { +func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) { deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { @@ -142,7 +482,10 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration) { // Output schon da? if out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 { - // echter Download -> im UI sichtbar machen + if onOutput != nil { + onOutput() + } + publishJob(jobID) return } diff --git a/backend/pending_autostart.go b/backend/pending_autostart.go index 6147944..b81d070 100644 --- a/backend/pending_autostart.go +++ b/backend/pending_autostart.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "sync" ) @@ -74,10 +75,152 @@ func safeUserKeyForFile(v string) string { return re.ReplaceAllString(v, "_") } +func pendingProviderFromURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + u, err := url.Parse(raw) + if err != nil { + return "" + } + + host := strings.ToLower(strings.TrimSpace(u.Hostname())) + host = strings.TrimPrefix(host, "www.") + + switch { + case host == "chaturbate.com" || strings.HasSuffix(host, ".chaturbate.com"): + return "chaturbate" + case host == "myfreecams.com" || strings.HasSuffix(host, ".myfreecams.com"): + return "mfc" + default: + return "" + } +} + func pendingAutoStartFilePath(userKey string) string { return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json") } +func listPendingAutoStartUserKeys() ([]string, error) { + dir := filepath.Join("data", "pending-autostart") + + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, err + } + + out := make([]string, 0, len(entries)) + globalFile := safeUserKeyForFile(pendingAutoStartGlobalUserKey) + ".json" + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := strings.TrimSpace(entry.Name()) + if name == "" { + continue + } + if !strings.HasSuffix(strings.ToLower(name), ".json") { + continue + } + if name == globalFile { + continue + } + + userKey := strings.TrimSuffix(name, filepath.Ext(name)) + if userKey == "" { + continue + } + + out = append(out, userKey) + } + + sort.Strings(out) + return out, nil +} + +func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return nil, errors.New("missing provider") + } + + userKeys, err := listPendingAutoStartUserKeys() + if err != nil { + return nil, err + } + + out := make([]PendingAutoStartItem, 0, 32) + + for _, userKey := range userKeys { + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + return nil, err + } + + for _, it := range items { + if normalizePendingSourceServer(it.Source) != "manual" { + continue + } + if pendingProviderFromURL(it.URL) != provider { + continue + } + out = append(out, it) + } + } + + return out, nil +} + +func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error { + provider = strings.ToLower(strings.TrimSpace(provider)) + modelKey = strings.ToLower(strings.TrimSpace(modelKey)) + + if provider == "" || modelKey == "" { + return nil + } + + userKeys, err := listPendingAutoStartUserKeys() + if err != nil { + return err + } + + for _, userKey := range userKeys { + items, err := loadPendingAutoStartItems(userKey) + if err != nil { + return err + } + + changed := false + next := make([]PendingAutoStartItem, 0, len(items)) + + for _, it := range items { + if normalizePendingSourceServer(it.Source) == "manual" && + pendingProviderFromURL(it.URL) == provider && + strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey { + changed = true + continue + } + + next = append(next, it) + } + + if changed { + if err := savePendingAutoStartItems(userKey, next); err != nil { + return err + } + } + } + + return nil +} + func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { path := pendingAutoStartFilePath(userKey) @@ -133,36 +276,60 @@ func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, er return nil, err } - byKey := make(map[string]PendingAutoStartItem) + out := make([]PendingAutoStartItem, 0, len(globalItems)+len(userItems)) + indexByKey := make(map[string]int) for _, it := range globalItems { k := strings.ToLower(strings.TrimSpace(it.ModelKey)) if k == "" { continue } - byKey[k] = it + + indexByKey[k] = len(out) + out = append(out, it) } - // user-spezifisch überschreibt global + // user-spezifisch überschreibt an gleicher Position for _, it := range userItems { k := strings.ToLower(strings.TrimSpace(it.ModelKey)) if k == "" { continue } - byKey[k] = it - } - out := make([]PendingAutoStartItem, 0, len(byKey)) - for _, it := range byKey { + if idx, ok := indexByKey[k]; ok { + out[idx] = it + continue + } + + indexByKey[k] = len(out) out = append(out, it) } return out, nil } -func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error { - next := make([]PendingAutoStartItem, 0, len(items)) +func saveWatchedPendingAutoStartItemsForProvider(provider string, items []PendingAutoStartItem) error { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return errors.New("missing provider") + } + existing, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) + if err != nil { + return err + } + + next := make([]PendingAutoStartItem, 0, len(existing)+len(items)) + + // alte watched-Einträge dieses Providers entfernen, andere behalten + for _, it := range existing { + if normalizePendingSourceServer(it.Source) == "watched" && pendingProviderFromURL(it.URL) == provider { + continue + } + next = append(next, it) + } + + // neue watched-Einträge dieses Providers anhängen for _, it := range items { it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey)) it.URL = strings.TrimSpace(it.URL) @@ -181,9 +348,39 @@ func saveWatchedPendingAutoStartItems(items []PendingAutoStartItem) error { return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) } -func removeWatchedPendingAutoStartItem(modelKey string) error { +func hasHiddenProbeRunningForProvider(provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + return false + } + + jobsMu.RLock() + defer jobsMu.RUnlock() + + for _, j := range jobs { + if j == nil { + continue + } + if j.Status != JobRunning { + continue + } + if !j.Hidden { + continue + } + if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != provider { + continue + } + return true + } + + return false +} + +func removeWatchedPendingAutoStartItemForProvider(provider, modelKey string) error { + provider = strings.ToLower(strings.TrimSpace(provider)) modelKey = strings.ToLower(strings.TrimSpace(modelKey)) - if modelKey == "" { + + if provider == "" || modelKey == "" { return nil } @@ -194,7 +391,9 @@ func removeWatchedPendingAutoStartItem(modelKey string) error { next := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { - if strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey { + if normalizePendingSourceServer(it.Source) == "watched" && + strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey && + pendingProviderFromURL(it.URL) == provider { continue } next = append(next, it) diff --git a/backend/routes.go b/backend/routes.go index 1539601..3f2d4f3 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -69,6 +69,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/record/delete-many", handleDeleteMany) api.HandleFunc("/api/record/keep-many", handleKeepMany) api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus) + mux.HandleFunc("/api/record/release-file", handleReleaseFileTasks) api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) diff --git a/backend/server.go b/backend/server.go index efc1540..0c755a4 100644 --- a/backend/server.go +++ b/backend/server.go @@ -50,6 +50,10 @@ func main() { store := registerRoutes(mux, auth) setChaturbateOnlineModelStore(store) + if err := clearAllPendingAutoStartOnStartup(); err != nil { + fmt.Println("⚠️ [pending-autostart] startup clear failed:", err) + } + // Hintergrund-Worker go startChaturbateOnlinePoller(store) go startChaturbateAutoStartWorker(store) diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 3539ed6..f6ed9a5 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -201,7 +201,7 @@ func runGenerateMissingAssets(ctx context.Context) { return } - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { st.Error = "Abgebrochen" } else { st.Error = err.Error() @@ -209,6 +209,17 @@ func runGenerateMissingAssets(ctx context.Context) { }) } + abortIfCanceled := func(stepErr error, fileName string) bool { + if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil { + if strings.TrimSpace(fileName) != "" { + clearAssetsTaskFinishedPostworkStates(fileName) + } + finishWithErr(context.Canceled) + return true + } + return false + } + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { @@ -409,6 +420,9 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL) + if abortIfCanceled(merr, it.name) { + return + } if merr != nil || !okMeta { publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") } else { @@ -430,6 +444,9 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) + if abortIfCanceled(terr, it.name) { + return + } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") } else if finishedPhaseTruthForID(id).ThumbReady { @@ -456,6 +473,9 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL) + if abortIfCanceled(terr, it.name) { + return + } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") } else if finishedPhaseTruthForID(id).TeaserReady { @@ -482,6 +502,9 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") _, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL) + if abortIfCanceled(serr, it.name) { + return + } if serr != nil { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } else if finishedPhaseTruthForID(id).SpritesReady { @@ -505,6 +528,9 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse") _, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw") + if abortIfCanceled(aerr, it.name) { + return + } if aerr != nil { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") } else if finishedPhaseTruthForID(id).AnalyzeReady { diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 0cf19d6..dbaf763 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -29,9 +29,11 @@ type cleanupResp struct { DeletedBytesHuman string `json:"deletedBytesHuman"` ErrorCount int `json:"errorCount"` - // ✅ NEU: Generated-GC separat (nicht in orphanIds reinmischen) - GeneratedOrphansChecked int `json:"generatedOrphansChecked"` - GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"` + // Generated-GC + GeneratedOrphansChecked int `json:"generatedOrphansChecked"` + GeneratedOrphansRemoved int `json:"generatedOrphansRemoved"` + GeneratedOrphansRemovedBytes int64 `json:"generatedOrphansRemovedBytes"` + GeneratedOrphansRemovedBytesHuman string `json:"generatedOrphansRemovedBytesHuman"` } // Optional: falls du später Threshold per Body überschreiben willst. @@ -103,11 +105,16 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { gcStats := triggerGeneratedGarbageCollectorSync() resp.GeneratedOrphansChecked = gcStats.Checked resp.GeneratedOrphansRemoved = gcStats.Removed + resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes + resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) + // ✅ Generated-GC zur insgesamt freigegebenen Größe addieren + resp.DeletedBytes += gcStats.RemovedBytes resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) - orphansTotalRemoved := resp.GeneratedOrphansRemoved - setCleanupTaskDone(fmt.Sprintf("geprüft: %d · Orphans: %d", resp.ScannedFiles, orphansTotalRemoved)) + setCleanupTaskDone( + cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), + ) time.AfterFunc(7*time.Second, func() { clearCleanupTaskState() @@ -116,6 +123,15 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +func cleanupProgressText(deleted int, total int, freedBytes int64) string { + return fmt.Sprintf( + "gelöscht: %d/%d · freigegeben: %s", + deleted, + total, + formatBytesSI(freedBytes), + ) +} + func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { isCandidate := func(name string) bool { low := strings.ToLower(name) @@ -201,38 +217,76 @@ func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) { resp.ScannedFiles = len(candidates) - setCleanupTaskProgress(0, resp.ScannedFiles, "", "Prüfe Dateien…") + setCleanupTaskProgress( + 0, + resp.ScannedFiles, + "", + cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), + ) for i, c := range candidates { - setCleanupTaskProgress(i+1, resp.ScannedFiles, c.name, fmt.Sprintf("Prüfe %s", c.name)) + if c.size < threshold { + base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) + id := stripHotPrefix(base) - if c.size >= threshold { - continue - } + if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { + resp.DeletedFiles++ + resp.DeletedBytes += c.size - base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) - id := stripHotPrefix(base) + if strings.TrimSpace(id) != "" { + removeGeneratedForID(id) + } - if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { - resp.DeletedFiles++ - resp.DeletedBytes += c.size - - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) + purgeDurationCacheForPath(c.path) + } else { + resp.ErrorCount++ } - - purgeDurationCacheForPath(c.path) - } else { - resp.ErrorCount++ } + + setCleanupTaskProgress( + i+1, + resp.ScannedFiles, + "", + cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), + ) } } var generatedGCRunning int32 type generatedGCStats struct { - Checked int - Removed int + Checked int + Removed int + RemovedBytes int64 +} + +func dirSizeBytes(root string) int64 { + root = strings.TrimSpace(root) + if root == "" { + return 0 + } + + var total int64 + + _ = filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + return nil + } + + info, err := d.Info() + if err != nil { + return nil + } + if info.Size() > 0 { + total += info.Size() + } + return nil + }) + + return total } // Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response). @@ -338,6 +392,7 @@ func runGeneratedGarbageCollector() generatedGCStats { removedMeta := 0 checkedMeta := 0 + removedBytes := int64(0) if entries, err := os.ReadDir(metaRoot); err == nil { for _, e := range entries { @@ -354,14 +409,22 @@ func runGeneratedGarbageCollector() generatedGCStats { continue } + orphanDir := filepath.Join(metaRoot, id) + orphanBytes := dirSizeBytes(orphanDir) + removeGeneratedForID(id) - removedMeta++ + + if _, err := os.Stat(orphanDir); os.IsNotExist(err) { + removedMeta++ + removedBytes += orphanBytes + } } } - //fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta) + //fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d bytes=%d\n", checkedMeta, removedMeta, removedBytes) stats.Checked += checkedMeta stats.Removed += removedMeta + stats.RemovedBytes += removedBytes return stats } diff --git a/backend/tasks_release_file.go b/backend/tasks_release_file.go new file mode 100644 index 0000000..0b22047 --- /dev/null +++ b/backend/tasks_release_file.go @@ -0,0 +1,215 @@ +// backend\tasks_release_file.go + +package main + +import ( + "encoding/json" + "net/http" + "path/filepath" + "strings" + "time" +) + +type releaseFileTasksRequest struct { + File string `json:"file"` +} + +type releaseFileTasksResponse struct { + OK bool `json:"ok"` + File string `json:"file"` + ReleasedAny bool `json:"releasedAny"` + AssetsTaskCancelled bool `json:"assetsTaskCancelled"` + RegenerateCancelled bool `json:"regenerateCancelled"` + PostworkCancelled bool `json:"postworkCancelled"` + EnrichCancelled bool `json:"enrichCancelled"` +} + +func assetIDFromAnyFileName(file string) string { + base := strings.TrimSpace(filepath.Base(strings.TrimSpace(file))) + if base == "" { + return "" + } + + stem := strings.TrimSuffix(base, filepath.Ext(base)) + return strings.TrimSpace(stripHotPrefix(stem)) +} + +func sameBaseFile(a, b string) bool { + aa := strings.TrimSpace(filepath.Base(strings.TrimSpace(a))) + bb := strings.TrimSpace(filepath.Base(strings.TrimSpace(b))) + if aa == "" || bb == "" { + return false + } + return strings.EqualFold(aa, bb) +} + +func cancelAssetsTaskForFile(file string) bool { + assetsTaskMu.Lock() + cancel := assetsTaskCancel + running := assetsTaskState.Running + currentFile := assetsTaskState.CurrentFile + assetsTaskMu.Unlock() + + if !running || cancel == nil { + return false + } + if !sameBaseFile(currentFile, file) { + return false + } + + cancel() + return true +} + +func cancelRegenerateAssetsTaskForFile(file string) bool { + regenerateAssetsMu.Lock() + + var matched []*regenerateAssetsJob + for _, job := range regenerateAssetsJobs { + if job == nil { + continue + } + if !sameBaseFile(job.File, file) { + continue + } + matched = append(matched, job) + } + + regenerateAssetsMu.Unlock() + + cancelled := false + for _, job := range matched { + if job != nil && job.Cancel != nil { + job.Cancel() + cancelled = true + } + } + + return cancelled +} + +func cancelQueuesForFile(file string) (postworkCancelled bool, enrichCancelled bool) { + file = strings.TrimSpace(file) + + // 1) Direkt aus Dateiname ableiten: + // Für laufende Sprites ist das meistens der relevante Queue-Key. + if assetID := assetIDFromAnyFileName(file); assetID != "" { + enrichKey := "enrich:" + assetID + + if enrichQ.Cancel(enrichKey) { + enrichCancelled = true + } + if enrichQ.RemoveQueued(enrichKey) { + enrichCancelled = true + } + + clearEnrichStatusSnapshot(enrichKey) + } + + // 2) Zusätzlich wie bisher über jobs suchen + type queueKeys struct { + postworkKey string + enrichKey string + } + + matches := make([]queueKeys, 0, 8) + + jobsMu.RLock() + for _, job := range jobs { + if job == nil { + continue + } + if !sameBaseFile(job.Output, file) { + continue + } + + postworkKey := strings.TrimSpace(job.PostWorkKey) + + enrichKey := "" + if id := strings.TrimSpace(assetIDForJob(job)); id != "" { + enrichKey = "enrich:" + id + } + + matches = append(matches, queueKeys{ + postworkKey: postworkKey, + enrichKey: enrichKey, + }) + } + jobsMu.RUnlock() + + for _, item := range matches { + if item.postworkKey != "" { + if postWorkQ.Cancel(item.postworkKey) { + postworkCancelled = true + } + if postWorkQ.RemoveQueued(item.postworkKey) { + postworkCancelled = true + } + } + + if item.enrichKey != "" { + if enrichQ.Cancel(item.enrichKey) { + enrichCancelled = true + } + if enrichQ.RemoveQueued(item.enrichKey) { + enrichCancelled = true + } + clearEnrichStatusSnapshot(item.enrichKey) + } + } + + return postworkCancelled, enrichCancelled +} + +func releaseFileTasks(file string) releaseFileTasksResponse { + file = strings.TrimSpace(file) + + resp := releaseFileTasksResponse{ + OK: true, + File: file, + } + + if file == "" { + return resp + } + + resp.AssetsTaskCancelled = cancelAssetsTaskForFile(file) + resp.RegenerateCancelled = cancelRegenerateAssetsTaskForFile(file) + resp.PostworkCancelled, resp.EnrichCancelled = cancelQueuesForFile(file) + + resp.ReleasedAny = + resp.AssetsTaskCancelled || + resp.RegenerateCancelled || + resp.PostworkCancelled || + resp.EnrichCancelled + + // ffmpeg / Analyse / Sprite-Generierung einen Moment zum Beenden geben + if resp.ReleasedAny { + time.Sleep(900 * time.Millisecond) + } + + return resp +} + +func handleReleaseFileTasks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed) + return + } + + var req releaseFileTasksRequest + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&req) + } + + file := strings.TrimSpace(req.File) + if file == "" { + file = strings.TrimSpace(r.URL.Query().Get("file")) + } + if file == "" { + http.Error(w, "file fehlt", http.StatusBadRequest) + return + } + + writeJSON(w, http.StatusOK, releaseFileTasks(file)) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7a7ff08..a8d5ea4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -172,14 +172,6 @@ type PendingWatchedRoom = { imageUrl?: string } -type ParsedModel = { - input: string - isUrl: boolean - host?: string - path?: string - modelKey: string -} - type ChaturbateOnlineRoomItem = { username?: string current_show?: string @@ -1564,15 +1556,6 @@ export default function App() { imageUrl: String(msg?.modelImageUrl ?? '').trim(), chatRoomUrl: String(msg?.modelChatRoomUrl ?? '').trim(), }) - - const pendingUrl = String((pendingAutoStartByKeyRef.current || {})[modelKey] ?? '').trim() - if (roomStatus === 'public' && pendingUrl && !busyRef.current) { - enqueueStart({ - url: pendingUrl, - silent: true, - pendingKeyLower: modelKey, - }) - } } } } @@ -1697,13 +1680,14 @@ export default function App() { const lastClipboardUrlRef = useRef('') // --- START QUEUE (parallel) --- - const START_CONCURRENCY = 4 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start + const START_CONCURRENCY = 1 // ⬅️ kannst du höher setzen, aber 4 ist ein guter Start type StartQueueItem = { url: string silent: boolean pendingKeyLower?: string hidden?: boolean + source?: PendingAutoStartSource } const startQueueRef = useRef([]) @@ -1808,9 +1792,14 @@ export default function App() { try { const ok = await doStartNow(next.url, next.silent, { hidden: next.hidden }) - // wenn das aus pendingAutoStartByKey kam: nur bei Erfolg dort löschen - if (ok && next.pendingKeyLower) { - removePendingAutoStart(next.pendingKeyLower) + if (next.pendingKeyLower && next.source === 'manual') { + if (ok && !next.hidden) { + removePendingAutoStart(next.pendingKeyLower) + } + + if (!ok) { + removePendingAutoStart(next.pendingKeyLower) + } } } finally { // dedupe wieder freigeben @@ -2146,50 +2135,7 @@ export default function App() { doneSortRef.current = doneSort }, [doneSort]) - const applyPendingRoomSnapshot = useCallback( - ( - keyLower: string, - snap: { - show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' - imageUrl?: string - chatRoomUrl?: string - } - ) => { - if (!keyLower) return - - setModelsByKey((prev) => { - const cur = prev[keyLower] - if (!cur) return prev - - return { - ...prev, - [keyLower]: { - ...cur, - roomStatus: snap.show, - isOnline: snap.show !== 'offline', - imageUrl: snap.imageUrl || cur.imageUrl, - chatRoomUrl: snap.chatRoomUrl || cur.chatRoomUrl, - }, - } - }) - - setPendingWatchedRooms((prev) => { - const idx = prev.findIndex((x) => String(x.modelKey ?? '').trim().toLowerCase() === keyLower) - if (idx < 0) return prev - - const copy = [...prev] - copy[idx] = { - ...copy[idx], - currentShow: snap.show, - imageUrl: snap.imageUrl || copy[idx].imageUrl, - } - return copy - }) - }, - [] - ) - - // ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird) + // ✅ latest Refs für Poller-Closures (damit Poller nicht "stale" wird) const modelsByKeyRef = useRef(modelsByKey) useEffect(() => { modelsByKeyRef.current = modelsByKey @@ -2253,173 +2199,14 @@ export default function App() { }, [pendingAutoStartByKey, modelsByKey]) useEffect(() => { - if (!recSettings.useChaturbateApi) { - return - } + if (!authed) return - let cancelled = false - let timer: number | null = null - let inFlight = false + const id = window.setInterval(() => { + void loadPendingAutoStarts() + }, document.hidden ? 8000 : 3000) - const tick = async () => { - if (cancelled || inFlight) return - - const pendingMap = pendingAutoStartByKeyRef.current || {} - const entries = Object.entries(pendingMap) - - if (entries.length === 0) return - if (busyRef.current) return - - const keys = entries - .map(([rawKey]) => String(rawKey ?? '').trim().toLowerCase()) - .filter(Boolean) - - if (keys.length === 0) return - - inFlight = true - try { - const res = await fetch('/api/chaturbate/online', { - method: 'POST', - cache: 'no-store', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - q: keys, - }), - }) - - const byKey: Record< - string, - { - show: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' - imageUrl?: string - chatRoomUrl?: string - } - > = {} - - if (res.ok) { - const data = (await res.json().catch(() => null)) as ChaturbateOnlineResponse | null - const rooms = Array.isArray(data?.rooms) ? data.rooms : [] - - 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, - } - } - } - - const modeMap = pendingAutoStartModeByKeyRef.current || {} - const retryAtMap = pendingBlindRetryAtByKeyRef.current || {} - - for (const key of keys) { - 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, - }) - - applyPendingRoomSnapshot(key, snap) - - const url = String((pendingMap as any)?.[key] ?? '').trim() - if (!url) continue - - const mode = (modeMap[key] ?? 'wait_public') as PendingAutoStartMode - - const source = - (pendingAutoStartSourceByKeyRef.current[key] ?? 'manual') as PendingAutoStartSource - - if (snap.show === 'public') { - enqueueStart({ - url, - silent: true, - pendingKeyLower: key, - }) - continue - } - - if (mode === 'probe_retry') { - // Sobald API das Model als private/hidden/away kennt: - // nicht mehr blind probieren, sondern einfach auf public warten. - if ( - snap.show === 'private' || - snap.show === 'hidden' || - snap.show === 'away' - ) { - queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { - mode: 'wait_public', - source, - }) - continue - } - - const nextProbeAtMs = Number(retryAtMap[key] ?? 0) - if (!busyRef.current && Date.now() >= nextProbeAtMs) { - enqueueStart({ - url, - silent: true, - hidden: true, - }) - - queuePendingAutoStart(key, url, snap.show, snap.imageUrl, { - mode: 'probe_retry', - nextProbeAtMs: Date.now() + 60_000, - source, - }) - } - - continue - } - - // mode === 'wait_public' - // private / hidden / away / offline / unknown => einfach warten - } - } catch { - // ignore - } finally { - inFlight = false - } - } - - const schedule = (ms?: number) => { - if (cancelled) return - - const retryMap = pendingBlindRetryAtByKeyRef.current || {} - const hasProbeRetry = Object.keys(retryMap).length > 0 - - const nextMs = - ms ?? - (hasProbeRetry ? 1000 : (document.hidden ? 8000 : 3000)) - - timer = window.setTimeout(async () => { - await tick() - schedule() - }, nextMs) - } - - schedule(0) - - return () => { - cancelled = true - if (timer != null) window.clearTimeout(timer) - } - }, [ - recSettings.useChaturbateApi, - applyPendingRoomSnapshot, - enqueueStart, - maybeNotifyWatchedModelStatusChange, - removePendingAutoStart, - ]) + return () => window.clearInterval(id) + }, [authed, loadPendingAutoStarts]) const chaturbateStoreKeysLowerRef = useRef(chaturbateStoreKeysLower) @@ -2568,107 +2355,25 @@ export default function App() { }) if (alreadyRunning) return true - // Chaturbate-Startlogik mit Online-API: - // public -> direkt starten - // private/hidden/away -> wait_public - // offline/unknown -> probe_retry mit hidden start - if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi) { - try { - const parsed = await apiJSON('/api/models/parse', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: norm }), - }) + // Backend-only Autostart: + // Frontend speichert nur pending, Backend startet eigenständig. - const mkLower = String(parsed?.modelKey ?? '').trim().toLowerCase() + const keyLower = providerKeyLowerFromUrl(norm) - if (mkLower) { - let resolvedShow: 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' = 'unknown' - let resolvedImageUrl: string | undefined - let resolvedChatRoomUrl: string | undefined - let foundInApi = false + if (provider === 'chaturbate' && recSettingsRef.current.useChaturbateApi && keyLower) { + queuePendingAutoStart(keyLower, norm, 'unknown', undefined, { + mode: 'probe_retry', + source: 'manual', + }) + return true + } - try { - const onlineRes = await fetch('/api/chaturbate/online', { - method: 'POST', - cache: 'no-store', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - q: [mkLower], - refresh: true, - }), - }) - - 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) { - foundInApi = true - resolvedShow = normalizePendingShow(room.current_show) - resolvedImageUrl = String(room.image_url ?? '').trim() || undefined - resolvedChatRoomUrl = String(room.chat_room_url ?? '').trim() || undefined - } - } - } catch { - // best effort - } - - applyPendingRoomSnapshot(mkLower, { - show: resolvedShow, - imageUrl: resolvedImageUrl, - chatRoomUrl: resolvedChatRoomUrl, - }) - - // 1) in API + public => sofort normal starten - if (foundInApi && resolvedShow === 'public') { - removePendingAutoStart(mkLower) - // unten normal weiter - } else if ( - foundInApi && - (resolvedShow === 'private' || resolvedShow === 'hidden' || resolvedShow === 'away') - ) { - // 2) in API + nicht public => warten bis public - queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { - mode: 'wait_public', - source: 'manual', - }) - return true - } else { - // 3) nicht in API ODER offline/unknown => Blind-Probe jetzt starten - queuePendingAutoStart(mkLower, norm, resolvedShow, resolvedImageUrl, { - mode: 'probe_retry', - nextProbeAtMs: Date.now() + 60_000, - source: 'manual', - }) - - const ok = await doStartNow(norm, silent, { hidden: true }) - if (!ok) { - removePendingAutoStart(mkLower) - return false - } - return true - } - } - } catch { - const mkLower = providerKeyLowerFromUrl(norm) - if (mkLower) { - queuePendingAutoStart(mkLower, norm, 'unknown', undefined, { - mode: 'probe_retry', - nextProbeAtMs: Date.now() + 60_000, - source: 'manual', - }) - - const ok = await doStartNow(norm, silent, { hidden: true }) - if (!ok) { - removePendingAutoStart(mkLower) - return false - } - return true - } - } + if (provider === 'mfc' && recSettingsRef.current.useMyFreeCamsWatcher && keyLower) { + queuePendingAutoStart(keyLower, norm, 'offline', undefined, { + mode: 'probe_retry', + source: 'manual', + }) + return true } try { @@ -3833,8 +3538,7 @@ export default function App() { if (autoAddEnabled) setSourceUrl(url) if (autoStartEnabled) { - // ✅ immer enqueue (dedupe verhindert doppelt) - enqueueStart({ url, silent: false }) + void startUrl(url, { silent: false }) } } catch { // ignore @@ -3866,7 +3570,7 @@ export default function App() { window.removeEventListener('focus', kick) document.removeEventListener('visibilitychange', kick) } - }, [autoAddEnabled, autoStartEnabled, enqueueStart]) + }, [autoAddEnabled, autoStartEnabled, startUrl]) if (!authChecked) { return
Lade…
@@ -4187,8 +3891,6 @@ export default function App() { key={[ String((playerJob as any)?.id ?? ''), baseName(playerJob.output || ''), - // optional: assetNonce, wenn du auch Asset-Rebuilds “erzwingen” willst - String(assetNonce), ].join('::')} job={playerJob} modelKey={playerModelKey ?? undefined} diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index b8992f5..dfb33eb 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -37,6 +37,7 @@ type Props = { liked?: boolean | null watching?: boolean roomStatus?: string + imageUrl?: string } > roomStatusByModelKey?: Record @@ -67,6 +68,13 @@ const pendingModelName = (p: PendingWatchedRoom) => { ) } +const shouldRefreshPreview = (j: RecordJob) => { + const phase = String((j as any)?.phase ?? '').trim().toLowerCase() + const status = String(j.status ?? '').trim().toLowerCase() + + return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') +} + const pendingUrl = (p: PendingWatchedRoom) => { const anyP = p as any return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? '' @@ -119,6 +127,27 @@ const modelKeyFromJob = (job: RecordJob): string => { return String(modelNameFromOutput(j.output || '')).trim().toLowerCase() } +const onlineImageUrlOfJob = ( + job: RecordJob, + modelsByKey?: Record +): string => { + const anyJ = job as any + + const direct = + String( + anyJ?.modelImageUrl ?? + anyJ?.model?.imageUrl ?? + '' + ).trim() + + if (direct) return direct + + const modelKey = modelKeyFromJob(job) + if (!modelKey) return '' + + return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim() +} + const roomStatusOfJob = ( job: RecordJob, roomStatusByModelKey?: Record, @@ -578,14 +607,16 @@ export default function Downloads({ }, []) useEffect(() => { - const activeLiveJobs = jobs.filter((j) => { - if ((j as any).endedAt) return false - return String(j.status ?? '').trim().toLowerCase() === 'running' + const activePreviewJobs = jobs.filter((j) => { + const phase = String((j as any)?.phase ?? '').trim().toLowerCase() + const status = String(j.status ?? '').trim().toLowerCase() + + return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') }).length - if (activeLiveJobs === 0 || !pageVisible) return + if (activePreviewJobs === 0 || !pageVisible) return - const intervalMs = activeLiveJobs > 2 ? 30000 : 10000 + const intervalMs = activePreviewJobs > 2 ? 30000 : 10000 const id = window.setInterval(() => { setThumbTick((t) => t + 1) @@ -940,6 +971,9 @@ export default function Downloads({ cell: (r) => { if (r.kind === 'job') { const j = r.job + const providerFallbackSrc = `/api/preview?id=${encodeURIComponent(j.id)}&fallbackOnly=1` + const onlineImageUrl = onlineImageUrlOfJob(j, modelsByKey) + return (
diff --git a/frontend/src/components/ui/DownloadsCardRow.tsx b/frontend/src/components/ui/DownloadsCardRow.tsx index f8c7b0b..262aeff 100644 --- a/frontend/src/components/ui/DownloadsCardRow.tsx +++ b/frontend/src/components/ui/DownloadsCardRow.tsx @@ -30,6 +30,7 @@ type ModelFlags = { liked?: boolean | null watching?: boolean roomStatus?: string + imageUrl?: string } type Props = { @@ -125,6 +126,27 @@ const modelKeyFromJob = (job: RecordJob): string => { return String(modelNameFromOutput(j.output || '')).trim().toLowerCase() } +const onlineImageUrlOfJob = ( + job: RecordJob, + modelsByKey?: Record +): string => { + const anyJ = job as any + + const direct = + String( + anyJ?.modelImageUrl ?? + anyJ?.model?.imageUrl ?? + '' + ).trim() + + if (direct) return direct + + const modelKey = modelKeyFromJob(job) + if (!modelKey) return '' + + return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim() +} + const roomStatusOfJob = ( job: RecordJob, roomStatusByModelKey?: Record, @@ -638,6 +660,13 @@ export default function DownloadsCardRow({ const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) + const shouldRefreshPreview = (j: RecordJob) => { + const phase = String((j as any)?.phase ?? '').trim().toLowerCase() + const status = String(j.status ?? '').trim().toLowerCase() + + return !j.endedAt && status === 'running' && (phase === '' || phase === 'recording') + } + return (
= attempts) { throw e } - continue } } @@ -2185,7 +2262,7 @@ export default function FinishedDownloads({ ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler')) }, - [releasePlayingFile] + [releasePlayingFile, forceReleaseFileTasks] ) const runFileMutation = useCallback( @@ -2886,6 +2963,46 @@ export default function FinishedDownloads({ // ----------------------------------------------------------------------------- // effects // ----------------------------------------------------------------------------- + + useEffect(() => { + if (!globalFilterActive) return + if (page !== 1) onPageChange(1) + }, [globalFilterActive, deferredSearchQuery, tagFilter, page, onPageChange]) + + useEffect(() => { + if (!globalFilterActive) { + setAllDoneJobs(null) + setAllDoneJobsLoading(false) + return + } + + let cancelled = false + const reqId = ++allDoneJobsRequestRef.current + + setAllDoneJobsLoading(true) + + void loadAllDoneJobs() + .then((items) => { + if (cancelled) return + if (allDoneJobsRequestRef.current !== reqId) return + setAllDoneJobs(items) + }) + .catch(() => { + if (cancelled) return + if (allDoneJobsRequestRef.current !== reqId) return + setAllDoneJobs([]) + }) + .finally(() => { + if (cancelled) return + if (allDoneJobsRequestRef.current !== reqId) return + setAllDoneJobsLoading(false) + }) + + return () => { + cancelled = true + } + }, [globalFilterActive, loadAllDoneJobs]) + useEffect(() => { if (view !== 'gallery') return @@ -4098,7 +4215,7 @@ export default function FinishedDownloads({ }} isSmall={isSmall} jobForDetails={jobForDetails} - isLoading={false} + isLoading={allDoneJobsLoading} blurPreviews={blurPreviews} durations={durations} teaserState={teaserState} @@ -4160,7 +4277,7 @@ export default function FinishedDownloads({ {view === 'table' && ( { @@ -4231,7 +4348,7 @@ export default function FinishedDownloads({ if (checked) selectPageRows(pageRows) else deselectPageRows(pageRows) }} - isLoading={false} + isLoading={allDoneJobsLoading} jobForDetails={jobForDetails} blurPreviews={blurPreviews} durations={durations} diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 31d6af6..378fb74 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -765,7 +765,7 @@ export default function FinishedDownloadsCardsView({ onMouseDown={(e) => e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} aria-label="Teaser mit Ton abspielen" - title="Ton einschalten" + title="Ton aktivieren" > Ton aktivieren diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index d51e3c1..2b6ea3a 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -351,6 +351,7 @@ export default function FinishedVideoPreview({ const teaserMp4Ref = useRef(null) const clipsRef = useRef(null) const teaserSoundForcedRef = useRef(false) + const teaserMutedRef = useRef(muted) const setTeaserPlaybackRate = useCallback((rate: number) => { const el = teaserMp4Ref.current @@ -363,6 +364,24 @@ export default function FinishedVideoPreview({ // ignore } }, []) + + useEffect(() => { + teaserMutedRef.current = muted + + const el = teaserMp4Ref.current + if (!el) return + + const effectiveMuted = teaserSoundForcedRef.current ? false : teaserMutedRef.current + + applyInlineVideoPolicy(el, { muted: effectiveMuted }) + + try { + el.muted = effectiveMuted + el.defaultMuted = effectiveMuted + } catch { + // ignore + } + }, [muted]) const playTeaserFromGesture = useCallback((withSound = true) => { const el = teaserMp4Ref.current @@ -817,7 +836,7 @@ export default function FinishedVideoPreview({ ) const teaserActivatedSoftly = - (animatedTrigger === 'hover' && hovered) || forceActive + animatedTrigger === 'hover' && hovered const showTeaserLoadingOverlay = !showingInlineVideo && @@ -978,7 +997,7 @@ export default function FinishedVideoPreview({ return } - const effectiveMuted = teaserSoundForcedRef.current ? false : muted + const effectiveMuted = teaserSoundForcedRef.current ? false : teaserMutedRef.current applyInlineVideoPolicy(el, { muted: effectiveMuted }) @@ -1103,7 +1122,7 @@ export default function FinishedVideoPreview({ if (typeof raf2 === 'number') cancelAnimationFrame(raf2) window.clearInterval(watchdog) } - }, [teaserActive, animatedMode, teaserSrc, muted, pageVisible, teaserSpeedHold, setTeaserPlaybackRate]) + }, [teaserActive, animatedMode, teaserSrc, pageVisible]) useEffect(() => { if (!activationNonce) return @@ -1167,7 +1186,7 @@ export default function FinishedVideoPreview({ window.clearTimeout(retryTimer) } } - }, [activationNonce, teaserActive, animatedMode, showingInlineVideo, muted]) + }, [activationNonce, teaserActive, animatedMode, showingInlineVideo]) // ▶️ Progressbar: global relativ zur Vollvideo-Dauer (mit mapping => Sprünge) useEffect(() => { diff --git a/frontend/src/components/ui/ModelPreview.tsx b/frontend/src/components/ui/ModelPreview.tsx index 0132d46..5468c43 100644 --- a/frontend/src/components/ui/ModelPreview.tsx +++ b/frontend/src/components/ui/ModelPreview.tsx @@ -13,14 +13,14 @@ import LoadingSpinner from './LoadingSpinner' type Props = { jobId: string - modelKey?: string - live?: boolean - thumbTick?: number + enableHoverLive?: boolean + refreshKey?: number blur?: boolean className?: string fit?: 'cover' | 'contain' roomStatus?: string fallbackSrc?: string + initialSrc?: string } function buildChaturbateCookieHeader(): string { @@ -41,13 +41,14 @@ function buildChaturbateCookieHeader(): string { export default function ModelPreview({ jobId, - live = false, - thumbTick, + enableHoverLive = false, + refreshKey, blur = false, className, fit = 'cover', roomStatus, fallbackSrc, + initialSrc, }: Props) { const rootRef = useRef(null) const [inView, setInView] = useState(false) @@ -82,17 +83,19 @@ export default function ModelPreview({ const showLiveBadge = normalizedRoomStatus !== '' && normalizedRoomStatus !== 'offline' const previewSrc = useMemo(() => { + const explicit = String(initialSrc ?? '').trim() + if (explicit) { + return explicit + } + const base = `/api/preview?id=${encodeURIComponent(jobId)}&file=preview.jpg` - if (!live) return base - - // thumbTick kommt zentral vom Parent - if (typeof thumbTick === 'number') { - return `${base}&v=${thumbTick}` + if (typeof refreshKey === 'number') { + return `${base}&v=${refreshKey}` } return base - }, [jobId, live, thumbTick]) + }, [initialSrc, jobId, refreshKey]) const fallbackImgSrc = useMemo(() => { const s = String(fallbackSrc ?? '').trim() @@ -100,16 +103,11 @@ export default function ModelPreview({ return `/api/preview?id=${encodeURIComponent(jobId)}&fallbackOnly=1` }, [fallbackSrc, jobId]) - const liveStreamSrc = useMemo( - () => `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1`, - [jobId] - ) - useEffect(() => { const ac = new AbortController() const loadLive = async () => { - if (!live || !hoverOpen) { + if (!enableHoverLive || !hoverOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) @@ -122,7 +120,6 @@ export default function ModelPreview({ const cookieHeader = buildChaturbateCookieHeader() try { - // ✅ HLS-Refresh jetzt direkt über /api/preview/live const prepareRes = await fetch( `/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&prepare=1`, { @@ -136,31 +133,21 @@ export default function ModelPreview({ ) if (ac.signal.aborted) return - - // Auch wenn prepare fehlschlägt: - // best effort -> Stream-Endpunkt trotzdem versuchen await prepareRes.json().catch(() => null) - if (ac.signal.aborted) return - setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`) + setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) } catch { if (ac.signal.aborted) return - - setLiveSrc(`${liveStreamSrc}&ts=${Date.now()}`) + setLiveSrc(`/api/preview/live?id=${encodeURIComponent(jobId)}&hover=1&play=1&ts=${Date.now()}`) } finally { - if (!ac.signal.aborted) { - setLivePreparing(false) - } + if (!ac.signal.aborted) setLivePreparing(false) } } void loadLive() - - return () => { - ac.abort() - } - }, [hoverOpen, live, jobId, liveStreamSrc]) + return () => ac.abort() + }, [enableHoverLive, hoverOpen, jobId]) useEffect(() => { const el = rootRef.current @@ -192,15 +179,18 @@ export default function ModelPreview({ return ( { - setHoverOpen(open) - if (!open) { + const nextOpen = enableHoverLive && open + + setHoverOpen(nextOpen) + + if (!nextOpen) { setLiveSrc('') setLivePreparing(false) setLiveReady(false) } }} content={(open, { close }) => - open && ( + enableHoverLive && open ? (
- ) + ) : null } >
{ const p = playerRef.current diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 419b059..a7d518f 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -419,7 +419,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { ...t, status: 'running', title: 'Aufräumen', - text: String(cleanup.currentFile || cleanup.text || 'Räume auf…'), + text: String(cleanup.text || cleanup.currentFile || 'Räume auf…'), done: Number(cleanup.done ?? 0), total: Number(cleanup.total ?? 0), err: undefined, @@ -711,11 +711,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const data = await res.json() const scannedFiles = Number(data.scannedFiles ?? 0) - - const orphanRemoved = Number(data.orphanIdsRemoved ?? 0) - const genRemoved = Number(data.generatedOrphansRemoved ?? 0) - - const orphansTotalRemoved = orphanRemoved + genRemoved + const deletedFiles = Number(data.deletedFiles ?? 0) + const deletedBytesHuman = String(data.deletedBytesHuman ?? '0 B') setCleanupTask((t) => ({ ...t, @@ -723,7 +720,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { done: scannedFiles, total: scannedFiles, title: 'Aufräumen', - text: `geprüft: ${scannedFiles} · Orphans: ${orphansTotalRemoved}`, + text: `gelöscht: ${deletedFiles}/${scannedFiles} · freigegeben: ${deletedBytesHuman}`, })) fadeOutTask(setCleanupTask, 7000, 500) diff --git a/frontend/src/components/ui/VideoSplitModal.tsx b/frontend/src/components/ui/VideoSplitModal.tsx index 7a1355d..c60c809 100644 --- a/frontend/src/components/ui/VideoSplitModal.tsx +++ b/frontend/src/components/ui/VideoSplitModal.tsx @@ -1683,7 +1683,12 @@ export default function VideoSplitModal({