// backend\chaturbate_autostart.go package main import ( "encoding/json" "errors" "fmt" "net/url" "os" "path/filepath" "sort" "strings" "time" ) type autoStartItem struct { userKey string url string blind bool source string // "watched" | "manual" | "resume" force bool // true = ignoriert Autostart-Pause + Download-Limit } 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 "" } 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() // 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() } if onOutput != nil { onOutput() } 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) } } if onNoOutput != nil { onNoOutput() } jobsMu.Lock() j := jobs[jobID] delete(jobs, jobID) jobsMu.Unlock() if wasVisible && j != nil { publishJobRemove(j) } } 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 } path := filepath.Join(dir, name) raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } return err } var f pendingAutoStartFile if err := json.Unmarshal(raw, &f); err != nil { _ = os.Remove(path) continue } kept := make([]PendingAutoStartItem, 0, len(f.Items)) for _, it := range f.Items { if normalizePendingSourceServer(it.Source) == "resume" { kept = append(kept, it) } } if len(kept) == 0 { if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { return err } continue } if err := savePendingAutoStartItems(strings.TrimSuffix(name, filepath.Ext(name)), kept); err != nil { return err } } return nil } // 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 = 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{} probeCursor := 0 scanTicker := time.NewTicker(scanInterval) startTicker := time.NewTicker(startInterval) defer scanTicker.Stop() defer startTicker.Stop() var lastCookieHdr string for { select { case <-scanTicker.C: autostartPaused := isAutostartPaused() s := getSettings() if !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{} hiddenProbeUser := "" 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 if j.Hidden && hiddenProbeUser == "" { hiddenProbeUser = u } } 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 { 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 := 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 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)+len(resumeOrder)+len(runningByUser)) now := time.Now() 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 := normalizePendingShowServer(showByUser[user]) if show != "private" && show != "hidden" && show != "away" { continue } u := strings.TrimSpace(runningJob.SourceURL) if u == "" { u = fmt.Sprintf("https://chaturbate.com/%s/", user) } img := strings.TrimSpace(imageByUser[user]) nextPending = append(nextPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", }) resumePendingThisScan[user] = 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 := normalizePendingShowServer(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", "offline", "unknown": if resumePendingThisScan[user] { continue } nextPending = append(nextPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "resume", }) } } for _, user := range watchedOrder { m := watchedByUser[user] u := resolveChaturbateURL(m) if u == "" { continue } show := normalizePendingShowServer(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": nextPending = append(nextPending, PendingAutoStartItem{ ModelKey: user, URL: u, Mode: "wait_public", CurrentShow: show, ImageURL: img, Source: "watched", }) default: if autostartPaused { continue } 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: "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 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", }) queued[user] = true case "private", "hidden", "away": // manual pending bleibt einfach stehen und wartet auf public continue default: if autostartPaused { continue } 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. // Bei pausiertem Autostart werden oben keine normalen Offline-Kandidaten erzeugt. 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 != "" && !autostartPaused { 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() _ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending) pendingAutoStartMu.Unlock() case <-startTicker.C: s := getSettings() if !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] = strings.ToLower(strings.TrimSpace(r.CurrentShow)) } paused := isAutostartPaused() startIdx := -1 for i, candidate := range queue { if paused && !candidate.force { continue } startIdx = i break } if startIdx < 0 { continue } it := queue[startIdx] queue = append(queue[:startIdx], queue[startIdx+1:]...) delete(queued, it.userKey) show := strings.TrimSpace(showByUser[it.userKey]) isPublic := strings.Contains(show, "public") // Nicht-public nur einzeln nacheinander prüfen. if it.blind && hasHiddenProbeRunningForProvider("chaturbate") { continue } if isJobRunningForURL(it.url) { continue } if !it.blind && isPublic { lastTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: lastCookieHdr, IgnoreConcurrentLimit: it.force, }) if err != nil { if verboseLogs() { appLogln("❌ [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) } } } else { if paused && !it.force { continue } lastBlindTry[it.userKey] = time.Now() job, err := startRecordingInternal(RecordRequest{ URL: it.url, Cookie: lastCookieHdr, Hidden: true, IgnoreConcurrentLimit: it.force, }) if err != nil || job == nil { if verboseLogs() { appLogln("❌ [autostart] blind start failed:", it.url, err) } continue } if verboseLogs() { appLogln("▶️ [autostart] blind try started:", it.url) } 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) } } } } }