nsfwapp/backend/models_store.go
2026-03-06 14:50:56 +01:00

1551 lines
37 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
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 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,''), -- optional
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,
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, -- fallback dummy
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,
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
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,
&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,
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)
}
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) 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
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,
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,
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,
&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,
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
}