nsfwapp/backend/models_api.go
2026-01-13 14:00:05 +01:00

413 lines
11 KiB
Go

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