diff --git a/backend/data/models_store.db b/backend/data/models_store.db new file mode 100644 index 0000000..4410bda Binary files /dev/null and b/backend/data/models_store.db differ diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm new file mode 100644 index 0000000..dad2464 Binary files /dev/null and b/backend/data/models_store.db-shm differ diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal new file mode 100644 index 0000000..f1269b8 Binary files /dev/null and b/backend/data/models_store.db-wal differ diff --git a/backend/go.mod b/backend/go.mod index 1beac26..98beb2e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -11,6 +11,16 @@ require ( require ( github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf // indirect github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/sys v0.38.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.41.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 97ce432..439f685 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -4,11 +4,19 @@ github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf h1:FPsprx82rdrX2j github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s= github.com/grafov/m3u8 v0.12.1/go.mod h1:nqzOkfBiZJENr52zTVd/Dcl03yzphIMbJqkXGu+u080= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -18,6 +26,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -47,11 +57,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -77,3 +90,11 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.41.0 h1:bJXddp4ZpsqMsNN1vS0jWo4IJTZzb8nWpcgvyCFG9Ck= +modernc.org/sqlite v1.41.0/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= diff --git a/backend/main.go b/backend/main.go index b96cc3b..c4330e6 100644 --- a/backend/main.go +++ b/backend/main.go @@ -98,7 +98,7 @@ func detectFFmpegPath() string { if p := strings.TrimSpace(s.FFmpegPath); p != "" { // Relativ zur EXE auflösen, falls nötig if !filepath.IsAbs(p) { - if abs, err := resolvePathRelativeToExe(p); err == nil { + if abs, err := resolvePathRelativeToApp(p); err == nil { p = abs } } @@ -543,7 +543,7 @@ func recordPreview(w http.ResponseWriter, r *http.Request) { outPath = filepath.Clean(outPath) if !filepath.IsAbs(outPath) { - if abs, err := resolvePathRelativeToExe(outPath); err == nil { + if abs, err := resolvePathRelativeToApp(outPath); err == nil { outPath = abs } } @@ -586,8 +586,8 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri } s := getSettings() - recordAbs, _ := resolvePathRelativeToExe(s.RecordDir) - doneAbs, _ := resolvePathRelativeToExe(s.DoneDir) + recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) + doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) candidates := []string{ filepath.Join(doneAbs, id+".mp4"), @@ -853,25 +853,41 @@ func extractFirstFrameJPEG(path string) ([]byte, error) { return out.Bytes(), nil } -func resolvePathRelativeToExe(p string) (string, error) { +func resolvePathRelativeToApp(p string) (string, error) { p = strings.TrimSpace(p) if p == "" { return "", nil } - // akzeptiere sowohl "records/done" als auch "records\done" p = filepath.Clean(filepath.FromSlash(p)) - if filepath.IsAbs(p) { return p, nil } exe, err := os.Executable() + if err == nil { + exeDir := filepath.Dir(exe) + low := strings.ToLower(exeDir) + + // Heuristik: go run / tests -> exe liegt in Temp/go-build + isTemp := strings.Contains(low, `\appdata\local\temp`) || + strings.Contains(low, `\temp\`) || + strings.Contains(low, `\tmp\`) || + strings.Contains(low, `\go-build`) || + strings.Contains(low, `/tmp/`) || + strings.Contains(low, `/go-build`) + + if !isTemp { + return filepath.Join(exeDir, p), nil + } + } + + // Fallback: Working Directory (Dev) + wd, err := os.Getwd() if err != nil { return "", err } - base := filepath.Dir(exe) - return filepath.Join(base, p), nil + return filepath.Join(wd, p), nil } // routes.go (package main) @@ -887,7 +903,9 @@ func registerRoutes(mux *http.ServeMux) { mux.HandleFunc("/api/record/video", recordVideo) mux.HandleFunc("/api/record/done", recordDoneList) - modelsPath, _ := resolvePathRelativeToExe("data/models_store.json") + modelsPath, _ := resolvePathRelativeToApp("data/models_store.db") + fmt.Println("📦 Models DB:", modelsPath) + store := NewModelStore(modelsPath) if err := store.Load(); err != nil { fmt.Println("⚠️ models load:", err) @@ -1094,8 +1112,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { } s := getSettings() - recordAbs, _ := resolvePathRelativeToExe(s.RecordDir) - doneAbs, _ := resolvePathRelativeToExe(s.DoneDir) + recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) + doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) candidates := []string{ filepath.Join(doneAbs, file), // bevorzugt doneDir @@ -1148,7 +1166,7 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { } if !filepath.IsAbs(outPath) { - abs, err := resolvePathRelativeToExe(outPath) + abs, err := resolvePathRelativeToApp(outPath) if err != nil { http.Error(w, "pfad auflösung fehlgeschlagen", http.StatusInternalServerError) return @@ -1180,7 +1198,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) { } s := getSettings() - doneAbs, err := resolvePathRelativeToExe(s.DoneDir) + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { http.Error(w, "doneDir auflösung fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) return @@ -1304,7 +1322,7 @@ func moveToDoneDir(outputPath string) (string, error) { s := getSettings() // ✅ doneDir relativ zur exe auflösen (funktion hast du schon) - doneDirAbs, err := resolvePathRelativeToExe(s.DoneDir) + doneDirAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { return "", err } diff --git a/backend/models_store.go b/backend/models_store.go index bc30aa5..ada8e61 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -1,19 +1,22 @@ +// models_store.go package main import ( + "database/sql" "encoding/json" "errors" "net/url" "os" "path/filepath" - "sort" "strings" "sync" "time" + + _ "modernc.org/sqlite" ) type StoredModel struct { - ID string `json:"id"` // i.d.R. modelKey (unique) + 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"` @@ -30,94 +33,6 @@ type StoredModel struct { UpdatedAt string `json:"updatedAt"` } -type ModelStore struct { - path string - mu sync.RWMutex - items map[string]StoredModel -} - -func NewModelStore(path string) *ModelStore { - return &ModelStore{ - path: path, - items: map[string]StoredModel{}, - } -} - -func (s *ModelStore) Load() error { - s.mu.Lock() - defer s.mu.Unlock() - - b, err := os.ReadFile(s.path) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - return nil // ok - } - return err - } - - var list []StoredModel - if err := json.Unmarshal(b, &list); err != nil { - return err - } - - s.items = map[string]StoredModel{} - for _, m := range list { - if m.ID == "" { - m.ID = m.ModelKey - } - if m.ID != "" { - s.items[m.ID] = m - } - } - return nil -} - -func (s *ModelStore) saveLocked() error { - if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { - return err - } - - list := make([]StoredModel, 0, len(s.items)) - for _, m := range s.items { - list = append(list, m) - } - - // Neueste zuerst - sort.Slice(list, func(i, j int) bool { return list[i].UpdatedAt > list[j].UpdatedAt }) - - b, err := json.MarshalIndent(list, "", " ") - if err != nil { - return err - } - - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, b, 0o644); err != nil { - return err - } - - // Windows: Rename überschreibt nicht immer zuverlässig -> erst versuchen, sonst löschen & retry - if err := os.Rename(tmp, s.path); err != nil { - _ = os.Remove(s.path) - if err2 := os.Rename(tmp, s.path); err2 != nil { - _ = os.Remove(tmp) - return err2 - } - } - return nil -} - -func (s *ModelStore) List() []StoredModel { - s.mu.RLock() - defer s.mu.RUnlock() - - out := make([]StoredModel, 0, len(s.items)) - for _, m := range s.items { - out = append(out, m) - } - sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) - return out -} - type ParsedModelDTO struct { Input string `json:"input"` IsURL bool `json:"isUrl"` @@ -126,7 +41,338 @@ type ParsedModelDTO struct { 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") } @@ -142,97 +388,166 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { if err != nil || u.Scheme == "" || u.Hostname() == "" { return StoredModel{}, errors.New("Ungültige URL.") } - if strings.TrimSpace(p.ModelKey) == "" { - return StoredModel{}, errors.New("ModelKey fehlt.") - } + + 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() - id := p.ModelKey - existing, ok := s.items[id] - if !ok { - existing = StoredModel{ - ID: id, - CreatedAt: now, - } - } - - // Felder aktualisieren - existing.Input = p.Input - existing.IsURL = p.IsURL - existing.Host = p.Host - existing.Path = p.Path - existing.ModelKey = p.ModelKey - existing.UpdatedAt = now - - s.items[id] = existing - if err := s.saveLocked(); err != nil { + _, 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 existing, nil -} -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"` + 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") } - now := time.Now().UTC().Format(time.RFC3339Nano) - s.mu.Lock() defer s.mu.Unlock() - m, ok := s.items[patch.ID] - if !ok { - return StoredModel{}, errors.New("model nicht gefunden") + // 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 { - m.Watching = *patch.Watching + watching = boolToInt(*patch.Watching) } if patch.Favorite != nil { - m.Favorite = *patch.Favorite + favorite = boolToInt(*patch.Favorite) } if patch.Hot != nil { - m.Hot = *patch.Hot + hot = boolToInt(*patch.Hot) } if patch.Keep != nil { - m.Keep = *patch.Keep + keep = boolToInt(*patch.Keep) } + if patch.ClearLiked { - m.Liked = nil + liked = sql.NullInt64{Valid: false} } else if patch.Liked != nil { - m.Liked = patch.Liked + liked = sql.NullInt64{Valid: true, Int64: boolToInt(*patch.Liked)} } - m.UpdatedAt = now - s.items[m.ID] = m + now := time.Now().UTC().Format(time.RFC3339Nano) - if err := s.saveLocked(); err != nil { + 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 m, nil + + 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() - delete(s.items, id) - return s.saveLocked() + + _, 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 } diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..3da4261 --- /dev/null +++ b/frontend/.env @@ -0,0 +1 @@ +DATABASE_URL="file:./prisma/models.db" diff --git a/frontend/src/components/ui/ModelsTab.tsx b/frontend/src/components/ui/ModelsTab.tsx index 0f17889..c67918b 100644 --- a/frontend/src/components/ui/ModelsTab.tsx +++ b/frontend/src/components/ui/ModelsTab.tsx @@ -1,6 +1,7 @@ 'use client' import * as React from 'react' +import clsx from 'clsx' import Card from './Card' import Button from './Button' import Table, { type Column } from './Table' @@ -40,17 +41,72 @@ async function apiJSON(url: string, init?: RequestInit): Promise { const badge = (on: boolean, label: string) => ( {label} ) +/** Erlaubt nur http(s) URLs. Optional: ohne Scheme -> https:// */ +function normalizeHttpUrl(raw: string): string | null { + let v = (raw ?? '').trim() + if (!v) return null + + if (!/^https?:\/\//i.test(v)) v = `https://${v}` + + try { + const u = new URL(v) + if (u.protocol !== 'http:' && u.protocol !== 'https:') return null + return u.toString() + } catch { + return null + } +} + +function IconToggle({ + title, + active, + hiddenUntilHover, + onClick, + icon, +}: { + title: string + active?: boolean + hiddenUntilHover?: boolean + onClick: (e: React.MouseEvent) => void + icon: React.ReactNode +}) { + return ( + + + + ) +} + + export default function ModelsTab() { const [models, setModels] = React.useState([]) const [loading, setLoading] = React.useState(false) @@ -79,22 +135,36 @@ export default function ModelsTab() { refresh() }, [refresh]) - // Parse (debounced) via existing /api/models/parse + // Parse (debounced) nur bei gültiger URL React.useEffect(() => { - const v = input.trim() - if (!v) { + const raw = input.trim() + if (!raw) { setParsed(null) setParseError(null) return } - const t = setTimeout(async () => { + const normalized = normalizeHttpUrl(raw) + if (!normalized) { + setParsed(null) + setParseError('Bitte nur gültige http(s) URLs einfügen (keine Modelnamen).') + return + } + + const t = window.setTimeout(async () => { try { const p = await apiJSON('/api/models/parse', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: v }), + body: JSON.stringify({ input: normalized }), }) + + if (!p?.isUrl) { + setParsed(null) + setParseError('Bitte nur URLs einfügen (keine Modelnamen).') + return + } + setParsed(p) setParseError(null) } catch (e: any) { @@ -103,24 +173,25 @@ export default function ModelsTab() { } }, 300) - return () => clearTimeout(t) + return () => window.clearTimeout(t) }, [input]) const filtered = React.useMemo(() => { const needle = q.trim().toLowerCase() if (!needle) return models return models.filter((m) => { - const hay = [ - m.modelKey, - m.host ?? '', - m.input ?? '', - ].join(' ').toLowerCase() + const hay = [m.modelKey, m.host ?? '', m.input ?? ''].join(' ').toLowerCase() return hay.includes(needle) }) }, [models, q]) const upsertFromParsed = async () => { if (!parsed) return + if (!parsed.isUrl) { + setParseError('Bitte nur URLs einfügen (keine Modelnamen).') + return + } + setAdding(true) setErr(null) try { @@ -143,34 +214,107 @@ export default function ModelsTab() { } const patch = async (id: string, body: any) => { - const updated = await apiJSON('/api/models/flags', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id, ...body }), - }) - setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))) - } - - const del = async (id: string) => { - await apiJSON('/api/models/delete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }) - setModels((prev) => prev.filter((m) => m.id !== id)) + setErr(null) + try { + const updated = await apiJSON('/api/models/flags', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...body }), + }) + setModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))) + } catch (e: any) { + setErr(e?.message ?? String(e)) + } } const columns = React.useMemo[]>(() => { return [ + { + key: 'statusAll', + header: 'Status', + align: 'center', + cell: (m) => { + const liked = m.liked === true + const fav = m.favorite === true + const watch = m.watching === true + + // wenn gar nichts aktiv ist -> wirklich leer, Icons erst bei Hover + const hideUntilHover = !watch && !fav && !liked + + return ( +
+ {/* Beobachten */} + + + + + {/* Favorit */} + { + e.stopPropagation() + if (fav) { + patch(m.id, { favorite: false }) + } else { + // exklusiv: Favorit setzt ♥ zurück + patch(m.id, { favorite: true, clearLiked: true }) + } + }} + icon={} + /> + + {/* Gefällt mir */} + { + e.stopPropagation() + if (liked) { + patch(m.id, { clearLiked: true }) + } else { + // exklusiv: ♥ setzt Favorit zurück + patch(m.id, { liked: true, favorite: false }) + } + }} + icon={} + /> +
+ ) + }, + }, + { key: 'model', header: 'Model', cell: (m) => (
{m.modelKey}
-
- {m.host ?? '—'} -
+
{m.host ?? '—'}
), }, @@ -182,7 +326,7 @@ export default function ModelsTab() { href={m.input} target="_blank" rel="noreferrer" - className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block" + className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]" onClick={(e) => e.stopPropagation()} title={m.input} > @@ -191,78 +335,35 @@ export default function ModelsTab() { ), }, { - key: 'flags', - header: 'Status', + key: 'tags', + header: 'Tags', cell: (m) => (
- {badge(m.watching, '👁 Beobachten')} - {badge(m.favorite, '★ Favorit')} - {badge(m.hot, '🔥 HOT')} - {badge(m.keep, '📌 Behalten')} - - {m.liked === true ? '👍' : m.liked === false ? '👎' : '—'} - -
- ), - }, - { - key: 'actions', - header: 'Aktion', - align: 'right', - srOnlyHeader: true, - cell: (m) => ( -
- - - - - - - - - - + {m.hot ? badge(true, '🔥 HOT') : null} + {m.keep ? badge(true, '📌 Behalten') : null}
), }, ] }, []) + return (
- Model hinzufügen
} - grayBody - > + Model hinzufügen} grayBody>
setInput(e.target.value)} - placeholder="URL oder Modelname…" + placeholder="https://…" className="flex-1 rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" /> @@ -280,18 +381,14 @@ export default function ModelsTab() {
) : null} - {err ? ( -
{err}
- ) : null} + {err ?
{err}
: null}
-
- Models ({filtered.length}) -
+
Models ({filtered.length})
setQ(e.target.value)} @@ -307,10 +404,10 @@ export default function ModelsTab() { columns={columns} getRowKey={(m) => m.id} striped + compact fullWidth - onRowClick={(m) => { - if (m.input) window.open(m.input, '_blank', 'noreferrer') - }} + stickyHeader + onRowClick={(m) => m.input && window.open(m.input, '_blank', 'noreferrer')} />