// 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: 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 }