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

543 lines
11 KiB
Go

// backend\myfreecams_autostart.go
package main
import (
"fmt"
"net/url"
"os"
"strings"
"time"
)
func mfcUserFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
lower := strings.ToLower(raw)
if !strings.Contains(lower, "://") {
if strings.HasPrefix(lower, "myfreecams.com/") || strings.HasPrefix(lower, "www.myfreecams.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 != "myfreecams.com" && !strings.HasSuffix(host, ".myfreecams.com") {
return ""
}
hash := strings.TrimPrefix(strings.TrimSpace(u.Fragment), "/")
if hash != "" {
parts := strings.Split(hash, "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
return strings.ToLower(p)
}
}
}
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
for i := len(parts) - 1; i >= 0; i-- {
p := strings.TrimSpace(parts[i])
if p != "" {
return strings.ToLower(p)
}
}
return ""
}
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
key := strings.ToLower(strings.TrimSpace(m.ModelKey))
if key != "" {
return key
}
in := strings.TrimSpace(m.Input)
if in == "" {
return ""
}
if u := mfcUserFromURL(in); u != "" {
return u
}
return strings.ToLower(strings.Trim(in, "/"))
}
func resolveMyFreeCamsURL(m WatchedModelLite) string {
if u := mfcUserFromURL(m.Input); u != "" {
return fmt.Sprintf("https://www.myfreecams.com/#%s", u)
}
key := watchedMyFreeCamsUserKey(m)
if key == "" {
return ""
}
return fmt.Sprintf("https://www.myfreecams.com/#%s", key)
}
// Startet watched MyFreeCams Models (ohne API) "best-effort".
// Wenn nach kurzer Zeit keine Output-Datei existiert (oder 0 Bytes), wird abgebrochen und der Job wieder entfernt.
func startMyFreeCamsAutoStartWorker(store *ModelStore) {
if store == nil {
return
}
const scanInterval = 1 * time.Second
const startInterval = 2 * time.Second
const cooldown = 2 * time.Minute
const outputProbeMax = 20 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
lastAttempt := map[string]time.Time{}
probeCursor := 0
scanTicker := time.NewTicker(scanInterval)
startTicker := time.NewTicker(startInterval)
defer scanTicker.Stop()
defer startTicker.Stop()
for {
select {
case <-scanTicker.C:
if isAutostartPaused() {
continue
}
s := getSettings()
if !s.UseMyFreeCamsWatcher {
queue = queue[:0]
queued = map[string]bool{}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil)
pendingAutoStartMu.Unlock()
continue
}
watched := store.ListWatchedLite("myfreecams.com")
pendingAutoStartMu.Lock()
manualItems, err := loadManualPendingAutoStartItemsForProvider("mfc")
pendingAutoStartMu.Unlock()
if err != nil {
fmt.Println("⚠️ [mfc autostart] load manual pending failed:", err)
continue
}
if len(watched) == 0 && len(manualItems) == 0 {
queue = queue[:0]
queued = map[string]bool{}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nil)
pendingAutoStartMu.Unlock()
continue
}
watchedByUser := map[string]WatchedModelLite{}
watchedOrder := make([]string, 0, len(watched))
manualByUser := map[string]PendingAutoStartItem{}
manualOrder := make([]string, 0, len(manualItems))
for _, it := range manualItems {
key := strings.ToLower(strings.TrimSpace(it.ModelKey))
if key == "" {
key = mfcUserFromURL(it.URL)
}
if key == "" {
continue
}
it.ModelKey = key
it.URL = strings.TrimSpace(it.URL)
if it.URL == "" {
continue
}
if _, exists := manualByUser[key]; exists {
continue
}
manualByUser[key] = it
manualOrder = append(manualOrder, key)
}
for _, m := range watched {
if !m.Watching {
continue
}
userKey := watchedMyFreeCamsUserKey(m)
if userKey == "" {
continue
}
if _, exists := watchedByUser[userKey]; exists {
continue
}
watchedByUser[userKey] = m
watchedOrder = append(watchedOrder, userKey)
}
runningByUser := map[string]bool{}
hiddenProbeUser := ""
jobsMu.RLock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status != JobRunning {
continue
}
if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != "mfc" {
continue
}
u := mfcUserFromURL(j.SourceURL)
if u == "" {
continue
}
runningByUser[u] = true
if j.Hidden && hiddenProbeUser == "" {
hiddenProbeUser = u
}
}
jobsMu.RUnlock()
nextQueue := queue[:0]
nextQueued := map[string]bool{}
for _, it := range queue {
if runningByUser[it.userKey] {
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 = resolveMyFreeCamsURL(m)
if it.url == "" {
continue
}
}
nextQueue = append(nextQueue, it)
nextQueued[it.userKey] = true
}
queue = nextQueue
queued = nextQueued
now := time.Now()
blindQueued := false
selectedWatchedBlindUser := ""
if _, ok := watchedByUser[hiddenProbeUser]; ok {
selectedWatchedBlindUser = hiddenProbeUser
}
for _, it := range queue {
if it.blind {
blindQueued = true
if it.source == "watched" && selectedWatchedBlindUser == "" {
selectedWatchedBlindUser = it.userKey
}
break
}
}
candidates := make([]autoStartItem, 0, len(watchedOrder)+len(manualOrder))
nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
// watched MFC Kandidaten
for _, user := range watchedOrder {
m := watchedByUser[user]
u := resolveMyFreeCamsURL(m)
if u == "" {
continue
}
if runningByUser[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown {
continue
}
candidates = append(candidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "watched",
})
}
// manuelle MFC Kandidaten, watched gewinnt bei Kollision
for _, user := range manualOrder {
if _, exists := watchedByUser[user]; exists {
continue
}
itm := manualByUser[user]
u := strings.TrimSpace(itm.URL)
if u == "" {
continue
}
if runningByUser[user] {
continue
}
if queued[user] {
continue
}
if t, ok := lastAttempt[user]; ok && now.Sub(t) < cooldown {
continue
}
candidates = append(candidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "manual",
})
}
if !blindQueued && !hasHiddenProbeRunningForProvider("mfc") && len(candidates) > 0 {
n := len(candidates)
start := 0
if probeCursor > 0 {
start = probeCursor % n
}
for i := 0; i < n; i++ {
idx := (start + i) % n
it := candidates[idx]
if queued[it.userKey] || runningByUser[it.userKey] {
continue
}
queue = append(queue, it)
queued[it.userKey] = true
if it.source == "watched" {
selectedWatchedBlindUser = it.userKey
}
probeCursor = (idx + 1) % n
break
}
}
if selectedWatchedBlindUser != "" {
if m, ok := watchedByUser[selectedWatchedBlindUser]; ok {
u := resolveMyFreeCamsURL(m)
if u != "" {
nextProbeAtMs := now.Add(cooldown).UnixMilli()
if t, ok := lastAttempt[selectedWatchedBlindUser]; ok {
nextProbeAtMs = t.Add(cooldown).UnixMilli()
}
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: selectedWatchedBlindUser,
URL: u,
Mode: "probe_retry",
NextProbeAtMs: nextProbeAtMs,
CurrentShow: "offline",
Source: "watched",
})
}
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("mfc", nextPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
if isAutostartPaused() {
continue
}
if !getSettings().UseMyFreeCamsWatcher {
continue
}
if len(queue) == 0 {
continue
}
// Nur EIN Hidden-Probe gleichzeitig
if hasHiddenProbeRunningForProvider("mfc") {
continue
}
it := queue[0]
queue = queue[1:]
delete(queued, it.userKey)
if isJobRunningForURL(it.url) {
continue
}
lastAttempt[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
URL: it.url,
Hidden: true,
})
if err != nil || job == nil {
continue
}
if it.source == "manual" {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey)
pendingAutoStartMu.Unlock()
})
} else {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil)
}
}
}
}
func isJobRunningForURL(u string) bool {
u = strings.TrimSpace(u)
if u == "" {
return false
}
jobsMu.RLock()
defer jobsMu.RUnlock()
for _, j := range jobs {
if j == nil {
continue
}
if j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == u {
return true
}
}
return false
}
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
deadline := time.Now().Add(maxWait)
for time.Now().Before(deadline) {
jobsMu.RLock()
job := jobs[jobID]
status := JobStatus("")
out := ""
if job != nil {
status = job.Status
out = strings.TrimSpace(job.Output)
}
jobsMu.RUnlock()
// Job schon weg oder nicht mehr running -> nichts tun
if job == nil || status != JobRunning {
return
}
// Output schon da?
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
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)
jobsMu.Unlock()
if previewCancel != nil {
previewCancel()
}
if pc != nil && pc.Process != nil {
_ = pc.Process.Kill()
}
if recordCancel != nil {
recordCancel()
}
// 0-Byte Datei ggf. wegwerfen
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
_ = os.Remove(out)
}
}
jobsMu.Lock()
j := jobs[jobID]
wasVisible := (j != nil && !j.Hidden)
delete(jobs, jobID)
jobsMu.Unlock()
if wasVisible {
publishJobRemove(j)
}
}