This commit is contained in:
Linrador 2026-01-20 13:48:05 +01:00
parent e3387dd6fe
commit 50515d44b0
17 changed files with 1738 additions and 372 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6,13 +6,16 @@ import (
"bufio" "bufio"
"bytes" "bytes"
"context" "context"
"crypto/sha1"
"encoding/binary" "encoding/binary"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"io" "io"
"math" "math"
"math/rand"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -1260,7 +1263,12 @@ func removeGeneratedForID(id string) {
return return
} }
// 1) NEU: generated/<id>/ (enthält thumbs.jpg, preview.mp4, meta.json, t_*.jpg, ...) // 1) NEU: generated/meta/<id>/ ...
if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id))
}
// (optional aber sinnvoll) 1b) Legacy: generated/<id>/ (falls noch alte Assets existieren)
if root, _ := generatedRoot(); strings.TrimSpace(root) != "" { if root, _ := generatedRoot(); strings.TrimSpace(root) != "" {
_ = os.RemoveAll(filepath.Join(root, id)) _ = os.RemoveAll(filepath.Join(root, id))
} }
@ -2297,10 +2305,419 @@ func stripHotPrefix(s string) string {
return s return s
} }
// --------------------------
// Covers: generated/covers/<category>.<ext>
// --------------------------
func splitTagsLoose(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.FieldsFunc(raw, func(r rune) bool {
switch r {
case '\n', ',', ';', '|':
return true
}
return false
})
out := make([]string, 0, len(parts))
seen := map[string]struct{}{}
for _, p := range parts {
t := strings.TrimSpace(p)
if t == "" {
continue
}
low := strings.ToLower(t)
if _, ok := seen[low]; ok {
continue
}
seen[low] = struct{}{}
out = append(out, t)
}
return out
}
func hasTag(tagsRaw string, want string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" {
return false
}
for _, t := range splitTagsLoose(tagsRaw) {
if strings.ToLower(strings.TrimSpace(t)) == want {
return true
}
}
return false
}
// ✅ Passe diese Struct/Methoden an dein echtes ModelStore-API an.
type coverModel struct {
Key string // z.B. model key/name
Tags string // raw tags (csv/newline/…)
}
func listModelsForCovers() ([]coverModel, error) {
if coverModelStore == nil {
return nil, fmt.Errorf("model store not set")
}
ms := coverModelStore.List() // ✅ existiert bei dir
out := make([]coverModel, 0, len(ms))
for _, m := range ms {
key := strings.TrimSpace(m.ModelKey)
if key == "" {
continue
}
out = append(out, coverModel{
Key: key,
Tags: m.Tags,
})
}
return out, nil
}
func pickRandomThumbForCategory(ctx context.Context, category string) (thumbPath string, err error) {
category = strings.TrimSpace(category)
if category == "" {
return "", fmt.Errorf("category empty")
}
// Optional: früh abbrechen, wenn Request schon tot ist
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
models, err := listModelsForCovers()
if err != nil {
return "", err
}
// 1) Kandidaten-Models nach Tag filtern
cands := make([]coverModel, 0, 64)
for _, m := range models {
key := strings.TrimSpace(m.Key)
if key == "" {
continue
}
if hasTag(m.Tags, category) {
cands = append(cands, coverModel{Key: key, Tags: m.Tags})
}
}
if len(cands) == 0 {
return "", fmt.Errorf("no model with tag")
}
// 2) Kandidaten mischen und nacheinander probieren (robuster als 1 random pick)
rand.Shuffle(len(cands), func(i, j int) { cands[i], cands[j] = cands[j], cands[i] })
// 3) done dirs (einmalig auflösen)
s := getSettings()
doneAbs, derr := resolvePathRelativeToApp(s.DoneDir)
if derr != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir resolve failed: %v", derr)
}
type candFile struct {
videoPath string
id string
}
isVideo := func(name string) bool {
low := strings.ToLower(name)
if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") {
return false
}
ext := strings.ToLower(filepath.Ext(name))
return ext == ".mp4" || ext == ".ts"
}
// 4) Für jedes passende Model: Dateien sammeln, random wählen, Thumb prüfen
for _, m := range cands {
// Context check pro Iteration
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
modelKey := strings.TrimSpace(m.Key)
if modelKey == "" {
continue
}
// Kandidaten: done/<model>/ und done/keep/<model>/
dirs := []string{
filepath.Join(doneAbs, modelKey),
filepath.Join(doneAbs, "keep", modelKey),
}
files := make([]candFile, 0, 128)
for _, d := range dirs {
ents, err := os.ReadDir(d)
if err != nil {
continue
}
for _, e := range ents {
if e.IsDir() {
continue
}
name := e.Name()
if !isVideo(name) {
continue
}
full := filepath.Join(d, name)
stem := strings.TrimSuffix(name, filepath.Ext(name))
id := stripHotPrefix(strings.TrimSpace(stem))
if id == "" {
continue
}
files = append(files, candFile{videoPath: full, id: id})
}
}
if len(files) == 0 {
// ✅ dieses Model hat (noch) keine Downloads -> nächstes Model probieren
continue
}
// random file innerhalb des Models
cf := files[rand.Intn(len(files))]
// thumbs sicherstellen (best effort)
_ = ensureAssetsForVideo(cf.videoPath)
tp, terr := generatedThumbFile(cf.id)
if terr != nil {
// nächstes Model probieren
continue
}
if fi, serr := os.Stat(tp); serr == nil && !fi.IsDir() && fi.Size() > 0 {
return tp, nil
}
// ✅ Thumb fehlt -> nächstes Model probieren
}
return "", fmt.Errorf("no downloads/thumbs for category")
}
func coversRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "covers"))
}
func ensureCoversDir() (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer")
}
if err := os.MkdirAll(root, 0o755); err != nil {
return "", err
}
return root, nil
}
var coverKeyRe = regexp.MustCompile(`[^a-z0-9._-]+`)
func sanitizeCoverKey(category string) (string, error) {
c := strings.ToLower(strings.TrimSpace(category))
if c == "" {
sum := sha1.Sum([]byte(category))
c = "tag_" + hex.EncodeToString(sum[:8]) // 16 hex chars reichen
}
if c == "" {
return "", fmt.Errorf("category fehlt")
}
// Windows & FS safe
c = strings.ReplaceAll(c, " ", "_")
c = coverKeyRe.ReplaceAllString(c, "_")
c = strings.Trim(c, "._-")
if c == "" {
return "", fmt.Errorf("category ungültig")
}
if len(c) > 120 {
c = c[:120]
}
return c, nil
}
func detectImageExt(contentType string, b []byte) (ext string, ct string) {
ct = strings.ToLower(strings.TrimSpace(contentType))
// wenn server CT liefert
switch {
case strings.Contains(ct, "image/jpeg") || strings.Contains(ct, "image/jpg"):
return ".jpg", "image/jpeg"
case strings.Contains(ct, "image/png"):
return ".png", "image/png"
case strings.Contains(ct, "image/webp"):
return ".webp", "image/webp"
case strings.Contains(ct, "image/gif"):
return ".gif", "image/gif"
}
// Magic bytes fallback
if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
return ".jpg", "image/jpeg"
}
if len(b) >= 8 && bytes.Equal(b[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}) {
return ".png", "image/png"
}
if len(b) >= 12 && string(b[:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
return ".webp", "image/webp"
}
if len(b) >= 6 && (string(b[:6]) == "GIF87a" || string(b[:6]) == "GIF89a") {
return ".gif", "image/gif"
}
// default: jpg
return ".jpg", "image/jpeg"
}
func coverPathForCategory(key string, ext string) (string, error) {
root, err := coversRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("covers root ist leer")
}
if ext == "" {
ext = ".jpg"
}
return filepath.Join(root, key+ext), nil
}
func findExistingCoverFile(key string) (string, os.FileInfo, bool) {
root, err := coversRoot()
if err != nil || strings.TrimSpace(root) == "" {
return "", nil, false
}
// probiere gängige Endungen
exts := []string{".jpg", ".png", ".webp", ".gif"}
for _, ext := range exts {
p := filepath.Join(root, key+ext)
if fi, err := os.Stat(p); err == nil && !fi.IsDir() && fi.Size() > 0 {
return p, fi, true
}
}
return "", nil, false
}
func downloadBytes(ctx context.Context, rawURL string, ua string) ([]byte, string, error) {
rawURL = strings.TrimSpace(rawURL)
if rawURL == "" {
return nil, "", fmt.Errorf("src fehlt")
}
// ✅ 1) Lokaler Pfad: nur /generated/... erlauben
if strings.HasPrefix(rawURL, "/") {
// URL-Pfad normalisieren und Traversal verhindern
clean := path.Clean(rawURL) // URL-style cleaning
if !strings.HasPrefix(clean, "/generated/") {
return nil, "", fmt.Errorf("src ungültig")
}
if strings.Contains(clean, "..") {
return nil, "", fmt.Errorf("src ungültig")
}
// "/generated/..." -> "generated/..." (relativ zur App)
rel := strings.TrimPrefix(clean, "/")
abs, err := resolvePathRelativeToApp(rel)
if err != nil || strings.TrimSpace(abs) == "" {
return nil, "", fmt.Errorf("src ungültig")
}
f, err := os.Open(abs)
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
defer f.Close()
// max 10MB lesen
b, err := io.ReadAll(io.LimitReader(f, 10*1024*1024))
if err != nil {
return nil, "", fmt.Errorf("download failed: %v", err)
}
if len(b) == 0 {
return nil, "", fmt.Errorf("download empty")
}
// Content-Type grob nach Extension (Magic-bytes macht detectImageExt später sowieso)
ext := strings.ToLower(filepath.Ext(abs))
ct := "application/octet-stream"
switch ext {
case ".jpg", ".jpeg":
ct = "image/jpeg"
case ".png":
ct = "image/png"
case ".webp":
ct = "image/webp"
case ".gif":
ct = "image/gif"
}
return b, ct, nil
}
// ✅ 2) Remote URL: wie bisher nur http/https
u, err := url.Parse(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return nil, "", fmt.Errorf("src ungültig")
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("src schema nicht erlaubt")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, "", err
}
if strings.TrimSpace(ua) == "" {
ua = "Mozilla/5.0"
}
req.Header.Set("User-Agent", ua)
req.Header.Set("Accept", "image/*,*/*;q=0.8")
client := &http.Client{Timeout: 12 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
b, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) // max 10MB
if err != nil {
return nil, "", err
}
if len(b) == 0 {
return nil, "", fmt.Errorf("download empty")
}
return b, resp.Header.Get("Content-Type"), nil
}
func generatedRoot() (string, error) { func generatedRoot() (string, error) {
return resolvePathRelativeToApp("generated") return resolvePathRelativeToApp("generated")
} }
func generatedMetaRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "meta"))
}
// Legacy (falls noch alte Assets liegen): // Legacy (falls noch alte Assets liegen):
func generatedThumbsRoot() (string, error) { func generatedThumbsRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) return resolvePathRelativeToApp(filepath.Join("generated", "thumbs"))
@ -2309,57 +2726,240 @@ func generatedTeaserRoot() (string, error) {
return resolvePathRelativeToApp(filepath.Join("generated", "teaser")) return resolvePathRelativeToApp(filepath.Join("generated", "teaser"))
} }
func generatedCover(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "Nur GET/HEAD erlaubt", http.StatusMethodNotAllowed)
return
}
category := r.URL.Query().Get("category")
key, err := sanitizeCoverKey(category)
if err != nil {
http.Error(w, "category ungültig: "+err.Error(), http.StatusBadRequest)
return
}
refresh := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("refresh")))
force := refresh == "1" || refresh == "true" || refresh == "yes"
// Optional: src (Frontend kann ein konkretes Thumb vorgeben)
src := strings.TrimSpace(r.URL.Query().Get("src"))
// 1) Cache hit: direkt von Disk (nur wenn nicht force)
if !force {
if p, fi, ok := findExistingCoverFile(key); ok {
w.Header().Set("Cache-Control", "public, max-age=31536000")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext := strings.ToLower(filepath.Ext(p))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
}
// 2) Kein Cache (oder force): Cover erzeugen und persistieren
if _, err := ensureCoversDir(); err != nil {
http.Error(w, "covers-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
var (
b []byte
mimeType string
ext string
)
// 2a) Wenn src gegeben: daraus bauen (lokal /generated/... oder http(s))
if src != "" {
var derr error
b, mimeType, derr = downloadBytes(ctx, src, r.Header.Get("User-Agent"))
if derr != nil {
http.Error(w, "src download failed: "+derr.Error(), http.StatusBadRequest)
return
}
ext, mimeType = detectImageExt(mimeType, b)
if len(b) == 0 {
http.Error(w, "src leer", http.StatusBadRequest)
return
}
} else {
// 2b) Sonst: Backend wählt random Thumb passend zur Category
thumbPath, perr := pickRandomThumbForCategory(ctx, category)
if perr != nil {
if p, fi, ok := findExistingCoverFile(key); ok {
// vorhandenes Cover liefern statt Placeholder
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("X-Content-Type-Options", "nosniff")
ext := strings.ToLower(filepath.Ext(p))
switch ext {
case ".png":
w.Header().Set("Content-Type", "image/png")
case ".webp":
w.Header().Set("Content-Type", "image/webp")
case ".gif":
w.Header().Set("Content-Type", "image/gif")
default:
w.Header().Set("Content-Type", "image/jpeg")
}
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
f, err := os.Open(p)
if err != nil {
servePreviewStatusSVG(w, "No Cover")
return
}
defer f.Close()
http.ServeContent(w, r, filepath.Base(p), fi.ModTime(), f)
return
}
servePreviewStatusSVG(w, "No Cover")
return
}
b, err = os.ReadFile(thumbPath)
if err != nil || len(b) == 0 {
http.Error(w, "cover read fehlgeschlagen", http.StatusInternalServerError)
return
}
// thumbs bei dir sind JPEG
ext = ".jpg"
mimeType = "image/jpeg"
}
// 3) Vorherige Cover-Dateien mit allen Endungen entfernen (damit es nur 1 gibt)
root, _ := coversRoot()
for _, e := range []string{".jpg", ".png", ".webp", ".gif"} {
_ = os.Remove(filepath.Join(root, key+e))
}
// 4) Persistieren
dst, err := coverPathForCategory(key, ext)
if err != nil {
http.Error(w, "cover path: "+err.Error(), http.StatusInternalServerError)
return
}
if err := atomicWriteFile(dst, b); err != nil {
http.Error(w, "cover write: "+err.Error(), http.StatusInternalServerError)
return
}
// 5) Ausliefern
// Nicht zu aggressiv cachen, weil Tags/Randomisierung sich ändern können.
w.Header().Set("Cache-Control", "public, max-age=600")
w.Header().Set("Content-Type", mimeType)
w.Header().Set("X-Content-Type-Options", "nosniff")
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(b)
}
// -------------------------- // --------------------------
// generated/<id>/meta.json // generated/meta/<id>/meta.json
// -------------------------- // --------------------------
type videoMetaV1 struct { type videoMetaV2 struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
// liest v2 (oder v1) und validiert gegen fi (Size/ModTime)
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return 0, 0, 0, 0, false
}
// 1) erst v2 versuchen
var m2 videoMetaV2
if err := json.Unmarshal(b, &m2); err == nil && (m2.Version == 2 || m2.Version == 1) {
// gleiche Felder für v1/v2: duration/size/mod
if m2.FileSize != fi.Size() || m2.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m2.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m2.DurationSeconds, m2.VideoWidth, m2.VideoHeight, m2.FPS, true
}
// 2) Fallback: altes v1 Format (nur Duration)
var m1 struct {
Version int `json:"version"` Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"` DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"` FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"` FileModUnix int64 `json:"fileModUnix"`
UpdatedAtUnix int64 `json:"updatedAtUnix"` UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
if err := json.Unmarshal(b, &m1); err != nil {
return 0, 0, 0, 0, false
}
if m1.Version != 1 {
return 0, 0, 0, 0, false
}
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m1.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m1.DurationSeconds, 0, 0, 0, true
} }
func generatedMetaFile(id string) (string, error) { func writeVideoMetaV2(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64) error {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
if err != nil {
return "", err
}
return filepath.Join(dir, "meta.json"), nil
}
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (sec float64, ok bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return 0, false
}
var m videoMetaV1
if err := json.Unmarshal(b, &m); err != nil {
return 0, false
}
if m.Version != 1 {
return 0, false
}
// Invalidation: wenn Datei geändert wurde -> Meta ignorieren
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return 0, false
}
if m.DurationSeconds <= 0 {
return 0, false
}
return m.DurationSeconds, true
}
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 { if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil return nil
} }
m := videoMetaV1{ m := videoMetaV2{
Version: 1, Version: 2,
DurationSeconds: dur, DurationSeconds: dur,
FileSize: fi.Size(), FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(), FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
UpdatedAtUnix: time.Now().Unix(), UpdatedAtUnix: time.Now().Unix(),
} }
buf, err := json.Marshal(m) buf, err := json.Marshal(m)
@ -2370,18 +2970,37 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
return atomicWriteFile(metaPath, buf) return atomicWriteFile(metaPath, buf)
} }
// ✅ Neu: /generated/<id>/thumbs.jpg + /generated/<id>/preview.mp4 // ✅ Convenience: nur Duration aus meta.json (v2/v1) lesen
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
d, _, _, _, ok := readVideoMeta(metaPath, fi)
return d, ok
}
// ✅ Convenience: Duration-only schreiben (v2), ohne Props (w/h/fps bleiben 0)
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64) error {
return writeVideoMetaV2(metaPath, fi, dur, 0, 0, 0)
}
func generatedMetaFile(id string) (string, error) {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
if err != nil {
return "", err
}
return filepath.Join(dir, "meta.json"), nil
}
// ✅ Neu: /generated/meta/<id>/...
func generatedDirForID(id string) (string, error) { func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id) id, err := sanitizeID(id)
if err != nil { if err != nil {
return "", err return "", err
} }
root, err := generatedRoot() root, err := generatedMetaRoot()
if err != nil { if err != nil {
return "", err return "", err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated root ist leer") return "", fmt.Errorf("generated meta root ist leer")
} }
return filepath.Join(root, id), nil return filepath.Join(root, id), nil
} }
@ -2414,12 +3033,12 @@ func generatedPreviewFile(id string) (string, error) {
} }
func ensureGeneratedDirs() error { func ensureGeneratedDirs() error {
root, err := generatedRoot() root, err := generatedMetaRoot()
if err != nil { if err != nil {
return err return err
} }
if strings.TrimSpace(root) == "" { if strings.TrimSpace(root) == "" {
return fmt.Errorf("generated root ist leer") return fmt.Errorf("generated meta root ist leer")
} }
return os.MkdirAll(root, 0o755) return os.MkdirAll(root, 0o755)
} }
@ -3174,6 +3793,14 @@ var ffmpegInputTol = []string{
"-max_error_rate", "1.0", "-max_error_rate", "1.0",
} }
var coverModelStore *ModelStore
func setCoverModelStore(s *ModelStore) {
coverModelStore = s
// random seed (einmalig)
rand.Seed(time.Now().UnixNano())
}
func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error {
if durSec <= 0 { if durSec <= 0 {
durSec = 8 durSec = 8
@ -4646,6 +5273,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler) mux.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
mux.HandleFunc("/api/generated/teaser", generatedTeaser) mux.HandleFunc("/api/generated/teaser", generatedTeaser)
mux.HandleFunc("/api/generated/cover", generatedCover)
// Tasks // Tasks
mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets)
@ -4657,6 +5286,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore {
fmt.Println("⚠️ models load:", err) fmt.Println("⚠️ models load:", err)
} }
setCoverModelStore(store)
// ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete // ✅ registriert /api/models/list, /parse, /upsert, /flags, /delete
RegisterModelAPI(mux, store) RegisterModelAPI(mux, store)
@ -4684,6 +5315,10 @@ func main() {
go startMyFreeCamsAutoStartWorker(store) go startMyFreeCamsAutoStartWorker(store)
go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend go startDiskSpaceGuard() // ✅ reagiert auch ohne Frontend
if _, err := ensureCoversDir(); err != nil {
fmt.Println("⚠️ covers dir:", err)
}
fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999") fmt.Println("🌐 HTTP-API aktiv: http://localhost:9999")
if err := http.ListenAndServe(":9999", mux); err != nil { if err := http.ListenAndServe(":9999", mux); err != nil {
fmt.Println("❌ HTTP-Server Fehler:", err) fmt.Println("❌ HTTP-Server Fehler:", err)
@ -4698,40 +5333,9 @@ type RecordRequest struct {
Hidden bool `json:"hidden,omitempty"` Hidden bool `json:"hidden,omitempty"`
} }
type videoMeta struct {
SizeBytes int64 `json:"sizeBytes"`
ModTimeUnix int64 `json:"modTimeUnix"`
DurationSeconds float64 `json:"durationSeconds"`
}
func readVideoMeta(metaPath string) (videoMeta, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return videoMeta{}, false
}
var m videoMeta
if json.Unmarshal(b, &m) != nil {
return videoMeta{}, false
}
if m.SizeBytes <= 0 || m.ModTimeUnix <= 0 || m.DurationSeconds <= 0 {
return videoMeta{}, false
}
return m, true
}
func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) { func durationFromMetaIfFresh(videoPath, assetDir string, fi os.FileInfo) (float64, bool) {
metaPath := filepath.Join(assetDir, "meta.json") metaPath := filepath.Join(assetDir, "meta.json")
m, ok := readVideoMeta(metaPath) return readVideoMetaDuration(metaPath, fi)
if !ok {
return 0, false
}
if m.SizeBytes != fi.Size() {
return 0, false
}
if m.ModTimeUnix != fi.ModTime().Unix() {
return 0, false
}
return m.DurationSeconds, true
} }
// shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt // shared: wird vom HTTP-Handler UND vom Autostart-Worker genutzt

View File

@ -1,3 +1,5 @@
// backend\models.go
package main package main
import ( import (

View File

@ -1,4 +1,4 @@
// models_store.go // backend\models_store.go
package main package main
import ( import (

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>App</title> <title>App</title>
<script type="module" crossorigin src="/assets/index-Czq-AJKF.js"></script> <script type="module" crossorigin src="/assets/index-DSZfASIn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CAKbyWZn.css"> <link rel="stylesheet" crossorigin href="/assets/index-hlx7oHN0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -17,6 +17,7 @@ import { SignalIcon, HeartIcon, HandThumbUpIcon, EyeIcon } from '@heroicons/reac
import PerformanceMonitor from './components/ui/PerformanceMonitor' import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify' import { useNotify } from './components/ui/notify'
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
const COOKIE_STORAGE_KEY = 'record_cookies' const COOKIE_STORAGE_KEY = 'record_cookies'
@ -655,6 +656,7 @@ export default function App() {
{ id: 'running', label: 'Laufende Downloads', count: runningJobs.length }, { id: 'running', label: 'Laufende Downloads', count: runningJobs.length },
{ id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount }, { id: 'finished', label: 'Abgeschlossene Downloads', count: doneCount },
{ id: 'models', label: 'Models', count: modelsCount }, { id: 'models', label: 'Models', count: modelsCount },
{ id: 'categories', label: 'Kategorien' },
{ id: 'settings', label: 'Einstellungen' }, { id: 'settings', label: 'Einstellungen' },
] ]
@ -1014,6 +1016,20 @@ export default function App() {
return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener) return () => window.removeEventListener('finished-downloads:count-hint', onHint as EventListener)
}, [refreshDoneNow]) }, [refreshDoneNow])
useEffect(() => {
const onNav = (ev: Event) => {
const d = (ev as CustomEvent<any>).detail || {}
if (d.tab === 'finished') setSelectedTab('finished')
if (d.tab === 'categories') setSelectedTab('categories')
if (d.tab === 'models') setSelectedTab('models')
if (d.tab === 'running') setSelectedTab('running')
if (d.tab === 'settings') setSelectedTab('settings')
}
window.addEventListener('app:navigate-tab', onNav as EventListener)
return () => window.removeEventListener('app:navigate-tab', onNav as EventListener)
}, [])
// ---- Player model sync (wie bei dir) ---- // ---- Player model sync (wie bei dir) ----
useEffect(() => { useEffect(() => {
if (!playerJob) { if (!playerJob) {
@ -2138,6 +2154,7 @@ export default function App() {
) : null} ) : null}
{selectedTab === 'models' ? <ModelsTab /> : null} {selectedTab === 'models' ? <ModelsTab /> : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}
{selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null} {selectedTab === 'settings' ? <RecorderSettings onAssetsGenerated={bumpAssets} /> : null}
</main> </main>
@ -2165,6 +2182,11 @@ export default function App() {
runningJobs={runningJobs} runningJobs={runningJobs}
cookies={cookies} cookies={cookies}
blurPreviews={recSettings.blurPreviews} blurPreviews={recSettings.blurPreviews}
onToggleHot={handleToggleHot}
onDelete={handleDeleteJob}
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
/> />
{playerJob ? ( {playerJob ? (

View File

@ -0,0 +1,344 @@
// frontend\src\components\ui\CategoriesTab.tsx
'use client'
import * as React from 'react'
import clsx from 'clsx'
import Card from './Card'
import Button from './Button'
import type { StoredModel } from './ModelsTab' // falls nicht exportiert: lokalen Typ verwenden
import type { RecordJob } from '../../types'
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
function coverSrc(category: string, token?: number, refresh?: boolean) {
const base = `/api/generated/cover?category=${encodeURIComponent(category)}`
const v = token ? `&v=${token}` : ''
const r = refresh ? `&refresh=1` : ''
return `${base}${v}${r}`
}
function splitTags(raw?: string): string[] {
if (!raw) return []
const tags = raw
.split(/[\n,;|]+/g)
.map((t) => t.trim())
.filter(Boolean)
return Array.from(new Set(tags))
}
function baseName(p: string): string {
return String(p || '').replaceAll('\\', '/').split('/').pop() || ''
}
function stripHotPrefix(name: string): string {
return name.toUpperCase().startsWith('HOT ') ? name.slice(4).trim() : name
}
// ID = basename ohne ext, ohne HOT prefix (wie bei dir im Backend)
function assetIdFromOutput(output?: string): string {
const file = stripHotPrefix(baseName(output || ''))
if (!file) return ''
return file.replace(/\.[^.]+$/, '')
}
// modelKey aus Dateiname ableiten (wie in App.tsx)
const reModel = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/
function modelKeyFromFilename(fileOrPath: string): string | null {
const file = stripHotPrefix(baseName(fileOrPath))
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(reModel)
if (m?.[1]?.trim()) return m[1].trim()
const i = stem.lastIndexOf('_')
if (i > 0) return stem.slice(0, i).trim()
return stem ? stem.trim() : null
}
// ✅ passt zu Backend: generated/meta/<id>/thumbs.jpg
function thumbUrlFromOutput(output: string): string | null {
const id = assetIdFromOutput(output)
if (!id) return null
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg`
}
async function ensureCover(category: string, thumbPath: string, refresh: boolean) {
const url =
`/api/generated/cover?category=${encodeURIComponent(category)}` +
`&src=${encodeURIComponent(thumbPath)}` +
(refresh ? `&refresh=1` : ``)
await fetch(url, { method: 'GET', cache: 'no-store' as any })
}
type TagRow = {
tag: string // lowercased (so wie du es baust)
modelsCount: number
downloadsCount: number
}
export default function CategoriesTab() {
const [rows, setRows] = React.useState<TagRow[]>([])
const [loading, setLoading] = React.useState(false)
const [err, setErr] = React.useState<string | null>(null)
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
// TagLower -> outputs[]
const candidatesRef = React.useRef<Record<string, string[]>>({})
const buildCandidates = React.useCallback((models: StoredModel[], doneJobs: RecordJob[]) => {
// 1) modelKeyLower -> tagsLower[]
const tagsByModelKey: Record<string, string[]> = {}
for (const m of Array.isArray(models) ? models : []) {
const mk = String((m as any)?.modelKey ?? '').trim().toLowerCase()
if (!mk) continue
const tags = splitTags((m as any)?.tags).map((t) => t.toLowerCase())
if (tags.length) tagsByModelKey[mk] = tags
}
// 2) tagLower -> outputs[]
const out: Record<string, string[]> = {}
for (const j of Array.isArray(doneJobs) ? doneJobs : []) {
const output = String((j as any)?.output ?? '')
if (!output) continue
const mk = (modelKeyFromFilename(output) || '').trim().toLowerCase()
if (!mk) continue
const tags = tagsByModelKey[mk]
if (!tags || tags.length === 0) continue
for (const t of tags) {
if (!t) continue
if (!out[t]) out[t] = []
out[t].push(output)
}
}
// de-dupe pro tag
for (const [t, list] of Object.entries(out)) {
out[t] = Array.from(new Set(list))
}
candidatesRef.current = out
}, [])
const goToFinishedDownloadsWithTag = React.useCallback((tag: string) => {
const clean = String(tag || '').trim()
if (!clean) return
// ✅ Filter puffern (falls FinishedDownloads noch nicht gemountet ist)
try {
localStorage.setItem('finishedDownloads_pendingTags', JSON.stringify([clean]))
} catch {}
// ✅ Parent soll auf FinishedDownloads tab wechseln
window.dispatchEvent(
new CustomEvent('app:navigate-tab', {
detail: { tab: 'finished' },
})
)
// ✅ wenn FinishedDownloads schon gemountet ist, kommt es sofort an
window.dispatchEvent(
new CustomEvent('finished-downloads:tag-filter', {
detail: { tags: [clean], mode: 'replace' },
})
)
}, [])
const refresh = React.useCallback(async () => {
setLoading(true)
setErr(null)
try {
// Models (für Tags)
const models = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
// Done-Jobs (für Downloads pro Tag + optional Seeding)
const doneResp = await apiJSON<any>(
`/api/record/done?page=1&pageSize=2000&sort=completed_desc`,
{ cache: 'no-store' as any }
)
const doneJobs: RecordJob[] = Array.isArray(doneResp?.items)
? (doneResp.items as RecordJob[])
: Array.isArray(doneResp)
? (doneResp as RecordJob[])
: []
buildCandidates(Array.isArray(models) ? models : [], doneJobs)
// modelsCount pro Tag (über models)
const modelCountByTag = new Map<string, number>()
for (const m of Array.isArray(models) ? models : []) {
for (const t of splitTags((m as any)?.tags)) {
const k = t.toLowerCase()
modelCountByTag.set(k, (modelCountByTag.get(k) ?? 0) + 1)
}
}
const candMap = candidatesRef.current || {}
const outRows: TagRow[] = Array.from(modelCountByTag.entries())
.map(([tagLower, n]) => ({
tag: tagLower,
modelsCount: n,
downloadsCount: (candMap[tagLower] || []).length,
}))
.sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' }))
setRows(outRows)
// Optional: Seed Covers (limitiert)
const SEED_LIMIT = 60
let seeded = 0
for (const r of outRows) {
if (seeded >= SEED_LIMIT) break
const list = candMap[r.tag] || []
if (list.length === 0) continue
const pick = list[Math.floor(Math.random() * list.length)]
const thumb = pick ? thumbUrlFromOutput(pick) : null
if (!thumb) continue
seeded++
void ensureCover(r.tag, thumb, false).catch(() => {})
}
setCoverBust(Date.now())
} catch (e: any) {
setErr(e?.message ?? String(e))
setRows([])
candidatesRef.current = {}
} finally {
setLoading(false)
}
}, [buildCandidates])
React.useEffect(() => {
void refresh()
}, [refresh])
const renewCovers = React.useCallback(async () => {
try {
const candMap = candidatesRef.current || {}
const results = await Promise.all(
rows.map(async (r) => {
const list = candMap[r.tag] || []
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
const thumb = pick ? thumbUrlFromOutput(pick) : null
try {
if (thumb) {
await ensureCover(r.tag, thumb, true)
return { tag: r.tag, ok: true, status: 200, text: '' }
}
const res = await fetch(coverSrc(r.tag, Date.now(), true), {
method: 'GET',
cache: 'no-store',
})
const text = !res.ok ? await res.text().catch(() => '') : ''
const ok = res.ok || res.status === 404
return { tag: r.tag, ok, status: res.status, text }
} catch (e: any) {
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
}
})
)
const failedNo404 = results.filter((x) => !x.ok && x.status !== 404)
if (failedNo404.length) {
console.warn('Cover renew failed:', failedNo404.slice(0, 20))
const sample = failedNo404.slice(0, 8).map((f) => `${f.tag} (${f.status || 'ERR'})`).join(', ')
setErr(`Covers fehlgeschlagen: ${failedNo404.length}/${results.length} — z.B.: ${sample}`)
} else {
setErr(null)
}
} finally {
setCoverBust(Date.now())
}
}, [rows])
return (
<Card
header={
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Kategorien <span className="text-gray-500 dark:text-gray-400">({rows.length})</span>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="md" onClick={renewCovers} disabled={loading}>
Cover erneuern
</Button>
<Button variant="secondary" size="md" onClick={refresh} disabled={loading}>
Aktualisieren
</Button>
</div>
</div>
}
grayBody
>
{err ? <div className="text-xs text-red-600 dark:text-red-300">{err}</div> : null}
{rows.length === 0 && !loading ? (
<div className="text-sm text-gray-600 dark:text-gray-300">Keine Kategorien/Tags gefunden.</div>
) : null}
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{rows.map((r) => {
const img = coverSrc(r.tag, coverBust)
return (
<button
key={r.tag}
type="button"
onClick={() => goToFinishedDownloadsWithTag(r.tag)}
className={clsx(
'group text-left rounded-xl ring-1 ring-gray-200/70 dark:ring-white/10',
'bg-white/70 hover:bg-white dark:bg-white/5 dark:hover:bg-white/10',
'overflow-hidden transition',
'focus:outline-none focus:ring-2 focus:ring-indigo-500'
)}
title="In FinishedDownloads öffnen (Tag-Filter setzen)"
>
<div className="relative h-24">
<img
src={img}
alt={r.tag}
className="absolute inset-0 h-full w-full object-cover"
loading="lazy"
/>
</div>
<div className="px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-semibold text-gray-900 dark:text-white truncate">{r.tag}</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
{r.downloadsCount} Downloads {r.modelsCount} Models
</div>
</div>
<span className="shrink-0 rounded-md bg-gray-100 px-2 py-1 text-xs font-semibold text-gray-900 dark:bg-white/10 dark:text-white tabular-nums">
{r.downloadsCount}
</span>
</div>
</div>
</button>
)
})}
</div>
</Card>
)
}

View File

@ -317,6 +317,27 @@ export default function FinishedDownloads({
const clearTagFilter = useCallback(() => setTagFilter([]), []) const clearTagFilter = useCallback(() => setTagFilter([]), [])
useEffect(() => {
// ✅ Wenn wir aus CategoriesTab kommen, liegt evtl. ein "pending" Filter in localStorage
try {
const raw = localStorage.getItem('finishedDownloads_pendingTags')
if (!raw) return
const arr = JSON.parse(raw)
const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : []
if (tags.length === 0) return
flushSync(() => setTagFilter(tags))
if (page !== 1) onPageChange(1)
} catch {
// ignore
} finally {
try {
localStorage.removeItem('finishedDownloads_pendingTags')
} catch {}
}
}, []) // nur einmal beim Mount
useEffect(() => { useEffect(() => {
if (!globalFilterActive) return if (!globalFilterActive) return
@ -1330,7 +1351,7 @@ export default function FinishedDownloads({
/> />
{(searchQuery || '').trim() !== '' ? ( {(searchQuery || '').trim() !== '' ? (
<Button size="sm" variant="soft" onClick={clearSearch}> <Button size="sm" variant="soft" onClick={clearSearch}>
Clear Leeren
</Button> </Button>
) : null} ) : null}
</div> </div>

View File

@ -8,6 +8,7 @@ import type { RecordJob } from '../../types'
import Modal from './Modal' import Modal from './Modal'
import Button from './Button' import Button from './Button'
import TagBadge from './TagBadge' import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import { import {
ArrowTopRightOnSquareIcon, ArrowTopRightOnSquareIcon,
CalendarDaysIcon, CalendarDaysIcon,
@ -28,7 +29,6 @@ import {
EyeIcon as EyeSolidIcon, EyeIcon as EyeSolidIcon,
} from '@heroicons/react/24/solid' } from '@heroicons/react/24/solid'
function cn(...parts: Array<string | false | null | undefined>) { function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(' ') return parts.filter(Boolean).join(' ')
} }
@ -86,11 +86,6 @@ function splitTags(v?: string | null) {
.filter(Boolean) .filter(Boolean)
} }
function isKeptOutputPath(output: string) {
const s = output.toLowerCase()
return s.includes('/keep/') || s.includes('\\keep\\')
}
function baseName(path: string) { function baseName(path: string) {
return (path || '').split(/[\\/]/).pop() || '' return (path || '').split(/[\\/]/).pop() || ''
} }
@ -110,6 +105,21 @@ function stripHotPrefix(name: string) {
return name.startsWith('HOT ') ? name.slice(4) : name return name.startsWith('HOT ') ? name.slice(4) : name
} }
function isHotName(name: string) {
return String(name || '').startsWith('HOT ')
}
function replaceBaseName(path: string, newBase: string) {
const p = String(path || '')
if (!p) return p
// ersetzt nur das letzte Segment (funktioniert für / und \)
return p.replace(/([\\/])[^\\/]*$/, `$1${newBase}`)
}
function toggleHotFileName(file: string) {
return isHotName(file) ? stripHotPrefix(file) : `HOT ${file}`
}
function modelNameFromOutput(output?: string) { function modelNameFromOutput(output?: string) {
const fileRaw = baseName(output || '') const fileRaw = baseName(output || '')
const file = stripHotPrefix(fileRaw) const file = stripHotPrefix(fileRaw)
@ -279,6 +289,12 @@ type Props = {
cookies?: Record<string, string> cookies?: Record<string, string>
runningJobs?: RecordJob[] runningJobs?: RecordJob[]
blurPreviews?: boolean blurPreviews?: boolean
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleHot?: (job: RecordJob) => void | Promise<void>
onDelete?: (job: RecordJob) => void | Promise<void>
} }
function normalizeModelKey(raw: string | null | undefined): string { function normalizeModelKey(raw: string | null | undefined): string {
@ -326,7 +342,21 @@ function buildChaturbateCookieHeader(cookies?: Record<string, string>): string {
} }
export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, cookies, runningJobs, blurPreviews }: Props) { export default function ModelDetails({
open,
modelKey,
onClose,
onOpenPlayer,
cookies,
runningJobs,
blurPreviews,
onToggleWatch,
onToggleFavorite,
onToggleLike,
onToggleHot,
onDelete,
}: Props) {
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = React.useState<StoredModel[]>([])
const [modelsLoading, setModelsLoading] = React.useState(false) const [modelsLoading, setModelsLoading] = React.useState(false)
@ -349,6 +379,42 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null) const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
const refetchModels = React.useCallback(async () => {
try {
const r = await fetch('/api/models/list', { cache: 'no-store' })
const data = (await r.json().catch(() => null)) as any
setModels(Array.isArray(data) ? data : [])
} catch {
// ignore
}
}, [])
const refetchDone = React.useCallback(async () => {
try {
const url = `/api/record/done?all=1&sort=completed_desc&includeKeep=1`
const r = await fetch(url, { cache: 'no-store' })
const data = await r.json().catch(() => null)
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray((data as any)?.items)
? ((data as any).items as RecordJob[])
: []
setDone(items)
} catch {
// ignore
}
}, [])
// erzeugt ein "Job"-Objekt, das für deine Toggle-Handler reicht
function jobFromModelKey(key: string): RecordJob {
// muss zum Regex in App.tsx passen: <model>_MM_DD_YYYY__HH-MM-SS.ext
return {
id: `model:${key}`,
output: `${key}_01_01_2000__00-00-00.mp4`,
status: 'finished',
} as any
}
const openImage = React.useCallback((src?: string | null, alt?: string) => { const openImage = React.useCallback((src?: string | null, alt?: string) => {
const s = String(src ?? '').trim() const s = String(src ?? '').trim()
if (!s) return if (!s) return
@ -638,6 +704,105 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
</div> </div>
) )
const handleToggleHot = React.useCallback(
async (job: RecordJob) => {
const out = job.output || ''
const oldFile = baseName(out)
if (!oldFile) {
await onToggleHot?.(job)
return
}
const newFile = toggleHotFileName(oldFile)
// ✅ 1) UI sofort updaten (optimistisch)
setDone((prev) =>
prev.map((j) => {
const f = baseName(j.output || '')
if (f !== oldFile) return j
return { ...j, output: replaceBaseName(j.output || '', newFile) }
})
)
// ✅ 2) Backend/App Handler
await onToggleHot?.(job)
// ✅ 3) Server-Truth nachziehen (falls Backend anders renamed)
refetchDone()
},
[onToggleHot, refetchDone]
)
const handleDelete = React.useCallback(
async (job: RecordJob) => {
const out = job.output || ''
const file = baseName(out)
// ✅ UI sofort: raus aus Liste
if (file) {
setDone((prev) => prev.filter((j) => baseName(j.output || '') !== file))
}
await onDelete?.(job)
refetchDone()
},
[onDelete, refetchDone]
)
const handleToggleFavoriteModel = React.useCallback(async () => {
if (!key) return
// ✅ UI sofort (optimistisch)
setModels((prev) =>
prev.map((m) =>
(m.modelKey || '').toLowerCase() === key
? { ...m, favorite: !Boolean(m.favorite) }
: m
)
)
// ✅ Backend/App Handler
await onToggleFavorite?.(jobFromModelKey(key))
// ✅ Server-Truth nachziehen
refetchModels()
}, [key, onToggleFavorite, refetchModels])
const handleToggleLikeModel = React.useCallback(async () => {
if (!key) return
// liked ist bei dir boolean | null -> wir togglen "true <-> false"
setModels((prev) =>
prev.map((m) =>
(m.modelKey || '').toLowerCase() === key
? { ...m, liked: m.liked === true ? false : true }
: m
)
)
await onToggleLike?.(jobFromModelKey(key))
refetchModels()
}, [key, onToggleLike, refetchModels])
const handleToggleWatchModel = React.useCallback(async () => {
if (!key) return
// ✅ UI sofort (optimistisch)
setModels((prev) =>
prev.map((m) =>
(m.modelKey || '').toLowerCase() === key
? { ...m, watching: !Boolean(m.watching) }
: m
)
)
// ✅ Backend/App Handler
await onToggleWatch?.(jobFromModelKey(key))
// ✅ Server-Truth nachziehen
refetchModels()
}, [key, onToggleWatch, refetchModels])
return ( return (
<Modal <Modal
open={open} open={open}
@ -757,14 +922,23 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
{/* Local flags icons (unten rechts im Hero) */} {/* Local flags icons (unten rechts im Hero) */}
<div className="absolute bottom-3 right-3 flex items-center gap-2"> <div className="absolute bottom-3 right-3 flex items-center gap-2">
{/* Favorite = Star */} {/* Favorite = Star */}
<span <button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleFavoriteModel()
}}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.favorite model?.favorite
? 'bg-amber-500/25 ring-amber-200/30' ? 'bg-amber-500/25 ring-amber-200/30'
: 'bg-black/20 ring-white/15' : 'bg-black/20 ring-white/15'
)} )}
title={model?.favorite ? 'Favorit' : 'Nicht favorisiert'} title={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
aria-pressed={Boolean(model?.favorite)}
aria-label={model?.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
> >
<span className="relative inline-block size-4"> <span className="relative inline-block size-4">
<StarOutlineIcon <StarOutlineIcon
@ -782,17 +956,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
)} )}
/> />
</span> </span>
</span> </button>
{/* Like = Heart */} {/* Like = Heart */}
<span <button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleLikeModel()
}}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.liked model?.liked
? 'bg-rose-500/25 ring-rose-200/30' ? 'bg-rose-500/25 ring-rose-200/30'
: 'bg-black/20 ring-white/15' : 'bg-black/20 ring-white/15'
)} )}
title={model?.liked ? 'Gefällt mir' : 'Nicht geliked'} title={model?.liked ? 'Like entfernen' : 'Liken'}
aria-pressed={model?.liked === true}
aria-label={model?.liked ? 'Like entfernen' : 'Liken'}
> >
<span className="relative inline-block size-4"> <span className="relative inline-block size-4">
<HeartOutlineIcon <HeartOutlineIcon
@ -810,17 +993,26 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
)} )}
/> />
</span> </span>
</span> </button>
{/* Watched = Eye */} {/* Watched = Eye */}
<span <button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleWatchModel()
}}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur', 'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.watching model?.watching
? 'bg-sky-500/25 ring-sky-200/30' ? 'bg-sky-500/25 ring-sky-200/30'
: 'bg-black/20 ring-white/15' : 'bg-black/20 ring-white/15'
)} )}
title={model?.watching ? 'Watched' : 'Nicht auf Watch'} title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
> >
<span className="relative inline-block size-4"> <span className="relative inline-block size-4">
<EyeOutlineIcon <EyeOutlineIcon
@ -838,7 +1030,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
)} )}
/> />
</span> </span>
</span> </button>
</div> </div>
</div> </div>
@ -1196,7 +1388,6 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
<div className="grid gap-2"> <div className="grid gap-2">
{doneMatchesPage.map((j) => { {doneMatchesPage.map((j) => {
const file = baseName(j.output || '') const file = baseName(j.output || '')
const kept = isKeptOutputPath(j.output || '')
const hot = file.startsWith('HOT ') const hot = file.startsWith('HOT ')
const ended = j.endedAt ? fmtDateTime(j.endedAt as any) : '—' const ended = j.endedAt ? fmtDateTime(j.endedAt as any) : '—'
return ( return (
@ -1218,7 +1409,7 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
<div className="min-w-0"> <div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-white"> <div className="min-w-0 flex-1 truncate text-sm font-medium text-gray-900 dark:text-white">
{file || '—'} {stripHotPrefix(file) || '—'}
</div> </div>
{hot ? ( {hot ? (
@ -1226,16 +1417,10 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
HOT HOT
</span> </span>
) : null} ) : null}
{kept ? (
<span className={pill('shrink-0 bg-indigo-500/10 text-indigo-900 ring-indigo-200 dark:text-indigo-200 dark:ring-indigo-400/20')}>
KEEP
</span>
) : null}
</div> </div>
<div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300"> <div className="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-600 dark:text-gray-300">
<span> <span>
Ende: <span className="font-medium text-gray-900 dark:text-white">{ended}</span> Datum: <span className="font-medium text-gray-900 dark:text-white">{ended}</span>
</span> </span>
<span> <span>
Dauer:{' '} Dauer:{' '}
@ -1252,11 +1437,28 @@ export default function ModelDetails({ open, modelKey, onClose, onOpenPlayer, co
</div> </div>
</div> </div>
{onOpenPlayer ? ( <div className="shrink-0 flex items-center gap-2">
<span className="hidden shrink-0 text-xs font-medium text-indigo-600 opacity-0 transition group-hover:opacity-100 dark:text-indigo-400 sm:inline"> {/* Record actions (Details etc.) */}
Öffnen <span
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
// ✅ Status an RecordJobActions geben (damit Icons korrekt aussehen)
isHot={hot} // hot hast du oben schon: const hot = file.startsWith('HOT ')
isFavorite={Boolean(model?.favorite)}
isLiked={model?.liked === true}
isWatching={Boolean(model?.watching)}
onToggleHot={handleToggleHot}
onDelete={handleDelete}
order={['hot', 'delete']}
className="flex items-center"
/>
</span> </span>
) : null} </div>
</div> </div>
) )
})} })}

View File

@ -105,6 +105,31 @@ function canonicalHost(raw?: string): string {
.replace(/^www\./, '') .replace(/^www\./, '')
} }
function baseNameFromPath(p: string): string {
const n = String(p || '').replaceAll('\\', '/')
const parts = n.split('/')
return parts[parts.length - 1] || ''
}
function stripHotPrefixLocal(file: string): string {
const f = String(file || '')
return f.toUpperCase().startsWith('HOT ') ? f.slice(4).trim() : f
}
// exakt wie in FinishedDownloads / backend: <modelKey>_MM_DD_YYYY__HH-MM-SS.ext
function modelKeyFromOutput(output?: string): string {
const fileRaw = baseNameFromPath(output || '')
const file = stripHotPrefixLocal(fileRaw)
if (!file) return '—'
const stem = file.replace(/\.[^.]+$/, '')
const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/)
if (m?.[1]) return m[1]
const i = stem.lastIndexOf('_')
return i > 0 ? stem.slice(0, i) : stem
}
function modelHref(m: StoredModel): string | null { function modelHref(m: StoredModel): string | null {
// 1) Wenn Backend eine echte URL gespeichert hat // 1) Wenn Backend eine echte URL gespeichert hat
if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) { if (m.isUrl && /^https?:\/\//i.test(String(m.input ?? ''))) {
@ -167,6 +192,14 @@ function IconToggle({
) )
} }
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
if (typeof v === 'number') return { isNull: false, kind: 'number', value: v }
if (typeof v === 'boolean') return { isNull: false, kind: 'number', value: v ? 1 : 0 }
if (typeof v === 'bigint') return { isNull: false, kind: 'number', value: Number(v) }
return { isNull: false, kind: 'string', value: String(v).toLocaleLowerCase() }
}
export default function ModelsTab() { export default function ModelsTab() {
const [models, setModels] = React.useState<StoredModel[]>([]) const [models, setModels] = React.useState<StoredModel[]>([])
@ -183,6 +216,46 @@ export default function ModelsTab() {
// 🏷️ Tag-Filter (klickbar) // 🏷️ Tag-Filter (klickbar)
const [tagFilter, setTagFilter] = React.useState<string[]>([]) const [tagFilter, setTagFilter] = React.useState<string[]>([])
const [videoCounts, setVideoCounts] = React.useState<Record<string, number>>({})
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
// 🔽 Table sorting (global über alle filtered Einträge)
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
key: 'videos',
direction: 'desc',
})
const refreshVideoCounts = React.useCallback(async () => {
setVideoCountsLoading(true)
try {
const res = await fetch('/api/record/done?all=1&includeKeep=1', { cache: 'no-store' as any })
if (!res.ok) return
const data = await res.json().catch(() => null)
// dein /done liefert { items, count }, aber wir sind tolerant:
const items: RecordJob[] = Array.isArray(data?.items)
? (data.items as RecordJob[])
: Array.isArray(data)
? (data as RecordJob[])
: []
const counts: Record<string, number> = {}
for (const j of items) {
const mk = modelKeyFromOutput(j.output)
if (!mk || mk === '—') continue
const k = mk.trim().toLowerCase()
if (!k) continue
counts[k] = (counts[k] ?? 0) + 1
}
setVideoCounts(counts)
} finally {
setVideoCountsLoading(false)
}
}, [])
const activeTagSet = React.useMemo(() => { const activeTagSet = React.useMemo(() => {
return new Set(tagFilter.map((t) => t.toLowerCase())) return new Set(tagFilter.map((t) => t.toLowerCase()))
}, [tagFilter]) }, [tagFilter])
@ -197,6 +270,17 @@ export default function ModelsTab() {
const clearTagFilter = React.useCallback(() => setTagFilter([]), []) const clearTagFilter = React.useCallback(() => setTagFilter([]), [])
React.useEffect(() => {
const onSet = (ev: Event) => {
const e = ev as CustomEvent<{ tags?: string[] }>
const tags = Array.isArray(e.detail?.tags) ? e.detail.tags : []
setTagFilter(tags)
}
window.addEventListener('models:set-tag-filter', onSet as any)
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
}, [])
const [input, setInput] = React.useState('') const [input, setInput] = React.useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null) const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null) const [parseError, setParseError] = React.useState<string | null>(null)
@ -258,12 +342,17 @@ export default function ModelsTab() {
try { try {
const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' }) const list = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' })
setModels(Array.isArray(list) ? list : []) setModels(Array.isArray(list) ? list : [])
void refreshVideoCounts() // ✅ Counts parallel aktualisieren
} catch (e: any) { } catch (e: any) {
setErr(e?.message ?? String(e)) setErr(e?.message ?? String(e))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, []) }, [refreshVideoCounts])
React.useEffect(() => {
void refreshVideoCounts()
}, [refreshVideoCounts])
React.useEffect(() => { React.useEffect(() => {
void refresh() void refresh()
@ -350,6 +439,200 @@ export default function ModelsTab() {
const deferredQ = React.useDeferredValue(q) const deferredQ = React.useDeferredValue(q)
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
const hideUntilHover = !watch && !fav && !liked
return (
<div className="group flex items-center justify-center gap-1 w-[110px]">
{/* 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, { watched: !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 patch(m.id, { favorite: true, liked: false })
}}
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, { liked: false })
else 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',
sortable: true,
sortValue: (m) => (m.modelKey || '').toLowerCase(),
cell: (m) => {
const href = modelHref(m)
return (
<div className="min-w-0">
<div className="flex items-center gap-2 min-w-0">
<div className="font-medium truncate">{m.modelKey}</div>
{/* kleiner Link-Indikator (damit klar ist: Klick öffnet) */}
{href ? (
<span
className="shrink-0 text-xs text-gray-400 dark:text-gray-500"
title={href}
onClick={(e) => {
// nicht row-click doppeln
e.stopPropagation()
window.open(href, '_blank', 'noreferrer')
}}
>
</span>
) : null}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{m.host ?? '—'}</div>
{/* URL optional als dritte Zeile, aber kurz + trunc */}
{href ? (
<div className="text-xs text-indigo-600 dark:text-indigo-400 truncate max-w-[520px]">
{href}
</div>
) : null}
</div>
)
},
},
{
key: 'videos',
header: 'Videos',
sortable: true,
sortValue: (m) => {
const k = String(m.modelKey || '').trim().toLowerCase()
const n = videoCounts[k]
return typeof n === 'number' ? n : 0
},
align: 'right',
cell: (m) => {
const k = String(m.modelKey || '').trim().toLowerCase()
const n = videoCounts[k]
return (
<span className="tabular-nums text-sm text-gray-900 dark:text-white w-[64px] inline-block text-right">
{typeof n === 'number' ? n : videoCountsLoading ? '…' : 0}
</span>
)
},
},
{
key: 'tags',
header: 'Tags',
sortable: true,
sortValue: (m) => splitTags(m.tags).length,
cell: (m) => {
const tags = splitTags(m.tags)
const shown = tags.slice(0, 6)
const rest = tags.length - shown.length
const full = tags.join(', ')
return (
<div className="flex flex-wrap gap-2 max-w-[520px]" title={full || undefined}>
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
{shown.map((t) => (
<TagBadge
key={t}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))}
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
{!m.hot && !m.keep && tags.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
) : null}
</div>
)
},
},
{
key: 'actions',
header: '',
align: 'right',
cell: (m) => (
<div className="flex justify-end w-[56px]">
<RecordJobActions
job={jobForDetails(m.modelKey)}
variant="table"
order={['details']}
className="flex items-center"
/>
</div>
),
},
]
}, [activeTagSet, toggleTagFilter, videoCounts, videoCountsLoading])
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
const needle = deferredQ.trim().toLowerCase() const needle = deferredQ.trim().toLowerCase()
@ -372,11 +655,46 @@ export default function ModelsTab() {
}) })
}, [models, modelsWithHay, deferredQ, activeTagSet]) }, [models, modelsWithHay, deferredQ, activeTagSet])
const sortedAll = React.useMemo(() => {
if (!sort) return filtered
const col = columns.find((c) => c.key === sort.key)
if (!col) return filtered
const dirMul = sort.direction === 'asc' ? 1 : -1
const decorated = filtered.map((r, i) => ({ r, i }))
decorated.sort((x, y) => {
let res = 0
if (col.sortFn) {
res = col.sortFn(x.r, y.r)
} else {
const ax = col.sortValue ? col.sortValue(x.r) : (x.r as any)?.[col.key]
const by = col.sortValue ? col.sortValue(y.r) : (y.r as any)?.[col.key]
const na = normalizeSortValue(ax)
const nb = normalizeSortValue(by)
// nulls immer nach hinten
if (na.isNull && !nb.isNull) res = 1
else if (!na.isNull && nb.isNull) res = -1
else if (na.kind === 'number' && nb.kind === 'number') res = na.value < nb.value ? -1 : na.value > nb.value ? 1 : 0
else res = String(na.value).localeCompare(String(nb.value), undefined, { numeric: true })
}
if (res === 0) return x.i - y.i // stable
return res * dirMul
})
return decorated.map((d) => d.r)
}, [filtered, sort, columns])
React.useEffect(() => { React.useEffect(() => {
setPage(1) setPage(1)
}, [q, tagFilter]) }, [q, tagFilter])
const totalItems = filtered.length const totalItems = sortedAll.length
const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize]) const totalPages = React.useMemo(() => Math.max(1, Math.ceil(totalItems / pageSize)), [totalItems, pageSize])
React.useEffect(() => { React.useEffect(() => {
@ -385,8 +703,8 @@ export default function ModelsTab() {
const pageRows = React.useMemo(() => { const pageRows = React.useMemo(() => {
const start = (page - 1) * pageSize const start = (page - 1) * pageSize
return filtered.slice(start, start + pageSize) return sortedAll.slice(start, start + pageSize)
}, [filtered, page, pageSize]) }, [sortedAll, page, pageSize])
const upsertFromParsed = async () => { const upsertFromParsed = async () => {
if (!parsed) return if (!parsed) return
@ -417,7 +735,7 @@ export default function ModelsTab() {
} }
} }
const patch = async (id: string, body: any) => { async function patch(id: string, body: any) {
setErr(null) setErr(null)
// ✅ In-flight guard // ✅ In-flight guard
@ -498,172 +816,6 @@ export default function ModelsTab() {
} }
} }
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, { watched: !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, liked: false })
}
}}
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, { liked: false })
} 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>
),
},
{
key: 'url',
header: 'URL',
cell: (m) => {
const href = modelHref(m)
const label = href ?? (m.isUrl ? (m.input || '—') : '—')
if (!href) {
return <span className="text-gray-400 dark:text-gray-500"></span>
}
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="text-indigo-600 dark:text-indigo-400 hover:underline truncate block max-w-[520px]"
onClick={(e) => e.stopPropagation()}
title={href}
>
{label}
</a>
)
},
},
{
key: 'tags',
header: 'Tags',
cell: (m) => {
const tags = splitTags(m.tags)
const shown = tags.slice(0, 6)
const rest = tags.length - shown.length
const full = tags.join(', ')
return (
<div className="flex flex-wrap gap-2" title={full || undefined}>
{m.hot ? badge(true, '🔥 HOT') : null}
{m.keep ? badge(true, '📌 Behalten') : null}
{shown.map((t) => (
<TagBadge
key={t}
tag={t}
title={t}
active={activeTagSet.has(t.toLowerCase())}
onClick={toggleTagFilter}
/>
))}
{rest > 0 ? <TagBadge title={full}>+{rest}</TagBadge> : null}
{!m.hot && !m.keep && tags.length === 0 ? (
<span className="text-xs text-gray-400 dark:text-gray-500"></span>
) : null}
</div>
)
},
},
{
key: 'actions',
header: '',
align: 'right',
cell: (m) => (
<div className="flex justify-end">
<RecordJobActions
job={jobForDetails(m.modelKey)}
variant="table"
order={['details']}
className="flex items-center"
/>
</div>
),
},
]
}, [activeTagSet, toggleTagFilter, patch])
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Card <Card
@ -776,6 +928,8 @@ export default function ModelsTab() {
} }
noBodyPadding noBodyPadding
> >
<div className="overflow-x-auto">
<div className="min-w-[980px]">
<Table <Table
rows={pageRows} rows={pageRows}
columns={columns} columns={columns}
@ -784,11 +938,15 @@ export default function ModelsTab() {
compact compact
fullWidth fullWidth
stickyHeader stickyHeader
sort={sort}
onSortChange={(next) => setSort(next)}
onRowClick={(m) => { onRowClick={(m) => {
const href = modelHref(m) const href = modelHref(m)
if (href) window.open(href, '_blank', 'noreferrer') if (href) window.open(href, '_blank', 'noreferrer')
}} }}
/> />
</div>
</div>
<Pagination <Pagination
page={page} page={page}

View File

@ -93,6 +93,15 @@ function justifyForAlign(a?: Align) {
return 'justify-start' return 'justify-start'
} }
function xPadForColumn(colIndex: number, totalCols: number) {
const isFirst = colIndex === 0
const isLast = colIndex === totalCols - 1
// außen weniger Padding, innen normal
if (isFirst) return 'pl-2 pr-2'
if (isLast) return 'pl-2 pr-2'
return 'px-2'
}
function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } { function normalizeSortValue(v: any): { isNull: boolean; kind: 'number' | 'string'; value: number | string } {
if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' } if (v === null || v === undefined) return { isNull: true, kind: 'string', value: '' }
if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() } if (v instanceof Date) return { isNull: false, kind: 'number', value: v.getTime() }
@ -199,7 +208,7 @@ export default function Table<T>({
<div className={cn(title || description || actions ? 'mt-8' : '')}> <div className={cn(title || description || actions ? 'mt-8' : '')}>
<div className="flow-root"> <div className="flow-root">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<div className={cn('inline-block min-w-full py-2 align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}> <div className={cn('inline-block min-w-full align-middle', fullWidth ? '' : 'sm:px-6 lg:px-8')}>
<div <div
className={cn( className={cn(
card && card &&
@ -215,7 +224,8 @@ export default function Table<T>({
)} )}
> >
<tr> <tr>
{columns.map((col) => { {columns.map((col, colIndex) => {
const xPad = xPadForColumn(colIndex, columns.length)
const isSorted = !!sortState && sortState.key === col.key const isSorted = !!sortState && sortState.key === col.key
const dir = isSorted ? sortState!.direction : undefined const dir = isSorted ? sortState!.direction : undefined
const ariaSort = const ariaSort =
@ -241,7 +251,8 @@ export default function Table<T>({
aria-sort={ariaSort as any} aria-sort={ariaSort as any}
className={cn( className={cn(
headY, headY,
'px-3 text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap', xPad,
'text-xs font-semibold tracking-wide text-gray-700 dark:text-gray-200 whitespace-nowrap',
alignTd(col.align), alignTd(col.align),
col.widthClassName, col.widthClassName,
col.headerClassName col.headerClassName
@ -294,13 +305,13 @@ export default function Table<T>({
> >
{isLoading ? ( {isLoading ? (
<tr> <tr>
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}> <td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
Lädt Lädt
</td> </td>
</tr> </tr>
) : sortedRows.length === 0 ? ( ) : sortedRows.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length} className={cn(cellY, 'px-3 text-sm text-gray-500 dark:text-gray-400')}> <td colSpan={columns.length} className={cn(cellY, 'px-2 text-sm text-gray-500 dark:text-gray-400')}>
{emptyLabel} {emptyLabel}
</td> </td>
</tr> </tr>
@ -327,7 +338,8 @@ export default function Table<T>({
: undefined : undefined
} }
> >
{columns.map((col) => { {columns.map((col, colIndex) => {
const xPad = xPadForColumn(colIndex, columns.length)
const content = const content =
col.cell?.(row, rowIndex) ?? col.cell?.(row, rowIndex) ??
col.accessor?.(row) ?? col.accessor?.(row) ??
@ -338,7 +350,8 @@ export default function Table<T>({
key={col.key} key={col.key}
className={cn( className={cn(
cellY, cellY,
'px-3 text-sm whitespace-nowrap', xPad,
'text-sm whitespace-nowrap',
alignTd(col.align), alignTd(col.align),
col.className, col.className,
col.key === columns[0]?.key col.key === columns[0]?.key