1893 lines
44 KiB
Go
1893 lines
44 KiB
Go
// 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
|
|
}
|