1218 lines
28 KiB
Go
1218 lines
28 KiB
Go
// models_store.go
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
_ "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
|
|
Tags string `json:"tags,omitempty"`
|
|
LastStream string `json:"lastStream,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"`
|
|
UpdatedAt string `json:"updatedAt"`
|
|
}
|
|
|
|
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"` // ✅ neu
|
|
ModelKey string `json:"modelKey,omitempty"` // ✅ wenn id fehlt
|
|
ID string `json:"id,omitempty"` // ✅ optional
|
|
|
|
Watched *bool `json:"watched,omitempty"`
|
|
Favorite *bool `json:"favorite,omitempty"`
|
|
Liked *bool `json:"liked,omitempty"`
|
|
}
|
|
|
|
type ModelStore struct {
|
|
dbPath string
|
|
legacyJSONPath string
|
|
|
|
db *sql.DB
|
|
initOnce sync.Once
|
|
initErr error
|
|
|
|
// serialize writes (einfach & robust)
|
|
mu sync.Mutex
|
|
}
|
|
|
|
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(?))
|
|
AND lower(trim(model_key)) = lower(trim(?))
|
|
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=0, input=modelKey), ABER host gesetzt
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
model_key=excluded.model_key,
|
|
host=excluded.host,
|
|
updated_at=excluded.updated_at;
|
|
`,
|
|
id, key, int64(0), h, "", key,
|
|
"", "",
|
|
int64(0), int64(0), int64(0), int64(0), 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)
|
|
// Dadurch funktionieren QuickActions (Like/Favorite) auch bei fertigen Videos,
|
|
// bei denen keine SourceURL mehr vorhanden ist.
|
|
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")
|
|
}
|
|
|
|
// Erst schauen ob es das Model schon gibt (egal welcher Host)
|
|
// Erst schauen ob es das Model schon gibt (egal welcher Host)
|
|
var existingID string
|
|
err := s.db.QueryRow(`
|
|
SELECT id
|
|
FROM models
|
|
WHERE lower(trim(model_key)) = lower(trim(?))
|
|
ORDER BY
|
|
CASE WHEN is_url=1 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
|
|
}
|
|
|
|
// Neu anlegen als "manual" (is_url = 0), input = modelKey (NOT NULL)
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
model_key=excluded.model_key,
|
|
updated_at=excluded.updated_at;
|
|
`,
|
|
id, key, int64(0), "", "", key,
|
|
"", "",
|
|
int64(0), int64(0), int64(0), int64(0), 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().Format(time.RFC3339Nano)
|
|
|
|
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 = ?, updated_at = ?
|
|
WHERE lower(trim(host)) = 'chaturbate.com'
|
|
AND lower(trim(model_key)) = lower(trim(?))
|
|
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()
|
|
}
|
|
|
|
// Backwards compatible:
|
|
// - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db"
|
|
// und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt.
|
|
func NewModelStore(path string) *ModelStore {
|
|
path = strings.TrimSpace(path)
|
|
|
|
lower := strings.ToLower(path)
|
|
dbPath := path
|
|
legacy := ""
|
|
|
|
if strings.HasSuffix(lower, ".json") {
|
|
legacy = path
|
|
dbPath = strings.TrimSuffix(path, filepath.Ext(path)) + ".db" // z.B. models_store.db
|
|
} else if strings.HasSuffix(lower, ".db") || strings.HasSuffix(lower, ".sqlite") || strings.HasSuffix(lower, ".sqlite3") {
|
|
legacy = filepath.Join(filepath.Dir(path), "models_store.json")
|
|
}
|
|
|
|
return &ModelStore{
|
|
dbPath: dbPath,
|
|
legacyJSONPath: legacy,
|
|
}
|
|
}
|
|
|
|
// main.go ruft aktuell store.Load() auf :contentReference[oaicite:4]{index=4}
|
|
// -> wir lassen Load() als Alias für Init() drin.
|
|
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.dbPath) == "" {
|
|
return errors.New("db path fehlt")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(s.dbPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
|
|
db, err := sql.Open("sqlite", s.dbPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// SQLite am besten single-conn im Server-Prozess
|
|
db.SetMaxOpenConns(5)
|
|
db.SetMaxIdleConns(5)
|
|
_, _ = db.Exec(`PRAGMA busy_timeout = 2500;`)
|
|
|
|
// Pragmas (einzeln ausführen)
|
|
_, _ = db.Exec(`PRAGMA foreign_keys = ON;`)
|
|
_, _ = db.Exec(`PRAGMA journal_mode = WAL;`)
|
|
_, _ = db.Exec(`PRAGMA synchronous = NORMAL;`)
|
|
|
|
// ✅ zuerst Schema/Columns auf "db" erstellen
|
|
if err := createModelsSchema(db); err != nil {
|
|
_ = db.Close()
|
|
return err
|
|
}
|
|
if err := ensureModelsColumns(db); err != nil {
|
|
_ = db.Close()
|
|
return err
|
|
}
|
|
|
|
// ✅ erst danach in den Store übernehmen
|
|
s.db = db
|
|
|
|
// 1x Migration: wenn DB leer ist und Legacy JSON existiert
|
|
if s.legacyJSONPath != "" {
|
|
if err := s.migrateFromJSONIfEmpty(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ✅ beim Einlesen normalisieren
|
|
if err := s.normalizeNameOnlyChaturbate(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func createModelsSchema(db *sql.DB) error {
|
|
_, err := db.Exec(`
|
|
CREATE TABLE IF NOT EXISTS models (
|
|
id TEXT PRIMARY KEY,
|
|
input TEXT NOT NULL,
|
|
is_url INTEGER NOT NULL,
|
|
host TEXT,
|
|
path TEXT,
|
|
model_key TEXT NOT NULL,
|
|
tags TEXT NOT NULL DEFAULT '',
|
|
last_stream TEXT,
|
|
|
|
biocontext_json TEXT,
|
|
biocontext_fetched_at TEXT,
|
|
|
|
watching INTEGER NOT NULL DEFAULT 0,
|
|
favorite INTEGER NOT NULL DEFAULT 0,
|
|
hot INTEGER NOT NULL DEFAULT 0,
|
|
keep INTEGER NOT NULL DEFAULT 0,
|
|
liked INTEGER NULL, -- NULL/0/1
|
|
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, _ = db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_models_host_key ON models(host, model_key);`)
|
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_models_updated ON models(updated_at);`)
|
|
return nil
|
|
}
|
|
|
|
func ensureModelsColumns(db *sql.DB) error {
|
|
cols := map[string]bool{}
|
|
|
|
rows, err := db.Query(`PRAGMA table_info(models);`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var cid int
|
|
var name, typ string
|
|
var notnull, pk int
|
|
var dflt sql.NullString
|
|
if err := rows.Scan(&cid, &name, &typ, ¬null, &dflt, &pk); err != nil {
|
|
return err
|
|
}
|
|
cols[name] = true
|
|
}
|
|
|
|
if !cols["tags"] {
|
|
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN tags TEXT NOT NULL DEFAULT '';`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !cols["last_stream"] {
|
|
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN last_stream TEXT;`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// ✅ Biocontext (persistente Bio-Infos)
|
|
if !cols["biocontext_json"] {
|
|
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_json TEXT;`); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !cols["biocontext_fetched_at"] {
|
|
if _, err := db.Exec(`ALTER TABLE models ADD COLUMN biocontext_fetched_at TEXT;`); 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 boolToInt(b bool) int64 {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func nullLikedFromPtr(p *bool) sql.NullInt64 {
|
|
if p == nil {
|
|
return sql.NullInt64{Valid: false}
|
|
}
|
|
return sql.NullInt64{Valid: true, Int64: boolToInt(*p)}
|
|
}
|
|
|
|
func ptrLikedFromNull(n sql.NullInt64) *bool {
|
|
if !n.Valid {
|
|
return nil
|
|
}
|
|
v := n.Int64 != 0
|
|
return &v
|
|
}
|
|
|
|
// --- Biocontext Cache (persistente Bio-Infos aus Chaturbate) ---
|
|
|
|
// GetBioContext liefert das zuletzt gespeicherte Biocontext-JSON (+ Zeitstempel).
|
|
// ok=false wenn nichts gespeichert ist.
|
|
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.NullString
|
|
err = s.db.QueryRow(`
|
|
SELECT biocontext_json, biocontext_fetched_at
|
|
FROM models
|
|
WHERE lower(trim(host)) = lower(trim(?))
|
|
AND lower(trim(model_key)) = lower(trim(?))
|
|
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 "", strings.TrimSpace(ts.String), false, nil
|
|
}
|
|
return val, strings.TrimSpace(ts.String), true, nil
|
|
}
|
|
|
|
// SetBioContext speichert/aktualisiert das Biocontext-JSON dauerhaft in der DB.
|
|
// Es legt das Model (host+modelKey) bei Bedarf minimal an.
|
|
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 := strings.TrimSpace(fetchedAt)
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
res, err := s.db.Exec(`
|
|
UPDATE models
|
|
SET biocontext_json=?, biocontext_fetched_at=?, updated_at=?
|
|
WHERE lower(trim(host)) = lower(trim(?))
|
|
AND lower(trim(model_key)) = lower(trim(?));
|
|
`, js, ts, now, host, key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
aff, _ := res.RowsAffected()
|
|
if aff > 0 {
|
|
return nil
|
|
}
|
|
|
|
// Model existiert noch nicht -> minimal anlegen (als URL)
|
|
id := canonicalID(host, key)
|
|
input := "https://" + host + "/" + key + "/"
|
|
path := "/" + key + "/"
|
|
|
|
_, err = s.db.Exec(`
|
|
INSERT INTO models (
|
|
id,input,is_url,host,path,model_key,
|
|
tags,last_stream,
|
|
biocontext_json,biocontext_fetched_at,
|
|
watching,favorite,hot,keep,liked,
|
|
created_at,updated_at
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
biocontext_json=excluded.biocontext_json,
|
|
biocontext_fetched_at=excluded.biocontext_fetched_at,
|
|
updated_at=excluded.updated_at;
|
|
`, id, input, int64(1), host, path, key,
|
|
"", "",
|
|
js, ts,
|
|
int64(0), int64(0), int64(0), int64(0), nil,
|
|
now, now,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (s *ModelStore) migrateFromJSONIfEmpty() error {
|
|
// DB leer?
|
|
var cnt int
|
|
if err := s.db.QueryRow(`SELECT COUNT(1) FROM models;`).Scan(&cnt); err != nil {
|
|
return err
|
|
}
|
|
if cnt != 0 {
|
|
return nil
|
|
}
|
|
|
|
// Legacy JSON vorhanden?
|
|
b, err := os.ReadFile(s.legacyJSONPath)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if len(bytesTrimSpace(b)) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var list []StoredModel
|
|
if err := json.Unmarshal(b, &list); err != nil {
|
|
return err
|
|
}
|
|
if len(list) == 0 {
|
|
return nil
|
|
}
|
|
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
stmt, err := tx.Prepare(`
|
|
INSERT INTO models (
|
|
id,input,is_url,host,path,model_key,
|
|
tags,last_stream,
|
|
watching,favorite,hot,keep,liked,
|
|
created_at,updated_at
|
|
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
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;
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
|
|
for _, m := range list {
|
|
host := canonicalHost(m.Host)
|
|
modelKey := strings.TrimSpace(m.ModelKey)
|
|
if modelKey == "" {
|
|
continue
|
|
}
|
|
|
|
// alte IDs (oft nur modelKey) werden auf host:modelKey normalisiert
|
|
id := canonicalID(host, modelKey)
|
|
|
|
created := strings.TrimSpace(m.CreatedAt)
|
|
updated := strings.TrimSpace(m.UpdatedAt)
|
|
if created == "" {
|
|
created = now
|
|
}
|
|
if updated == "" {
|
|
updated = now
|
|
}
|
|
|
|
liked := nullLikedFromPtr(m.Liked)
|
|
var likedArg any
|
|
if liked.Valid {
|
|
likedArg = liked.Int64
|
|
} else {
|
|
likedArg = nil
|
|
}
|
|
|
|
_, err = stmt.Exec(
|
|
id,
|
|
m.Input,
|
|
boolToInt(m.IsURL),
|
|
host,
|
|
m.Path,
|
|
modelKey,
|
|
boolToInt(m.Watching),
|
|
boolToInt(m.Favorite),
|
|
boolToInt(m.Hot),
|
|
boolToInt(m.Keep),
|
|
likedArg,
|
|
created,
|
|
updated,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func bytesTrimSpace(b []byte) []byte {
|
|
return []byte(strings.TrimSpace(string(b)))
|
|
}
|
|
|
|
func (s *ModelStore) normalizeNameOnlyChaturbate() error {
|
|
// Kandidaten: is_url=0 UND input==model_key UND host leer oder schon chaturbate
|
|
rows, err := s.db.Query(`
|
|
SELECT
|
|
id, model_key,
|
|
tags, COALESCE(last_stream,''),
|
|
watching,favorite,hot,keep,liked,
|
|
created_at,updated_at
|
|
FROM models
|
|
WHERE is_url = 0
|
|
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, lastStream, createdAt, updatedAt string
|
|
watching, favorite, hot, keep int64
|
|
liked sql.NullInt64
|
|
}
|
|
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.key = strings.TrimSpace(r.key)
|
|
if r.key == "" || strings.TrimSpace(r.oldID) == "" {
|
|
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 {
|
|
newInput := "https://" + host + "/" + it.key + "/"
|
|
newPath := "/" + it.key + "/"
|
|
|
|
// Ziel-Datensatz: wenn bereits chaturbate.com:<key> existiert, dorthin mergen
|
|
var targetID string
|
|
err := tx.QueryRow(`
|
|
SELECT id
|
|
FROM models
|
|
WHERE lower(trim(host)) = lower(?) AND lower(trim(model_key)) = lower(?)
|
|
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.Int64
|
|
} else {
|
|
likedArg = nil
|
|
}
|
|
|
|
// Wenn es keinen Ziel-Datensatz gibt: neu anlegen mit canonical ID
|
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?);
|
|
`,
|
|
targetID, newInput, int64(1), host, newPath, it.key,
|
|
it.tags, it.lastStream,
|
|
it.watching, it.favorite, it.hot, it.keep, likedArg,
|
|
it.createdAt, it.updatedAt,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Ziel existiert: Flags mergen + fehlende Felder auffüllen
|
|
_, err = tx.Exec(`
|
|
UPDATE models SET
|
|
input = CASE
|
|
WHEN is_url=0 OR input IS NULL OR trim(input)='' OR lower(trim(input))=lower(trim(model_key))
|
|
THEN ? ELSE input END,
|
|
is_url = CASE WHEN is_url=0 THEN 1 ELSE is_url END,
|
|
host = CASE WHEN host IS NULL OR trim(host)='' THEN ? ELSE host END,
|
|
path = CASE WHEN path IS NULL OR trim(path)='' THEN ? ELSE path END,
|
|
|
|
tags = CASE WHEN (tags IS NULL OR tags='') AND ?<>'' THEN ? ELSE tags END,
|
|
last_stream = CASE WHEN (last_stream IS NULL OR last_stream='') AND ?<>'' THEN ? ELSE last_stream END,
|
|
|
|
watching = CASE WHEN ?=1 THEN 1 ELSE watching END,
|
|
favorite = CASE WHEN ?=1 THEN 1 ELSE favorite END,
|
|
hot = CASE WHEN ?=1 THEN 1 ELSE hot END,
|
|
keep = CASE WHEN ?=1 THEN 1 ELSE keep END,
|
|
liked = CASE WHEN liked IS NULL AND ? IS NOT NULL THEN ? ELSE liked END,
|
|
|
|
updated_at = CASE WHEN updated_at < ? THEN ? ELSE updated_at END
|
|
WHERE id = ?;
|
|
`,
|
|
newInput, host, newPath,
|
|
it.tags, it.tags,
|
|
it.lastStream, it.lastStream,
|
|
it.watching, it.favorite, it.hot, it.keep,
|
|
likedArg, likedArg,
|
|
it.updatedAt, it.updatedAt,
|
|
targetID,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// alten "manual" Datensatz löschen (nur wenn anderer Ziel-Datensatz)
|
|
if it.oldID != targetID {
|
|
if _, err := tx.Exec(`DELETE FROM models WHERE id=?;`, it.oldID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *ModelStore) List() []StoredModel {
|
|
if err := s.ensureInit(); err != nil {
|
|
return []StoredModel{}
|
|
}
|
|
|
|
rows, err := s.db.Query(`
|
|
SELECT
|
|
id,input,is_url,host,path,model_key,
|
|
tags, COALESCE(last_stream,''),
|
|
watching,favorite,hot,keep,liked,
|
|
created_at,updated_at
|
|
FROM models
|
|
ORDER BY updated_at DESC;
|
|
`)
|
|
if err != nil {
|
|
return []StoredModel{}
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]StoredModel, 0, 64)
|
|
|
|
for rows.Next() {
|
|
var (
|
|
id, input, host, path, modelKey, tags, lastStream, createdAt, updatedAt string
|
|
isURL, watching, favorite, hot, keep int64
|
|
liked sql.NullInt64
|
|
)
|
|
if err := rows.Scan(
|
|
&id, &input, &isURL, &host, &path, &modelKey,
|
|
&tags, &lastStream,
|
|
&watching, &favorite, &hot, &keep, &liked,
|
|
&createdAt, &updatedAt,
|
|
); err != nil {
|
|
continue
|
|
}
|
|
|
|
out = append(out, StoredModel{
|
|
ID: id,
|
|
Input: input,
|
|
IsURL: isURL != 0,
|
|
Host: host,
|
|
Path: path,
|
|
ModelKey: modelKey,
|
|
Watching: watching != 0,
|
|
Tags: tags,
|
|
LastStream: lastStream,
|
|
Favorite: favorite != 0,
|
|
Hot: hot != 0,
|
|
Keep: keep != 0,
|
|
Liked: ptrLikedFromNull(liked),
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
})
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
func (s *ModelStore) Meta() ModelsMeta {
|
|
if err := s.ensureInit(); err != nil {
|
|
return ModelsMeta{Count: 0, UpdatedAt: ""}
|
|
}
|
|
|
|
var count int
|
|
var updatedAt string
|
|
err := s.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(updated_at), '') FROM models;`).Scan(&count, &updatedAt)
|
|
if err != nil {
|
|
return ModelsMeta{Count: 0, UpdatedAt: ""}
|
|
}
|
|
return ModelsMeta{Count: count, UpdatedAt: updatedAt}
|
|
}
|
|
|
|
// 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,host,model_key,watching
|
|
FROM models
|
|
WHERE watching = 1
|
|
ORDER BY updated_at DESC;
|
|
`)
|
|
} else {
|
|
rows, err = s.db.Query(`
|
|
SELECT id,input,host,model_key,watching
|
|
FROM models
|
|
WHERE watching = 1 AND host = ?
|
|
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 int64
|
|
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 != 0,
|
|
})
|
|
}
|
|
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().Format(time.RFC3339Nano)
|
|
|
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
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(),
|
|
int64(1),
|
|
host,
|
|
p.Path,
|
|
modelKey,
|
|
"", "", // ✅ tags, last_stream
|
|
int64(0), int64(0), int64(0), int64(0), 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()
|
|
|
|
// aktuelle Flags lesen
|
|
var (
|
|
watching, favorite, hot, keep int64
|
|
liked sql.NullInt64
|
|
)
|
|
err := s.db.QueryRow(`SELECT watching,favorite,hot,keep,liked FROM models WHERE id=?;`, 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
|
|
}
|
|
|
|
// ✅ watched -> watching (DB)
|
|
if patch.Watched != nil {
|
|
watching = boolToInt(*patch.Watched)
|
|
}
|
|
|
|
if patch.Favorite != nil {
|
|
favorite = boolToInt(*patch.Favorite)
|
|
}
|
|
|
|
// ✅ liked ist true/false (kein ClearLiked mehr)
|
|
if patch.Liked != nil {
|
|
liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)}
|
|
}
|
|
|
|
// ✅ Exklusivität serverseitig (robust):
|
|
// - liked=true => favorite=false
|
|
// - favorite=true => liked=false (nicht NULL)
|
|
if patch.Liked != nil && *patch.Liked {
|
|
favorite = int64(0)
|
|
}
|
|
if patch.Favorite != nil && *patch.Favorite {
|
|
// Wenn Frontend nicht explizit liked=true sendet, force liked=false
|
|
if patch.Liked == nil || !*patch.Liked {
|
|
liked = sql.NullInt64{Valid: true, Int64: 0}
|
|
}
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339Nano)
|
|
|
|
var likedArg any
|
|
if liked.Valid {
|
|
likedArg = liked.Int64
|
|
} else {
|
|
likedArg = nil
|
|
}
|
|
|
|
_, err = s.db.Exec(`
|
|
UPDATE models
|
|
SET watching=?, favorite=?, hot=?, keep=?, liked=?, updated_at=?
|
|
WHERE id=?;
|
|
`, 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=?;`, 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().Format(time.RFC3339Nano)
|
|
|
|
// kind: "favorite" | "liked"
|
|
fav := int64(0)
|
|
var likedArg any = nil
|
|
if kind == "favorite" {
|
|
fav = int64(1)
|
|
}
|
|
if kind == "liked" {
|
|
likedArg = int64(1)
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// exists?
|
|
inserted := false
|
|
var dummy int
|
|
err = s.db.QueryRow(`SELECT 1 FROM models WHERE id=? 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
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=1 THEN 1 ELSE favorite END,
|
|
liked=CASE WHEN excluded.liked IS NOT NULL THEN excluded.liked ELSE liked END,
|
|
updated_at=excluded.updated_at;
|
|
`,
|
|
id, u.String(), int64(1), host, p.Path, modelKey,
|
|
tags, lastStream,
|
|
boolToInt(watch), fav, int64(0), int64(0), 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, lastStream, createdAt, updatedAt string
|
|
isURL, watching, favorite, hot, keep int64
|
|
liked sql.NullInt64
|
|
)
|
|
|
|
err := s.db.QueryRow(`
|
|
SELECT
|
|
input,is_url,host,path,model_key,
|
|
tags, COALESCE(last_stream,''),
|
|
watching,favorite,hot,keep,liked,
|
|
created_at,updated_at
|
|
FROM models
|
|
WHERE id=?;
|
|
`, id).Scan(
|
|
&input, &isURL, &host, &path, &modelKey,
|
|
&tags, &lastStream,
|
|
&watching, &favorite, &hot, &keep, &liked,
|
|
&createdAt, &updatedAt,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return StoredModel{}, errors.New("model nicht gefunden")
|
|
}
|
|
return StoredModel{}, err
|
|
}
|
|
|
|
return StoredModel{
|
|
ID: id,
|
|
Input: input,
|
|
IsURL: isURL != 0,
|
|
Host: host,
|
|
Path: path,
|
|
ModelKey: modelKey,
|
|
Tags: tags,
|
|
LastStream: lastStream,
|
|
Watching: watching != 0,
|
|
Favorite: favorite != 0,
|
|
Hot: hot != 0,
|
|
Keep: keep != 0,
|
|
Liked: ptrLikedFromNull(liked),
|
|
CreatedAt: createdAt,
|
|
UpdatedAt: updatedAt,
|
|
}, nil
|
|
}
|