nsfwapp/backend/models_store.go
2026-06-23 15:03:27 +02:00

2236 lines
53 KiB
Go

// backend/models_store.go
package main
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
type StoredModel struct {
ID string `json:"id"` // unique (wir verwenden host:modelKey)
Input string `json:"input"` // Original-URL/Eingabe
IsURL bool `json:"isUrl"` // vom Parser
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"` // Display/Key
Gender string `json:"gender,omitempty"`
Country string `json:"country,omitempty"`
Tags string `json:"tags,omitempty"`
LastStream string `json:"lastStream,omitempty"` // RFC3339Nano
LastSeenOnline *bool `json:"lastSeenOnline,omitempty"` // nil = unbekannt
LastSeenOnlineAt string `json:"lastSeenOnlineAt,omitempty"` // RFC3339Nano
// ✅ Chaturbate Online Snapshot (persistiert aus chaturbate_online.go)
CbOnlineJSON string `json:"cbOnlineJson,omitempty"`
CbOnlineFetchedAt string `json:"cbOnlineFetchedAt,omitempty"`
CbOnlineLastError string `json:"cbOnlineLastError,omitempty"`
ProfileImageURL string `json:"profileImageUrl,omitempty"`
ProfileImageCached string `json:"profileImageCached,omitempty"` // z.B. /api/models/image?id=...
ProfileImageUpdatedAt string `json:"profileImageUpdatedAt,omitempty"` // RFC3339Nano
RoomStatus string `json:"roomStatus,omitempty"`
IsOnline bool `json:"isOnline,omitempty"`
ChatRoomURL string `json:"chatRoomUrl,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
LastOnlineAt string `json:"lastOnlineAt,omitempty"`
LastOfflineAt string `json:"lastOfflineAt,omitempty"`
LastRoomSyncAt string `json:"lastRoomSyncAt,omitempty"`
Watching bool `json:"watching"`
Favorite bool `json:"favorite"`
Hot bool `json:"hot"`
Keep bool `json:"keep"`
Liked *bool `json:"liked,omitempty"` // null => unbekannt
CreatedAt string `json:"createdAt"` // RFC3339Nano
UpdatedAt string `json:"updatedAt"` // RFC3339Nano
}
type ModelsMeta struct {
Count int `json:"count"`
UpdatedAt string `json:"updatedAt"`
}
// Kleine Payload für "watched" Listen (für Autostart/Abgleich)
type WatchedModelLite struct {
ID string `json:"id"`
Input string `json:"input"`
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey"`
Watching bool `json:"watching"`
}
type ParsedModelDTO struct {
Input string `json:"input"`
IsURL bool `json:"isUrl"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
ModelKey string `json:"modelKey"`
}
type ModelFlagsPatch struct {
Host string `json:"host,omitempty"`
ModelKey string `json:"modelKey,omitempty"`
ID string `json:"id,omitempty"`
Watched *bool `json:"watched,omitempty"`
Favorite *bool `json:"favorite,omitempty"`
Liked *bool `json:"liked,omitempty"`
}
type ModelStore struct {
config ModelStoreConfig
db *sql.DB
initOnce sync.Once
initErr error
// serialize writes (einfach & robust)
mu sync.Mutex
}
func fmtTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339Nano)
}
func fmtNullTime(nt sql.NullTime) string {
if !nt.Valid || nt.Time.IsZero() {
return ""
}
return nt.Time.UTC().Format(time.RFC3339Nano)
}
type dbNullTime struct {
sql.NullTime
}
func (nt *dbNullTime) Scan(value any) error {
if value == nil {
nt.Valid = false
nt.Time = time.Time{}
return nil
}
switch v := value.(type) {
case time.Time:
nt.Valid = true
nt.Time = v.UTC()
return nil
case string:
return nt.scanString(v)
case []byte:
return nt.scanString(string(v))
default:
var raw sql.NullTime
if err := raw.Scan(value); err != nil {
return err
}
nt.NullTime = raw
if nt.Valid {
nt.Time = nt.Time.UTC()
}
return nil
}
}
func (nt *dbNullTime) scanString(value string) error {
value = strings.TrimSpace(value)
if value == "" {
nt.Valid = false
nt.Time = time.Time{}
return nil
}
layouts := []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999-07:00",
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05.999999999Z07:00",
"2006-01-02 15:04:05.999999Z07:00",
"2006-01-02 15:04:05Z07:00",
"2006-01-02 15:04:05.999999999 -0700 MST",
"2006-01-02 15:04:05.999999 -0700 MST",
"2006-01-02 15:04:05 -0700 MST",
"2006-01-02 15:04:05.999999999",
"2006-01-02 15:04:05.999999",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, value); err == nil {
nt.Valid = true
nt.Time = t.UTC()
return nil
}
}
return fmt.Errorf("invalid time value %q", value)
}
func nullableTimeArg(nt sql.NullTime) any {
if !nt.Valid {
return nil
}
return nt.Time
}
func ptrBoolFromNullBool(n sql.NullBool) *bool {
if !n.Valid {
return nil
}
v := n.Bool
return &v
}
func ptrLikedFromNullBool(n sql.NullBool) *bool {
if !n.Valid {
return nil
}
v := n.Bool
return &v
}
// parseRFC3339Nano: akzeptiert RFC3339/RFC3339Nano, sonst "invalid" -> (Valid=false)
func parseRFC3339Nano(s string) sql.NullTime {
s = strings.TrimSpace(s)
if s == "" {
return sql.NullTime{Valid: false}
}
// RFC3339Nano ist superset, aber manche Werte sind RFC3339
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return sql.NullTime{Valid: true, Time: t.UTC()}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return sql.NullTime{Valid: true, Time: t.UTC()}
}
return sql.NullTime{Valid: false}
}
func NewModelStore(config ModelStoreConfig) *ModelStore {
config.Type = normalizeDatabaseType(config.Type)
config.DSN = strings.TrimSpace(config.DSN)
config.SQLitePath = strings.TrimSpace(config.SQLitePath)
return &ModelStore{config: config}
}
func (s *ModelStore) Load() error { return s.ensureInit() }
func (s *ModelStore) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
func (s *ModelStore) ensureInit() error {
s.initOnce.Do(func() {
s.initErr = s.init()
})
return s.initErr
}
func (s *ModelStore) init() error {
config := s.config
config.Type = normalizeDatabaseType(config.Type)
driverName := "pgx"
dsn := strings.TrimSpace(config.DSN)
if config.Type == databaseTypeSQLite {
sqlitePath := strings.TrimSpace(config.SQLitePath)
if sqlitePath == "" {
sqlitePath = dsn
}
if strings.TrimSpace(sqlitePath) == "" {
return errors.New("sqlite path fehlt")
}
info, err := os.Stat(sqlitePath)
if err == nil && info.IsDir() {
return fmt.Errorf("sqlite path ist ein ordner: %s", sqlitePath)
}
if err := os.MkdirAll(filepath.Dir(sqlitePath), 0755); err != nil {
return err
}
driverName = "sqlite"
dsn = sqlitePath
config.SQLitePath = sqlitePath
}
if strings.TrimSpace(dsn) == "" {
return errors.New("db dsn fehlt")
}
db, err := sql.Open(driverName, dsn)
if err != nil {
return err
}
if config.Type == databaseTypeSQLite {
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
} else {
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(10)
}
if err := db.Ping(); err != nil {
_ = db.Close()
return err
}
s.db = db
s.config = config
if config.Type == databaseTypeSQLite {
if err := s.ensureSQLiteSchema(); err != nil {
_ = db.Close()
return err
}
} else {
if err := s.ensurePostgresSchema(); err != nil {
_ = db.Close()
return err
}
}
if err := s.normalizeNameOnlyChaturbate(); err != nil {
return err
}
return nil
}
func (s *ModelStore) isSQLite() bool {
return s != nil && normalizeDatabaseType(s.config.Type) == databaseTypeSQLite
}
func (s *ModelStore) blobLengthExpr() string {
if s.isSQLite() {
return "length(profile_image_blob)"
}
return "octet_length(profile_image_blob)"
}
func (s *ModelStore) emptyTextExpr() string {
if s.isSQLite() {
return "''"
}
return "''::text"
}
func (s *ModelStore) ensureSQLiteSchema() error {
stmts := []string{
`PRAGMA busy_timeout = 5000;`,
`PRAGMA foreign_keys = ON;`,
`PRAGMA journal_mode = WAL;`,
`CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
input TEXT NOT NULL DEFAULT '',
is_url BOOLEAN NOT NULL DEFAULT 0,
host TEXT DEFAULT '',
path TEXT DEFAULT '',
model_key TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '',
last_stream DATETIME NULL,
last_seen_online BOOLEAN NULL,
last_seen_online_at DATETIME NULL,
cb_online_json TEXT DEFAULT '',
cb_online_fetched_at DATETIME NULL,
cb_online_last_error TEXT DEFAULT '',
profile_image_url TEXT DEFAULT '',
profile_image_mime TEXT DEFAULT '',
profile_image_blob BLOB NULL,
profile_image_updated_at DATETIME NULL,
room_status TEXT DEFAULT '',
is_online BOOLEAN NOT NULL DEFAULT 0,
chat_room_url TEXT DEFAULT '',
image_url TEXT DEFAULT '',
last_online_at DATETIME NULL,
last_offline_at DATETIME NULL,
last_room_sync_at DATETIME NULL,
biocontext_json TEXT DEFAULT '',
biocontext_fetched_at DATETIME NULL,
watching BOOLEAN NOT NULL DEFAULT 0,
favorite BOOLEAN NOT NULL DEFAULT 0,
hot BOOLEAN NOT NULL DEFAULT 0,
keep BOOLEAN NOT NULL DEFAULT 0,
liked BOOLEAN NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`,
`CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *ModelStore) ensurePostgresSchema() error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS models (
id TEXT PRIMARY KEY,
input TEXT NOT NULL DEFAULT '',
is_url BOOLEAN NOT NULL DEFAULT false,
host TEXT DEFAULT '',
path TEXT DEFAULT '',
model_key TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '',
last_stream TIMESTAMPTZ NULL,
last_seen_online BOOLEAN NULL,
last_seen_online_at TIMESTAMPTZ NULL,
cb_online_json TEXT DEFAULT '',
cb_online_fetched_at TIMESTAMPTZ NULL,
cb_online_last_error TEXT DEFAULT '',
profile_image_url TEXT DEFAULT '',
profile_image_mime TEXT DEFAULT '',
profile_image_blob BYTEA NULL,
profile_image_updated_at TIMESTAMPTZ NULL,
room_status TEXT DEFAULT '',
is_online BOOLEAN NOT NULL DEFAULT false,
chat_room_url TEXT DEFAULT '',
image_url TEXT DEFAULT '',
last_online_at TIMESTAMPTZ NULL,
last_offline_at TIMESTAMPTZ NULL,
last_room_sync_at TIMESTAMPTZ NULL,
biocontext_json TEXT DEFAULT '',
biocontext_fetched_at TIMESTAMPTZ NULL,
watching BOOLEAN NOT NULL DEFAULT false,
favorite BOOLEAN NOT NULL DEFAULT false,
hot BOOLEAN NOT NULL DEFAULT false,
keep BOOLEAN NOT NULL DEFAULT false,
liked BOOLEAN NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);`,
`CREATE INDEX IF NOT EXISTS idx_models_host_model_key ON models(host, model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_model_key ON models(model_key);`,
`CREATE INDEX IF NOT EXISTS idx_models_watching_host ON models(watching, host);`,
`CREATE INDEX IF NOT EXISTS idx_models_updated_at ON models(updated_at);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *ModelStore) SetChaturbateRoomState(
host string,
modelKey string,
roomStatus string,
isOnline bool,
chatRoomURL string,
imageURL string,
seenAt time.Time,
) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
modelKey = strings.TrimSpace(modelKey)
roomStatus = strings.ToLower(strings.TrimSpace(roomStatus))
chatRoomURL = strings.TrimSpace(chatRoomURL)
imageURL = strings.TrimSpace(imageURL)
if host == "" {
host = "chaturbate.com"
}
if modelKey == "" {
return errors.New("modelKey fehlt")
}
seenAt = seenAt.UTC()
now := time.Now().UTC()
_, err := s.db.Exec(`
UPDATE models
SET
room_status = $1,
is_online = $2,
chat_room_url = $3,
image_url = CASE
WHEN COALESCE(trim($4), '') <> '' THEN $4
ELSE image_url
END,
last_room_sync_at = $5,
last_online_at = CASE
WHEN $2 = true THEN $5
ELSE last_online_at
END,
last_offline_at = CASE
WHEN $2 = false THEN $5
ELSE last_offline_at
END,
updated_at = $6
WHERE lower(trim(host)) = lower(trim($7))
AND lower(trim(model_key)) = lower(trim($8));
`,
roomStatus,
isOnline,
nullableStringArg(chatRoomURL),
nullableStringArg(imageURL),
seenAt,
now,
host,
modelKey,
)
return err
}
func (s *ModelStore) GetByHostAndModelKey(host string, modelKey string) (*StoredModel, bool) {
if err := s.ensureInit(); err != nil {
return nil, false
}
host = canonicalHost(host)
modelKey = strings.TrimSpace(modelKey)
if host == "" || modelKey == "" {
return nil, false
}
var id string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, modelKey).Scan(&id)
if err != nil {
return nil, false
}
m, err := s.getByID(id)
if err != nil {
return nil, false
}
return &m, true
}
func (s *ModelStore) IsFavoriteModel(host string, modelKey string) bool {
if s == nil {
return false
}
if err := s.ensureInit(); err != nil {
return false
}
host = canonicalHost(host)
modelKey = strings.TrimSpace(modelKey)
if modelKey == "" {
return false
}
var ok bool
// host ist optional:
// - Wenn host gesetzt ist: exakter Host oder alte Legacy-Einträge ohne Host schützen.
// - Wenn host leer ist: alle Hosts nach diesem model_key prüfen.
err := s.db.QueryRow(`
SELECT EXISTS (
SELECT 1
FROM models
WHERE lower(trim(model_key)) = lower(trim($1))
AND COALESCE(favorite, false) = true
AND (
trim($2) = ''
OR lower(trim(COALESCE(host, ''))) = lower(trim($2))
OR trim(COALESCE(host, '')) = ''
)
);
`, modelKey, host).Scan(&ok)
if err != nil {
return false
}
return ok
}
func canonicalHost(host string) string {
h := strings.ToLower(strings.TrimSpace(host))
h = strings.TrimPrefix(h, "www.")
return h
}
func canonicalID(host, modelKey string) string {
h := canonicalHost(host)
k := strings.TrimSpace(modelKey)
if h != "" {
return h + ":" + k
}
return k
}
func (s *ModelStore) EnsureByHostModelKey(host, modelKey string) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
key := strings.TrimSpace(modelKey)
if key == "" {
return StoredModel{}, errors.New("modelKey fehlt")
}
h := canonicalHost(host)
// host optional: wenn leer -> fallback auf bisherigen Weg (best match über alle Hosts)
if h == "" {
return s.EnsureByModelKey(key)
}
// 1) explizit host+key suchen
var existingID string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, h, key).Scan(&existingID)
if err == nil && existingID != "" {
return s.getByID(existingID)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, err
}
// 2) nicht vorhanden -> "manual" anlegen (is_url=false, input=modelKey), ABER host gesetzt
now := time.Now().UTC()
id := canonicalID(h, key)
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT(id) DO UPDATE SET
model_key=EXCLUDED.model_key,
host=EXCLUDED.host,
updated_at=EXCLUDED.updated_at;
`,
id, key, false, h, "", key,
"", nil,
false, false, false, false, nil,
now, now,
)
if err != nil {
return StoredModel{}, err
}
return s.getByID(id)
}
// EnsureByModelKey:
// - liefert ein bestehendes Model (best match) wenn vorhanden
// - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false)
func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
key := strings.TrimSpace(modelKey)
if key == "" {
return StoredModel{}, errors.New("modelKey fehlt")
}
var existingID string
err := s.db.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(model_key)) = lower(trim($1))
ORDER BY
CASE WHEN is_url=true THEN 1 ELSE 0 END DESC,
CASE WHEN host IS NOT NULL AND trim(host)<>'' THEN 1 ELSE 0 END DESC,
favorite DESC,
updated_at DESC
LIMIT 1;
`, key).Scan(&existingID)
if err == nil && existingID != "" {
return s.getByID(existingID)
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, err
}
now := time.Now().UTC()
id := canonicalID("", key)
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT(id) DO UPDATE SET
model_key=EXCLUDED.model_key,
updated_at=EXCLUDED.updated_at;
`,
id, key, false, "", "", key,
"", nil,
false, false, false, false, nil,
now, now,
)
if err != nil {
return StoredModel{}, err
}
return s.getByID(id)
}
func (s *ModelStore) FillMissingTagsFromChaturbateOnline(rooms []ChaturbateRoom) {
if err := s.ensureInit(); err != nil {
return
}
if len(rooms) == 0 {
return
}
now := time.Now().UTC()
tx, err := s.db.Begin()
if err != nil {
return
}
defer func() { _ = tx.Rollback() }()
stmt, err := tx.Prepare(`
UPDATE models
SET tags = $1, updated_at = $2
WHERE lower(trim(host)) = 'chaturbate.com'
AND lower(trim(model_key)) = lower(trim($3))
AND (tags IS NULL OR trim(tags) = '');
`)
if err != nil {
return
}
defer stmt.Close()
for _, rm := range rooms {
key := strings.TrimSpace(rm.Username)
if key == "" || len(rm.Tags) == 0 {
continue
}
tags := strings.TrimSpace(strings.Join(rm.Tags, ", "))
if tags == "" {
continue
}
_, _ = stmt.Exec(tags, now, key)
}
_ = tx.Commit()
}
// --- Profile image cache ---
// SetProfileImage speichert Bild-URL + MIME + Blob.
// Legt den Datensatz bei Bedarf minimal an.
func (s *ModelStore) SetProfileImage(host, modelKey, sourceURL, mime string, data []byte, updatedAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
if len(data) == 0 {
return errors.New("image data fehlt")
}
src := strings.TrimSpace(sourceURL)
mime = strings.TrimSpace(strings.ToLower(mime))
if mime == "" || mime == "application/octet-stream" {
detected := http.DetectContentType(data)
if strings.TrimSpace(detected) != "" {
mime = detected
}
}
if mime == "" {
mime = "image/jpeg"
}
nt := parseRFC3339Nano(updatedAt)
if !nt.Valid {
nt = sql.NullTime{Valid: true, Time: time.Now().UTC()}
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
// Erst Update versuchen
res, err := s.db.Exec(`
UPDATE models
SET profile_image_url=$1, profile_image_mime=$2, profile_image_blob=$3, profile_image_updated_at=$4, updated_at=$5
WHERE lower(trim(host)) = lower(trim($6))
AND lower(trim(model_key)) = lower(trim($7));
`, src, mime, data, nullableTimeArg(nt), now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Kein Auto-Insert: Profilbild nur für bereits bestehende Models speichern.
return nil
}
// SetProfileImageURLOnly speichert nur die letzte bekannte Bild-URL (+Zeit), ohne Blob.
// Praktisch als Fallback, wenn Download fehlschlägt.
func (s *ModelStore) SetProfileImageURLOnly(host, modelKey, sourceURL, updatedAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
src := strings.TrimSpace(sourceURL)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
if src == "" {
return nil
}
nt := parseRFC3339Nano(updatedAt)
if !nt.Valid {
nt = sql.NullTime{Valid: true, Time: time.Now().UTC()}
}
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`
UPDATE models
SET profile_image_url=$1, profile_image_updated_at=$2, updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, src, nullableTimeArg(nt), now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Kein Auto-Insert: Bild-URL nur für bereits bestehende Models speichern.
return nil
}
func (s *ModelStore) GetProfileImageByID(id string) (mime string, data []byte, ok bool, err error) {
if err := s.ensureInit(); err != nil {
return "", nil, false, err
}
id = strings.TrimSpace(id)
if id == "" {
return "", nil, false, errors.New("id fehlt")
}
var mimeNS sql.NullString
var blob []byte
err = s.db.QueryRow(`
SELECT profile_image_mime, profile_image_blob
FROM models
WHERE id = $1
LIMIT 1;
`, id).Scan(&mimeNS, &blob)
if errors.Is(err, sql.ErrNoRows) {
return "", nil, false, nil
}
if err != nil {
return "", nil, false, err
}
if len(blob) == 0 {
return "", nil, false, nil
}
m := strings.TrimSpace(mimeNS.String)
if m == "" {
m = http.DetectContentType(blob)
if m == "" {
m = "application/octet-stream"
}
}
return m, blob, true, nil
}
// --- Biocontext Cache ---
func (s *ModelStore) GetBioContext(host, modelKey string) (jsonStr string, fetchedAt string, ok bool, err error) {
if err := s.ensureInit(); err != nil {
return "", "", false, err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return "", "", false, errors.New("host/modelKey fehlt")
}
var js sql.NullString
var ts dbNullTime
err = s.db.QueryRow(`
SELECT biocontext_json, biocontext_fetched_at
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, key).Scan(&js, &ts)
if errors.Is(err, sql.ErrNoRows) {
return "", "", false, nil
}
if err != nil {
return "", "", false, err
}
val := strings.TrimSpace(js.String)
if val == "" {
return "", fmtNullTime(ts.NullTime), false, nil
}
return val, fmtNullTime(ts.NullTime), true, nil
}
func (s *ModelStore) SetBioContext(host, modelKey, jsonStr, fetchedAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
js := strings.TrimSpace(jsonStr)
ts := parseRFC3339Nano(fetchedAt) // NullTime
now := time.Now().UTC() // time.Time
s.mu.Lock()
defer s.mu.Unlock()
res, err := s.db.Exec(`
UPDATE models
SET biocontext_json=$1, biocontext_fetched_at=$2, updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, js, nullableTimeArg(ts), now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Kein Auto-Insert: Biocontext nur für vorhandene Models.
return nil
}
// SetLastSeenOnline speichert Online/Offline Status
func (s *ModelStore) SetLastSeenOnline(host, modelKey string, online bool, seenAt string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
nt := parseRFC3339Nano(seenAt)
if !nt.Valid {
nt = sql.NullTime{Valid: true, Time: time.Now().UTC()}
}
now := time.Now().UTC()
// ✅ last_seen_online ist in deiner DB BOOLEAN (nullable)
var onlineArg any
if online {
onlineArg = true
} else {
onlineArg = false
}
res, err := s.db.Exec(`
UPDATE models
SET last_seen_online=$1, last_seen_online_at=$2, updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, onlineArg, nullableTimeArg(nt), now, host, key)
if err != nil {
return err
}
aff, _ := res.RowsAffected()
if aff > 0 {
return nil
}
// Wichtig: Keine Auto-Erzeugung durch Online-Poller.
// Nur bereits manuell/importiert vorhandene Models werden aktualisiert.
return nil
}
func (s *ModelStore) normalizeNameOnlyChaturbate() error {
// ✅ last_stream ist TIMESTAMPTZ -> niemals COALESCE(...,'')
rows, err := s.db.Query(`
SELECT
id,
model_key,
tags,
last_stream,
watching,favorite,hot,keep,liked,
created_at,
updated_at
FROM models
WHERE is_url = false
AND lower(trim(input)) = lower(trim(model_key))
AND (host IS NULL OR trim(host)='' OR lower(trim(host))='chaturbate.com');
`)
if err != nil {
return err
}
defer rows.Close()
type rowT struct {
oldID, key, tags string
lastStream dbNullTime
watching, favorite, hot, keep bool
liked sql.NullBool
createdAt, updatedAt dbNullTime
}
var items []rowT
for rows.Next() {
var r rowT
if err := rows.Scan(
&r.oldID,
&r.key,
&r.tags,
&r.lastStream,
&r.watching,
&r.favorite,
&r.hot,
&r.keep,
&r.liked,
&r.createdAt,
&r.updatedAt,
); err != nil {
continue
}
r.oldID = strings.TrimSpace(r.oldID)
r.key = strings.TrimSpace(r.key)
if r.oldID == "" || r.key == "" {
continue
}
items = append(items, r)
}
if len(items) == 0 {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
const host = "chaturbate.com"
for _, it := range items {
now := time.Now().UTC()
created := now
if it.createdAt.Valid && !it.createdAt.Time.IsZero() {
created = it.createdAt.Time.UTC()
}
updated := now
if it.updatedAt.Valid && !it.updatedAt.Time.IsZero() {
updated = it.updatedAt.Time.UTC()
}
newInput := "https://" + host + "/" + it.key + "/"
newPath := "/" + it.key + "/"
var targetID string
err := tx.QueryRow(`
SELECT id
FROM models
WHERE lower(trim(host)) = lower($1) AND lower(trim(model_key)) = lower($2)
LIMIT 1;
`, host, it.key).Scan(&targetID)
if errors.Is(err, sql.ErrNoRows) {
targetID = ""
err = nil
}
if err != nil {
return err
}
var likedArg any
if it.liked.Valid {
likedArg = it.liked.Bool
} else {
likedArg = nil
}
lastStreamArg := nullableTimeArg(it.lastStream.NullTime)
if targetID == "" {
targetID = canonicalID(host, it.key)
_, err = tx.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15);
`,
targetID, newInput, true, host, newPath, it.key,
it.tags, lastStreamArg,
it.watching, it.favorite, it.hot, it.keep, likedArg,
created, updated,
)
if err != nil {
return err
}
} else {
updateSQL := `
UPDATE models SET
input = CASE
WHEN is_url=false OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key))
THEN $1 ELSE input END,
is_url = CASE WHEN is_url=false THEN true ELSE is_url END,
host = CASE WHEN host IS NULL OR trim(host)='' THEN $2 ELSE host END,
path = CASE WHEN path IS NULL OR trim(path)='' THEN $3 ELSE path END,
tags = CASE WHEN (tags IS NULL OR tags='') AND $4<>'' THEN $5 ELSE tags END,
-- ✅ last_stream ist timestamptz: nur setzen, wenn aktuell NULL und wir einen gültigen Wert haben
last_stream = CASE
WHEN last_stream IS NULL AND $6::timestamptz IS NOT NULL THEN $6::timestamptz
ELSE last_stream
END,
watching = CASE WHEN $7=true THEN true ELSE watching END,
favorite = CASE WHEN $8=true THEN true ELSE favorite END,
hot = CASE WHEN $9=true THEN true ELSE hot END,
keep = CASE WHEN $10=true THEN true ELSE keep END,
liked = CASE
WHEN liked IS NULL AND $11::boolean IS NOT NULL THEN $11::boolean
ELSE liked
END,
updated_at = CASE WHEN updated_at < $12 THEN $12 ELSE updated_at END
WHERE id = $13;
`
if s.isSQLite() {
updateSQL = strings.ReplaceAll(updateSQL, "$6::timestamptz", "$6")
updateSQL = strings.ReplaceAll(updateSQL, "$11::boolean", "$11")
}
_, err = tx.Exec(updateSQL,
newInput, host, newPath,
it.tags, it.tags,
lastStreamArg,
it.watching, it.favorite, it.hot, it.keep,
likedArg,
updated,
targetID,
)
if err != nil {
return err
}
}
if it.oldID != targetID {
if _, err := tx.Exec(`DELETE FROM models WHERE id=$1;`, it.oldID); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *ModelStore) List() []StoredModel {
if err := s.ensureInit(); err != nil {
return []StoredModel{}
}
blobLengthExpr := s.blobLengthExpr()
emptyTextExpr := s.emptyTextExpr()
q1 := fmt.Sprintf(`
SELECT
id,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
ORDER BY updated_at DESC;
`, blobLengthExpr)
q2 := fmt.Sprintf(`
SELECT
id,
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
%s as cb_online_last_error,
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
ORDER BY updated_at DESC;
`, emptyTextExpr, blobLengthExpr)
rows, err := s.db.Query(q1)
if err != nil {
// ✅ genau dein Fall: "Spalte existiert nicht" -> fallback
appLogln("models List query err (q1):", err)
rows, err = s.db.Query(q2)
if err != nil {
appLogln("models List query err (q2):", err)
return []StoredModel{}
}
}
defer rows.Close()
out := make([]StoredModel, 0, 64)
for rows.Next() {
var (
id, input, host, path, modelKey, tags string
isURL bool
lastStream dbNullTime
lastSeenOnline sql.NullBool
lastSeenOnlineAt dbNullTime
cbOnlineJSON string
cbOnlineFetchedAt dbNullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt dbNullTime
hasProfileImage int64
roomStatus string
isOnline bool
chatRoomURL string
imageURL string
lastOnlineAt dbNullTime
lastOfflineAt dbNullTime
lastRoomSyncAt dbNullTime
watching, favorite, hot, keep bool
liked sql.NullBool
createdAt, updatedAt dbNullTime
)
if err := rows.Scan(
&id, &input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&roomStatus, &isOnline, &chatRoomURL, &imageURL,
&lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
); err != nil {
continue
}
var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
}
}
m := StoredModel{
ID: id,
Input: input,
IsURL: isURL,
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Country: country,
Tags: tags,
LastStream: fmtNullTime(lastStream.NullTime),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime),
CbOnlineLastError: cbOnlineLastError,
ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime),
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime),
LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime),
Watching: watching,
Favorite: favorite,
Hot: hot,
Keep: keep,
Liked: ptrLikedFromNullBool(liked),
CreatedAt: fmtNullTime(createdAt.NullTime),
UpdatedAt: fmtNullTime(updatedAt.NullTime),
}
if hasProfileImage != 0 {
m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id)
}
out = append(out, m)
}
return out
}
func (s *ModelStore) Meta() ModelsMeta {
if err := s.ensureInit(); err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""}
}
var count int
var updatedAt dbNullTime
err := s.db.QueryRow(`SELECT COUNT(*), MAX(updated_at) FROM models;`).Scan(&count, &updatedAt)
if err != nil {
return ModelsMeta{Count: 0, UpdatedAt: ""}
}
return ModelsMeta{Count: count, UpdatedAt: fmtNullTime(updatedAt.NullTime)}
}
type ChaturbateOnlineSnapshot struct {
Username string `json:"username"`
DisplayName string `json:"display_name,omitempty"`
CurrentShow string `json:"current_show,omitempty"` // public/private/hidden/away
RoomSubject string `json:"room_subject,omitempty"`
Location string `json:"location,omitempty"`
Country string `json:"country,omitempty"`
SpokenLanguages string `json:"spoken_languages,omitempty"`
Gender string `json:"gender,omitempty"`
NumUsers int `json:"num_users,omitempty"`
NumFollowers int `json:"num_followers,omitempty"`
IsHD bool `json:"is_hd,omitempty"`
IsNew bool `json:"is_new,omitempty"`
Age int `json:"age,omitempty"`
SecondsOnline int `json:"seconds_online,omitempty"`
ImageURL string `json:"image_url,omitempty"`
ImageURL360 string `json:"image_url_360x270,omitempty"`
ChatRoomURL string `json:"chat_room_url,omitempty"`
ChatRoomURLRS string `json:"chat_room_url_revshare,omitempty"`
Tags []string `json:"tags,omitempty"`
}
func (s *ModelStore) ListModelKeysByHost(host string) ([]string, error) {
if err := s.ensureInit(); err != nil {
return nil, err
}
host = canonicalHost(host)
if host == "" {
return nil, errors.New("host fehlt")
}
rows, err := s.db.Query(`
SELECT model_key
FROM models
WHERE lower(trim(host)) = lower(trim($1));
`, host)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0, 128)
for rows.Next() {
var k string
if err := rows.Scan(&k); err != nil {
continue
}
k = strings.ToLower(strings.TrimSpace(k))
if k != "" {
out = append(out, k)
}
}
return out, nil
}
func (s *ModelStore) SyncChaturbateOnlineForKnownModels(rooms []ChaturbateRoom, fetchedAt time.Time) error {
if err := s.ensureInit(); err != nil {
return err
}
const host = "chaturbate.com"
fetchedAt = fetchedAt.UTC()
knownKeys, err := s.ListModelKeysByHost(host)
if err != nil {
return err
}
if len(knownKeys) == 0 {
return nil
}
roomsByUser := indexRoomsByUser(rooms)
tx, err := s.db.Begin()
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// ONLINE: voller Snapshot wird gespeichert
stmtOnline1, errOnline1 := tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_json = $3,
cb_online_fetched_at = $4,
cb_online_last_error = $5,
profile_image_url = CASE
WHEN COALESCE(trim($6), '') <> '' THEN $6
ELSE profile_image_url
END,
profile_image_updated_at = CASE
WHEN COALESCE(trim($6), '') <> '' THEN $4
ELSE profile_image_updated_at
END,
updated_at = $7
WHERE lower(trim(host)) = lower(trim($8))
AND lower(trim(model_key)) = lower(trim($9));
`)
var stmtOnline2 *sql.Stmt
if errOnline1 != nil {
stmtOnline2, err = tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_json = $3,
cb_online_fetched_at = $4,
profile_image_url = CASE
WHEN COALESCE(trim($5), '') <> '' THEN $5
ELSE profile_image_url
END,
profile_image_updated_at = CASE
WHEN COALESCE(trim($5), '') <> '' THEN $4
ELSE profile_image_updated_at
END,
updated_at = $6
WHERE lower(trim(host)) = lower(trim($7))
AND lower(trim(model_key)) = lower(trim($8));
`)
if err != nil {
return err
}
defer stmtOnline2.Close()
} else {
defer stmtOnline1.Close()
}
// OFFLINE: nur Status updaten, Snapshot NICHT überschreiben
stmtOffline1, errOffline1 := tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
cb_online_last_error = $3,
updated_at = $4
WHERE lower(trim(host)) = lower(trim($5))
AND lower(trim(model_key)) = lower(trim($6));
`)
var stmtOffline2 *sql.Stmt
if errOffline1 != nil {
stmtOffline2, err = tx.Prepare(`
UPDATE models
SET
last_seen_online = $1,
last_seen_online_at = $2,
updated_at = $3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`)
if err != nil {
return err
}
defer stmtOffline2.Close()
} else {
defer stmtOffline1.Close()
}
now := time.Now().UTC()
for _, key := range knownKeys {
key = strings.TrimSpace(key)
if key == "" {
continue
}
if rm, ok := roomsByUser[strings.ToLower(key)]; ok {
imgURL := strings.TrimSpace(selectBestRoomImageURL(rm))
snap := &ChaturbateOnlineSnapshot{
Username: rm.Username,
DisplayName: rm.DisplayName,
CurrentShow: strings.TrimSpace(rm.CurrentShow),
RoomSubject: rm.RoomSubject,
Location: rm.Location,
Country: rm.Country,
SpokenLanguages: rm.SpokenLanguages,
Gender: rm.Gender,
NumUsers: rm.NumUsers,
NumFollowers: rm.NumFollowers,
IsHD: rm.IsHD,
IsNew: rm.IsNew,
Age: rm.Age,
SecondsOnline: rm.SecondsOnline,
ImageURL: rm.ImageURL,
ImageURL360: rm.ImageURL360,
ChatRoomURL: rm.ChatRoomURL,
ChatRoomURLRS: rm.ChatRoomURLRS,
Tags: rm.Tags,
}
jsonStr := ""
if b, err := json.Marshal(snap); err == nil {
jsonStr = strings.TrimSpace(string(b))
}
if stmtOnline1 != nil {
if _, err := stmtOnline1.Exec(
true,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
"",
imgURL,
now,
host,
key,
); err != nil {
return err
}
} else {
if _, err := stmtOnline2.Exec(
true,
fetchedAt,
nullableStringArg(jsonStr),
fetchedAt,
imgURL,
now,
host,
key,
); err != nil {
return err
}
}
continue
}
// OFFLINE:
// KEIN cb_online_json Update
// KEIN cb_online_fetched_at Update
if stmtOffline1 != nil {
if _, err := stmtOffline1.Exec(
false,
fetchedAt,
"",
now,
host,
key,
); err != nil {
return err
}
} else {
if _, err := stmtOffline2.Exec(
false,
fetchedAt,
now,
host,
key,
); err != nil {
return err
}
}
}
return tx.Commit()
}
func (s *ModelStore) SetChaturbateOnlineSnapshot(host, modelKey string, snap *ChaturbateOnlineSnapshot, fetchedAt string, lastErr string) error {
if err := s.ensureInit(); err != nil {
return err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return errors.New("host/modelKey fehlt")
}
var jsonStr string
if snap != nil {
b, err := json.Marshal(snap)
if err == nil {
jsonStr = strings.TrimSpace(string(b))
}
}
ft := parseRFC3339Nano(fetchedAt)
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
// NOTE: cb_online_last_error nur updaten, wenn Spalte existiert.
// Wenn du die optionale Spalte nicht anlegst: entferne die beiden Stellen.
_, err := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
cb_online_last_error=$3,
updated_at=$4
WHERE lower(trim(host)) = lower(trim($5))
AND lower(trim(model_key)) = lower(trim($6));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), strings.TrimSpace(lastErr), now, host, key)
if err != nil {
// falls cb_online_last_error nicht existiert -> fallback ohne die Spalte
_, err2 := s.db.Exec(`
UPDATE models
SET
cb_online_json=$1,
cb_online_fetched_at=$2,
updated_at=$3
WHERE lower(trim(host)) = lower(trim($4))
AND lower(trim(model_key)) = lower(trim($5));
`, nullableStringArg(jsonStr), nullableTimeArg(ft), now, host, key)
return err2
}
return nil
}
func (s *ModelStore) GetChaturbateOnlineSnapshot(host, modelKey string) (*ChaturbateOnlineSnapshot, string, bool, error) {
if err := s.ensureInit(); err != nil {
return nil, "", false, err
}
host = canonicalHost(host)
key := strings.TrimSpace(modelKey)
if host == "" || key == "" {
return nil, "", false, errors.New("host/modelKey fehlt")
}
var js sql.NullString
var fetchedAt dbNullTime
err := s.db.QueryRow(`
SELECT cb_online_json, cb_online_fetched_at
FROM models
WHERE lower(trim(host)) = lower(trim($1))
AND lower(trim(model_key)) = lower(trim($2))
LIMIT 1;
`, host, key).Scan(&js, &fetchedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, "", false, nil
}
if err != nil {
return nil, "", false, err
}
raw := strings.TrimSpace(js.String)
if raw == "" {
return nil, fmtNullTime(fetchedAt.NullTime), false, nil
}
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(raw), &snap); err != nil {
return nil, fmtNullTime(fetchedAt.NullTime), false, err
}
return &snap, fmtNullTime(fetchedAt.NullTime), true, nil
}
func nullableStringArg(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
// hostFilter: z.B. "chaturbate.com" (leer => alle Hosts)
func (s *ModelStore) ListWatchedLite(hostFilter string) []WatchedModelLite {
if err := s.ensureInit(); err != nil {
return []WatchedModelLite{}
}
hostFilter = canonicalHost(hostFilter)
var (
rows *sql.Rows
err error
)
if hostFilter == "" {
rows, err = s.db.Query(`
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true
ORDER BY updated_at DESC;
`)
} else {
rows, err = s.db.Query(`
SELECT id,input,COALESCE(host,'') as host,model_key,watching
FROM models
WHERE watching = true
AND lower(trim(COALESCE(host,''))) = lower(trim($1))
ORDER BY updated_at DESC;
`, hostFilter)
}
if err != nil {
return []WatchedModelLite{}
}
defer rows.Close()
out := make([]WatchedModelLite, 0, 64)
for rows.Next() {
var id, input, host, modelKey string
var watching bool
if err := rows.Scan(&id, &input, &host, &modelKey, &watching); err != nil {
continue
}
out = append(out, WatchedModelLite{
ID: id,
Input: input,
Host: host,
ModelKey: modelKey,
Watching: watching,
})
}
return out
}
func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
if p.ModelKey == "" {
return StoredModel{}, errors.New("modelKey fehlt")
}
input := strings.TrimSpace(p.Input)
if input == "" {
return StoredModel{}, errors.New("URL fehlt.")
}
if !p.IsURL {
return StoredModel{}, errors.New("Nur URL erlaubt.")
}
u, err := url.Parse(input)
if err != nil || u.Scheme == "" || u.Hostname() == "" {
return StoredModel{}, errors.New("Ungültige URL.")
}
host := canonicalHost(p.Host)
modelKey := strings.TrimSpace(p.ModelKey)
id := canonicalID(host, modelKey)
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT(id) DO UPDATE SET
input=EXCLUDED.input,
is_url=EXCLUDED.is_url,
host=EXCLUDED.host,
path=EXCLUDED.path,
model_key=EXCLUDED.model_key,
updated_at=EXCLUDED.updated_at;
`,
id,
u.String(),
true,
host,
p.Path,
modelKey,
"", nil,
false, false, false, false, nil,
now,
now,
)
if err != nil {
return StoredModel{}, err
}
return s.getByID(id)
}
func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, err
}
if patch.ID == "" {
return StoredModel{}, errors.New("id fehlt")
}
var (
watching, favorite, hot, keep bool
liked sql.NullBool
)
err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=$1;`, patch.ID).
Scan(&watching, &favorite, &hot, &keep, &liked)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
return StoredModel{}, err
}
if patch.Watched != nil {
watching = *patch.Watched
}
if patch.Favorite != nil {
favorite = *patch.Favorite
}
if patch.Liked != nil {
liked = sql.NullBool{Valid: true, Bool: *patch.Liked}
}
// Exklusivität
if patch.Liked != nil && *patch.Liked {
favorite = false
}
if patch.Favorite != nil && *patch.Favorite {
if patch.Liked == nil || !*patch.Liked {
liked = sql.NullBool{Valid: true, Bool: false}
}
}
now := time.Now().UTC()
var likedArg any
if liked.Valid {
likedArg = liked.Bool
} else {
likedArg = nil
}
_, err = s.db.Exec(`
UPDATE models
SET watching=$1, favorite=$2, hot=$3, keep=$4, liked=$5, updated_at=$6
WHERE id=$7;
`, watching, favorite, hot, keep, likedArg, now, patch.ID)
if err != nil {
return StoredModel{}, err
}
return s.getByID(patch.ID)
}
func (s *ModelStore) Delete(id string) error {
if err := s.ensureInit(); err != nil {
return err
}
if id == "" {
return errors.New("id fehlt")
}
_, err := s.db.Exec(`DELETE FROM models WHERE id=$1;`, id)
return err
}
func (s *ModelStore) UpsertFromImport(p ParsedModelDTO, tags, lastStream string, watch bool, kind string) (StoredModel, bool, error) {
if err := s.ensureInit(); err != nil {
return StoredModel{}, false, err
}
input := strings.TrimSpace(p.Input)
if input == "" || !p.IsURL {
return StoredModel{}, false, errors.New("Nur URL erlaubt.")
}
u, err := url.Parse(input)
if err != nil || u.Scheme == "" || u.Hostname() == "" {
return StoredModel{}, false, errors.New("Ungültige URL.")
}
host := canonicalHost(p.Host)
modelKey := strings.TrimSpace(p.ModelKey)
id := canonicalID(host, modelKey)
now := time.Now().UTC()
fav := false
var likedArg any = nil
if kind == "favorite" {
fav = true
}
if kind == "liked" {
likedArg = true
}
// last_stream kommt aus CSV als String -> parse
ls := parseRFC3339Nano(lastStream)
s.mu.Lock()
defer s.mu.Unlock()
inserted := false
var dummy int
err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=$1 LIMIT 1;`, id).Scan(&dummy)
if err == sql.ErrNoRows {
inserted = true
} else if err != nil {
return StoredModel{}, false, err
}
_, err = s.db.Exec(`
INSERT INTO models (
id,input,is_url,host,path,model_key,
tags,last_stream,
watching,favorite,hot,keep,liked,
created_at,updated_at
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
ON CONFLICT(id) DO UPDATE SET
input=EXCLUDED.input,
is_url=EXCLUDED.is_url,
host=EXCLUDED.host,
path=EXCLUDED.path,
model_key=EXCLUDED.model_key,
tags=EXCLUDED.tags,
last_stream=EXCLUDED.last_stream,
watching=EXCLUDED.watching,
favorite=CASE WHEN EXCLUDED.favorite=true THEN true ELSE models.favorite END,
liked=CASE WHEN EXCLUDED.liked IS NOT NULL THEN EXCLUDED.liked ELSE models.liked END,
updated_at=EXCLUDED.updated_at;
`,
id, u.String(), true, host, p.Path, modelKey,
tags, nullableTimeArg(ls),
watch, fav, false, false, likedArg,
now, now,
)
if err != nil {
return StoredModel{}, false, err
}
m, err := s.getByID(id)
return m, inserted, err
}
func (s *ModelStore) getByID(id string) (StoredModel, error) {
var (
input, host, path, modelKey, tags string
isURL bool
lastStream dbNullTime
lastSeenOnline sql.NullBool
lastSeenOnlineAt dbNullTime
cbOnlineJSON string
cbOnlineFetchedAt dbNullTime
cbOnlineLastError string
profileImageURL string
profileImageUpdatedAt dbNullTime
hasProfileImage int64
roomStatus string
isOnline bool
chatRoomURL string
imageURL string
lastOnlineAt dbNullTime
lastOfflineAt dbNullTime
lastRoomSyncAt dbNullTime
watching, favorite, hot, keep bool
liked sql.NullBool
createdAt, updatedAt dbNullTime
)
blobLengthExpr := s.blobLengthExpr()
// q1: mit optionaler Spalte cb_online_last_error
q1 := fmt.Sprintf(`
SELECT
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
COALESCE(cb_online_last_error,''),
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
WHERE id=$1;
`, blobLengthExpr)
// q2: Fallback, falls cb_online_last_error nicht existiert (oder q1 aus anderen Gründen scheitert)
// Wichtig: gleiche Spaltenreihenfolge + Typen kompatibel halten.
q2 := fmt.Sprintf(`
SELECT
COALESCE(input,'') as input,
is_url,
COALESCE(host,'') as host,
COALESCE(path,'') as path,
COALESCE(model_key,'') as model_key,
COALESCE(tags,'') as tags,
last_stream,
last_seen_online,
last_seen_online_at,
COALESCE(cb_online_json,''),
cb_online_fetched_at,
'' as cb_online_last_error,
COALESCE(profile_image_url,''),
profile_image_updated_at,
CASE WHEN profile_image_blob IS NOT NULL AND %s > 0 THEN 1 ELSE 0 END as has_profile_image,
COALESCE(room_status,'') as room_status,
COALESCE(is_online,false) as is_online,
COALESCE(chat_room_url,'') as chat_room_url,
COALESCE(image_url,'') as image_url,
last_online_at,
last_offline_at,
last_room_sync_at,
watching,favorite,hot,keep,liked,
created_at, updated_at
FROM models
WHERE id=$1;
`, blobLengthExpr)
scan := func(q string) error {
return s.db.QueryRow(q, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&tags, &lastStream,
&lastSeenOnline, &lastSeenOnlineAt,
&cbOnlineJSON, &cbOnlineFetchedAt, &cbOnlineLastError,
&profileImageURL, &profileImageUpdatedAt, &hasProfileImage,
&roomStatus, &isOnline, &chatRoomURL, &imageURL,
&lastOnlineAt, &lastOfflineAt, &lastRoomSyncAt,
&watching, &favorite, &hot, &keep, &liked,
&createdAt, &updatedAt,
)
}
err := scan(q1)
if err != nil {
// Wenn die Zeile nicht existiert, nicht noch fallbacken.
if errors.Is(err, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
// Fallback versuchen (typisch: "column cb_online_last_error does not exist")
err2 := scan(q2)
if err2 != nil {
// wenn fallback auch kein Row findet, sauber melden
if errors.Is(err2, sql.ErrNoRows) {
return StoredModel{}, errors.New("model nicht gefunden")
}
// sonst ursprünglichen Fehler behalten? -> ich gebe hier err2 zurück, weil er meist aussagekräftiger ist.
return StoredModel{}, err2
}
}
var gender, country string
if strings.TrimSpace(cbOnlineJSON) != "" {
var snap ChaturbateOnlineSnapshot
if err := json.Unmarshal([]byte(cbOnlineJSON), &snap); err == nil {
gender = strings.TrimSpace(snap.Gender)
country = strings.TrimSpace(snap.Country)
}
}
m := StoredModel{
ID: id,
Input: input,
IsURL: isURL,
Host: host,
Path: path,
ModelKey: modelKey,
Gender: gender,
Country: country,
Tags: tags,
LastStream: fmtNullTime(lastStream.NullTime),
LastSeenOnline: ptrBoolFromNullBool(lastSeenOnline),
LastSeenOnlineAt: fmtNullTime(lastSeenOnlineAt.NullTime),
CbOnlineJSON: cbOnlineJSON,
CbOnlineFetchedAt: fmtNullTime(cbOnlineFetchedAt.NullTime),
CbOnlineLastError: cbOnlineLastError,
RoomStatus: roomStatus,
IsOnline: isOnline,
ChatRoomURL: chatRoomURL,
ImageURL: imageURL,
LastOnlineAt: fmtNullTime(lastOnlineAt.NullTime),
LastOfflineAt: fmtNullTime(lastOfflineAt.NullTime),
LastRoomSyncAt: fmtNullTime(lastRoomSyncAt.NullTime),
Watching: watching,
Favorite: favorite,
Hot: hot,
Keep: keep,
Liked: ptrLikedFromNullBool(liked),
CreatedAt: fmtNullTime(createdAt.NullTime),
UpdatedAt: fmtNullTime(updatedAt.NullTime),
ProfileImageURL: profileImageURL,
ProfileImageUpdatedAt: fmtNullTime(profileImageUpdatedAt.NullTime),
}
if hasProfileImage != 0 {
m.ProfileImageCached = "/api/models/image?id=" + url.QueryEscape(id)
}
return m, nil
}