nsfwapp/backend/chaturbate_autostart.go
2026-06-05 13:17:04 +02:00

994 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 (
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
blind bool
source string // "watched" | "manual" | "resume"
force bool
ownerUserKey string // nur für manuell hinzugefügte Pending-Einträge
}
var lastChaturbateDownloadLimitLogAt time.Time
func logChaturbateAutoStartError(prefix string, modelURL string, err error) {
if err == nil {
return
}
// Download-Limit nur selten loggen, weil der Autostart-Worker regelmäßig retryt.
if isDownloadLimitReachedError(err) {
now := time.Now()
if !lastChaturbateDownloadLimitLogAt.IsZero() &&
now.Sub(lastChaturbateDownloadLimitLogAt) < 60*time.Second {
return
}
lastChaturbateDownloadLimitLogAt = now
appLogln(prefix, modelURL, err)
return
}
appLogln(prefix, modelURL, err)
}
func normUser(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func chaturbateShowFromSnapshot(showByUser map[string]string, user string) string {
user = normUser(user)
if user == "" {
return "unknown"
}
show, exists := showByUser[user]
if !exists {
return "offline"
}
return normalizePendingShowServer(show)
}
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()
// echte Output-Datei vorhanden?
// Wichtig: zuerst prüfen, bevor wir bei "nicht mehr running" zurückkehren.
// Ein Job kann sehr schnell in Postwork/Stopped/Failed wechseln.
if job != nil && 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
}
}
// Job schon weg oder nicht mehr running, aber keine Datei entstanden.
// Für resume ist das wichtig: Der Pending-Eintrag wurde beim Start entfernt
// und muss bei Start ohne Output wiederhergestellt werden.
if job == nil || status != JobRunning {
if onNoOutput != nil {
onNoOutput()
}
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
// Sichtbare Jobs nicht endlos als "running" stehen lassen,
// wenn nach outputProbeMax keine Datei entstanden ist.
if wasVisible {
if recordCancel != nil {
recordCancel()
}
now := time.Now()
job.Status = JobFailed
job.EndedAt = &now
job.EndedAtMs = now.UnixMilli()
job.Phase = ""
job.Progress = 100
job.Error = "Kein Output nach Start ffmpeg konnte keine Datei schreiben"
job.PostWorkKey = ""
job.PostWork = nil
jobsMu.Unlock()
if verboseLogs() {
appLogln("❌ [autostart] no output after probe; marking failed:", jobID)
}
_ = publishJobSnapshot(jobID)
if onNoOutput != nil {
onNoOutput()
}
return
}
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)
}
}
// 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
// wie lange wir warten, ob eine echte Datei entsteht
const outputProbeMax = 60 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastTry := map[string]time.Time{}
// verhindert Stop/Resume-Flackern bei kurzen API-Statuswechseln
nonPublicSince := 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:
s := getSettings()
autostartPaused := isAutostartPaused() || !s.AutoStartAddedDownloads
if !s.UseProviderAPIs || !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{}
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
}
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 {
// alte Blind-Probe-Einträge aus früheren Versionen verwerfen
if it.blind {
continue
}
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 := chaturbateShowFromSnapshot(showByUser, it.userKey)
if show != "public" {
continue
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
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 := chaturbateShowFromSnapshot(showByUser, user)
if show != "private" && show != "hidden" && show != "away" {
delete(nonPublicSince, user)
continue
}
firstSeen, ok := nonPublicSince[user]
if !ok {
nonPublicSince[user] = now
continue
}
// Nicht sofort stoppen. CB/API kann kurz flackern.
if now.Sub(firstSeen) < 30*time.Second {
continue
}
u := strings.TrimSpace(runningJob.SourceURL)
if u == "" {
u = fmt.Sprintf("https://chaturbate.com/%s/", user)
}
img := strings.TrimSpace(imageByUser[user])
// Wichtig:
// Resume NICHT in nextPending speichern.
// nextPending wird später über saveWatchedPendingAutoStartItemsForProvider()
// gespeichert, und diese Funktion setzt Source hart auf "watched".
// Deshalb Resume separat in der globalen Pending-Datei speichern.
pendingAutoStartMu.Lock()
if err := saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
}); err != nil && verboseLogs() {
appLogln("⚠️ [autostart] save resume pending failed:", user, err)
}
pendingAutoStartMu.Unlock()
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 := chaturbateShowFromSnapshot(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":
if resumePendingThisScan[user] {
continue
}
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
case "offline":
// Wenn ein Resume-Model wirklich offline ist, nicht mehr in Pending anzeigen.
pendingAutoStartMu.Lock()
_ = removeResumePendingAutoStartItemForProvider("chaturbate", user)
pendingAutoStartMu.Unlock()
case "unknown":
if resumePendingThisScan[user] {
continue
}
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
}
for _, user := range watchedOrder {
m := watchedByUser[user]
u := resolveChaturbateURL(m)
if u == "" {
continue
}
show := chaturbateShowFromSnapshot(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":
// Wenn der User in diesem Scan gerade wegen private/hidden/away
// aus einem laufenden Download gestoppt wurde, existiert bereits
// ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag
// zusätzlich speichern.
if resumePendingThisScan[user] {
continue
}
nextWatchedPending = append(nextWatchedPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
case "offline":
// Watched-Model ist wirklich offline:
// nicht als "Wartend" anzeigen und keinen Blind-Probe starten.
continue
default:
// unknown nicht aggressiv in Wartend schieben.
// Wenn kein frischer Online-Status da ist, lieber nichts tun.
continue
}
}
for _, user := range manualOrder {
if _, exists := watchedByUser[user]; exists {
// watched gewinnt
continue
}
itm := manualByUser[user]
u := strings.TrimSpace(itm.URL)
if u == "" {
continue
}
show := chaturbateShowFromSnapshot(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",
ownerUserKey: itm.OwnerUserKey,
})
queued[user] = true
case "private", "hidden", "away":
// Manual Pending bleibt wartend, solange das Model nicht offline ist.
continue
case "offline":
// Offline gehört nicht in Pending Autostart.
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", user)
pendingAutoStartMu.Unlock()
continue
default:
// unknown nicht als offline/wartend speichern.
continue
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
s := getSettings()
if !s.UseProviderAPIs || !s.UseChaturbateAPI || !s.AutoStartAddedDownloads || isAutostartPaused() {
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] = normalizePendingShowServer(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)
// Blind-Probes sind deaktiviert. Alte Queue-Einträge aus früheren Versionen
// werden hier nur noch verworfen.
if it.blind {
continue
}
show := chaturbateShowFromSnapshot(showByUser, it.userKey)
isPublic := show == "public"
// Normale queued Starts dürfen NICHT als Blind-Probe gestartet werden.
// Wenn ein resume/watched/manual Eintrag inzwischen nicht mehr public ist,
// bleibt er wartend bzw. wird im nächsten Scan korrekt neu bewertet.
if !isPublic {
if it.source == "resume" {
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: show,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
continue
}
if isJobRunningForURL(it.url) {
continue
}
lastTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: it.url,
Cookie: lastCookieHdr,
IgnoreConcurrentLimit: it.force,
})
if err != nil {
if verboseLogs() {
logChaturbateAutoStartError("❌ [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)
}
}
}
}
}