// backend\pending_autostart.go package main import ( "encoding/json" "errors" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strings" "sync" ) type PendingAutoStartItem struct { ModelKey string `json:"modelKey"` URL string `json:"url"` Mode string `json:"mode,omitempty"` NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` CurrentShow string `json:"currentShow,omitempty"` ImageURL string `json:"imageUrl,omitempty"` Source string `json:"source,omitempty"` OwnerUserKey string `json:"-"` } type PendingAutoStartResponse struct { Items []PendingAutoStartItem `json:"items"` } type pendingAutoStartFile struct { Items []PendingAutoStartItem `json:"items"` } var pendingAutoStartMu sync.Mutex const pendingAutoStartGlobalUserKey = "__global__" func normalizePendingSourceServer(v string) string { switch strings.TrimSpace(strings.ToLower(v)) { case "watched": return "watched" case "resume": return "resume" default: return "manual" } } func loadResumePendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return nil, errors.New("missing provider") } items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return nil, err } out := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { if normalizePendingSourceServer(it.Source) != "resume" { continue } if pendingProviderFromURL(it.URL) != provider { continue } out = append(out, it) } return out, nil } func removeResumePendingAutoStartItemForProvider(provider, modelKey string) error { provider = strings.ToLower(strings.TrimSpace(provider)) modelKey = strings.ToLower(strings.TrimSpace(modelKey)) if provider == "" || modelKey == "" { return nil } items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return err } next := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { if normalizePendingSourceServer(it.Source) == "resume" && pendingProviderFromURL(it.URL) == provider && strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey { continue } next = append(next, it) } return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) } func saveResumePendingAutoStartItemForProvider(provider string, item PendingAutoStartItem) error { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return errors.New("missing provider") } items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return err } item.ModelKey = strings.ToLower(strings.TrimSpace(item.ModelKey)) item.URL = strings.TrimSpace(item.URL) item.Mode = normalizePendingModeServer(item.Mode) item.CurrentShow = normalizePendingShowServer(item.CurrentShow) item.ImageURL = strings.TrimSpace(item.ImageURL) item.Source = "resume" if item.ModelKey == "" || item.URL == "" { return nil } replaced := false for i := range items { if normalizePendingSourceServer(items[i].Source) == "resume" && pendingProviderFromURL(items[i].URL) == provider && strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == item.ModelKey { items[i] = item replaced = true break } } if !replaced { items = append(items, item) } return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, items) } func normalizePendingModeServer(v string) string { if strings.TrimSpace(strings.ToLower(v)) == "probe_retry" { return "probe_retry" } return "wait_public" } func normalizePendingShowServer(v string) string { switch strings.TrimSpace(strings.ToLower(v)) { case "public": return "public" case "private": return "private" case "hidden": return "hidden" case "away": return "away" case "offline": return "offline" default: return "unknown" } } func sanitizePendingAutoStartItems(items []PendingAutoStartItem) []PendingAutoStartItem { out := make([]PendingAutoStartItem, 0, len(items)) seen := make(map[string]struct{}, len(items)) for _, it := range items { it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey)) it.URL = strings.TrimSpace(it.URL) it.Mode = normalizePendingModeServer(it.Mode) it.CurrentShow = normalizePendingShowServer(it.CurrentShow) it.ImageURL = strings.TrimSpace(it.ImageURL) it.Source = normalizePendingSourceServer(it.Source) if it.ModelKey == "" || it.URL == "" { continue } // Wichtig: // Offline-Models gehören nicht in Pending Autostart. if it.CurrentShow == "offline" { continue } dedupeKey := it.Source + "|" + pendingProviderFromURL(it.URL) + "|" + it.ModelKey if _, exists := seen[dedupeKey]; exists { continue } seen[dedupeKey] = struct{}{} out = append(out, it) } return out } func safeUserKeyForFile(v string) string { re := regexp.MustCompile(`[^a-zA-Z0-9._-]+`) return re.ReplaceAllString(v, "_") } func pendingProviderFromURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } u, err := url.Parse(raw) if err != nil { return "" } host := strings.ToLower(strings.TrimSpace(u.Hostname())) host = strings.TrimPrefix(host, "www.") switch { case host == "chaturbate.com" || strings.HasSuffix(host, ".chaturbate.com"): return "chaturbate" case host == "myfreecams.com" || strings.HasSuffix(host, ".myfreecams.com"): return "mfc" default: return "" } } func pendingAutoStartFilePath(userKey string) string { return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json") } func clearAllPendingAutoStartOnStartup() error { pendingAutoStartMu.Lock() defer pendingAutoStartMu.Unlock() dir := filepath.Join("data", "pending-autostart") if err := os.RemoveAll(dir); err != nil { return err } return nil } func listPendingAutoStartUserKeys() ([]string, error) { dir := filepath.Join("data", "pending-autostart") entries, err := os.ReadDir(dir) if err != nil { if errors.Is(err, os.ErrNotExist) { return []string{}, nil } return nil, err } out := make([]string, 0, len(entries)) globalFile := safeUserKeyForFile(pendingAutoStartGlobalUserKey) + ".json" for _, entry := range entries { if entry.IsDir() { continue } name := strings.TrimSpace(entry.Name()) if name == "" { continue } if !strings.HasSuffix(strings.ToLower(name), ".json") { continue } if name == globalFile { continue } userKey := strings.TrimSuffix(name, filepath.Ext(name)) if userKey == "" { continue } out = append(out, userKey) } sort.Strings(out) return out, nil } func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoStartItem, error) { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return nil, errors.New("missing provider") } userKeys, err := listPendingAutoStartUserKeys() if err != nil { return nil, err } out := make([]PendingAutoStartItem, 0, 32) for _, userKey := range userKeys { items, err := loadPendingAutoStartItems(userKey) if err != nil { return nil, err } for _, it := range items { if normalizePendingSourceServer(it.Source) != "manual" { continue } if pendingProviderFromURL(it.URL) != provider { continue } it.OwnerUserKey = userKey out = append(out, it) } } return out, nil } func saveManualPendingAutoStartItemForUser(userKey string, item PendingAutoStartItem) error { userKey = strings.ToLower(strings.TrimSpace(userKey)) if userKey == "" { return errors.New("missing userKey") } items, err := loadPendingAutoStartItems(userKey) if err != nil { return err } item.ModelKey = strings.ToLower(strings.TrimSpace(item.ModelKey)) item.URL = strings.TrimSpace(item.URL) item.Mode = normalizePendingModeServer(item.Mode) item.CurrentShow = normalizePendingShowServer(item.CurrentShow) item.ImageURL = strings.TrimSpace(item.ImageURL) item.Source = "manual" if item.ModelKey == "" || item.URL == "" { return nil } replaced := false for i := range items { if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == item.ModelKey { items[i] = item replaced = true break } } if !replaced { items = append(items, item) } return savePendingAutoStartItems(userKey, items) } func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error { provider = strings.ToLower(strings.TrimSpace(provider)) modelKey = strings.ToLower(strings.TrimSpace(modelKey)) if provider == "" || modelKey == "" { return nil } userKeys, err := listPendingAutoStartUserKeys() if err != nil { return err } for _, userKey := range userKeys { items, err := loadPendingAutoStartItems(userKey) if err != nil { return err } changed := false next := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { if normalizePendingSourceServer(it.Source) == "manual" && pendingProviderFromURL(it.URL) == provider && strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey { changed = true continue } next = append(next, it) } if changed { if err := savePendingAutoStartItems(userKey, next); err != nil { return err } } } return nil } func removeAllPendingAutoStartItemsForProvider(provider string) (int, error) { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return 0, nil } userKeys, err := listPendingAutoStartUserKeys() if err != nil { return 0, err } keys := append([]string{pendingAutoStartGlobalUserKey}, userKeys...) removed := 0 for _, userKey := range keys { items, err := loadPendingAutoStartItems(userKey) if err != nil { return removed, err } changed := false next := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { if pendingProviderFromURL(it.URL) == provider { removed++ changed = true continue } next = append(next, it) } if changed { if err := savePendingAutoStartItems(userKey, next); err != nil { return removed, err } } } return removed, nil } func loadPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { path := pendingAutoStartFilePath(userKey) raw, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return []PendingAutoStartItem{}, nil } return nil, err } var f pendingAutoStartFile if err := json.Unmarshal(raw, &f); err != nil { // kaputte Datei => lieber leer zurückgeben statt alles zu crashen return []PendingAutoStartItem{}, nil } if len(f.Items) == 0 { return []PendingAutoStartItem{}, nil } out := make([]PendingAutoStartItem, 0, len(f.Items)) seen := make(map[string]struct{}, len(f.Items)) for _, it := range f.Items { key := strings.ToLower(strings.TrimSpace(it.ModelKey)) u := strings.TrimSpace(it.URL) if key == "" || u == "" { continue } show := normalizePendingShowServer(it.CurrentShow) // Offline-Models gehören nicht in Pending Autostart. // Alte persistierte Offline-Einträge werden dadurch beim Lesen ignoriert. if show == "offline" { continue } source := normalizePendingSourceServer(it.Source) mode := normalizePendingModeServer(it.Mode) imageURL := strings.TrimSpace(it.ImageURL) provider := pendingProviderFromURL(u) dedupeKey := source + "|" + provider + "|" + key if _, exists := seen[dedupeKey]; exists { continue } seen[dedupeKey] = struct{}{} out = append(out, PendingAutoStartItem{ ModelKey: key, URL: u, Mode: mode, NextProbeAtMs: it.NextProbeAtMs, CurrentShow: show, ImageURL: imageURL, Source: source, }) } return out, nil } func loadMergedPendingAutoStartItems(userKey string) ([]PendingAutoStartItem, error) { globalItems, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return nil, err } userItems, err := loadPendingAutoStartItems(userKey) if err != nil { return nil, err } out := make([]PendingAutoStartItem, 0, len(globalItems)+len(userItems)) indexByKey := make(map[string]int) for _, it := range globalItems { k := strings.ToLower(strings.TrimSpace(it.ModelKey)) if k == "" { continue } indexByKey[k] = len(out) out = append(out, it) } // user-spezifisch überschreibt an gleicher Position for _, it := range userItems { k := strings.ToLower(strings.TrimSpace(it.ModelKey)) if k == "" { continue } if idx, ok := indexByKey[k]; ok { out[idx] = it continue } indexByKey[k] = len(out) out = append(out, it) } return out, nil } func saveWatchedPendingAutoStartItemsForProvider(provider string, items []PendingAutoStartItem) error { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return errors.New("missing provider") } existing, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return err } next := make([]PendingAutoStartItem, 0, len(existing)+len(items)) // alte watched-Einträge dieses Providers entfernen, andere behalten for _, it := range existing { if normalizePendingSourceServer(it.Source) == "watched" && pendingProviderFromURL(it.URL) == provider { continue } next = append(next, it) } // neue watched-Einträge dieses Providers anhängen for _, it := range items { it.ModelKey = strings.ToLower(strings.TrimSpace(it.ModelKey)) it.URL = strings.TrimSpace(it.URL) it.Mode = normalizePendingModeServer(it.Mode) it.CurrentShow = normalizePendingShowServer(it.CurrentShow) it.ImageURL = strings.TrimSpace(it.ImageURL) it.Source = "watched" if it.ModelKey == "" || it.URL == "" { continue } next = append(next, it) } return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) } func hasHiddenProbeRunningForProvider(provider string) bool { provider = strings.ToLower(strings.TrimSpace(provider)) if provider == "" { return false } jobsMu.RLock() defer jobsMu.RUnlock() for _, j := range jobs { if j == nil { continue } if j.Status != JobRunning { continue } if !j.Hidden { continue } if pendingProviderFromURL(strings.TrimSpace(j.SourceURL)) != provider { continue } return true } return false } func removeWatchedPendingAutoStartItemForProvider(provider, modelKey string) error { provider = strings.ToLower(strings.TrimSpace(provider)) modelKey = strings.ToLower(strings.TrimSpace(modelKey)) if provider == "" || modelKey == "" { return nil } items, err := loadPendingAutoStartItems(pendingAutoStartGlobalUserKey) if err != nil { return err } next := make([]PendingAutoStartItem, 0, len(items)) for _, it := range items { if normalizePendingSourceServer(it.Source) == "watched" && strings.ToLower(strings.TrimSpace(it.ModelKey)) == modelKey && pendingProviderFromURL(it.URL) == provider { continue } next = append(next, it) } return savePendingAutoStartItems(pendingAutoStartGlobalUserKey, next) } func savePendingAutoStartItems(userKey string, items []PendingAutoStartItem) error { path := pendingAutoStartFilePath(userKey) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } payload := pendingAutoStartFile{ Items: sanitizePendingAutoStartItems(items), } raw, err := json.MarshalIndent(payload, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, raw, 0o644); err != nil { return err } return os.Rename(tmp, path) } func handlePendingAutoStart(auth *AuthManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { sess, _ := auth.getSession(r) if sess == nil || !sess.Authed || strings.TrimSpace(sess.User) == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } userKey := strings.ToLower(strings.TrimSpace(sess.User)) pendingAutoStartMu.Lock() defer pendingAutoStartMu.Unlock() switch r.Method { case http.MethodGet: items, err := loadMergedPendingAutoStartItems(userKey) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(PendingAutoStartResponse{ Items: items, }) return case http.MethodPost: var in PendingAutoStartItem if err := json.NewDecoder(r.Body).Decode(&in); err != nil { http.Error(w, "invalid json body", http.StatusBadRequest) return } in.ModelKey = strings.ToLower(strings.TrimSpace(in.ModelKey)) in.URL = strings.TrimSpace(in.URL) in.Mode = normalizePendingModeServer(in.Mode) in.CurrentShow = normalizePendingShowServer(in.CurrentShow) in.ImageURL = strings.TrimSpace(in.ImageURL) in.Source = normalizePendingSourceServer(in.Source) if in.ModelKey == "" { http.Error(w, "missing modelKey", http.StatusBadRequest) return } if in.URL == "" { http.Error(w, "missing url", http.StatusBadRequest) return } u, err := url.Parse(in.URL) if err != nil || (u.Scheme != "http" && u.Scheme != "https") { http.Error(w, "invalid url", http.StatusBadRequest) return } items, err := loadPendingAutoStartItems(userKey) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } replaced := false for i := range items { if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == in.ModelKey { items[i] = in replaced = true break } } if !replaced { items = append(items, in) } if err := savePendingAutoStartItems(userKey, items); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return case http.MethodDelete: modelKey := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("modelKey"))) if modelKey == "" { http.Error(w, "missing modelKey", http.StatusBadRequest) return } items, err := loadPendingAutoStartItems(userKey) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } next := make([]PendingAutoStartItem, 0, len(items)) for _, item := range items { if strings.ToLower(strings.TrimSpace(item.ModelKey)) == modelKey { continue } next = append(next, item) } if err := savePendingAutoStartItems(userKey, next); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) return default: w.Header().Set("Allow", "GET, POST, DELETE") http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } } }