337 lines
8.5 KiB
Go
337 lines
8.5 KiB
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", "watch"}
|
|
for _, k := range need {
|
|
if _, ok := idx[k]; !ok {
|
|
return importResult{}, errors.New("CSV: Spalte fehlt: " + k)
|
|
}
|
|
}
|
|
|
|
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")
|
|
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)
|
|
})
|
|
|
|
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
|
|
}
|
|
m, err := store.PatchFlags(req)
|
|
if err != nil {
|
|
modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
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})
|
|
})
|
|
}
|