1271 lines
29 KiB
Go
1271 lines
29 KiB
Go
// 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
|
|
}
|