// backend\models_api.go package main import ( "encoding/csv" "encoding/json" "errors" "io" "net/http" "net/url" "strconv" "strings" ) // ✅ umbenannt, damit es nicht mit models.go kollidiert func modelsWriteJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func modelsReadJSON(r *http.Request, v any) error { if r.Body == nil { return errors.New("missing body") } defer r.Body.Close() return json.NewDecoder(r.Body).Decode(v) } type parseReq struct { Input string `json:"input"` } func parseModelFromURL(raw string) (ParsedModelDTO, error) { in := strings.TrimSpace(raw) if in == "" { return ParsedModelDTO{}, errors.New("Bitte eine URL eingeben.") } // scheme ergänzen, falls User "chaturbate.com/xyz" eingibt if !strings.Contains(in, "://") { in = "https://" + in } u, err := url.Parse(in) if err != nil || u.Scheme == "" || u.Hostname() == "" { return ParsedModelDTO{}, errors.New("Ungültige URL.") } host := strings.ToLower(u.Hostname()) host = strings.TrimPrefix(host, "www.") // ModelKey aus Pfad/Fragment ableiten path := strings.Trim(u.Path, "/") segs := strings.Split(path, "/") skip := map[string]bool{ "models": true, "model": true, "profile": true, "users": true, "user": true, } var key string for _, s := range segs { s = strings.TrimSpace(s) if s == "" || skip[strings.ToLower(s)] { continue } key = s break } if key == "" && strings.TrimSpace(u.Fragment) != "" { key = strings.Trim(strings.TrimSpace(u.Fragment), "/") } if key == "" { return ParsedModelDTO{}, errors.New("Konnte keinen Modelnamen aus der URL ableiten.") } // URL-decode + kleines Sanitizing if dec, err := url.PathUnescape(key); err == nil { key = dec } key = strings.TrimPrefix(strings.TrimSpace(key), "@") key = strings.Map(func(r rune) rune { switch { case r >= 'a' && r <= 'z': return r case r >= 'A' && r <= 'Z': return r case r >= '0' && r <= '9': return r case r == '_' || r == '-' || r == '.': return r default: return -1 } }, key) if key == "" { return ParsedModelDTO{}, errors.New("Ungültiger Modelname in URL.") } return ParsedModelDTO{ Input: u.String(), // ✅ speicherst du als URL IsURL: true, Host: host, Path: u.Path, ModelKey: key, // ✅ kommt IMMER aus URL }, nil } type importResult struct { Processed int `json:"processed"` Inserted int `json:"inserted"` Updated int `json:"updated"` Skipped int `json:"skipped"` } func importModelsCSV(store *ModelStore, r io.Reader, kind string) (importResult, error) { cr := csv.NewReader(r) cr.Comma = ';' cr.FieldsPerRecord = -1 cr.TrimLeadingSpace = true header, err := cr.Read() if err != nil { return importResult{}, errors.New("CSV: header fehlt") } idx := map[string]int{} for i, h := range header { idx[strings.ToLower(strings.TrimSpace(h))] = i } need := []string{"url", "last_stream", "tags"} for _, k := range need { if _, ok := idx[k]; !ok { return importResult{}, errors.New("CSV: Spalte fehlt: " + k) } } // ✅ watch ODER watched akzeptieren if _, ok := idx["watch"]; !ok { if _, ok2 := idx["watched"]; !ok2 { return importResult{}, errors.New("CSV: Spalte fehlt: watch oder watched") } } seen := map[string]bool{} out := importResult{} for { rec, err := cr.Read() if err == io.EOF { break } if err != nil { return out, errors.New("CSV: ungültige Zeile") } get := func(key string) string { i := idx[key] if i < 0 || i >= len(rec) { return "" } return strings.TrimSpace(rec[i]) } urlRaw := get("url") if urlRaw == "" { out.Skipped++ continue } dto, err := parseModelFromURL(urlRaw) if err != nil { out.Skipped++ continue } tags := get("tags") lastStream := get("last_stream") watchStr := get("watch") if watchStr == "" { watchStr = get("watched") } watch := false if watchStr != "" { if n, err := strconv.Atoi(watchStr); err == nil { watch = n != 0 } else { // "true"/"false" fallback watch = strings.EqualFold(watchStr, "true") || strings.EqualFold(watchStr, "yes") } } // dedupe innerhalb der Datei (host:modelKey) key := strings.ToLower(dto.Host) + ":" + strings.ToLower(dto.ModelKey) if seen[key] { continue } seen[key] = true _, inserted, err := store.UpsertFromImport(dto, tags, lastStream, watch, kind) if err != nil { out.Skipped++ continue } out.Processed++ if inserted { out.Inserted++ } else { out.Updated++ } } return out, nil } func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { // ✅ NEU: Parse-Endpoint (nur URL erlaubt) mux.HandleFunc("/api/models/parse", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req parseReq if err := modelsReadJSON(r, &req); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } dto, err := parseModelFromURL(req.Input) if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } modelsWriteJSON(w, http.StatusOK, dto) }) mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) { modelsWriteJSON(w, http.StatusOK, store.Meta()) }) mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) { host := strings.TrimSpace(r.URL.Query().Get("host")) modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host)) }) mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) { modelsWriteJSON(w, http.StatusOK, store.List()) }) mux.HandleFunc("/api/models/upsert", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req ParsedModelDTO if err := modelsReadJSON(r, &req); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } // ✅ Server-seitig: nur URL akzeptieren (wird zusätzlich im Store geprüft) if !req.IsURL { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "Nur URL erlaubt."}) return } m, err := store.UpsertFromParsed(req) if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } modelsWriteJSON(w, http.StatusOK, m) }) // ✅ NEU: Ensure-Endpoint (für QuickActions aus FinishedDownloads) // Erst versucht er ein bestehendes Model via modelKey zu finden, sonst legt er ein "manual" Model an. mux.HandleFunc("/api/models/ensure", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req struct { ModelKey string `json:"modelKey"` Host string `json:"host,omitempty"` } if err := modelsReadJSON(r, &req); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } key := strings.TrimSpace(req.ModelKey) host := strings.ToLower(strings.TrimSpace(req.Host)) host = strings.TrimPrefix(host, "www.") if key == "" { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"}) return } m, err := store.EnsureByHostModelKey(host, key) if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } modelsWriteJSON(w, http.StatusOK, m) }) mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } if err := r.ParseMultipartForm(32 << 20); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"}) return } kind := strings.ToLower(strings.TrimSpace(r.FormValue("kind"))) if kind != "favorite" && kind != "liked" { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": `kind must be "favorite" or "liked"`}) return } f, _, err := r.FormFile("file") if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file"}) return } defer f.Close() res, err := importModelsCSV(store, f, kind) if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } modelsWriteJSON(w, http.StatusOK, res) }) mux.HandleFunc("/api/models/flags", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req ModelFlagsPatch if err := modelsReadJSON(r, &req); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } // ✅ id optional: wenn fehlt -> per (host, modelKey) sicherstellen + id setzen if strings.TrimSpace(req.ID) == "" { key := strings.TrimSpace(req.ModelKey) host := strings.TrimSpace(req.Host) if key == "" { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "id oder modelKey fehlt"}) return } ensured, err := store.EnsureByHostModelKey(host, key) // host darf leer sein if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } req.ID = ensured.ID } m, err := store.PatchFlags(req) if err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } // ✅ Wenn ein Model weder beobachtet noch favorisiert/geliked ist, fliegt es aus dem Store. // (Damit bleibt der Store „sauber“ und ModelsTab listet nur relevante Einträge.) likedOn := (m.Liked != nil && *m.Liked) if !m.Watching && !m.Favorite && !likedOn { _ = store.Delete(m.ID) // best-effort: Patch war erfolgreich, Delete darf hier nicht „fatal“ sein w.WriteHeader(http.StatusNoContent) return } modelsWriteJSON(w, http.StatusOK, m) }) mux.HandleFunc("/api/models/delete", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } var req struct { ID string `json:"id"` } if err := modelsReadJSON(r, &req); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } if err := store.Delete(req.ID); err != nil { modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } modelsWriteJSON(w, http.StatusOK, map[string]any{"ok": true}) }) }