nsfwapp/backend/chaturbate_autostart.go
2026-05-02 19:41:29 +02:00

1041 lines
22 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 (
"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() {
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:
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() {
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{}
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() {
fmt.Println("⚠️ [autostart] load manual chaturbate pending failed:", err)
}
continue
}
pendingAutoStartMu.Lock()
resumeItems, err := loadResumePendingAutoStartItemsForProvider("chaturbate")
pendingAutoStartMu.Unlock()
if err != nil {
if verboseLogs() {
fmt.Println("⚠️ [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() {
fmt.Println("⏸️ [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() {
fmt.Println("❌ [autostart] start failed:", it.url, err)
}
continue
}
if verboseLogs() {
if it.source == "resume" {
fmt.Println("▶️ [autostart] resumed:", it.url)
} else {
fmt.Println("▶️ [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() {
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)
}
}
}
}
}