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