545 lines
11 KiB
Go
545 lines
11 KiB
Go
// backend\myfreecams_autostart.go
|
|
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func normalizeMFCUserKey(s string) string {
|
|
return strings.ToLower(strings.TrimSpace(s))
|
|
}
|
|
|
|
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 p
|
|
}
|
|
}
|
|
}
|
|
|
|
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
for i := len(parts) - 1; i >= 0; i-- {
|
|
p := strings.TrimSpace(parts[i])
|
|
if p != "" {
|
|
return p
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func mfcUserKeyFromURL(raw string) string {
|
|
return normalizeMFCUserKey(mfcUserFromURL(raw))
|
|
}
|
|
|
|
func watchedMyFreeCamsUsername(m WatchedModelLite) string {
|
|
if u := mfcUserFromURL(m.Input); u != "" {
|
|
return u
|
|
}
|
|
|
|
if key := strings.TrimSpace(m.ModelKey); key != "" {
|
|
return key
|
|
}
|
|
|
|
return strings.TrimSpace(strings.Trim(m.Input, "/"))
|
|
}
|
|
|
|
func watchedMyFreeCamsUserKey(m WatchedModelLite) string {
|
|
return normalizeMFCUserKey(watchedMyFreeCamsUsername(m))
|
|
}
|
|
|
|
func resolveMyFreeCamsURL(m WatchedModelLite) string {
|
|
user := watchedMyFreeCamsUsername(m)
|
|
if user == "" {
|
|
return ""
|
|
}
|
|
|
|
return fmt.Sprintf("https://www.myfreecams.com/#%s", user)
|
|
}
|
|
|
|
// 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 := normalizeMFCUserKey(strings.TrimSpace(it.ModelKey))
|
|
if key == "" {
|
|
key = mfcUserKeyFromURL(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 := mfcUserKeyFromURL(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)
|
|
}
|
|
}
|