// 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 { ID string `json:"id"` Watching *bool `json:"watching,omitempty"` Favorite *bool `json:"favorite,omitempty"` Hot *bool `json:"hot,omitempty"` Keep *bool `json:"keep,omitempty"` Liked *bool `json:"liked,omitempty"` ClearLiked bool `json:"clearLiked,omitempty"` } type ModelStore struct { dbPath string legacyJSONPath string db *sql.DB initOnce sync.Once initErr error // serialize writes (einfach & robust) mu sync.Mutex } // 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(1) // 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 } } 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, 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 } // optionaler Unique-Index (hilft bei Konsistenz) _, _ = 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 } } 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 } 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) 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,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, int64(0), int64(0), int64(0), int64(0), nil, // Flags nur bei neuem Insert (Update fasst sie nicht an) 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 } if patch.Watching != nil { watching = boolToInt(*patch.Watching) } if patch.Favorite != nil { favorite = boolToInt(*patch.Favorite) } if patch.Hot != nil { hot = boolToInt(*patch.Hot) } if patch.Keep != nil { keep = boolToInt(*patch.Keep) } if patch.ClearLiked { liked = sql.NullInt64{Valid: false} } else if patch.Liked != nil { liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)} } 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, lastStream, 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 }