// backend/models_store.go package main import ( "crypto/sha256" "database/sql" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "path/filepath" "sort" "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 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"` } const ( modelStatusFlushDelay = 1500 * time.Millisecond ) // 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 modelLastSeenUpdate struct { host string modelKey string online bool seenAt time.Time updatedAt time.Time } type modelRoomStateUpdate struct { host string modelKey string roomStatus string isOnline bool chatRoomURL string imageURL string seenAt time.Time updatedAt time.Time } type ModelStore struct { config ModelStoreConfig db *sql.DB initOnce sync.Once initErr error // coarse lock for legacy write paths that still need local serialization mu sync.Mutex statusMu sync.Mutex statusFlushTimer *time.Timer pendingLastSeen map[string]modelLastSeenUpdate pendingRoomState map[string]modelRoomStateUpdate } 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 } } nt.Valid = false nt.Time = time.Time{} return nil } 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 } func nullableBoolPtrArg(v *bool) any { if v == nil { return nil } 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) 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 } if err := s.flushPendingStatusUpdates(); err != nil { appLogln("models status flush before close failed:", err) } s.statusMu.Lock() if s.statusFlushTimer != nil { s.statusFlushTimer.Stop() s.statusFlushTimer = nil } s.statusMu.Unlock() 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 strings.TrimSpace(dsn) == "" { return errors.New("db dsn fehlt") } db, err := sql.Open(driverName, dsn) if err != nil { return err } db.SetMaxOpenConns(10) db.SetMaxIdleConns(10) if err := db.Ping(); err != nil { _ = db.Close() return err } s.db = db s.config = config if err := s.ensurePostgresSchema(); err != nil { _ = db.Close() return err } if err := s.normalizeNameOnlyChaturbate(); err != nil { return err } return nil } func (s *ModelStore) blobLengthExpr() string { return "octet_length(profile_image_blob)" } func (s *ModelStore) profileImagePresenceExpr() string { return fmt.Sprintf("CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' OR (profile_image_blob IS NOT NULL AND %s > 0) THEN 1 ELSE 0 END", s.blobLengthExpr()) } func (s *ModelStore) profileImagePathPresenceExpr() string { return "CASE WHEN trim(COALESCE(profile_image_path,'')) <> '' THEN 1 ELSE 0 END" } func (s *ModelStore) emptyTextExpr() string { return "''::text" } 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_path TEXT DEFAULT '', profile_image_hash 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_host_model_key_norm ON models ((lower(trim(host))), (lower(trim(model_key))));`, `CREATE INDEX IF NOT EXISTS idx_models_model_key_norm_updated ON models ((lower(trim(model_key))), updated_at DESC);`, `CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`, `CREATE INDEX IF NOT EXISTS idx_models_watching_updated ON models(watching, updated_at DESC);`, `CREATE INDEX IF NOT EXISTS idx_models_favorite_updated ON models(favorite, updated_at DESC);`, `CREATE INDEX IF NOT EXISTS idx_models_liked_updated ON models(liked, updated_at DESC);`, `CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`, `ALTER TABLE models ADD COLUMN IF NOT EXISTS cb_online_last_error TEXT DEFAULT '';`, `ALTER TABLE models ADD COLUMN IF NOT EXISTS profile_image_path TEXT DEFAULT '';`, `ALTER TABLE models ADD COLUMN IF NOT EXISTS profile_image_hash TEXT DEFAULT '';`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } func modelStatusUpdateKey(host, modelKey string) string { return strings.ToLower(strings.TrimSpace(host)) + "\x00" + strings.ToLower(strings.TrimSpace(modelKey)) } func (s *ModelStore) scheduleStatusFlushLocked() { if s.statusFlushTimer == nil { s.statusFlushTimer = time.AfterFunc(modelStatusFlushDelay, func() { if err := s.flushPendingStatusUpdates(); err != nil { appLogln("models status flush failed:", err) } }) return } s.statusFlushTimer.Reset(modelStatusFlushDelay) } func (s *ModelStore) enqueueLastSeenUpdate(update modelLastSeenUpdate) { key := modelStatusUpdateKey(update.host, update.modelKey) s.statusMu.Lock() if s.pendingLastSeen == nil { s.pendingLastSeen = make(map[string]modelLastSeenUpdate, 64) } s.pendingLastSeen[key] = update s.scheduleStatusFlushLocked() s.statusMu.Unlock() } func (s *ModelStore) enqueueRoomStateUpdate(update modelRoomStateUpdate) { key := modelStatusUpdateKey(update.host, update.modelKey) s.statusMu.Lock() if s.pendingRoomState == nil { s.pendingRoomState = make(map[string]modelRoomStateUpdate, 64) } s.pendingRoomState[key] = update s.scheduleStatusFlushLocked() s.statusMu.Unlock() } func (s *ModelStore) flushPendingStatusUpdates() error { if s == nil || s.db == nil { return nil } s.statusMu.Lock() lastSeen := s.pendingLastSeen roomState := s.pendingRoomState s.pendingLastSeen = nil s.pendingRoomState = nil if s.statusFlushTimer != nil { s.statusFlushTimer.Stop() s.statusFlushTimer = nil } s.statusMu.Unlock() if len(lastSeen) == 0 && len(roomState) == 0 { return nil } var lastErr error for attempt := 0; attempt < 3; attempt++ { if attempt > 0 { time.Sleep(time.Duration(100*attempt) * time.Millisecond) } err := s.flushStatusUpdateBatch(lastSeen, roomState) if err == nil { if attempt > 0 { appLogln("models status flush retry ok") } return nil } if !isRetryablePostgresTxError(err) { return err } lastErr = err appLogln("models status flush retry:", err) } return lastErr } func (s *ModelStore) flushStatusUpdateBatch(lastSeen map[string]modelLastSeenUpdate, roomState map[string]modelRoomStateUpdate) error { tx, err := s.db.Begin() if err != nil { return err } defer func() { _ = tx.Rollback() }() if len(lastSeen) > 0 { stmt, 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)) AND (last_seen_online IS NULL OR COALESCE(last_seen_online,false) <> $1); `) if err != nil { return err } keys := make([]string, 0, len(lastSeen)) for key := range lastSeen { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { update := lastSeen[key] if _, err := stmt.Exec(update.online, update.seenAt, update.updatedAt, update.host, update.modelKey); err != nil { _ = stmt.Close() return err } } if err := stmt.Close(); err != nil { return err } } if len(roomState) > 0 { stmt, err := tx.Prepare(` 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 AND COALESCE(is_online,false) <> true THEN $5 ELSE last_online_at END, last_offline_at = CASE WHEN $2 = false AND COALESCE(is_online,false) <> 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)) AND ( COALESCE(room_status,'') <> $1 OR COALESCE(is_online,false) <> $2 OR COALESCE(chat_room_url,'') <> COALESCE($3,'') OR (COALESCE(trim($4), '') <> '' AND COALESCE(image_url,'') <> $4) ); `) if err != nil { return err } keys := make([]string, 0, len(roomState)) for key := range roomState { keys = append(keys, key) } sort.Strings(keys) for _, key := range keys { update := roomState[key] if _, err := stmt.Exec( update.roomStatus, update.isOnline, nullableStringArg(update.chatRoomURL), nullableStringArg(update.imageURL), update.seenAt, update.updatedAt, update.host, update.modelKey, ); err != nil { _ = stmt.Close() return err } } if err := stmt.Close(); err != nil { return err } } return tx.Commit() } func isRetryablePostgresTxError(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "sqlstate 40p01") || strings.Contains(msg, "deadlock") || strings.Contains(msg, "verklemmung") || strings.Contains(msg, "sqlstate 40001") || strings.Contains(msg, "serialization") } 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() s.enqueueRoomStateUpdate(modelRoomStateUpdate{ host: host, modelKey: modelKey, roomStatus: roomStatus, isOnline: isOnline, chatRoomURL: chatRoomURL, imageURL: imageURL, seenAt: seenAt, updatedAt: now, }) return nil } 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) _, 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) _, 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 --- func profileImageHash(data []byte) string { sum := sha256.Sum256(data) return fmt.Sprintf("%x", sum[:]) } func (s *ModelStore) resolveProfileImagePath(storedPath string) (string, error) { storedPath = strings.TrimSpace(storedPath) if storedPath == "" { return "", errors.New("profile image path fehlt") } local := filepath.Clean(filepath.FromSlash(storedPath)) if filepath.IsAbs(local) { return local, nil } if local == "." || local == ".." || strings.HasPrefix(local, ".."+string(os.PathSeparator)) { return "", fmt.Errorf("ungueltiger profile image path: %s", storedPath) } slashPath := filepath.ToSlash(local) if slashPath == "generated" || strings.HasPrefix(slashPath, "generated/") { return resolvePathRelativeToApp(slashPath) } return "", fmt.Errorf("ungueltiger profile image path: %s", storedPath) } // SetProfileImage speichert Bild-URL, MIME und Bilddaten in Postgres. // Speichert nur fuer bereits bestehende Models. 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" } 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; `, host, key).Scan(&existingID) if errors.Is(err, sql.ErrNoRows) { return nil } if err != nil { return err } hash := profileImageHash(data) 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_mime=$2, profile_image_path='', profile_image_hash=$3, profile_image_blob=$4, profile_image_updated_at=$5, updated_at=$6 WHERE lower(trim(host)) = lower(trim($7)) AND lower(trim(model_key)) = lower(trim($8)); `, src, mime, hash, 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 pathNS sql.NullString var blob []byte err = s.db.QueryRow(` SELECT profile_image_mime, profile_image_path, profile_image_blob FROM models WHERE id = $1 LIMIT 1; `, id).Scan(&mimeNS, &pathNS, &blob) if errors.Is(err, sql.ErrNoRows) { return "", nil, false, nil } if err != nil { return "", nil, false, err } m := strings.TrimSpace(mimeNS.String) storedPath := strings.TrimSpace(pathNS.String) if storedPath != "" { absPath, perr := s.resolveProfileImagePath(storedPath) if perr == nil { if b, rerr := os.ReadFile(absPath); rerr == nil && len(b) > 0 { if m == "" { m = http.DetectContentType(b) } if m == "" { m = "application/octet-stream" } return m, b, true, nil } } } if len(blob) == 0 { return "", nil, false, nil } 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() s.enqueueLastSeenUpdate(modelLastSeenUpdate{ host: host, modelKey: key, online: online, seenAt: nt.Time.UTC(), updatedAt: now, }) 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; ` _, 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{} } emptyTextExpr := s.emptyTextExpr() hasProfileImageExpr := s.profileImagePresenceExpr() 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, %s as cb_online_json, cb_online_fetched_at, COALESCE(cb_online_last_error,''), COALESCE(profile_image_url,''), profile_image_updated_at, %s 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, hasProfileImageExpr) 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, %s as cb_online_json, cb_online_fetched_at, %s as cb_online_last_error, COALESCE(profile_image_url,''), profile_image_updated_at, %s 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, emptyTextExpr, hasProfileImageExpr) 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) scanErrCount := 0 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 { scanErrCount++ 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) } if scanErrCount > 0 { appLogln("models List skipped rows after scan errors:", scanErrCount) } return out } func (s *ModelStore) ListForModelTab() []StoredModel { if err := s.ensureInit(); err != nil { return []StoredModel{} } rows, err := s.db.Query(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, COALESCE(cb_online_json,'') as cb_online_json, last_stream, COALESCE(profile_image_url,''), profile_image_updated_at, %s 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; `, s.profileImagePathPresenceExpr())) if err != nil { appLogln("models ListForModelTab query err:", err) return []StoredModel{} } defer rows.Close() out := make([]StoredModel, 0, 256) scanErrCount := 0 for rows.Next() { var ( id, input, host, path, modelKey, tags string cbOnlineJSON string isURL bool lastStream dbNullTime 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, &cbOnlineJSON, &lastStream, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &roomStatus, &isOnline, &chatRoomURL, &imageURL, &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ); err != nil { scanErrCount++ 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, Tags: tags, LastStream: fmtNullTime(lastStream.NullTime), Gender: gender, Country: country, 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) } if err := rows.Err(); err != nil { appLogln("models ListForModelTab rows err:", err) } if scanErrCount > 0 { appLogln("models ListForModelTab skipped rows after scan errors:", scanErrCount) } 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)) AND ( last_seen_online IS NULL OR COALESCE(last_seen_online,false) <> true OR COALESCE(cb_online_json,'') = '' OR COALESCE(cb_online_last_error,'') <> '' OR (COALESCE(trim($6), '') <> '' AND COALESCE(profile_image_url,'') <> $6) ); `) 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)) AND ( last_seen_online IS NULL OR COALESCE(last_seen_online,false) <> true OR COALESCE(cb_online_json,'') = '' OR (COALESCE(trim($5), '') <> '' AND COALESCE(profile_image_url,'') <> $5) ); `) 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)) AND ( last_seen_online IS NULL OR COALESCE(last_seen_online,false) <> false OR COALESCE(cb_online_last_error,'') <> $3 ); `) 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)) AND ( last_seen_online IS NULL OR COALESCE(last_seen_online,false) <> false ); `) 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() _, 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) PatchFlagsFast(patch ModelFlagsPatch) (StoredModel, error) { if err := s.ensureInit(); err != nil { return StoredModel{}, err } if patch.ID == "" { return StoredModel{}, errors.New("id fehlt") } now := time.Now().UTC() var updatedID string err := s.db.QueryRow(` WITH cur AS ( SELECT id, watching, favorite, liked FROM models WHERE id=$1 ), updated AS ( UPDATE models AS m SET watching = CASE WHEN $2::boolean IS NULL THEN cur.watching ELSE $2::boolean END, favorite = CASE WHEN $4::boolean IS TRUE THEN false WHEN $3::boolean IS NULL THEN cur.favorite ELSE $3::boolean END, liked = CASE WHEN $4::boolean IS NOT NULL THEN $4::boolean WHEN $3::boolean IS TRUE THEN false ELSE cur.liked END, updated_at=$5 FROM cur WHERE m.id=cur.id RETURNING m.id ) SELECT id FROM updated; `, patch.ID, nullableBoolPtrArg(patch.Watched), nullableBoolPtrArg(patch.Favorite), nullableBoolPtrArg(patch.Liked), now, ).Scan(&updatedID) if err != nil { if errors.Is(err, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } return StoredModel{}, err } return s.getModelTabByID(updatedID) } 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) getModelTabByID(id string) (StoredModel, error) { id = strings.TrimSpace(id) if id == "" { return StoredModel{}, errors.New("id fehlt") } var ( input, host, path, modelKey, tags string cbOnlineJSON string isURL bool lastStream dbNullTime 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 ) err := s.db.QueryRow(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, COALESCE(cb_online_json,'') as cb_online_json, last_stream, COALESCE(profile_image_url,''), profile_image_updated_at, %s 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; `, s.profileImagePathPresenceExpr()), id).Scan( &input, &isURL, &host, &path, &modelKey, &tags, &cbOnlineJSON, &lastStream, &profileImageURL, &profileImageUpdatedAt, &hasProfileImage, &roomStatus, &isOnline, &chatRoomURL, &imageURL, &lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt, &watching, &favorite, &hot, &keep, &liked, &createdAt, &updatedAt, ) if errors.Is(err, sql.ErrNoRows) { return StoredModel{}, errors.New("model nicht gefunden") } if err != nil { return StoredModel{}, err } 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, Tags: tags, LastStream: fmtNullTime(lastStream.NullTime), Gender: gender, Country: country, 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) } return m, nil } 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 ) hasProfileImageExpr := s.profileImagePresenceExpr() // 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, %s 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; `, hasProfileImageExpr) // 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, %s 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; `, hasProfileImageExpr) 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 }