nsfwapp/backend/models_store.go
2025-12-22 14:47:57 +01:00

554 lines
12 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
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 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;`)
if err := createModelsSchema(db); err != nil {
_ = db.Close()
return err
}
s.db = db
// 1x Migration: wenn DB leer ist und Legacy JSON existiert
if s.legacyJSONPath != "" {
if err := s.migrateFromJSONIfEmpty(); err != nil {
// Migration-Fehler nicht hart killen, aber zurückgeben ist auch ok.
// Ich gebe zurück, damit du es direkt siehst.
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,
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 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,
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,
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, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
)
if err := rows.Scan(
&id, &input, &isURL, &host, &path, &modelKey,
&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,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
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,
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) getByID(id string) (StoredModel, error) {
var (
input, host, path, modelKey, createdAt, updatedAt string
isURL, watching, favorite, hot, keep int64
liked sql.NullInt64
)
err := s.db.QueryRow(`
SELECT
input,is_url,host,path,model_key,
watching,favorite,hot,keep,liked,
created_at,updated_at
FROM models
WHERE id=?;
`, id).Scan(
&input, &isURL, &host, &path, &modelKey,
&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,
Watching: watching != 0,
Favorite: favorite != 0,
Hot: hot != 0,
Keep: keep != 0,
Liked: ptrLikedFromNull(liked),
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}