This commit is contained in:
Linrador 2025-12-22 14:47:57 +01:00
parent 86ac5b9fb3
commit d3deac7b36
9 changed files with 709 additions and 247 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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
)

View File

@ -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=

View File

@ -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
}

View File

@ -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
}

1
frontend/.env Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="file:./prisma/models.db"

View File

@ -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<T>(url: string, init?: RequestInit): Promise<T> {
const badge = (on: boolean, label: string) => (
<span
className={[
className={clsx(
'inline-flex items-center rounded-md px-2 py-0.5 text-xs',
on
? 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-200'
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300',
].join(' ')}
: 'bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300'
)}
>
{label}
</span>
)
/** 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 (
<span
className={clsx(
hiddenUntilHover && !active
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
)}
>
<Button
variant={active ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'px-2 py-1 leading-none',
// damit es wie ein Icon-Button wirkt
active ? '' : 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={title}
onClick={onClick}
>
<span className="text-base leading-none">{icon}</span>
</Button>
</span>
)
}
export default function ModelsTab() {
const [models, setModels] = React.useState<StoredModel[]>([])
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<ParsedModel>('/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<StoredModel>('/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<StoredModel>('/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<Column<StoredModel>[]>(() => {
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 (
<div className="group flex items-center justify-center gap-1">
{/* Beobachten */}
<span
className={clsx(
hideUntilHover && !watch
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-opacity'
: 'opacity-100'
)}
>
<Button
variant={watch ? 'soft' : 'secondary'}
size="xs"
className={clsx(
'px-2 py-1 leading-none',
!watch && 'bg-transparent shadow-none hover:bg-gray-50 dark:hover:bg-white/10'
)}
title={watch ? 'Nicht mehr beobachten' : 'Beobachten'}
onClick={(e) => {
e.stopPropagation()
patch(m.id, { watching: !watch })
}}
>
<span className={clsx('text-base leading-none', watch ? 'text-indigo-600 dark:text-indigo-400' : 'text-gray-400 dark:text-gray-500')}>
👁
</span>
</Button>
</span>
{/* Favorit */}
<IconToggle
title={fav ? 'Favorit entfernen' : 'Als Favorit markieren'}
active={fav}
hiddenUntilHover={hideUntilHover}
onClick={(e) => {
e.stopPropagation()
if (fav) {
patch(m.id, { favorite: false })
} else {
// exklusiv: Favorit setzt ♥ zurück
patch(m.id, { favorite: true, clearLiked: true })
}
}}
icon={<span className={fav ? 'text-amber-500' : 'text-gray-400 dark:text-gray-500'}></span>}
/>
{/* Gefällt mir */}
<IconToggle
title={liked ? 'Gefällt mir entfernen' : 'Gefällt mir'}
active={liked}
hiddenUntilHover={hideUntilHover}
onClick={(e) => {
e.stopPropagation()
if (liked) {
patch(m.id, { clearLiked: true })
} else {
// exklusiv: ♥ setzt Favorit zurück
patch(m.id, { liked: true, favorite: false })
}
}}
icon={<span className={liked ? 'text-rose-500' : 'text-gray-400 dark:text-gray-500'}></span>}
/>
</div>
)
},
},
{
key: 'model',
header: 'Model',
cell: (m) => (
<div className="min-w-0">
<div className="font-medium truncate">{m.modelKey}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
{m.host ?? '—'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{m.host ?? '—'}</div>
</div>
),
},
@ -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) => (
<div className="flex flex-wrap gap-2">
{badge(m.watching, '👁 Beobachten')}
{badge(m.favorite, '★ Favorit')}
{badge(m.hot, '🔥 HOT')}
{badge(m.keep, '📌 Behalten')}
<span className="inline-flex items-center rounded-md px-2 py-0.5 text-xs bg-gray-50 text-gray-600 dark:bg-white/5 dark:text-gray-300">
{m.liked === true ? '👍' : m.liked === false ? '👎' : '—'}
</span>
</div>
),
},
{
key: 'actions',
header: 'Aktion',
align: 'right',
srOnlyHeader: true,
cell: (m) => (
<div className="flex justify-end gap-2">
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { watching: !m.watching }) }}>
👁
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { favorite: !m.favorite }) }}>
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { hot: !m.hot }) }}>
🔥
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { keep: !m.keep }) }}>
📌
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: true }) }}>
👍
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { liked: false }) }}>
👎
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); patch(m.id, { clearLiked: true }) }}>
</Button>
<Button className="px-2 py-1 text-xs" onClick={(e) => { e.stopPropagation(); del(m.id) }} title="Löschen">
🗑
</Button>
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
</div>
),
},
]
}, [])
return (
<div className="space-y-4">
<Card
header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>}
grayBody
>
<Card header={<div className="text-sm font-medium text-gray-900 dark:text-white">Model hinzufügen</div>} grayBody>
<div className="grid gap-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
value={input}
onChange={(e) => 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"
/>
<Button
className="px-3 py-2 text-sm"
onClick={upsertFromParsed}
disabled={!parsed || adding}
title={!parsed ? 'Ungültig / nicht geparst' : 'In Models speichern'}
title={!parsed ? 'Bitte gültige URL einfügen' : 'In Models speichern'}
>
Hinzufügen
</Button>
@ -280,18 +381,14 @@ export default function ModelsTab() {
</div>
) : null}
{err ? (
<div className="text-xs text-red-600 dark:text-red-300">{err}</div>
) : null}
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
</div>
</Card>
<Card
header={
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Models ({filtered.length})
</div>
<div className="text-sm font-medium text-gray-900 dark:text-white">Models ({filtered.length})</div>
<input
value={q}
onChange={(e) => 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')}
/>
</Card>
</div>