// backend\chaturbate_autostart.go package main import ( "fmt" "net/url" "os" "sort" "strings" "time" ) type autoStartItem struct { userKey string url string blind bool source string // "watched" | "manual" | "resume" force bool ownerUserKey string // nur für manuell hinzugefügte Pending-Einträge } var lastChaturbateDownloadLimitLogAt time.Time func logChaturbateAutoStartError(prefix string, modelURL string, err error) { if err == nil { return } // Download-Limit nur selten loggen, weil der Autostart-Worker regelmäßig retryt. if isDownloadLimitReachedError(err) { now := time.Now() if !lastChaturbateDownloadLimitLogAt.IsZero() && now.Sub(lastChaturbateDownloadLimitLogAt) < 60*time.Second { return } lastChaturbateDownloadLimitLogAt = now appLogln(prefix, modelURL, err) return } appLogln(prefix, modelURL, err) } func chaturbateAutoStartCandidateIndex(queue []autoStartItem, paused bool) int { for i, candidate := range queue { if paused && !candidate.force { continue } return i } return -1 } func normUser(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func chaturbateShowFromSnapshot(showByUser map[string]string, user string) string { user = normUser(user) if user == "" { return "unknown" } show, exists := showByUser[user] if !exists { return "offline" } return normalizePendingShowServer(show) } func watchedChaturbateUserKey(m WatchedModelLite) string { key := normUser(m.ModelKey) if key != "" { return key } in := strings.TrimSpace(m.Input) if in == "" { return "" } // zuerst echte URL parsen if u := chaturbateUserFromURL(in); u != "" { return u } // fallback: roher input könnte schon nur der username sein return normUser(strings.Trim(in, "/")) } func chaturbateUserFromURL(raw string) string { raw = strings.TrimSpace(raw) 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(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) if p != "" { return normUser(p) } } return "" } func cookieHeaderFromSettings(s RecorderSettings) string { m, err := decryptCookieMap(s.EncryptedCookies) if err != nil || len(m) == 0 { return "" } keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) var b strings.Builder first := true for _, k := range keys { v := strings.TrimSpace(m[k]) k = strings.TrimSpace(k) if k == "" || v == "" { continue } if !first { b.WriteString("; ") } first = false b.WriteString(k) b.WriteString("=") b.WriteString(v) } return b.String() } func resolveChaturbateURL(m WatchedModelLite) string { if u := chaturbateUserFromURL(m.Input); u != "" { return fmt.Sprintf("https://chaturbate.com/%s/", u) } key := watchedChaturbateUserKey(m) if key == "" { return "" } return fmt.Sprintf("https://chaturbate.com/%s/", key) } func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func(), onNoOutput func()) { 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() // echte Output-Datei vorhanden? // Wichtig: zuerst prüfen, bevor wir bei "nicht mehr running" zurückkehren. // Ein Job kann sehr schnell in Postwork/Stopped/Failed wechseln. if job != nil && 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() } if onOutput != nil { onOutput() } publishJob(jobID) return } } // Job schon weg oder nicht mehr running, aber keine Datei entstanden. // Für resume ist das wichtig: Der Pending-Eintrag wurde beim Start entfernt // und muss bei Start ohne Output wiederhergestellt werden. if job == nil || status != JobRunning { if onNoOutput != nil { onNoOutput() } 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 // Sichtbare Jobs nicht endlos als "running" stehen lassen, // wenn nach outputProbeMax keine Datei entstanden ist. if wasVisible { if recordCancel != nil { recordCancel() } now := time.Now() job.Status = JobFailed job.EndedAt = &now job.EndedAtMs = now.UnixMilli() job.Phase = "" job.Progress = 100 job.Error = "Kein Output nach Start – ffmpeg konnte keine Datei schreiben" job.PostWorkKey = "" job.PostWork = nil jobsMu.Unlock() if verboseLogs() { appLogln("❌ [autostart] no output after probe; marking failed:", jobID) } _ = publishJobSnapshot(jobID) if onNoOutput != nil { onNoOutput() } return } 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) } } if onNoOutput != nil { onNoOutput() } 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 { if verboseLogs() { appLogln("⚠️ [autostart] model store is nil") } return } const scanInterval = 1 * time.Second const startInterval = 500 * time.Millisecond const pendingRefreshInterval = 5 * time.Second const snapshotMaxAge = 90 * time.Second // normaler Retry für API-public const retryCooldown = 25 * time.Second // wie lange wir warten, ob eine echte Datei entsteht const outputProbeMax = 60 * time.Second queue := make([]autoStartItem, 0, 64) queued := map[string]bool{} lastTry := map[string]time.Time{} // verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln nonPublicSince := map[string]time.Time{} scanTicker := time.NewTicker(scanInterval) startTicker := time.NewTicker(startInterval) defer scanTicker.Stop() defer startTicker.Stop() var lastCookieHdr string for { select { case <-scanTicker.C: s := getSettings() autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads if !s.UseProviderAPIs || !s.UseChaturbateAPI { queue = queue[:0] queued = map[string]bool{} lastCookieHdr = "" pendingAutoStartMu.Lock() _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nil) pendingAutoStartMu.Unlock() continue } cookieHdr := cookieHeaderFromSettings(s) if !hasChaturbateCookies(cookieHdr) { lastCookieHdr = "" continue } lastCookieHdr = cookieHdr rooms, fetchedAt, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge) if !fresh { if verboseLogs() { if fetchedAt.IsZero() { appLogln("⚠️ [autostart] chaturbate snapshot missing") } else { appLogln("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second)) } } continue } showByUser := map[string]string{} imageByUser := map[string]string{} for _, r := range rooms { u := normUser(r.Username) if u == "" { continue } showByUser[u] = normalizePendingShowServer(r.CurrentShow) imageByUser[u] = selectBestRoomImageURL(r) } pendingAutoStartMu.Lock() manualItems, err := loadManualPendingAutoStartItemsForProvider("chaturbate") pendingAutoStartMu.Unlock() if err != nil { if verboseLogs() { appLogln("⚠️ [autostart] load manual chaturbate pending failed:", err) } continue } pendingAutoStartMu.Lock() resumeItems, err := loadResumePendingAutoStartItemsForProvider("chaturbate") pendingAutoStartMu.Unlock() if err != nil { if verboseLogs() { appLogln("⚠️ [autostart] load resume 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) } resumeByUser := map[string]PendingAutoStartItem{} resumeOrder := make([]string, 0, len(resumeItems)) for _, it := range resumeItems { 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 := resumeByUser[key]; exists { continue } resumeByUser[key] = it resumeOrder = append(resumeOrder, key) } // laufende Jobs sammeln runningByUser := map[string]*RecordJob{} jobsMu.RLock() for _, j := range jobs { if j == nil { continue } if !isActiveRecordingJob(j) { continue } if detectProvider(strings.TrimSpace(j.SourceURL)) != "chaturbate" { continue } u := chaturbateUserFromURL(j.SourceURL) if u == "" { continue } runningByUser[u] = j } jobsMu.RUnlock() // 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 } host := strings.ToLower(strings.TrimSpace(m.Host)) host = strings.TrimPrefix(host, "www.") if host != "" && host != "chaturbate.com" { continue } key := watchedChaturbateUserKey(m) if key == "" { continue } if host == "" { in := strings.TrimSpace(m.Input) if in != "" { lowerIn := strings.ToLower(in) if strings.Contains(lowerIn, "http://") || strings.Contains(lowerIn, "https://") { if chaturbateUserFromURL(in) == "" { continue } } } } 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 { // alte Blind-Probe-Einträge aus früheren Versionen verwerfen if it.blind { continue } if runningByUser[it.userKey] != nil { continue } switch it.source { case "manual": m, ok := manualByUser[it.userKey] if !ok { continue } it.url = strings.TrimSpace(m.URL) if it.url == "" { continue } case "resume": m, ok := resumeByUser[it.userKey] if !ok { continue } it.url = strings.TrimSpace(m.URL) if it.url == "" { continue } it.force = true default: m, ok := watchedByUser[it.userKey] if !ok { continue } it.url = resolveChaturbateURL(m) if it.url == "" { continue } } show := chaturbateShowFromSnapshot(showByUser, it.userKey) if show != "public" { continue } nextQueue = append(nextQueue, it) nextQueued[it.userKey] = true } queue = nextQueue queued = nextQueued nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder)) now := time.Now() waitingForPublic := false resumePendingThisScan := map[string]bool{} // Sichtbare laufende Downloads bei private/hidden/away stoppen // und als "resume" merken. Diese Resume-Einträge starten später // unabhängig von Autostart-Pause und unabhängig vom Download-Limit. for user, runningJob := range runningByUser { if runningJob == nil { continue } if runningJob.Hidden { continue } show := chaturbateShowFromSnapshot(showByUser, user) if show != "private" && show != "hidden" && show != "away" { delete(nonPublicSince, user) continue } firstSeen, ok := nonPublicSince[user] if !ok { nonPublicSince[user] = now continue } // Nicht sofort stoppen. CB/API kann kurz flackern. if now.Sub(firstSeen) < 30*time.Second { continue } u := strings.TrimSpace(runningJob.SourceURL) if u == "" { u = fmt.Sprintf("https://chaturbate.com/%s/", user) } img := strings.TrimSpace(imageByUser[user]) // Wichtig: // Resume NICHT in nextPending speichern. // nextPending wird später über saveWatchedPendingAutoStartItemsForProvider() // gespeichert, und diese Funktion setzt Source hart auf "watched". // Deshalb Resume separat in der globalen Pending-Datei speichern. pendingAutoStartMu.Lock() if err := saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", }); err != nil && verboseLogs() { appLogln("⚠️ [autostart] save resume pending failed:", user, err) } pendingAutoStartMu.Unlock() resumePendingThisScan[user] = true waitingForPublic = true if verboseLogs() { appLogln("⏸️ [autostart] stopped because model is no longer public:", user, show) } stopJobsInternal([]*RecordJob{runningJob}) } // Resume hat Vorrang und ignoriert Autostart-Pause. for _, user := range resumeOrder { itm := resumeByUser[user] u := strings.TrimSpace(itm.URL) if u == "" { u = fmt.Sprintf("https://chaturbate.com/%s/", user) } show := chaturbateShowFromSnapshot(showByUser, user) img := strings.TrimSpace(imageByUser[user]) switch show { case "public": if runningByUser[user] != nil { continue } if queued[user] { continue } queue = append(queue, autoStartItem{ userKey: user, url: u, blind: false, source: "resume", force: true, }) queued[user] = true case "private", "hidden", "away": waitingForPublic = true if resumePendingThisScan[user] { continue } pendingAutoStartMu.Lock() _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", }) pendingAutoStartMu.Unlock() case "offline": // Wenn ein Resume-Model wirklich offline ist, nicht mehr in Pending anzeigen. pendingAutoStartMu.Lock() _ = removeResumePendingAutoStartItemForProvider("chaturbate", user) pendingAutoStartMu.Unlock() case "unknown": waitingForPublic = true if resumePendingThisScan[user] { continue } pendingAutoStartMu.Lock() _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", }) pendingAutoStartMu.Unlock() } } for _, user := range watchedOrder { m := watchedByUser[user] u := resolveChaturbateURL(m) if u == "" { continue } show := chaturbateShowFromSnapshot(showByUser, user) img := strings.TrimSpace(imageByUser[user]) switch show { case "public": if autostartPaused { continue } 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: "watched", }) queued[user] = true case "private", "hidden", "away": waitingForPublic = true // Wenn der User in diesem Scan gerade wegen private/hidden/away // aus einem laufenden Download gestoppt wurde, existiert bereits // ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag // zusätzlich speichern. if resumePendingThisScan[user] { continue } nextWatchedPending = append(nextWatchedPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "watched", }) case "offline": // Watched-Model ist wirklich offline: // nicht als "Wartend" anzeigen und keinen Blind-Probe starten. continue default: // unknown nicht aggressiv in Wartend schieben. // Wenn kein frischer Online-Status da ist, lieber nichts tun. continue } } for _, user := range manualOrder { if _, exists := watchedByUser[user]; exists { // watched gewinnt continue } itm := manualByUser[user] u := strings.TrimSpace(itm.URL) if u == "" { continue } show := chaturbateShowFromSnapshot(showByUser, user) switch show { case "public": if autostartPaused { continue } 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", ownerUserKey: itm.OwnerUserKey, }) queued[user] = true case "private", "hidden", "away": waitingForPublic = true // Manual Pending bleibt wartend, solange das Model nicht offline ist. continue case "offline": // Offline gehört nicht in Pending Autostart. pendingAutoStartMu.Lock() _ = removeManualPendingAutoStartItemForProvider("chaturbate", user) pendingAutoStartMu.Unlock() continue default: waitingForPublic = true // unknown nicht als offline/wartend speichern. continue } } pendingAutoStartMu.Lock() _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending) pendingAutoStartMu.Unlock() if waitingForPublic { requestChaturbateSnapshotRefresh(pendingRefreshInterval) } case <-startTicker.C: s := getSettings() if !s.UseProviderAPIs || !s.UseChaturbateAPI { continue } if !hasChaturbateCookies(lastCookieHdr) { continue } if len(queue) == 0 { continue } rooms, _, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge) if !fresh { continue } showByUser := map[string]string{} for _, r := range rooms { u := normUser(r.Username) if u == "" { continue } showByUser[u] = normalizePendingShowServer(r.CurrentShow) } paused := isAutostartPaused() || !s.AutoStartAddedDownloads startIdx := chaturbateAutoStartCandidateIndex(queue, paused) if startIdx < 0 { continue } it := queue[startIdx] queue = append(queue[:startIdx], queue[startIdx+1:]...) delete(queued, it.userKey) // Blind-Probes sind deaktiviert. Alte Queue-Einträge aus früheren Versionen // werden hier nur noch verworfen. if it.blind { continue } show := chaturbateShowFromSnapshot(showByUser, it.userKey) isPublic := show == "public" // Normale queued Starts dürfen NICHT als Blind-Probe gestartet werden. // Wenn ein resume/watched/manual Eintrag inzwischen nicht mehr public ist, // bleibt er wartend bzw. wird im nächsten Scan korrekt neu bewertet. if !isPublic { if it.source == "resume" { pendingAutoStartMu.Lock() _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: it.userKey, URL: it.url, Mode: "wait_public", CurrentShow: show, Source: "resume", }) pendingAutoStartMu.Unlock() } continue } if isJobRunningForURL(it.url) { continue } lastTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: lastCookieHdr, IgnoreConcurrentLimit: it.force, }) if err != nil { if verboseLogs() { logChaturbateAutoStartError("❌ [autostart] start failed:", it.url, err) } continue } if verboseLogs() { if it.source == "resume" { appLogln("▶️ [autostart] resumed:", it.url) } else { appLogln("▶️ [autostart] started:", it.url) } } if job != nil { if it.source == "resume" { pendingAutoStartMu.Lock() _ = removeResumePendingAutoStartItemForProvider("chaturbate", it.userKey) pendingAutoStartMu.Unlock() go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, func() { pendingAutoStartMu.Lock() _ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{ ModelKey: it.userKey, URL: it.url, Mode: "wait_public", CurrentShow: "unknown", Source: "resume", }) pendingAutoStartMu.Unlock() }) continue } 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) } } } } }