nsfwapp/backend/chaturbate_autostart.go
2026-04-09 12:48:17 +02:00

820 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend\chaturbate_autostart.go
package main
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
blind bool
source string // "watched" | "manual"
}
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
}
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 {
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{}
probeCursor := 0
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 = ""
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() {
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{}
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
}
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 {
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
}
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
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]
u := resolveChaturbateURL(m)
if u == "" {
continue
}
show := normalizePendingShowServer(showByUser[user])
img := strings.TrimSpace(imageByUser[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: "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",
})
if runningJob := runningByUser[user]; runningJob != nil {
stopJobsInternal([]*RecordJob{runningJob})
}
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: "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()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
if isAutostartPaused() {
continue
}
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))
}
it := queue[0]
show := strings.TrimSpace(showByUser[it.userKey])
isPublic := strings.Contains(show, "public")
// 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{
URL: it.url,
Cookie: lastCookieHdr,
})
if err != nil {
if verboseLogs() {
fmt.Println("❌ [autostart] start failed:", it.url, err)
}
continue
}
if verboseLogs() {
fmt.Println("▶️ [autostart] started:", it.url)
}
if job != nil {
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()
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)
}
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)
}
}
}
}
}