// backend\chaturbate_autostart.go package main import ( "fmt" "net/url" "os" "sort" "strings" "time" ) type autoStartItem struct { userKey string url string } func normUser(s string) string { return strings.ToLower(strings.TrimSpace(s)) } 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 "" } u, err := url.Parse(raw) if err != nil || u.Hostname() == "" { return "" } host := strings.ToLower(u.Hostname()) if !strings.Contains(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 { in := strings.TrimSpace(m.Input) if strings.HasPrefix(strings.ToLower(in), "http://") || strings.HasPrefix(strings.ToLower(in), "https://") { return in } key := strings.Trim(strings.TrimSpace(m.ModelKey), "/") if key == "" { key = watchedChaturbateUserKey(m) } if key == "" { return "" } return fmt.Sprintf("https://chaturbate.com/%s/", key) } func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration) { deadline := time.Now().Add(maxWait) for time.Now().Before(deadline) { jobsMu.RLock() job := jobs[jobID] status := JobStatus("") out := "" hidden := false if job != nil { status = job.Status out = strings.TrimSpace(job.Output) hidden = job.Hidden } jobsMu.RUnlock() // Job schon weg oder nicht mehr running -> nichts tun if job == nil || status != JobRunning { return } // echte Output-Datei vorhanden? if out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 { // hidden blind-try wird jetzt sichtbar gemacht if hidden { jobsMu.Lock() j := jobs[jobID] if j != nil { j.Hidden = false } jobsMu.Unlock() } publishJob(jobID) return } } time.Sleep(900 * time.Millisecond) } // nach Wartezeit immer noch keine Datei => stoppen + löschen jobsMu.Lock() job := jobs[jobID] if job == nil || job.Status != JobRunning { jobsMu.Unlock() return } pc := job.previewCmd job.previewCmd = nil previewCancel := job.previewCancel job.previewCancel = nil recordCancel := job.cancel out := strings.TrimSpace(job.Output) wasVisible := !job.Hidden jobsMu.Unlock() if previewCancel != nil { previewCancel() } if pc != nil && pc.Process != nil { _ = pc.Process.Kill() } if recordCancel != nil { recordCancel() } // 0-Byte Datei ggf. löschen if out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 { _ = os.Remove(out) } } jobsMu.Lock() j := jobs[jobID] delete(jobs, jobID) jobsMu.Unlock() if wasVisible && j != nil { publishJobRemove(j) } } // Startet watched+online(public) automatisch – unabhängig vom Frontend func startChaturbateAutoStartWorker(store *ModelStore) { if store == nil { if verboseLogs() { fmt.Println("⚠️ [autostart] model store is nil") } return } const scanInterval = 1 * time.Second const startInterval = 5 * time.Second const snapshotMaxAge = 90 * time.Second // normaler Retry für API-public const retryCooldown = 25 * time.Second // aggressiver vermeiden für "nicht in API, aber watched" Blind-Try const blindRetryCooldown = 2 * time.Minute // wie lange wir warten, ob eine echte Datei entsteht const outputProbeMax = 20 * time.Second queue := make([]autoStartItem, 0, 64) queued := map[string]bool{} lastTry := map[string]time.Time{} lastBlindTry := map[string]time.Time{} scanTicker := time.NewTicker(scanInterval) startTicker := time.NewTicker(startInterval) defer scanTicker.Stop() defer startTicker.Stop() var lastCookieHdr string for { select { case <-scanTicker.C: if isAutostartPaused() { continue } s := getSettings() if !s.UseChaturbateAPI { queue = queue[:0] queued = map[string]bool{} lastCookieHdr = "" continue } cookieHdr := cookieHeaderFromSettings(s) if !hasChaturbateCookies(cookieHdr) { lastCookieHdr = "" continue } lastCookieHdr = cookieHdr rooms, fetchedAt, fresh := getChaturbateRoomsSnapshot(snapshotMaxAge) if !fresh { if verboseLogs() { if fetchedAt.IsZero() { fmt.Println("⚠️ [autostart] chaturbate snapshot missing") } else { fmt.Println("⚠️ [autostart] chaturbate snapshot stale:", time.Since(fetchedAt).Round(time.Second)) } } continue } showByUser := map[string]string{} imageByUser := map[string]string{} seenInAPI := map[string]bool{} 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{} 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 != "" { 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{} 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 != "" { lowerIn := strings.ToLower(in) if strings.Contains(lowerIn, "http://") || strings.Contains(lowerIn, "https://") { if chaturbateUserFromURL(in) == "" { continue } } } } watchedByUser[key] = m } // 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 } nextQueue = append(nextQueue, it) nextQueued[it.userKey] = true } queue = nextQueue queued = nextQueued now := time.Now() nextPending := make([]PendingAutoStartItem, 0, len(watchedByUser)) for user, m := range watchedByUser { u := resolveChaturbateURL(m) if u == "" { continue } show := normalizePendingShowServer(showByUser[user]) img := strings.TrimSpace(imageByUser[user]) switch show { case "public": // public => kein Pending-Eintrag 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, }) queued[user] = true case "private", "hidden", "away": // laufenden watched-Download beenden und auf public warten nextPending = append(nextPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "watched", }) if runningJob := runningByUser[user]; runningJob != nil { stopJobsInternal([]*RecordJob{runningJob}) } 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 } if queued[user] { continue } if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown { continue } queue = append(queue, autoStartItem{ userKey: user, url: u, }) queued[user] = true } } pendingAutoStartMu.Lock() _ = saveWatchedPendingAutoStartItems(nextPending) pendingAutoStartMu.Unlock() case <-startTicker.C: if isAutostartPaused() { continue } s := getSettings() if !s.UseChaturbateAPI { continue } if !hasChaturbateCookies(lastCookieHdr) { continue } if len(queue) == 0 { 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 } showByUser := map[string]string{} for _, r := range rooms { u := normUser(r.Username) if u == "" { continue } showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) } show := strings.TrimSpace(showByUser[it.userKey]) isPublic := strings.Contains(show, "public") if isPublic { lastTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: lastCookieHdr, }) if err != nil { if verboseLogs() { fmt.Println("❌ [autostart] start failed:", it.url, err) } continue } pendingAutoStartMu.Lock() _ = removeWatchedPendingAutoStartItem(it.userKey) pendingAutoStartMu.Unlock() if verboseLogs() { fmt.Println("▶️ [autostart] started:", it.url) } if job != nil { go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) } } else { lastBlindTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: lastCookieHdr, Hidden: true, }) if err != nil || job == nil { if verboseLogs() { fmt.Println("❌ [autostart] blind start failed:", it.url, err) } continue } if verboseLogs() { fmt.Println("▶️ [autostart] blind try started:", it.url) } go chaturbateAbortIfNoOutput(job.ID, outputProbeMax) } } } }