// 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 = 30 * 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 { appLogln("⚠️ [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 mfcKeepIfOutputGrows(job.ID, outputProbeMax, func() { pendingAutoStartMu.Lock() _ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey) pendingAutoStartMu.Unlock() }) } else { go mfcKeepIfOutputGrows(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 die Output-Datei innerhalb maxWait sichtbar wächst, behalten wir den Job. // Wenn sie nicht wächst, stoppen wir den Hidden-Probe und löschen die angelegte Datei. // Hintergrund: MFC hat keine saubere API. Deshalb wird blind gestartet und anhand echter Dateiaktivität entschieden. func mfcKeepIfOutputGrows(jobID string, maxWait time.Duration, onKeep func()) { deadline := time.Now().Add(maxWait) var lastSize int64 = -1 var sawFile bool 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 } if out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() { size := fi.Size() sawFile = true // Erst behalten, wenn die Datei wirklich wächst. if lastSize >= 0 && size > lastSize { if onKeep != nil { onKeep() } // Hidden-Probe wird zu normal sichtbarem Recording. if hidden { jobsMu.Lock() if j := jobs[jobID]; j != nil && j.Status == JobRunning { j.Hidden = false } jobsMu.Unlock() } publishJob(jobID) return } lastSize = size } } time.Sleep(1 * time.Second) } // Nach Wartezeit keine wachsende 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() } // Blind-Probe-Datei entfernen, auch wenn sie schon ein paar Bytes hat, // denn sie ist nicht gewachsen und gilt damit als Fehlstart. if out != "" { if fi, err := os.Stat(out); err == nil && !fi.IsDir() { _ = os.Remove(out) } } jobsMu.Lock() j := jobs[jobID] delete(jobs, jobID) jobsMu.Unlock() if wasVisible && j != nil { publishJobRemove(j) } if sawFile { appLogln("🧹 [mfc autostart] Probe ohne Wachstum entfernt:", jobID) } }