414 lines
8.4 KiB
Go
414 lines
8.4 KiB
Go
// 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 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 == "" {
|
||
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 pollInterval = 5 * time.Second
|
||
const startGap = 1500 * time.Millisecond
|
||
|
||
// 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{}
|
||
|
||
var lastStart time.Time
|
||
|
||
for {
|
||
if isAutostartPaused() {
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
|
||
s := getSettings()
|
||
if !s.UseChaturbateAPI {
|
||
queue = queue[:0]
|
||
queued = map[string]bool{}
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
|
||
cookieHdr := cookieHeaderFromSettings(s)
|
||
if !hasChaturbateCookies(cookieHdr) {
|
||
time.Sleep(5 * time.Second)
|
||
continue
|
||
}
|
||
|
||
// online snapshot aus cache
|
||
cbMu.RLock()
|
||
rooms := append([]ChaturbateRoom(nil), cb.Rooms...)
|
||
cbMu.RUnlock()
|
||
|
||
showByUser := map[string]string{}
|
||
seenInAPI := map[string]bool{}
|
||
|
||
for _, r := range rooms {
|
||
u := normUser(r.Username)
|
||
if u == "" {
|
||
continue
|
||
}
|
||
seenInAPI[u] = true
|
||
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
||
}
|
||
|
||
// laufende Jobs sammeln
|
||
running := map[string]bool{}
|
||
jobsMu.RLock()
|
||
for _, j := range jobs {
|
||
if !isActiveRecordingJob(j) {
|
||
continue
|
||
}
|
||
u := chaturbateUserFromURL(j.SourceURL)
|
||
if u != "" {
|
||
running[u] = true
|
||
}
|
||
}
|
||
jobsMu.RUnlock()
|
||
|
||
// watched list aus DB
|
||
watched := store.ListWatchedLite("chaturbate.com")
|
||
watchedByUser := map[string]WatchedModelLite{}
|
||
for _, m := range watched {
|
||
key := normUser(m.ModelKey)
|
||
if key != "" && m.Watching {
|
||
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 running[it.userKey] {
|
||
continue
|
||
}
|
||
|
||
it.url = resolveChaturbateURL(m)
|
||
if it.url == "" {
|
||
continue
|
||
}
|
||
|
||
nextQueue = append(nextQueue, it)
|
||
nextQueued[it.userKey] = true
|
||
}
|
||
queue = nextQueue
|
||
queued = nextQueued
|
||
|
||
now := time.Now()
|
||
|
||
// ------------------------------------------------------------
|
||
// 1) watched + API sagt public => normal enqueue
|
||
// ------------------------------------------------------------
|
||
for user, m := range watchedByUser {
|
||
if showByUser[user] != "public" {
|
||
continue
|
||
}
|
||
if running[user] {
|
||
continue
|
||
}
|
||
if queued[user] {
|
||
continue
|
||
}
|
||
if t, ok := lastTry[user]; ok && now.Sub(t) < retryCooldown {
|
||
continue
|
||
}
|
||
|
||
u := resolveChaturbateURL(m)
|
||
if u == "" {
|
||
continue
|
||
}
|
||
|
||
queue = append(queue, autoStartItem{
|
||
userKey: user,
|
||
url: u,
|
||
})
|
||
queued[user] = true
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 2) watched + NICHT in API => blind best-effort try wie MFC
|
||
// aber nur wenn:
|
||
// - nicht schon running
|
||
// - nicht schon queued
|
||
// - nicht kürzlich versucht
|
||
// ------------------------------------------------------------
|
||
for user, m := range watchedByUser {
|
||
if seenInAPI[user] {
|
||
continue
|
||
}
|
||
if running[user] {
|
||
continue
|
||
}
|
||
if queued[user] {
|
||
continue
|
||
}
|
||
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
|
||
continue
|
||
}
|
||
|
||
u := resolveChaturbateURL(m)
|
||
if u == "" {
|
||
continue
|
||
}
|
||
|
||
// direkt "blind" starten, nicht erst in queue hängen,
|
||
// damit das Verhalten wie MFC ist
|
||
if !lastStart.IsZero() && time.Since(lastStart) < startGap {
|
||
continue
|
||
}
|
||
|
||
lastBlindTry[user] = time.Now()
|
||
|
||
job, err := startRecordingInternal(RecordRequest{
|
||
URL: u,
|
||
Cookie: cookieHdr,
|
||
Hidden: true,
|
||
})
|
||
if err != nil || job == nil {
|
||
if verboseLogs() {
|
||
fmt.Println("❌ [autostart] blind start failed:", u, err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if verboseLogs() {
|
||
fmt.Println("▶️ [autostart] blind try started:", u)
|
||
}
|
||
|
||
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
|
||
lastStart = time.Now()
|
||
}
|
||
|
||
// ------------------------------------------------------------
|
||
// 3) normale API-public queue abarbeiten
|
||
// ------------------------------------------------------------
|
||
if len(queue) > 0 && (lastStart.IsZero() || time.Since(lastStart) >= startGap) {
|
||
it := queue[0]
|
||
queue = queue[1:]
|
||
delete(queued, it.userKey)
|
||
lastTry[it.userKey] = time.Now()
|
||
|
||
job, err := startRecordingInternal(RecordRequest{
|
||
URL: it.url,
|
||
Cookie: cookieHdr,
|
||
})
|
||
if err != nil {
|
||
if verboseLogs() {
|
||
fmt.Println("❌ [autostart] start failed:", it.url, err)
|
||
}
|
||
} else {
|
||
if verboseLogs() {
|
||
fmt.Println("▶️ [autostart] started:", it.url)
|
||
}
|
||
|
||
// optional auch hier absichern; schadet nicht
|
||
if job != nil {
|
||
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax)
|
||
}
|
||
|
||
lastStart = time.Now()
|
||
}
|
||
}
|
||
|
||
time.Sleep(pollInterval)
|
||
}
|
||
}
|