// 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) } }