// backend/models_store.go package main import ( "database/sql" "errors" "net/http" "net/url" "strings" "sync" "time" _ "github.com/jackc/pgx/v5/stdlib" ) type StoredModel struct { ID string `json:"id"` // unique (wir verwenden host:modelKey) Input string `json:"input"` // Original-URL/Eingabe IsURL bool `json:"isUrl"` // vom Parser Host string `json:"host,omitempty"` Path string `json:"path,omitempty"` ModelKey string `json:"modelKey"` // Display/Key Tags string `json:"tags,omitempty"` LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano ProfileImageURL string `json:"profileImageUrl,omitempty"` ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=... ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano Watching bool `json:"watching"` Favorite bool `json:"favorite"` Hot bool `json:"hot"` Keep bool `json:"keep"` Liked *bool `json:"liked,omitempty"` // null => unbekannt CreatedAt string `json:"createdAt"` // RFC3339Nano UpdatedAt string `json:"updatedAt"` // RFC3339Nano } type ModelsMeta struct { Count int `json:"count"` UpdatedAt string `json:"updatedAt"` } // Kleine Payload für "watched" Listen (für Autostart/Abgleich) type WatchedModelLite struct { ID string `json:"id"` Input string `json:"input"` Host string `json:"host,omitempty"` ModelKey string `json:"modelKey"` Watching bool `json:"watching"` } type ParsedModelDTO struct { Input string `json:"input"` IsURL bool `json:"isUrl"` Host string `json:"host,omitempty"` Path string `json:"path,omitempty"` ModelKey string `json:"modelKey"` } type ModelFlagsPatch struct { Host string `json:"host,omitempty"` ModelKey string `json:"modelKey,omitempty"` ID string `json:"id,omitempty"` Watched *bool `json:"watched,omitempty"` Favorite *bool `json:"favorite,omitempty"` Liked *bool `json:"liked,omitempty"` } type ModelStore struct { dsn string db *sql.DB initOnce sync.Once initErr error // serialize writes (einfach & robust) mu sync.Mutex } func fmtTime(t time.Time) string { if t.IsZero() { return "" } return t.UTC().Format(time.RFC3339Nano) } func fmtNullTime(nt sql.NullTime) string { if !nt.Valid || nt.Time.IsZero() { return "" } return nt.Time.UTC().Format(time.RFC3339Nano) } func nullableTimeArg(nt sql.NullTime) any { if !nt.Valid { return nil } return nt.Time } func ptrBoolFromNullBool(n sql.NullBool) *bool { if !n.Valid { return nil } v := n.Bool return &v } func ptrLikedFromNullBool(n sql.NullBool) *bool { if !n.Valid { return nil } v := n.Bool return &v } // parseRFC3339Nano: akzeptiert RFC3339/RFC3339Nano, sonst "invalid" -> (Valid=false) func parseRFC3339Nano(s string) sql.NullTime { s = strings.TrimSpace(s) if s == "" { return sql.NullTime{Valid: false} } // RFC3339Nano ist superset, aber manche Werte sind RFC3339 if t, err := time.Parse(time.RFC3339Nano, s); err == nil { return sql.NullTime{Valid: true, Time: t.UTC()} } if t, err := time.Parse(time.RFC3339, s); err == nil { return sql.NullTime{Valid: true, Time: t.UTC()} } return sql.NullTime{Valid: false} } func NewModelStore(dsn string) *ModelStore { return &ModelStore{dsn: strings.TrimSpace(dsn)} } func (s *ModelStore) Load() error { return s.ensureInit() } func (s *ModelStore) ensureInit() error { s.initOnce.Do(func() { s.initErr = s.init() }) return s.initErr } func (s *ModelStore) init() error { if strings.TrimSpace(s.dsn) == "" { return errors.New("db dsn fehlt") } db, err := sql.Open("pgx", s.dsn) if err != nil { return err } db.SetMaxOpenConns(5) db.SetMaxIdleConns(5) if err := db.Ping(); err != nil { _ = db.Close() return err } // ✅ Du hast die Tabelle schon in Postgres angelegt (mit richtigen Typen). // Deshalb hier KEIN create/alter mehr, sonst riskierst du falsche Typen. s.db = db if err := s.normalizeNameOnlyChaturbate(); err != nil { return err } return nil } func canonicalHost(host string) string { h := strings.ToLower(strings.TrimSpace(host)) h = strings.TrimPrefix(h, "www.") return h } func canonicalID(host, modelKey string) string { h := canonicalHost(host) k := strings.TrimSpace(modelKey) if h != "" { return h + ":" + k } return k } func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err } key := strings.TrimSpace(modelKey) if key == "" { return StoredModel{}, errors.New("modelKey fehlt") } h := canonicalHost(host) // host optional: wenn leer -> fallback auf bisherigen Weg (best match über alle Hosts) if h == "" { return s.EnsureByModelKey(key) } // 1) explizit host+key suchen var existingID string err := s.db.QueryRow(` SELECT id FROM models WHERE lower(trim(host)) = lower(trim($1)) AND lower(trim(model_key)) = lower(trim($2)) LIMIT 1; `, h, key).Scan(&existingID) if err == nil && existingID != "" { return s.getByID(existingID) } if err != nil && !errors.Is(err, sql.ErrNoRows) { return StoredModel{}, err } // 2) nicht vorhanden -> "manual" anlegen (is_url=false, input=modelKey), ABER host gesetzt now := time.Now().UTC() id := canonicalID(h, key) s.mu.Lock() defer s.mu.Unlock() _, err = s.db.Exec(` INSERT INTO models ( id,input,is_url,host,path,model_key, tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET model_key=EXCLUDED.model_key, host=EXCLUDED.host, updated_at=EXCLUDED.updated_at; `, id, key, false, h, "", key, "", nil, false, false, false, false, nil, now, now, ) if err != nil { return StoredModel{}, err } return s.getByID(id) } // EnsureByModelKey: // - liefert ein bestehendes Model (best match) wenn vorhanden // - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false) func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err } key := strings.TrimSpace(modelKey) if key == "" { return StoredModel{}, errors.New("modelKey fehlt") } var existingID string err := s.db.QueryRow(` SELECT id FROM models WHERE lower(trim(model_key)) = lower(trim($1)) ORDER BY CASE WHEN is_url=true THEN 1 ELSE 0 END DESC, CASE WHEN host IS NOT NULL AND trim(host)<>'' THEN 1 ELSE 0 END DESC, favorite DESC, updated_at DESC LIMIT 1; `, key).Scan(&existingID) if err == nil && existingID != "" { return s.getByID(existingID) } if err != nil && !errors.Is(err, sql.ErrNoRows) { return StoredModel{}, err } now := time.Now().UTC() id := canonicalID("", key) s.mu.Lock() defer s.mu.Unlock() _, err = s.db.Exec(` INSERT INTO models ( id,input,is_url,host,path,model_key, tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET model_key=EXCLUDED.model_key, updated_at=EXCLUDED.updated_at; `, id, key, false, "", "", key, "", nil, false, false, false, false, nil, now, now, ) if err != nil { return StoredModel{}, err } return s.getByID(id) } func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom) { if err := s.ensureInit(); err != nil { return } if len(rooms) == 0 { return } now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() tx, err := s.db.Begin() if err != nil { return } defer func() { _ = tx.Rollback() }() stmt, err := tx.Prepare(` UPDATE models SET tags = $1, updated_at = $2 WHERE lower(trim(host)) = 'chaturbate.com' AND lower(trim(model_key)) = lower(trim($3)) AND (tags IS NULL OR trim(tags) = ''); `) if err != nil { return } defer stmt.Close() for _, rm := range rooms { key := strings.TrimSpace(rm.Username) if key == "" || len(rm.Tags) == 0 { continue } tags := strings.TrimSpace(strings.Join(rm.Tags, ", ")) if tags == "" { continue } _, _ = stmt.Exec(tags, now, key) } _ = tx.Commit() } // --- Profile image cache --- // SetProfileImage speichert Bild-URL + MIME + Blob. // Legt den Datensatz bei Bedarf minimal an. func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, data []byte, updatedAt string) error { if err := s.ensureInit(); err != nil { return err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) if host == "" || key == "" { return errors.New("host/modelKey fehlt") } if len(data) == 0 { return errors.New("image data fehlt") } src := strings.TrimSpace(sourceURL) mime = strings.TrimSpace(strings.ToLower(mime)) if mime == "" || mime == "application/octet-stream" { detected := http.DetectContentType(data) if strings.TrimSpace(detected) != "" { mime = detected } } if mime == "" { mime = "image/jpeg" } nt := parseRFC3339Nano(updatedAt) if !nt.Valid { nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() // Erst Update versuchen res, err := s.db.Exec(` UPDATE models SET profile_image_url=$1, profile_image_mime=$2, profile_image_blob=$3, profile_image_updated_at=$4, updated_at=$5 WHERE lower(trim(host)) = lower(trim($6)) AND lower(trim(model_key)) = lower(trim($7)); `, src, mime, data, nullableTimeArg(nt), now, host, key) if err != nil { return err } aff, _ := res.RowsAffected() if aff > 0 { return nil } // Kein Auto-Insert: Profilbild nur für bereits bestehende Models speichern. return nil } // SetProfileImageURLOnly speichert nur die letzte bekannte Bild-URL (+Zeit), ohne Blob. // Praktisch als Fallback, wenn Download fehlschlägt. func (s *ModelStore) SetProfileImageURLOnly(host, modelKey, sourceURL, updatedAt string) error { if err := s.ensureInit(); err != nil { return err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) src := strings.TrimSpace(sourceURL) if host == "" || key == "" { return errors.New("host/modelKey fehlt") } if src == "" { return nil } nt := parseRFC3339Nano(updatedAt) if !nt.Valid { nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() res, err := s.db.Exec(` UPDATE models SET profile_image_url=$1, profile_image_updated_at=$2, updated_at=$3 WHERE lower(trim(host)) = lower(trim($4)) AND lower(trim(model_key)) = lower(trim($5)); `, src, nullableTimeArg(nt), now, host, key) if err != nil { return err } aff, _ := res.RowsAffected() if aff > 0 { return nil } // Kein Auto-Insert: Bild-URL nur für bereits bestehende Models speichern. return nil } func (s *ModelStore) GetProfileImageByID(id string) (mime string, data []byte, ok bool, err error) { if err := s.ensureInit(); err != nil { return "", nil, false, err } id = strings.TrimSpace(id) if id == "" { return "", nil, false, errors.New("id fehlt") } var mimeNS sql.NullString var blob []byte err = s.db.QueryRow(` SELECT profile_image_mime, profile_image_blob FROM models WHERE id = $1 LIMIT 1; `, id).Scan(&mimeNS, &blob) if errors.Is(err, sql.ErrNoRows) { return "", nil, false, nil } if err != nil { return "", nil, false, err } if len(blob) == 0 { return "", nil, false, nil } m := strings.TrimSpace(mimeNS.String) if m == "" { m = http.DetectContentType(blob) if m == "" { m = "application/octet-stream" } } return m, blob, true, nil } // --- Biocontext Cache --- func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetchedAt string, ok bool, err error) { if err := s.ensureInit(); err != nil { return "", "", false, err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) if host == "" || key == "" { return "", "", false, errors.New("host/modelKey fehlt") } var js sql.NullString var ts sql.NullTime err = s.db.QueryRow(` SELECT biocontext_json, biocontext_fetched_at FROM models WHERE lower(trim(host)) = lower(trim($1)) AND lower(trim(model_key)) = lower(trim($2)) LIMIT 1; `, host, key).Scan(&js, &ts) if errors.Is(err, sql.ErrNoRows) { return "", "", false, nil } if err != nil { return "", "", false, err } val := strings.TrimSpace(js.String) if val == "" { return "", fmtNullTime(ts), false, nil } return val, fmtNullTime(ts), true, nil } func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error { if err := s.ensureInit(); err != nil { return err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) if host == "" || key == "" { return errors.New("host/modelKey fehlt") } js := strings.TrimSpace(jsonStr) ts := parseRFC3339Nano(fetchedAt) // NullTime now := time.Now().UTC() // time.Time s.mu.Lock() defer s.mu.Unlock() res, err := s.db.Exec(` UPDATE models SET biocontext_json=$1, biocontext_fetched_at=$2, updated_at=$3 WHERE lower(trim(host)) = lower(trim($4)) AND lower(trim(model_key)) = lower(trim($5)); `, js, nullableTimeArg(ts), now, host, key) if err != nil { return err } aff, _ := res.RowsAffected() if aff > 0 { return nil } // Kein Auto-Insert: Biocontext nur für vorhandene Models. return nil } // SetLastSeenOnline speichert Online/Offline Status func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenAt string) error { if err := s.ensureInit(); err != nil { return err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) if host == "" || key == "" { return errors.New("host/modelKey fehlt") } nt := parseRFC3339Nano(seenAt) if !nt.Valid { nt = sql.NullTime{Valid: true, Time: time.Now().UTC()} } now := time.Now().UTC() // ✅ last_seen_online ist in deiner DB BOOLEAN (nullable) var onlineArg any if online { onlineArg = true } else { onlineArg = false } s.mu.Lock() defer s.mu.Unlock() res, err := s.db.Exec(` UPDATE models SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3 WHERE lower(trim(host)) = lower(trim($4)) AND lower(trim(model_key)) = lower(trim($5)); `, onlineArg, nullableTimeArg(nt), now, host, key) if err != nil { return err } aff, _ := res.RowsAffected() if aff > 0 { return nil } // Wichtig: Keine Auto-Erzeugung durch Online-Poller. // Nur bereits manuell/importiert vorhandene Models werden aktualisiert. return nil } func (s *ModelStore) normalizeNameOnlyChaturbate() error { // ✅ last_stream ist TIMESTAMPTZ -> niemals COALESCE(...,'') rows, err := s.db.Query(` SELECT id, model_key, tags, last_stream, watching,favorite,hot,keep,liked, created_at, updated_at FROM models WHERE is_url = false AND lower(trim(input)) = lower(trim(model_key)) AND (host IS NULL OR trim(host)='' OR lower(trim(host))='chaturbate.com'); `) if err != nil { return err } defer rows.Close() type rowT struct { oldID, key, tags string lastStream sql.NullTime watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt sql.NullTime } var items []rowT for rows.Next() { var r rowT if err := rows.Scan( &r.oldID, &r.key, &r.tags, &r.lastStream, &r.watching, &r.favorite, &r.hot, &r.keep, &r.liked, &r.createdAt, &r.updatedAt, ); err != nil { continue } r.oldID = strings.TrimSpace(r.oldID) r.key = strings.TrimSpace(r.key) if r.oldID == "" || r.key == "" { continue } items = append(items, r) } if len(items) == 0 { return nil } s.mu.Lock() defer s.mu.Unlock() tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() const host = "chaturbate.com" for _, it := range items { now := time.Now().UTC() created := now if it.createdAt.Valid && !it.createdAt.Time.IsZero() { created = it.createdAt.Time.UTC() } updated := now if it.updatedAt.Valid && !it.updatedAt.Time.IsZero() { updated = it.updatedAt.Time.UTC() } newInput := "https://" + host + "/" + it.key + "/" newPath := "/" + it.key + "/" var targetID string err := tx.QueryRow(` SELECT id FROM models WHERE lower(trim(host)) = lower($1) AND lower(trim(model_key)) = lower($2) LIMIT 1; `, host, it.key).Scan(&targetID) if errors.Is(err, sql.ErrNoRows) { targetID = "" err = nil } if err != nil { return err } var likedArg any if it.liked.Valid { likedArg = it.liked.Bool } else { likedArg = nil } lastStreamArg := nullableTimeArg(it.lastStream) if targetID == "" { targetID = canonicalID(host, it.key) _, err = tx.Exec(` INSERT INTO models ( id,input,is_url,host,path,model_key, tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15); `, targetID, newInput, true, host, newPath, it.key, it.tags, lastStreamArg, it.watching, it.favorite, it.hot, it.keep, likedArg, created, updated, ) if err != nil { return err } } else { _, err = tx.Exec(` UPDATE models SET input = CASE WHEN is_url=false OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key)) THEN $1 ELSE input END, is_url = CASE WHEN is_url=false THEN true ELSE is_url END, host = CASE WHEN host IS NULL OR trim(host)='' THEN $2 ELSE host END, path = CASE WHEN path IS NULL OR trim(path)='' THEN $3 ELSE path END, tags = CASE WHEN (tags IS NULL OR tags='') AND $4<>'' THEN $5 ELSE tags END, -- ✅ last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben last_stream = CASE WHEN last_stream IS NULL AND $6 IS NOT NULL THEN $6 ELSE last_stream END, watching = CASE WHEN $7=true THEN true ELSE watching END, favorite = CASE WHEN $8=true THEN true ELSE favorite END, hot = CASE WHEN $9=true THEN true ELSE hot END, keep = CASE WHEN $10=true THEN true ELSE keep END, liked = CASE WHEN liked IS NULL AND $11 IS NOT NULL THEN $11 ELSE liked END, updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END WHERE id = $13; `, newInput, host, newPath, it.tags, it.tags, lastStreamArg, it.watching, it.favorite, it.hot, it.keep, likedArg, updated, targetID, ) if err != nil { return err } } if it.oldID != targetID { if _, err := tx.Exec(`DELETE FROM models WHERE id=$1;`, it.oldID); err != nil { return err } } } return tx.Commit() } func (s *ModelStore) List() []StoredModel { if err := s.ensureInit(); err != nil { return []StoredModel{} } // ✅ last_stream ist TIMESTAMPTZ -> direkt lesen (NullTime), niemals COALESCE(...,'') rows, err := s.db.Query(` SELECT id,input,is_url,host,path,model_key, tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, watching,favorite,hot,keep,liked, created_at, updated_at FROM models ORDER BY updated_at DESC; `) if err != nil { return []StoredModel{} } defer rows.Close() out := make([]StoredModel, 0, 64) for rows.Next() { var ( id, input, host, path, modelKey, tags string isURL bool lastStream sql.NullTime lastSeenOnline sql.NullBool lastSeenOnlineAt sql.NullTime profileImageURL string profileImageUpdatedAt sql.NullTime hasProfileImage int64 watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt time.Time ) if err := rows.Scan( &id, &input, &isURL, &host, &path, &modelKey, &tags, &lastStream, &lastSeenOnline, &lastSeenOnlineAt, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ); err != nil { continue } m := StoredModel{ ID: id, Input: input, IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), Watching: watching, Favorite: favorite, Hot: hot, Keep: keep, Liked: ptrLikedFromNullBool(liked), CreatedAt: fmtTime(createdAt), UpdatedAt: fmtTime(updatedAt), ProfileImageURL: profileImageURL, ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), } if hasProfileImage != 0 { m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id) } out = append(out, m) } return out } func (s *ModelStore) Meta() ModelsMeta { if err := s.ensureInit(); err != nil { return ModelsMeta{Count: 0, UpdatedAt: ""} } var count int var updatedAt sql.NullTime err := s.db.QueryRow(`SELECT COUNT(*), MAX(updated_at) FROM models;`).Scan(&count, &updatedAt) if err != nil { return ModelsMeta{Count: 0, UpdatedAt: ""} } return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt)} } // hostFilter: z.B. "chaturbate.com" (leer => alle Hosts) func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite { if err := s.ensureInit(); err != nil { return []WatchedModelLite{} } hostFilter = canonicalHost(hostFilter) var ( rows *sql.Rows err error ) if hostFilter == "" { rows, err = s.db.Query(` SELECT id,input,host,model_key,watching FROM models WHERE watching = true ORDER BY updated_at DESC; `) } else { rows, err = s.db.Query(` SELECT id,input,host,model_key,watching FROM models WHERE watching = true AND host = $1 ORDER BY updated_at DESC; `, hostFilter) } if err != nil { return []WatchedModelLite{} } defer rows.Close() out := make([]WatchedModelLite, 0, 64) for rows.Next() { var id, input, host, modelKey string var watching bool if err := rows.Scan(&id, &input, &host, &modelKey, &watching); err != nil { continue } out = append(out, WatchedModelLite{ ID: id, Input: input, Host: host, ModelKey: modelKey, Watching: watching, }) } return out } func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err } if p.ModelKey == "" { return StoredModel{}, errors.New("modelKey fehlt") } input := strings.TrimSpace(p.Input) if input == "" { return StoredModel{}, errors.New("URL fehlt.") } if !p.IsURL { return StoredModel{}, errors.New("Nur URL erlaubt.") } u, err := url.Parse(input) if err != nil || u.Scheme == "" || u.Hostname() == "" { return StoredModel{}, errors.New("Ungültige URL.") } host := canonicalHost(p.Host) modelKey := strings.TrimSpace(p.ModelKey) id := canonicalID(host, modelKey) now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() _, err = s.db.Exec(` INSERT INTO models ( id,input,is_url,host,path,model_key, tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET input=EXCLUDED.input, is_url=EXCLUDED.is_url, host=EXCLUDED.host, path=EXCLUDED.path, model_key=EXCLUDED.model_key, updated_at=EXCLUDED.updated_at; `, id, u.String(), true, host, p.Path, modelKey, "", nil, false, false, false, false, nil, now, now, ) if err != nil { return StoredModel{}, err } return s.getByID(id) } func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err } if patch.ID == "" { return StoredModel{}, errors.New("id fehlt") } s.mu.Lock() defer s.mu.Unlock() var ( watching, favorite, hot, keep bool liked sql.NullBool ) err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID). Scan(&watching, &favorite, &hot, &keep, &liked) if err != nil { if errors.Is(err, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } return StoredModel{}, err } if patch.Watched != nil { watching = *patch.Watched } if patch.Favorite != nil { favorite = *patch.Favorite } if patch.Liked != nil { liked = sql.NullBool{Valid: true, Bool: *patch.Liked} } // Exklusivität if patch.Liked != nil && *patch.Liked { favorite = false } if patch.Favorite != nil && *patch.Favorite { if patch.Liked == nil || !*patch.Liked { liked = sql.NullBool{Valid: true, Bool: false} } } now := time.Now().UTC() var likedArg any if liked.Valid { likedArg = liked.Bool } else { likedArg = nil } _, err = s.db.Exec(` UPDATE models SET watching=$1, favorite=$2, hot=$3, keep=$4, liked=$5, updated_at=$6 WHERE id=$7; `, watching, favorite, hot, keep, likedArg, now, patch.ID) if err != nil { return StoredModel{}, err } return s.getByID(patch.ID) } func (s *ModelStore) Delete(id string) error { if err := s.ensureInit(); err != nil { return err } if id == "" { return errors.New("id fehlt") } s.mu.Lock() defer s.mu.Unlock() _, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id) return err } func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string, watch bool, kind string) (StoredModel, bool, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, false, err } input := strings.TrimSpace(p.Input) if input == "" || !p.IsURL { return StoredModel{}, false, errors.New("Nur URL erlaubt.") } u, err := url.Parse(input) if err != nil || u.Scheme == "" || u.Hostname() == "" { return StoredModel{}, false, errors.New("Ungültige URL.") } host := canonicalHost(p.Host) modelKey := strings.TrimSpace(p.ModelKey) id := canonicalID(host, modelKey) now := time.Now().UTC() fav := false var likedArg any = nil if kind == "favorite" { fav = true } if kind == "liked" { likedArg = true } // last_stream kommt aus CSV als String -> parse ls := parseRFC3339Nano(lastStream) s.mu.Lock() defer s.mu.Unlock() inserted := false var dummy int err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=$1 LIMIT 1;`, id).Scan(&dummy) if err == sql.ErrNoRows { inserted = true } else if err != nil { return StoredModel{}, false, err } _, err = s.db.Exec(` INSERT INTO models ( id,input,is_url,host,path,model_key, tags,last_stream, watching,favorite,hot,keep,liked, created_at,updated_at ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO UPDATE SET input=EXCLUDED.input, is_url=EXCLUDED.is_url, host=EXCLUDED.host, path=EXCLUDED.path, model_key=EXCLUDED.model_key, tags=EXCLUDED.tags, last_stream=EXCLUDED.last_stream, watching=EXCLUDED.watching, favorite=CASE WHEN EXCLUDED.favorite=true THEN true ELSE models.favorite END, liked=CASE WHEN EXCLUDED.liked IS NOT NULL THEN EXCLUDED.liked ELSE models.liked END, updated_at=EXCLUDED.updated_at; `, id, u.String(), true, host, p.Path, modelKey, tags, nullableTimeArg(ls), watch, fav, false, false, likedArg, now, now, ) if err != nil { return StoredModel{}, false, err } m, err := s.getByID(id) return m, inserted, err } func (s *ModelStore) getByID(id string) (StoredModel, error) { var ( input, host, path, modelKey, tags string isURL bool lastStream sql.NullTime lastSeenOnline sql.NullBool lastSeenOnlineAt sql.NullTime profileImageURL string profileImageUpdatedAt sql.NullTime hasProfileImage int64 watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt time.Time ) err := s.db.QueryRow(` SELECT input,is_url,host,path,model_key, tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND octet_length(profile_image_blob) > 0 THEN 1 ELSE 0 END as has_profile_image, watching,favorite,hot,keep,liked, created_at, updated_at FROM models WHERE id=$1; `, id).Scan( &input, &isURL, &host, &path, &modelKey, &tags, &lastStream, &lastSeenOnline, &lastSeenOnlineAt, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } return StoredModel{}, err } m := StoredModel{ ID: id, Input: input, IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, Tags: tags, LastStream: fmtNullTime(lastStream), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt), Watching: watching, Favorite: favorite, Hot: hot, Keep: keep, Liked: ptrLikedFromNullBool(liked), CreatedAt: fmtTime(createdAt), UpdatedAt: fmtTime(updatedAt), ProfileImageURL: profileImageURL, ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt), } if hasProfileImage != 0 { m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id) } return m, nil }