nsfwapp/backend/models_store.go
2025-12-26 01:25:04 +01:00

760 lines
17 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 {
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, &notnull, &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
}