// backend/models_store.go package main import ( "database/sql" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" _ "github.com/jackc/pgx/v5/stdlib" _ "modernc.org/sqlite" ) 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 Gender string `json:"gender,omitempty"` Country string `json:"country,omitempty"` Tags string `json:"tags,omitempty"` LastStream string `json:"lastStream,omitempty"` // RFC3339Nano LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano // ✅ Chaturbate Online Snapshot (persistiert aus chaturbate_online.go) CbOnlineJSON string `json:"cbOnlineJson,omitempty"` CbOnlineFetchedAt string `json:"cbOnlineFetchedAt,omitempty"` CbOnlineLastError string `json:"cbOnlineLastError,omitempty"` ProfileImageURL string `json:"profileImageUrl,omitempty"` ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=... ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano RoomStatus string `json:"roomStatus,omitempty"` IsOnline bool `json:"isOnline,omitempty"` ChatRoomURL string `json:"chatRoomUrl,omitempty"` ImageURL string `json:"imageUrl,omitempty"` LastOnlineAt string `json:"lastOnlineAt,omitempty"` LastOfflineAt string `json:"lastOfflineAt,omitempty"` LastRoomSyncAt string `json:"lastRoomSyncAt,omitempty"` 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 { config ModelStoreConfig 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) } type dbNullTime struct { sql.NullTime } func (nt *dbNullTime) Scan(value any) error { if value == nil { nt.Valid = false nt.Time = time.Time{} return nil } switch v := value.(type) { case time.Time: nt.Valid = true nt.Time = v.UTC() return nil case string: return nt.scanString(v) case []byte: return nt.scanString(string(v)) default: var raw sql.NullTime if err := raw.Scan(value); err != nil { return err } nt.NullTime = raw if nt.Valid { nt.Time = nt.Time.UTC() } return nil } } func (nt *dbNullTime) scanString(value string) error { value = strings.TrimSpace(value) if value == "" { nt.Valid = false nt.Time = time.Time{} return nil } layouts := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05.999999-07:00", "2006-01-02 15:04:05-07:00", "2006-01-02 15:04:05.999999999Z07:00", "2006-01-02 15:04:05.999999Z07:00", "2006-01-02 15:04:05Z07:00", "2006-01-02 15:04:05.999999999 -0700 MST", "2006-01-02 15:04:05.999999 -0700 MST", "2006-01-02 15:04:05 -0700 MST", "2006-01-02 15:04:05.999999999", "2006-01-02 15:04:05.999999", "2006-01-02 15:04:05", } for _, layout := range layouts { if t, err := time.Parse(layout, value); err == nil { nt.Valid = true nt.Time = t.UTC() return nil } } return fmt.Errorf("invalid time value %q", value) } 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(config ModelStoreConfig) *ModelStore { config.Type = normalizeDatabaseType(config.Type) config.DSN = strings.TrimSpace(config.DSN) config.SQLitePath = strings.TrimSpace(config.SQLitePath) return &ModelStore{config: config} } func (s *ModelStore) Load() error { return s.ensureInit() } func (s *ModelStore) Close() error { if s == nil || s.db == nil { return nil } return s.db.Close() } func (s *ModelStore) ensureInit() error { s.initOnce.Do(func() { s.initErr = s.init() }) return s.initErr } func (s *ModelStore) init() error { config := s.config config.Type = normalizeDatabaseType(config.Type) driverName := "pgx" dsn := strings.TrimSpace(config.DSN) if config.Type == databaseTypeSQLite { sqlitePath := strings.TrimSpace(config.SQLitePath) if sqlitePath == "" { sqlitePath = dsn } if strings.TrimSpace(sqlitePath) == "" { return errors.New("sqlite path fehlt") } info, err := os.Stat(sqlitePath) if err == nil && info.IsDir() { return fmt.Errorf("sqlite path ist ein ordner: %s", sqlitePath) } if err := os.MkdirAll(filepath.Dir(sqlitePath), 0755); err != nil { return err } driverName = "sqlite" dsn = sqlitePath config.SQLitePath = sqlitePath } if strings.TrimSpace(dsn) == "" { return errors.New("db dsn fehlt") } db, err := sql.Open(driverName, dsn) if err != nil { return err } if config.Type == databaseTypeSQLite { db.SetMaxOpenConns(1) db.SetMaxIdleConns(1) } else { db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) } if err := db.Ping(); err != nil { _ = db.Close() return err } s.db = db s.config = config if config.Type == databaseTypeSQLite { if err := s.ensureSQLiteSchema(); err != nil { _ = db.Close() return err } } else { if err := s.ensurePostgresSchema(); err != nil { _ = db.Close() return err } } if err := s.normalizeNameOnlyChaturbate(); err != nil { return err } return nil } func (s *ModelStore) isSQLite() bool { return s != nil && normalizeDatabaseType(s.config.Type) == databaseTypeSQLite } func (s *ModelStore) blobLengthExpr() string { if s.isSQLite() { return "length(profile_image_blob)" } return "octet_length(profile_image_blob)" } func (s *ModelStore) emptyTextExpr() string { if s.isSQLite() { return "''" } return "''::text" } func (s *ModelStore) ensureSQLiteSchema() error { stmts := []string{ `PRAGMA busy_timeout = 5000;`, `PRAGMA foreign_keys = ON;`, `PRAGMA journal_mode = WAL;`, `CREATE TABLE IF NOT EXISTS models ( id TEXT PRIMARY KEY, input TEXT NOT NULL DEFAULT '', is_url BOOLEAN NOT NULL DEFAULT 0, host TEXT DEFAULT '', path TEXT DEFAULT '', model_key TEXT NOT NULL DEFAULT '', tags TEXT DEFAULT '', last_stream DATETIME NULL, last_seen_online BOOLEAN NULL, last_seen_online_at DATETIME NULL, cb_online_json TEXT DEFAULT '', cb_online_fetched_at DATETIME NULL, cb_online_last_error TEXT DEFAULT '', profile_image_url TEXT DEFAULT '', profile_image_mime TEXT DEFAULT '', profile_image_blob BLOB NULL, profile_image_updated_at DATETIME NULL, room_status TEXT DEFAULT '', is_online BOOLEAN NOT NULL DEFAULT 0, chat_room_url TEXT DEFAULT '', image_url TEXT DEFAULT '', last_online_at DATETIME NULL, last_offline_at DATETIME NULL, last_room_sync_at DATETIME NULL, biocontext_json TEXT DEFAULT '', biocontext_fetched_at DATETIME NULL, watching BOOLEAN NOT NULL DEFAULT 0, favorite BOOLEAN NOT NULL DEFAULT 0, hot BOOLEAN NOT NULL DEFAULT 0, keep BOOLEAN NOT NULL DEFAULT 0, liked BOOLEAN NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );`, `CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`, `CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`, `CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`, `CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } func (s *ModelStore) ensurePostgresSchema() error { stmts := []string{ `CREATE TABLE IF NOT EXISTS models ( id TEXT PRIMARY KEY, input TEXT NOT NULL DEFAULT '', is_url BOOLEAN NOT NULL DEFAULT false, host TEXT DEFAULT '', path TEXT DEFAULT '', model_key TEXT NOT NULL DEFAULT '', tags TEXT DEFAULT '', last_stream TIMESTAMPTZ NULL, last_seen_online BOOLEAN NULL, last_seen_online_at TIMESTAMPTZ NULL, cb_online_json TEXT DEFAULT '', cb_online_fetched_at TIMESTAMPTZ NULL, cb_online_last_error TEXT DEFAULT '', profile_image_url TEXT DEFAULT '', profile_image_mime TEXT DEFAULT '', profile_image_blob BYTEA NULL, profile_image_updated_at TIMESTAMPTZ NULL, room_status TEXT DEFAULT '', is_online BOOLEAN NOT NULL DEFAULT false, chat_room_url TEXT DEFAULT '', image_url TEXT DEFAULT '', last_online_at TIMESTAMPTZ NULL, last_offline_at TIMESTAMPTZ NULL, last_room_sync_at TIMESTAMPTZ NULL, biocontext_json TEXT DEFAULT '', biocontext_fetched_at TIMESTAMPTZ NULL, watching BOOLEAN NOT NULL DEFAULT false, favorite BOOLEAN NOT NULL DEFAULT false, hot BOOLEAN NOT NULL DEFAULT false, keep BOOLEAN NOT NULL DEFAULT false, liked BOOLEAN NULL, created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL );`, `CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`, `CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`, `CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`, `CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } func (s *ModelStore) SetChaturbateRoomState( host string, modelKey string, roomStatus string, isOnline bool, chatRoomURL string, imageURL string, seenAt time.Time, ) error { if err := s.ensureInit(); err != nil { return err } host = canonicalHost(host) modelKey = strings.TrimSpace(modelKey) roomStatus = strings.ToLower(strings.TrimSpace(roomStatus)) chatRoomURL = strings.TrimSpace(chatRoomURL) imageURL = strings.TrimSpace(imageURL) if host == "" { host = "chaturbate.com" } if modelKey == "" { return errors.New("modelKey fehlt") } seenAt = seenAt.UTC() now := time.Now().UTC() _, err := s.db.Exec(` UPDATE models SET room_status = $1, is_online = $2, chat_room_url = $3, image_url = CASE WHEN COALESCE(trim($4), '') <> '' THEN $4 ELSE image_url END, last_room_sync_at = $5, last_online_at = CASE WHEN $2 = true THEN $5 ELSE last_online_at END, last_offline_at = CASE WHEN $2 = false THEN $5 ELSE last_offline_at END, updated_at = $6 WHERE lower(trim(host)) = lower(trim($7)) AND lower(trim(model_key)) = lower(trim($8)); `, roomStatus, isOnline, nullableStringArg(chatRoomURL), nullableStringArg(imageURL), seenAt, now, host, modelKey, ) return err } func (s *ModelStore) GetByHostAndModelKey(host string, modelKey string) (*StoredModel, bool) { if err := s.ensureInit(); err != nil { return nil, false } host = canonicalHost(host) modelKey = strings.TrimSpace(modelKey) if host == "" || modelKey == "" { return nil, false } var id 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; `, host, modelKey).Scan(&id) if err != nil { return nil, false } m, err := s.getByID(id) if err != nil { return nil, false } return &m, true } func (s *ModelStore) IsFavoriteModel(host string, modelKey string) bool { if s == nil { return false } if err := s.ensureInit(); err != nil { return false } host = canonicalHost(host) modelKey = strings.TrimSpace(modelKey) if modelKey == "" { return false } var ok bool // host ist optional: // - Wenn host gesetzt ist: exakter Host oder alte Legacy-Einträge ohne Host schützen. // - Wenn host leer ist: alle Hosts nach diesem model_key prüfen. err := s.db.QueryRow(` SELECT EXISTS ( SELECT 1 FROM models WHERE lower(trim(model_key)) = lower(trim($1)) AND COALESCE(favorite, false) = true AND ( trim($2) = '' OR lower(trim(COALESCE(host, ''))) = lower(trim($2)) OR trim(COALESCE(host, '')) = '' ) ); `, modelKey, host).Scan(&ok) if err != nil { return false } return ok } 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() 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 dbNullTime 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.NullTime), false, nil } return val, fmtNullTime(ts.NullTime), 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 } 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 dbNullTime watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt dbNullTime } 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.NullTime) 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 { updateSQL := ` 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::timestamptz IS NOT NULL THEN $6::timestamptz 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::boolean IS NOT NULL THEN $11::boolean ELSE liked END, updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END WHERE id = $13; ` if s.isSQLite() { updateSQL = strings.ReplaceAll(updateSQL, "$6::timestamptz", "$6") updateSQL = strings.ReplaceAll(updateSQL, "$11::boolean", "$11") } _, err = tx.Exec(updateSQL, 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{} } blobLengthExpr := s.blobLengthExpr() emptyTextExpr := s.emptyTextExpr() q1 := fmt.Sprintf(` SELECT id, COALESCE(input,'') as input, is_url, COALESCE(host,'') as host, COALESCE(path,'') as path, COALESCE(model_key,'') as model_key, COALESCE(tags,'') as tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(cb_online_json,''), cb_online_fetched_at, COALESCE(cb_online_last_error,''), COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image, COALESCE(room_status,'') as room_status, COALESCE(is_online,false) as is_online, COALESCE(chat_room_url,'') as chat_room_url, COALESCE(image_url,'') as image_url, last_online_at, last_offline_at, last_room_sync_at, watching,favorite,hot,keep,liked, created_at, updated_at FROM models ORDER BY updated_at DESC; `, blobLengthExpr) q2 := fmt.Sprintf(` SELECT id, COALESCE(input,'') as input, is_url, COALESCE(host,'') as host, COALESCE(path,'') as path, COALESCE(model_key,'') as model_key, COALESCE(tags,'') as tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(cb_online_json,''), cb_online_fetched_at, %s as cb_online_last_error, COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image, COALESCE(room_status,'') as room_status, COALESCE(is_online,false) as is_online, COALESCE(chat_room_url,'') as chat_room_url, COALESCE(image_url,'') as image_url, last_online_at, last_offline_at, last_room_sync_at, watching,favorite,hot,keep,liked, created_at, updated_at FROM models ORDER BY updated_at DESC; `, emptyTextExpr, blobLengthExpr) rows, err := s.db.Query(q1) if err != nil { // ✅ genau dein Fall: "Spalte existiert nicht" -> fallback appLogln("models List query err (q1):", err) rows, err = s.db.Query(q2) if err != nil { appLogln("models List query err (q2):", err) return []StoredModel{} } } defer rows.Close() out := make([]StoredModel, 0, 64) for rows.Next() { var ( id, input, host, path, modelKey, tags string isURL bool lastStream dbNullTime lastSeenOnline sql.NullBool lastSeenOnlineAt dbNullTime cbOnlineJSON string cbOnlineFetchedAt dbNullTime cbOnlineLastError string profileImageURL string profileImageUpdatedAt dbNullTime hasProfileImage int64 roomStatus string isOnline bool chatRoomURL string imageURL string lastOnlineAt dbNullTime lastOfflineAt dbNullTime lastRoomSyncAt dbNullTime watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt dbNullTime ) if err := rows.Scan( &id, &input, &isURL, &host, &path, &modelKey, &tags, &lastStream, &lastSeenOnline, &lastSeenOnlineAt, &cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &roomStatus, &isOnline, &chatRoomURL, &imageURL, &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ); err != nil { continue } var gender, country string if strings.TrimSpace(cbOnlineJSON) != "" { var snap ChaturbateOnlineSnapshot if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { gender = strings.TrimSpace(snap.Gender) country = strings.TrimSpace(snap.Country) } } m := StoredModel{ ID: id, Input: input, IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, Gender: gender, Country: country, Tags: tags, LastStream: fmtNullTime(lastStream.NullTime), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime), CbOnlineJSON: cbOnlineJSON, CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime), CbOnlineLastError: cbOnlineLastError, ProfileImageURL: profileImageURL, ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime), RoomStatus: roomStatus, IsOnline: isOnline, ChatRoomURL: chatRoomURL, ImageURL: imageURL, LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime), LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime), LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime), Watching: watching, Favorite: favorite, Hot: hot, Keep: keep, Liked: ptrLikedFromNullBool(liked), CreatedAt: fmtNullTime(createdAt.NullTime), UpdatedAt: fmtNullTime(updatedAt.NullTime), } 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 dbNullTime 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.NullTime)} } type ChaturbateOnlineSnapshot struct { Username string `json:"username"` DisplayName string `json:"display_name,omitempty"` CurrentShow string `json:"current_show,omitempty"` // public/private/hidden/away RoomSubject string `json:"room_subject,omitempty"` Location string `json:"location,omitempty"` Country string `json:"country,omitempty"` SpokenLanguages string `json:"spoken_languages,omitempty"` Gender string `json:"gender,omitempty"` NumUsers int `json:"num_users,omitempty"` NumFollowers int `json:"num_followers,omitempty"` IsHD bool `json:"is_hd,omitempty"` IsNew bool `json:"is_new,omitempty"` Age int `json:"age,omitempty"` SecondsOnline int `json:"seconds_online,omitempty"` ImageURL string `json:"image_url,omitempty"` ImageURL360 string `json:"image_url_360x270,omitempty"` ChatRoomURL string `json:"chat_room_url,omitempty"` ChatRoomURLRS string `json:"chat_room_url_revshare,omitempty"` Tags []string `json:"tags,omitempty"` } func (s *ModelStore) ListModelKeysByHost(host string) ([]string, error) { if err := s.ensureInit(); err != nil { return nil, err } host = canonicalHost(host) if host == "" { return nil, errors.New("host fehlt") } rows, err := s.db.Query(` SELECT model_key FROM models WHERE lower(trim(host)) = lower(trim($1)); `, host) if err != nil { return nil, err } defer rows.Close() out := make([]string, 0, 128) for rows.Next() { var k string if err := rows.Scan(&k); err != nil { continue } k = strings.ToLower(strings.TrimSpace(k)) if k != "" { out = append(out, k) } } return out, nil } func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom, fetchedAt time.Time) error { if err := s.ensureInit(); err != nil { return err } const host = "chaturbate.com" fetchedAt = fetchedAt.UTC() knownKeys, err := s.ListModelKeysByHost(host) if err != nil { return err } if len(knownKeys) == 0 { return nil } roomsByUser := indexRoomsByUser(rooms) tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() // ONLINE: voller Snapshot wird gespeichert stmtOnline1, errOnline1 := tx.Prepare(` UPDATE models SET last_seen_online = $1, last_seen_online_at = $2, cb_online_json = $3, cb_online_fetched_at = $4, cb_online_last_error = $5, profile_image_url = CASE WHEN COALESCE(trim($6), '') <> '' THEN $6 ELSE profile_image_url END, profile_image_updated_at = CASE WHEN COALESCE(trim($6), '') <> '' THEN $4 ELSE profile_image_updated_at END, updated_at = $7 WHERE lower(trim(host)) = lower(trim($8)) AND lower(trim(model_key)) = lower(trim($9)); `) var stmtOnline2 *sql.Stmt if errOnline1 != nil { stmtOnline2, err = tx.Prepare(` UPDATE models SET last_seen_online = $1, last_seen_online_at = $2, cb_online_json = $3, cb_online_fetched_at = $4, profile_image_url = CASE WHEN COALESCE(trim($5), '') <> '' THEN $5 ELSE profile_image_url END, profile_image_updated_at = CASE WHEN COALESCE(trim($5), '') <> '' THEN $4 ELSE profile_image_updated_at END, updated_at = $6 WHERE lower(trim(host)) = lower(trim($7)) AND lower(trim(model_key)) = lower(trim($8)); `) if err != nil { return err } defer stmtOnline2.Close() } else { defer stmtOnline1.Close() } // OFFLINE: nur Status updaten, Snapshot NICHT überschreiben stmtOffline1, errOffline1 := tx.Prepare(` UPDATE models SET last_seen_online = $1, last_seen_online_at = $2, cb_online_last_error = $3, updated_at = $4 WHERE lower(trim(host)) = lower(trim($5)) AND lower(trim(model_key)) = lower(trim($6)); `) var stmtOffline2 *sql.Stmt if errOffline1 != nil { stmtOffline2, err = tx.Prepare(` 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)); `) if err != nil { return err } defer stmtOffline2.Close() } else { defer stmtOffline1.Close() } now := time.Now().UTC() for _, key := range knownKeys { key = strings.TrimSpace(key) if key == "" { continue } if rm, ok := roomsByUser[strings.ToLower(key)]; ok { imgURL := strings.TrimSpace(selectBestRoomImageURL(rm)) snap := &ChaturbateOnlineSnapshot{ Username: rm.Username, DisplayName: rm.DisplayName, CurrentShow: strings.TrimSpace(rm.CurrentShow), RoomSubject: rm.RoomSubject, Location: rm.Location, Country: rm.Country, SpokenLanguages: rm.SpokenLanguages, Gender: rm.Gender, NumUsers: rm.NumUsers, NumFollowers: rm.NumFollowers, IsHD: rm.IsHD, IsNew: rm.IsNew, Age: rm.Age, SecondsOnline: rm.SecondsOnline, ImageURL: rm.ImageURL, ImageURL360: rm.ImageURL360, ChatRoomURL: rm.ChatRoomURL, ChatRoomURLRS: rm.ChatRoomURLRS, Tags: rm.Tags, } jsonStr := "" if b, err := json.Marshal(snap); err == nil { jsonStr = strings.TrimSpace(string(b)) } if stmtOnline1 != nil { if _, err := stmtOnline1.Exec( true, fetchedAt, nullableStringArg(jsonStr), fetchedAt, "", imgURL, now, host, key, ); err != nil { return err } } else { if _, err := stmtOnline2.Exec( true, fetchedAt, nullableStringArg(jsonStr), fetchedAt, imgURL, now, host, key, ); err != nil { return err } } continue } // OFFLINE: // KEIN cb_online_json Update // KEIN cb_online_fetched_at Update if stmtOffline1 != nil { if _, err := stmtOffline1.Exec( false, fetchedAt, "", now, host, key, ); err != nil { return err } } else { if _, err := stmtOffline2.Exec( false, fetchedAt, now, host, key, ); err != nil { return err } } } return tx.Commit() } func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr 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") } var jsonStr string if snap != nil { b, err := json.Marshal(snap) if err == nil { jsonStr = strings.TrimSpace(string(b)) } } ft := parseRFC3339Nano(fetchedAt) now := time.Now().UTC() s.mu.Lock() defer s.mu.Unlock() // NOTE: cb_online_last_error nur updaten, wenn Spalte existiert. // Wenn du die optionale Spalte nicht anlegst: entferne die beiden Stellen. _, err := s.db.Exec(` UPDATE models SET cb_online_json=$1, cb_online_fetched_at=$2, cb_online_last_error=$3, updated_at=$4 WHERE lower(trim(host)) = lower(trim($5)) AND lower(trim(model_key)) = lower(trim($6)); `, nullableStringArg(jsonStr), nullableTimeArg(ft), strings.TrimSpace(lastErr), now, host, key) if err != nil { // falls cb_online_last_error nicht existiert -> fallback ohne die Spalte _, err2 := s.db.Exec(` UPDATE models SET cb_online_json=$1, cb_online_fetched_at=$2, updated_at=$3 WHERE lower(trim(host)) = lower(trim($4)) AND lower(trim(model_key)) = lower(trim($5)); `, nullableStringArg(jsonStr), nullableTimeArg(ft), now, host, key) return err2 } return nil } func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*ChaturbateOnlineSnapshot, string, bool, error) { if err := s.ensureInit(); err != nil { return nil, "", false, err } host = canonicalHost(host) key := strings.TrimSpace(modelKey) if host == "" || key == "" { return nil, "", false, errors.New("host/modelKey fehlt") } var js sql.NullString var fetchedAt dbNullTime err := s.db.QueryRow(` SELECT cb_online_json, cb_online_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, &fetchedAt) if errors.Is(err, sql.ErrNoRows) { return nil, "", false, nil } if err != nil { return nil, "", false, err } raw := strings.TrimSpace(js.String) if raw == "" { return nil, fmtNullTime(fetchedAt.NullTime), false, nil } var snap ChaturbateOnlineSnapshot if err := json.Unmarshal([]byte(raw), &snap); err != nil { return nil, fmtNullTime(fetchedAt.NullTime), false, err } return &snap, fmtNullTime(fetchedAt.NullTime), true, nil } func nullableStringArg(s string) any { if strings.TrimSpace(s) == "" { return nil } return s } // 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,COALESCE(host,'') as host,model_key,watching FROM models WHERE watching = true ORDER BY updated_at DESC; `) } else { rows, err = s.db.Query(` SELECT id,input,COALESCE(host,'') as host,model_key,watching FROM models WHERE watching = true AND lower(trim(COALESCE(host,''))) = lower(trim($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") } 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") } _, 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 dbNullTime lastSeenOnline sql.NullBool lastSeenOnlineAt dbNullTime cbOnlineJSON string cbOnlineFetchedAt dbNullTime cbOnlineLastError string profileImageURL string profileImageUpdatedAt dbNullTime hasProfileImage int64 roomStatus string isOnline bool chatRoomURL string imageURL string lastOnlineAt dbNullTime lastOfflineAt dbNullTime lastRoomSyncAt dbNullTime watching, favorite, hot, keep bool liked sql.NullBool createdAt, updatedAt dbNullTime ) blobLengthExpr := s.blobLengthExpr() // q1: mit optionaler Spalte cb_online_last_error q1 := fmt.Sprintf(` SELECT COALESCE(input,'') as input, is_url, COALESCE(host,'') as host, COALESCE(path,'') as path, COALESCE(model_key,'') as model_key, COALESCE(tags,'') as tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(cb_online_json,''), cb_online_fetched_at, COALESCE(cb_online_last_error,''), COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image, COALESCE(room_status,'') as room_status, COALESCE(is_online,false) as is_online, COALESCE(chat_room_url,'') as chat_room_url, COALESCE(image_url,'') as image_url, last_online_at, last_offline_at, last_room_sync_at, watching,favorite,hot,keep,liked, created_at, updated_at FROM models WHERE id=$1; `, blobLengthExpr) // q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert) // Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten. q2 := fmt.Sprintf(` SELECT COALESCE(input,'') as input, is_url, COALESCE(host,'') as host, COALESCE(path,'') as path, COALESCE(model_key,'') as model_key, COALESCE(tags,'') as tags, last_stream, last_seen_online, last_seen_online_at, COALESCE(cb_online_json,''), cb_online_fetched_at, '' as cb_online_last_error, COALESCE(profile_image_url,''), profile_image_updated_at, CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image, COALESCE(room_status,'') as room_status, COALESCE(is_online,false) as is_online, COALESCE(chat_room_url,'') as chat_room_url, COALESCE(image_url,'') as image_url, last_online_at, last_offline_at, last_room_sync_at, watching,favorite,hot,keep,liked, created_at, updated_at FROM models WHERE id=$1; `, blobLengthExpr) scan := func(q string) error { return s.db.QueryRow(q, id).Scan( &input, &isURL, &host, &path, &modelKey, &tags, &lastStream, &lastSeenOnline, &lastSeenOnlineAt, &cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &roomStatus, &isOnline, &chatRoomURL, &imageURL, &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ) } err := scan(q1) if err != nil { // Wenn die Zeile nicht existiert, nicht noch fallbacken. if errors.Is(err, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } // Fallback versuchen (typisch: "column cb_online_last_error does not exist") err2 := scan(q2) if err2 != nil { // wenn fallback auch kein Row findet, sauber melden if errors.Is(err2, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } // sonst ursprünglichen Fehler behalten? -> ich gebe hier err2 zurück, weil er meist aussagekräftiger ist. return StoredModel{}, err2 } } var gender, country string if strings.TrimSpace(cbOnlineJSON) != "" { var snap ChaturbateOnlineSnapshot if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil { gender = strings.TrimSpace(snap.Gender) country = strings.TrimSpace(snap.Country) } } m := StoredModel{ ID: id, Input: input, IsURL: isURL, Host: host, Path: path, ModelKey: modelKey, Gender: gender, Country: country, Tags: tags, LastStream: fmtNullTime(lastStream.NullTime), LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline), LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime), CbOnlineJSON: cbOnlineJSON, CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime), CbOnlineLastError: cbOnlineLastError, RoomStatus: roomStatus, IsOnline: isOnline, ChatRoomURL: chatRoomURL, ImageURL: imageURL, LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime), LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime), LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime), Watching: watching, Favorite: favorite, Hot: hot, Keep: keep, Liked: ptrLikedFromNullBool(liked), CreatedAt: fmtNullTime(createdAt.NullTime), UpdatedAt: fmtNullTime(updatedAt.NullTime), ProfileImageURL: profileImageURL, ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime), } if hasProfileImage != 0 { m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id) } return m, nil }