This commit is contained in:
Linrador 2026-02-06 09:35:43 +01:00
parent ce68074a5a
commit c830b3bf15
30 changed files with 4577 additions and 1218 deletions

625
backend/auth.go Normal file
View File

@ -0,0 +1,625 @@
// backend/auth.go
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
type authConfig struct {
Username string `json:"username"`
PasswordHash string `json:"passwordHash"` // bcrypt hash
TOTPEnabled bool `json:"totpEnabled"`
TOTPSecret string `json:"totpSecret"` // base32
}
// Session in memory (Restart = logout für alle)
type session struct {
User string
Authed bool // voll authed (nach 2FA)
Pending2FA bool
ExpiresAt time.Time
CreatedAt time.Time
LastSeenAt time.Time
}
type AuthManager struct {
confMu sync.Mutex
conf authConfig
sessMu sync.Mutex
sess map[string]*session
configPath string
}
const (
sessionCookieName = "sid"
sessionTTL = 7 * 24 * time.Hour
)
func NewAuthManager() (*AuthManager, error) {
am := &AuthManager{
sess: make(map[string]*session),
}
// optional: persist config in file
// (ENV-only geht auch, aber setup/enable kann ENV nicht zurückschreiben)
am.configPath = strings.TrimSpace(os.Getenv("AUTH_CONFIG_FILE"))
if am.configPath == "" {
am.configPath = "data/auth.json"
}
// 1) erst Datei laden, wenn vorhanden
if err := am.loadFromFileIfExists(); err != nil {
return nil, err
}
// 2) ENV überschreibt (wenn gesetzt)
if err := am.loadFromEnvOverride(); err != nil {
return nil, err
}
// 3) sanity
am.confMu.Lock()
defer am.confMu.Unlock()
if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" {
return nil, errors.New("AUTH_USER/AUTH_PASS_HASH fehlen (oder auth.json unvollständig)")
}
return am, nil
}
func (am *AuthManager) loadFromFileIfExists() error {
p := strings.TrimSpace(am.configPath)
if p == "" {
return nil
}
abs, _ := resolvePathRelativeToApp(p)
if abs == "" {
abs = p
}
b, err := os.ReadFile(abs)
if err != nil {
if os.IsNotExist(err) {
// ✅ Datei fehlt: bootstrap erzeugen + Passwort ausgeben
return am.bootstrapAuthFile(abs)
}
return fmt.Errorf("read auth config: %w", err)
}
var c authConfig
if err := json.Unmarshal(b, &c); err != nil {
return fmt.Errorf("parse auth config: %w", err)
}
am.confMu.Lock()
am.conf = c
am.confMu.Unlock()
return nil
}
func (am *AuthManager) loadFromEnvOverride() error {
u := strings.TrimSpace(os.Getenv("AUTH_USER"))
pwh := strings.TrimSpace(os.Getenv("AUTH_PASS_HASH"))
sec := strings.TrimSpace(os.Getenv("AUTH_TOTP_SECRET"))
en := strings.TrimSpace(os.Getenv("AUTH_TOTP_ENABLED"))
am.confMu.Lock()
defer am.confMu.Unlock()
// nur überschreiben wenn gesetzt
if u != "" {
am.conf.Username = u
}
if pwh != "" {
am.conf.PasswordHash = pwh
}
if sec != "" {
am.conf.TOTPSecret = sec
}
if en != "" {
am.conf.TOTPEnabled = (en == "1" || strings.EqualFold(en, "true") || strings.EqualFold(en, "yes"))
}
return nil
}
func (am *AuthManager) persistConfBestEffort() {
p := strings.TrimSpace(am.configPath)
if p == "" {
return
}
abs, _ := resolvePathRelativeToApp(p)
if abs == "" {
abs = p
}
_ = os.MkdirAll(filepath.Dir(abs), 0o755)
am.confMu.Lock()
c := am.conf
am.confMu.Unlock()
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return
}
b = append(b, '\n')
_ = atomicWriteFileCompat(abs, b)
}
func atomicWriteFileCompat(path string, data []byte) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
return os.Rename(tmp, path)
}
func generateRandomPassword() (string, error) {
// 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// wird nur aufgerufen, wenn auth.json nicht existiert
func (am *AuthManager) bootstrapAuthFile(abs string) error {
// Username: ENV bevorzugen, sonst "admin"
user := strings.TrimSpace(os.Getenv("AUTH_USER"))
if user == "" {
user = "admin"
}
// Wenn AUTH_PASS_HASH gesetzt ist, können wir KEIN Klartext-Passwort anzeigen.
// Dann schreiben wir die Datei "as-is" und loggen das entsprechend.
passHash := strings.TrimSpace(os.Getenv("AUTH_PASS_HASH"))
var plain string
if passHash == "" {
p, err := generateRandomPassword()
if err != nil {
return fmt.Errorf("generate password: %w", err)
}
plain = p
h, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("bcrypt: %w", err)
}
passHash = string(h)
}
c := authConfig{
Username: user,
PasswordHash: passHash,
TOTPEnabled: false,
TOTPSecret: "",
}
// In-memory setzen
am.confMu.Lock()
am.conf = c
am.confMu.Unlock()
// Datei schreiben
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return fmt.Errorf("mkdir auth dir: %w", err)
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return fmt.Errorf("marshal auth config: %w", err)
}
b = append(b, '\n')
if err := atomicWriteFileCompat(abs, b); err != nil {
return fmt.Errorf("write auth config: %w", err)
}
// Passwort in Konsole anzeigen (nur wenn wir es generiert haben)
if plain != "" {
fmt.Println("🔐 auth.json wurde neu erstellt:")
fmt.Println(" Datei: ", abs)
fmt.Println(" Username:", user)
fmt.Println(" Passwort:", plain)
fmt.Println(" Hinweis: Bitte sofort sichern/ändern das Passwort wird nur beim Erstellen angezeigt.")
} else {
fmt.Println("🔐 auth.json wurde neu erstellt (aus ENV AUTH_PASS_HASH):")
fmt.Println(" Datei: ", abs)
fmt.Println(" Username:", user)
fmt.Println(" Hinweis: Klartext-Passwort ist unbekannt (AUTH_PASS_HASH war gesetzt).")
}
return nil
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func isSecureRequest(r *http.Request) bool {
if strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
func setSessionCookie(w http.ResponseWriter, sid string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sid,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
Expires: time.Now().Add(sessionTTL),
})
}
func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Expires: time.Unix(0, 0),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
})
}
func (am *AuthManager) getSession(r *http.Request) (*session, string) {
c, err := r.Cookie(sessionCookieName)
if err != nil || strings.TrimSpace(c.Value) == "" {
return nil, ""
}
sid := strings.TrimSpace(c.Value)
am.sessMu.Lock()
defer am.sessMu.Unlock()
s := am.sess[sid]
if s == nil {
return nil, sid
}
if time.Now().After(s.ExpiresAt) {
delete(am.sess, sid)
return nil, sid
}
s.LastSeenAt = time.Now()
s.ExpiresAt = time.Now().Add(sessionTTL) // sliding
return s, sid
}
// ---------------------------------------------------------------------
// Middleware: schützt /api/* (außer /api/auth/*) + optional Frontend (/)
// ---------------------------------------------------------------------
// protectFrontend=true => auch "/" (SPA) schützen.
// allowPaths sind public prefixes wie "/api/auth/" oder "/login".
func requireAuth(am *AuthManager, next http.Handler, protectFrontend bool, allowPaths ...string) http.Handler {
allowed := func(p string) bool {
for _, a := range allowPaths {
if a != "" && strings.HasPrefix(p, a) {
return true
}
}
return false
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
// allowlist (z.B. "/login", "/assets/", "/favicon.ico")
if allowed(p) {
next.ServeHTTP(w, r)
return
}
// /api/auth/* immer frei (Login/Logout/Me/2FA Setup/Enable)
if strings.HasPrefix(p, "/api/auth/") {
next.ServeHTTP(w, r)
return
}
// wenn nicht Frontend-schutz: alles außerhalb /api/ durchlassen
if !protectFrontend && !strings.HasPrefix(p, "/api/") {
next.ServeHTTP(w, r)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
// API: weiterhin 401 (kein Redirect)
if strings.HasPrefix(p, "/api/") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Frontend: Redirect auf /login?next=...
if protectFrontend {
nextPath := r.URL.RequestURI() // inkl. Query
if nextPath == "" || !strings.HasPrefix(nextPath, "/") || strings.HasPrefix(nextPath, "/login") {
nextPath = "/"
}
http.Redirect(w, r, "/login?next="+url.QueryEscape(nextPath), http.StatusFound)
return
}
// falls protectFrontend=false: durchlassen
next.ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------
// Handler Factories (passen zu deinem registerRoutes())
// ---------------------------------------------------------------------
func authMeHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, _ := am.getSession(r)
am.confMu.Lock()
totpEnabled := am.conf.TOTPEnabled && strings.TrimSpace(am.conf.TOTPSecret) != ""
am.confMu.Unlock()
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": s != nil && s.Authed,
"pending2fa": s != nil && s.Pending2FA,
"totpEnabled": totpEnabled,
})
}
}
func authLogoutHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, sid := am.getSession(r)
if sid != "" {
am.sessMu.Lock()
delete(am.sess, sid)
am.sessMu.Unlock()
}
clearSessionCookie(w)
w.WriteHeader(http.StatusNoContent)
}
}
type loginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
func authLoginHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
u := strings.TrimSpace(req.Username)
p := req.Password
am.confMu.Lock()
wantU := am.conf.Username
hash := am.conf.PasswordHash
totpRequired := am.conf.TOTPEnabled && strings.TrimSpace(am.conf.TOTPSecret) != ""
am.confMu.Unlock()
if u == "" || p == "" || u != wantU {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(p)); err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
sid, err := newSessionID()
if err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
now := time.Now()
s := &session{
User: u,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(sessionTTL),
}
if totpRequired {
s.Pending2FA = true
s.Authed = false
} else {
s.Pending2FA = false
s.Authed = true
}
am.sessMu.Lock()
am.sess[sid] = s
am.sessMu.Unlock()
setSessionCookie(w, sid, isSecureRequest(r))
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"totpRequired": totpRequired,
})
}
}
type codeReq struct {
Code string `json:"code"`
}
// /api/auth/2fa/setup -> secret + otpauth (nur wenn bereits authed)
func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// 1) aktuellen State lesen (ohne lange Lock-Haltezeit)
am.confMu.Lock()
if strings.TrimSpace(am.conf.TOTPSecret) != "" {
am.confMu.Unlock()
http.Error(w, "2fa already configured", http.StatusConflict)
return
}
username := am.conf.Username
am.confMu.Unlock()
// 2) Secret erzeugen
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "Recorder",
AccountName: username,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
http.Error(w, "totp generate failed", http.StatusInternalServerError)
return
}
// 3) setzen (und nochmal prüfen, falls parallel jemand war)
am.confMu.Lock()
if strings.TrimSpace(am.conf.TOTPSecret) != "" {
am.confMu.Unlock()
http.Error(w, "2fa already configured", http.StatusConflict)
return
}
am.conf.TOTPSecret = key.Secret()
am.conf.TOTPEnabled = false // erst nach Enable aktiv
am.confMu.Unlock()
// 4) persist ohne Lock
go am.persistConfBestEffort()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"secret": key.Secret(),
"otpauth": key.URL(),
})
}
}
// /api/auth/2fa/enable -> Code prüfen
// - wenn Session pending2fa: verifiziert Login (setzt Authed=true)
// - wenn Session authed und totp noch nicht enabled: aktiviert 2FA global
func auth2FAEnableHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST required", http.StatusMethodNotAllowed)
return
}
s, sid := am.getSession(r)
if s == nil || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req codeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
code := strings.TrimSpace(req.Code)
if code == "" {
http.Error(w, "code required", http.StatusBadRequest)
return
}
// Secret holen
am.confMu.Lock()
secret := strings.TrimSpace(am.conf.TOTPSecret)
wasEnabled := am.conf.TOTPEnabled
am.confMu.Unlock()
if secret == "" {
http.Error(w, "2fa not configured", http.StatusBadRequest)
return
}
if !totp.Validate(code, secret) {
http.Error(w, "invalid code", http.StatusUnauthorized)
return
}
now := time.Now()
// 1) Session verifizieren (auch wenn pending2fa)
am.sessMu.Lock()
cur := am.sess[sid]
if cur == nil {
am.sessMu.Unlock()
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// optional harte Regel: nur wenn pending2fa oder bereits authed
if !cur.Authed && !cur.Pending2FA {
am.sessMu.Unlock()
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
cur.Pending2FA = false
cur.Authed = true
cur.LastSeenAt = now
cur.ExpiresAt = now.Add(sessionTTL)
am.sessMu.Unlock()
// 2) 2FA global aktivieren, falls noch nicht enabled
changed := false
if !wasEnabled {
am.confMu.Lock()
if !am.conf.TOTPEnabled {
am.conf.TOTPEnabled = true
changed = true
}
am.confMu.Unlock()
}
if changed {
go am.persistConfBestEffort()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}

303
backend/cleanup.go Normal file
View File

@ -0,0 +1,303 @@
package main
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
)
type cleanupResp struct {
// Small downloads cleanup
ScannedFiles int `json:"scannedFiles"`
DeletedFiles int `json:"deletedFiles"`
SkippedFiles int `json:"skippedFiles"`
DeletedBytes int64 `json:"deletedBytes"`
DeletedBytesHuman string `json:"deletedBytesHuman"`
ErrorCount int `json:"errorCount"`
// Orphans cleanup (previews/thumbs/generated ohne passende Video-Datei)
OrphanIDsScanned int `json:"orphanIdsScanned"`
OrphanIDsRemoved int `json:"orphanIdsRemoved"`
}
// Optional: falls du später Threshold per Body überschreiben willst.
// Frontend sendet aktuell nichts -> wir nutzen Settings.
type cleanupReq struct {
BelowMB *int `json:"belowMB,omitempty"`
}
// /api/settings/cleanup (POST)
// - löscht kleine Dateien < threshold MB (mp4/ts; skip .part/.tmp; skip keep-Ordner)
// - räumt Orphans (preview/thumbs + generated) auf
func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Nur POST erlaubt", http.StatusMethodNotAllowed)
return
}
s := getSettings()
// doneDir auflösen
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
http.Error(w, "doneDir auflösung fehlgeschlagen", http.StatusBadRequest)
return
}
// Threshold: standardmäßig aus Settings
mb := int(s.AutoDeleteSmallDownloadsBelowMB)
// optional Body-Override (wenn du es später brauchst)
// (Frontend sendet aktuell nichts; ist trotzdem safe)
var req cleanupReq
if r.Body != nil {
_ = json.NewDecoder(r.Body).Decode(&req)
}
if req.BelowMB != nil {
mb = *req.BelowMB
}
if mb < 0 {
mb = 0
}
resp := cleanupResp{}
// 1) Kleine Downloads löschen (wenn mb > 0)
if mb > 0 {
threshold := int64(mb) * 1024 * 1024
cleanupSmallFiles(doneAbs, threshold, &resp)
}
// 2) Orphans entfernen (immer sinnvoll, unabhängig von mb)
cleanupOrphanAssets(doneAbs, &resp)
// ✅ Wenn wir irgendwas gelöscht haben: generated GC nachziehen
if resp.DeletedFiles > 0 || resp.OrphanIDsRemoved > 0 {
triggerGeneratedGarbageCollectorAsync()
}
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
writeJSON(w, http.StatusOK, resp)
}
func cleanupSmallFiles(doneAbs string, threshold int64, resp *cleanupResp) {
isCandidate := 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"
}
// scan: doneAbs + 1-level subdirs, "keep" wird übersprungen
scanDir := func(dir string, allowSubdirs bool) {
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
full := filepath.Join(dir, e.Name())
if e.IsDir() {
if !allowSubdirs {
continue
}
if e.Name() == "keep" {
continue
}
sub, err := os.ReadDir(full)
if err != nil {
continue
}
for _, se := range sub {
if se.IsDir() {
continue
}
name := se.Name()
if !isCandidate(name) {
resp.SkippedFiles++
continue
}
p := filepath.Join(full, name)
fi, err := os.Stat(p)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
resp.SkippedFiles++
continue
}
resp.ScannedFiles++
if fi.Size() < threshold {
base := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p))
id := stripHotPrefix(base)
if derr := removeWithRetry(p); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += fi.Size()
// generated + legacy cleanup (best effort)
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
_ = os.RemoveAll(filepath.Join(doneAbs, "preview", id))
_ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id))
}
purgeDurationCacheForPath(p)
} else {
resp.ErrorCount++
}
}
}
continue
}
// root-level file
name := e.Name()
if !isCandidate(name) {
resp.SkippedFiles++
continue
}
fi, err := os.Stat(full)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
resp.SkippedFiles++
continue
}
resp.ScannedFiles++
if fi.Size() < threshold {
base := strings.TrimSuffix(filepath.Base(full), filepath.Ext(full))
id := stripHotPrefix(base)
if derr := removeWithRetry(full); derr == nil || os.IsNotExist(derr) {
resp.DeletedFiles++
resp.DeletedBytes += fi.Size()
if strings.TrimSpace(id) != "" {
removeGeneratedForID(id)
_ = os.RemoveAll(filepath.Join(doneAbs, "preview", id))
_ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id))
}
purgeDurationCacheForPath(full)
} else {
resp.ErrorCount++
}
}
}
}
scanDir(doneAbs, true)
}
// Orphans = Preview/Thumbs/Generated IDs, für die keine Video-Datei im doneAbs existiert.
func cleanupOrphanAssets(doneAbs string, resp *cleanupResp) {
// 1) Existierende Video-IDs einsammeln
existingIDs := collectExistingVideoIDs(doneAbs)
// 2) Orphan-IDs aus preview/thumbs ermitteln
previewDir := filepath.Join(doneAbs, "preview")
thumbsDir := filepath.Join(doneAbs, "thumbs")
ids := make(map[string]struct{})
addDirChildrenAsIDs := func(dir string) {
ents, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range ents {
if !e.IsDir() {
continue
}
id := strings.TrimSpace(e.Name())
if id == "" {
continue
}
ids[id] = struct{}{}
}
}
addDirChildrenAsIDs(previewDir)
addDirChildrenAsIDs(thumbsDir)
resp.OrphanIDsScanned = len(ids)
// 3) Alles löschen, was nicht mehr existiert
for id := range ids {
if _, ok := existingIDs[id]; ok {
continue
}
// remove generated artifacts (best effort)
removeGeneratedForID(id)
// remove legacy preview/thumbs
_ = os.RemoveAll(filepath.Join(previewDir, id))
_ = os.RemoveAll(filepath.Join(thumbsDir, id))
resp.OrphanIDsRemoved++
}
}
func collectExistingVideoIDs(doneAbs string) map[string]struct{} {
out := make(map[string]struct{})
isCandidate := 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"
}
addFile := func(p string) {
name := filepath.Base(p)
if !isCandidate(name) {
return
}
base := strings.TrimSuffix(name, filepath.Ext(name))
id := stripHotPrefix(base)
id = strings.TrimSpace(id)
if id != "" {
out[id] = struct{}{}
}
}
// root + 1-level subdirs (skip keep)
ents, err := os.ReadDir(doneAbs)
if err != nil {
return out
}
for _, e := range ents {
full := filepath.Join(doneAbs, e.Name())
if e.IsDir() {
if e.Name() == "keep" {
continue
}
sub, err := os.ReadDir(full)
if err != nil {
continue
}
for _, se := range sub {
if se.IsDir() {
continue
}
addFile(filepath.Join(full, se.Name()))
}
continue
}
addFile(full)
}
return out
}

6
backend/data/auth.json Normal file
View File

@ -0,0 +1,6 @@
{
"username": "admin",
"passwordHash": "$2a$10$ujxgEV/riwyxEfQKdG3hruUGljg/ts3bDETFAPhZb07N0TBY5LRNq",
"totpEnabled": false,
"totpSecret": ""
}

Binary file not shown.

Binary file not shown.

View File

@ -9,13 +9,16 @@ require (
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
)
require (
@ -28,8 +31,9 @@ require (
github.com/shirou/gopsutil/v3 v3.24.5
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
golang.org/x/image v0.35.0
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

View File

@ -4,6 +4,9 @@ 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/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
@ -20,8 +23,11 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
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/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
@ -30,6 +36,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@ -43,8 +51,12 @@ 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/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
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/image v0.35.0 h1:LKjiHdgMtO8z7Fh18nGY6KDcoEtVfsgLDPeLyguqb7I=
golang.org/x/image v0.35.0/go.mod h1:MwPLTVgvxSASsxdLzKrl8BRFuyqMyGhLwmC+TO1Sybk=
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=
@ -61,6 +73,8 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -85,6 +99,8 @@ 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/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.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=

File diff suppressed because it is too large Load Diff

Binary file not shown.

202
backend/postwork_queue.go Normal file
View File

@ -0,0 +1,202 @@
// backend/postwork_queue.go
package main
import (
"context"
"sync"
"time"
)
// Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten)
type PostWorkTask struct {
Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren
Run func(ctx context.Context) error
Added time.Time
}
type PostWorkQueue struct {
q chan PostWorkTask
ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig
mu sync.Mutex
inflight map[string]struct{} // dedupe: queued ODER running
queued int // Anzahl inflight (queued + running)
// ✅ für UI: Warteschlange + Running-Keys tracken
waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind
runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten)
}
func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue {
if queueSize <= 0 {
queueSize = 256
}
if maxParallelFFmpeg <= 0 {
maxParallelFFmpeg = 1 // Default: "nacheinander"
}
return &PostWorkQueue{
q: make(chan PostWorkTask, queueSize),
ffmpegSem: make(chan struct{}, maxParallelFFmpeg),
inflight: make(map[string]struct{}),
waitingKeys: make([]string, 0, queueSize),
runningKeys: make(map[string]struct{}),
}
}
// Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st)
func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool {
if task.Key == "" || task.Run == nil {
return false
}
pq.mu.Lock()
if _, ok := pq.inflight[task.Key]; ok {
pq.mu.Unlock()
return false // schon queued oder läuft
}
pq.inflight[task.Key] = struct{}{}
pq.waitingKeys = append(pq.waitingKeys, task.Key) // ✅ queued für UI
pq.queued++
pq.mu.Unlock()
select {
case pq.q <- task:
return true
default:
// Queue voll -> inflight zurückrollen
pq.mu.Lock()
delete(pq.inflight, task.Key)
if pq.queued > 0 {
pq.queued--
}
pq.removeWaitingKeyLocked(task.Key)
pq.mu.Unlock()
return false
}
}
func (pq *PostWorkQueue) removeWaitingKeyLocked(key string) {
for i, k := range pq.waitingKeys {
if k == key {
pq.waitingKeys = append(pq.waitingKeys[:i], pq.waitingKeys[i+1:]...)
return
}
}
}
func (pq *PostWorkQueue) workerLoop(id int) {
for task := range pq.q {
// 1) Heavy-Gate: erst wenn ein Slot frei ist, gilt der Task als "running"
pq.ffmpegSem <- struct{}{} // kann blocken
// 2) Ab hier startet er wirklich → waiting -> running
pq.mu.Lock()
pq.removeWaitingKeyLocked(task.Key)
pq.runningKeys[task.Key] = struct{}{}
pq.mu.Unlock()
func() {
defer func() {
// Panic-Schutz: Worker darf nicht sterben
if r := recover(); r != nil {
// optional: hier loggen
_ = r
}
// States immer konsistent aufräumen (auch wenn er nie sauber run() beendet)
pq.mu.Lock()
pq.removeWaitingKeyLocked(task.Key) // falls er noch als waiting drin hing
delete(pq.runningKeys, task.Key)
delete(pq.inflight, task.Key)
if pq.queued > 0 {
pq.queued--
}
pq.mu.Unlock()
// Slot freigeben
<-pq.ffmpegSem
}()
// 3) Optional: Task timeout (gegen hängende ffmpeg)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
defer cancel()
// 4) Run
if task.Run != nil {
_ = task.Run(ctx) // optional: loggen
}
}()
}
}
func (pq *PostWorkQueue) StartWorkers(n int) {
if n <= 0 {
n = 1
}
for i := 0; i < n; i++ {
go pq.workerLoop(i + 1)
}
}
// Stats bleibt kompatibel zu deinem bisherigen Code
func (pq *PostWorkQueue) Stats() (queued int, inflight int, maxParallel int) {
pq.mu.Lock()
defer pq.mu.Unlock()
return pq.queued, len(pq.inflight), cap(pq.ffmpegSem)
}
// Optional für UI/Debug (praktisch für "Warte… Position X/Y")
type PostWorkKeyStatus struct {
State string `json:"state"` // "queued" | "running" | "missing"
Position int `json:"position"` // 1..n (nur queued), 0 sonst
Waiting int `json:"waiting"` // Anzahl wartend
Running int `json:"running"` // Anzahl running
MaxParallel int `json:"maxParallel"` // cap(ffmpegSem)
}
func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
pq.mu.Lock()
defer pq.mu.Unlock()
waiting := len(pq.waitingKeys)
running := len(pq.runningKeys)
maxPar := cap(pq.ffmpegSem)
if _, ok := pq.runningKeys[key]; ok {
return PostWorkKeyStatus{
State: "running",
Position: 0,
Waiting: waiting,
Running: running,
MaxParallel: maxPar,
}
}
for i, k := range pq.waitingKeys {
if k == key {
return PostWorkKeyStatus{
State: "queued",
Position: i + 1, // 1-basiert
Waiting: waiting,
Running: running,
MaxParallel: maxPar,
}
}
}
// Key ist weder queued noch running (wahrscheinlich schon fertig oder nie queued)
return PostWorkKeyStatus{
State: "missing",
Position: 0,
Waiting: waiting,
Running: running,
MaxParallel: maxPar,
}
}
// global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 2

View File

@ -0,0 +1,40 @@
package main
import (
"reflect"
"strings"
"time"
)
func startPostWorkStatusRefresher() {
t := time.NewTicker(1 * time.Second)
go func() {
defer t.Stop()
for range t.C {
changed := false
jobsMu.Lock()
for _, job := range jobs {
key := strings.TrimSpace(job.PostWorkKey)
if key == "" {
continue
}
st := postWorkQ.StatusForKey(key)
// ✅ Kein Typname nötig: job.PostWork ist *<StatusType>, st ist <StatusType>
if job.PostWork == nil || !reflect.DeepEqual(*job.PostWork, st) {
tmp := st
job.PostWork = &tmp
changed = true
}
}
jobsMu.Unlock()
if changed {
notifyJobsChanged()
}
}
}()
}

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

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" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-BqjSaPox.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CRe6vAJq.css">
<script type="module" crossorigin src="/assets/index-DeJUGWfr.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-s1ZflJsu.css">
</head>
<body>
<div id="root"></div>

View File

@ -18,6 +18,7 @@ import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
import CategoriesTab from './components/ui/CategoriesTab'
import LoginPage from './components/ui/LoginPage'
const COOKIE_STORAGE_KEY = 'record_cookies'
@ -186,6 +187,24 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
export default function App() {
const [authChecked, setAuthChecked] = useState(false)
const [authed, setAuthed] = useState(false)
const checkAuth = useCallback(async () => {
try {
const res = await apiJSON<{ authenticated?: boolean }>('/api/auth/me', { cache: 'no-store' as any })
setAuthed(Boolean(res?.authenticated))
} catch {
setAuthed(false)
} finally {
setAuthChecked(true)
}
}, [])
useEffect(() => {
void checkAuth()
}, [checkAuth])
const notify = useNotify()
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
@ -209,8 +228,6 @@ export default function App() {
type DoneSortMode =
| 'completed_desc'
| 'completed_asc'
| 'model_asc'
| 'model_desc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
@ -635,7 +652,10 @@ export default function App() {
setPlayerExpanded(false)
}, [])
const runningJobs = jobs.filter((j) => j.status === 'running')
const runningJobs = jobs.filter((j) => {
const s = String((j as any)?.status ?? '').toLowerCase()
return s === 'running' || s === 'postwork'
})
// ✅ Anzahl Watched Models (aus Store), die online sind
const onlineWatchedModelsCount = useMemo(() => {
@ -798,16 +818,31 @@ export default function App() {
const prev = jobsRef.current
const prevById = new Map(prev.map((j) => [j.id, j.status]))
const endedNow = arr.some((j) => {
const terminal = new Set(['finished', 'stopped', 'failed'])
let endedDelta = 0
for (const j of arr) {
const ps = prevById.get(j.id)
return ps && ps !== j.status && (j.status === 'finished' || j.status === 'stopped')
})
if (!ps || ps === j.status) continue
// nur zählen, wenn wir "neu" in einen terminal state gehen
if (terminal.has(j.status) && !terminal.has(ps)) {
endedDelta++
}
}
if (endedDelta > 0) {
// ✅ Tabs/Count sofort aktualisieren auch wenn Finished-Tab nicht offen ist
window.dispatchEvent(
new CustomEvent('finished-downloads:count-hint', { detail: { delta: endedDelta } })
)
// deine bestehenden Asset-Bumps (thumbnails etc.)
bumpAssetsTwice()
}
setJobs(arr)
jobsRef.current = arr
setLastHeaderUpdateAtMs(Date.now())
if (endedNow) bumpAssetsTwice()
setPlayerJob((prevJob) => {
if (!prevJob) return prevJob
@ -1065,6 +1100,24 @@ export default function App() {
return startUrl(sourceUrl)
}
const handleAddToDownloads = useCallback(
async (job: RecordJob): Promise<boolean> => {
const raw = String((job as any)?.sourceUrl ?? '')
const url = extractFirstUrl(raw)
if (!url) return false
// silent=true -> keine rote Error-Box, wir geben Feedback über Checkmark/Toast
const ok = await startUrl(url, { silent: true })
if (!ok) {
notify.error('Konnte URL nicht hinzufügen', 'Start fehlgeschlagen oder URL ungültig.')
}
return ok
},
[startUrl, notify]
)
const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
const file = baseName(job.output || '')
@ -2029,6 +2082,14 @@ export default function App() {
return () => stop()
}, [recSettings.useChaturbateApi])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
if (!authed) {
return <LoginPage onLoggedIn={checkAuth} />
}
return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
@ -2182,6 +2243,7 @@ export default function App() {
onToggleFavorite={handleToggleFavorite}
onToggleLike={handleToggleLike}
onToggleWatch={handleToggleWatch}
onAddToDownloads={handleAddToDownloads}
blurPreviews={Boolean(recSettings.blurPreviews)}
/>
) : null}

View File

@ -18,11 +18,12 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
function coverSrc(category: string, token?: number, refresh?: boolean) {
function coverSrc(category: string, token?: number, refresh?: boolean, model?: string | null) {
const base = `/api/generated/cover?category=${encodeURIComponent(category)}`
const v = token ? `&v=${token}` : ''
const r = refresh ? `&refresh=1` : ''
return `${base}${v}${r}`
const m = model && model.trim() ? `&model=${encodeURIComponent(model.trim())}` : ''
return `${base}${v}${r}${m}`
}
function splitTags(raw?: string): string[] {
@ -68,10 +69,12 @@ function thumbUrlFromOutput(output: string): string | null {
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg`
}
async function ensureCover(category: string, thumbPath: string, refresh: boolean) {
async function ensureCover(category: string, thumbPath: string, modelName: string | null, refresh: boolean) {
const m = (modelName || '').trim()
const url =
`/api/generated/cover?category=${encodeURIComponent(category)}` +
`&src=${encodeURIComponent(thumbPath)}` +
(m ? `&model=${encodeURIComponent(m)}` : ``) +
(refresh ? `&refresh=1` : ``)
await fetch(url, { method: 'GET', cache: 'no-store' as any })
@ -83,12 +86,29 @@ type TagRow = {
downloadsCount: number
}
type CoverInfoListItem = {
category: string
model?: string
generatedAt?: string
hasCover: boolean
}
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())
const [coverState, setCoverState] = React.useState<Record<string, 'ok' | 'error'>>({})
const [renewing, setRenewing] = React.useState(false)
const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null)
// TagLower -> Modelname fürs Cover (UI-Overlay)
const [coverModelByTag, setCoverModelByTag] = React.useState<Record<string, string>>({})
const refreshAbortRef = React.useRef<AbortController | null>(null)
// TagLower -> outputs[]
const candidatesRef = React.useRef<Record<string, string[]>>({})
@ -152,21 +172,54 @@ export default function CategoriesTab() {
detail: { tags: [clean], mode: 'replace' },
})
)
}, [])
}, [])
const goToModelsWithTag = React.useCallback((tag: string) => {
const clean = String(tag || '').trim()
if (!clean) return
// ✅ Filter puffern (falls ModelsTab noch nicht gemountet ist)
try {
localStorage.setItem('models_pendingTags', JSON.stringify([clean]))
} catch {}
// ✅ Parent soll auf Models tab wechseln
window.dispatchEvent(
new CustomEvent('app:navigate-tab', {
detail: { tab: 'models' },
})
)
// ✅ wenn ModelsTab schon gemountet ist, kommt es sofort an
window.dispatchEvent(
new CustomEvent('models:set-tag-filter', {
detail: { tags: [clean] },
})
)
}, [])
const refresh = React.useCallback(async () => {
// laufenden refresh abbrechen
refreshAbortRef.current?.abort()
const ac = new AbortController()
refreshAbortRef.current = ac
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 }
)
// parallel laden
const [models, doneResp] = await Promise.all([
apiJSON<StoredModel[]>('/api/models/list', {
cache: 'no-store' as any,
signal: ac.signal as any,
}),
apiJSON<any>(`/api/record/done?page=1&pageSize=2000&sort=completed_desc`, {
cache: 'no-store' as any,
signal: ac.signal as any,
}),
])
const doneJobs: RecordJob[] = Array.isArray(doneResp?.items)
? (doneResp.items as RecordJob[])
@ -176,7 +229,7 @@ export default function CategoriesTab() {
buildCandidates(Array.isArray(models) ? models : [], doneJobs)
// modelsCount pro Tag (über models)
// modelsCount pro Tag
const modelCountByTag = new Map<string, number>()
for (const m of Array.isArray(models) ? models : []) {
for (const t of splitTags((m as any)?.tags)) {
@ -195,55 +248,104 @@ export default function CategoriesTab() {
}))
.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(() => {})
let coverInfoByTag = new Map<string, CoverInfoListItem>()
try {
const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', {
cache: 'no-store' as any,
signal: ac.signal as any,
})
for (const i of Array.isArray(infos) ? infos : []) {
const k = String(i?.category ?? '').trim().toLowerCase()
if (k) coverInfoByTag.set(k, i)
}
} catch {
// optional: still weiterlaufen ohne Fallback
}
// stabiles Model pro Tag (für &model= in <img>)
const coverModelByTag: Record<string, string> = {}
for (const r of outRows) {
// 1) bevorzugt aus candidates (doneJobs)
const list = candMap[r.tag] || []
const pick = list[0] || ''
let model = pick ? modelKeyFromFilename(pick) : null
// 2) Fallback aus coverinfo/list (aber nur wenn wirklich cover existiert!)
if (!model) {
const info = coverInfoByTag.get(r.tag)
if (info?.hasCover && info.model?.trim()) {
model = info.model.trim()
}
}
if (model) coverModelByTag[r.tag] = model
}
setCoverModelByTag(coverModelByTag)
setRows(outRows)
// nur cache-bust der IMG URLs (wenn du willst)
setCoverBust(Date.now())
} catch (e: any) {
if (e?.name === 'AbortError') return
setErr(e?.message ?? String(e))
setRows([])
candidatesRef.current = {}
setCoverModelByTag({})
} finally {
setLoading(false)
// nur "aus" schalten, wenn dieser refresh noch der aktuelle ist
if (refreshAbortRef.current === ac) {
refreshAbortRef.current = null
setLoading(false)
}
}
}, [buildCandidates])
React.useEffect(() => {
void refresh()
return () => {
refreshAbortRef.current?.abort()
}
}, [refresh])
const renewCovers = React.useCallback(async () => {
if (renewing) return
setRenewing(true)
setErr(null)
setRenewProgress({ done: 0, total: rows.length })
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 {
const list = candMap[r.tag] || []
const pick = list.length ? list[Math.floor(Math.random() * list.length)] : ''
const thumb = pick ? thumbUrlFromOutput(pick) : null
if (thumb) {
await ensureCover(r.tag, thumb, true)
const model = pick ? modelKeyFromFilename(pick) : null
await ensureCover(r.tag, thumb, model, true)
// ✅ Overlay-Model = wirklich genutztes Model
setCoverModelByTag((prev) => {
const next = { ...prev }
if (model?.trim()) next[r.tag] = model.trim()
else delete next[r.tag]
return next
})
return { tag: r.tag, ok: true, status: 200, text: '' }
}
const res = await fetch(coverSrc(r.tag, Date.now(), true), {
const model = coverModelByTag[r.tag] ?? ''
const res = await fetch(coverSrc(r.tag, Date.now(), true, model), {
method: 'GET',
cache: 'no-store',
})
@ -252,6 +354,8 @@ export default function CategoriesTab() {
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) }
} finally {
setRenewProgress((p) => (p ? { ...p, done: Math.min(p.total, p.done + 1) } : p))
}
})
)
@ -266,8 +370,11 @@ export default function CategoriesTab() {
}
} finally {
setCoverBust(Date.now())
setRenewing(false)
// optional: kurz stehen lassen, dann ausblenden
setTimeout(() => setRenewProgress(null), 400)
}
}, [rows])
}, [rows, renewing])
return (
<Card
@ -278,10 +385,30 @@ export default function CategoriesTab() {
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" size="md" onClick={renewCovers} disabled={loading}>
{renewProgress ? (
<div className="hidden sm:flex items-center gap-2 mr-2">
<div className="text-xs text-gray-600 dark:text-gray-300 tabular-nums">
Covers: {renewProgress.done}/{renewProgress.total}
</div>
<div className="h-2 w-28 rounded-full bg-gray-200 dark:bg-white/10 overflow-hidden">
<div
className="h-full bg-indigo-500"
style={{
width:
renewProgress.total > 0
? `${Math.round((renewProgress.done / renewProgress.total) * 100)}%`
: '0%',
}}
/>
</div>
</div>
) : null}
<Button variant="secondary" size="md" onClick={renewCovers} disabled={loading || renewing}>
Cover erneuern
</Button>
<Button variant="secondary" size="md" onClick={refresh} disabled={loading}>
<Button variant="secondary" size="md" onClick={refresh} disabled={loading || renewing}>
Aktualisieren
</Button>
</div>
@ -297,7 +424,10 @@ export default function CategoriesTab() {
<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)
const model = coverModelByTag[r.tag] ?? null
const img = coverSrc(r.tag, coverBust, false)
const isOk = coverState[r.tag] === 'ok'
const isErr = coverState[r.tag] === 'error'
return (
<button
@ -305,33 +435,170 @@ export default function CategoriesTab() {
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'
'group text-left rounded-2xl overflow-hidden transition',
// surface
'bg-white/70 dark:bg-white/[0.06]',
// border + shadow statt nur ring
'border border-gray-200/70 dark:border-white/10',
'shadow-sm hover:shadow-md',
// hover lift
'hover:-translate-y-[1px]',
// nicer hover background
'hover:bg-white dark:hover:bg-white/[0.09]',
// focus
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-950'
)}
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 className="relative w-full overflow-hidden aspect-[16/9] bg-gray-100/70 dark:bg-white/5">
{/* Wenn Fehler: hübscher Placeholder statt broken image */}
{isErr ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<div className="absolute inset-0 opacity-70"
style={{
background:
'radial-gradient(circle at 30% 20%, rgba(99,102,241,0.25), transparent 55%),' +
'radial-gradient(circle at 70% 80%, rgba(14,165,233,0.18), transparent 55%)',
}}
/>
<div className="relative z-10 rounded-xl bg-white/80 dark:bg-black/30 px-3 py-2.5 shadow-sm ring-1 ring-black/5 dark:ring-white/10 backdrop-blur-md">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Cover nicht verfügbar
</div>
{model ? (
<div className="mt-0.5 text-[11px] text-gray-700 dark:text-gray-300">
Model: <span className="font-semibold">{model}</span>
</div>
) : null}
<div className="mt-1 flex justify-center">
<span
className="text-[11px] inline-flex items-center rounded-full bg-indigo-50 px-2 py-1 font-semibold text-indigo-700 ring-1 ring-indigo-100 hover:bg-indigo-100
dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/20 dark:hover:bg-indigo-500/20"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
// retry: alle IMG-URLs neu laden (einfach)
setCoverState((s) => {
const n = { ...s }
delete n[r.tag]
return n
})
setCoverBust(Date.now())
}}
>
Retry
</span>
</div>
</div>
</div>
) : (
<>
{/* subtle top sheen + bottom gradient for readability */}
<div
aria-hidden="true"
className="absolute inset-0 z-[1] pointer-events-none"
style={{
background:
'linear-gradient(to bottom, rgba(255,255,255,0.10), rgba(255,255,255,0) 35%),' +
'linear-gradient(to top, rgba(0,0,0,0.35), rgba(0,0,0,0) 45%)',
}}
/>
{/* blurred fill */}
<img
src={img}
alt=""
aria-hidden="true"
className="absolute inset-0 z-0 h-full w-full object-cover blur-xl scale-110 opacity-60"
loading="lazy"
/>
{/* sharp image (entscheidend für ok/error) */}
<img
src={img}
alt={r.tag}
className="absolute inset-0 z-0 h-full w-full object-contain"
loading="lazy"
onLoad={() => setCoverState((s) => ({ ...s, [r.tag]: 'ok' }))}
onError={() => setCoverState((s) => ({ ...s, [r.tag]: 'error' }))}
/>
{/* Model-Overlay: nur wenn Bild wirklich OK */}
{isOk && model ? (
<div className="absolute left-3 bottom-3 z-10 max-w-[calc(100%-24px)]">
<div className={clsx(
'truncate rounded-full px-2.5 py-1 text-[11px] font-semibold',
'bg-black/40 text-white backdrop-blur-md',
'ring-1 ring-white/15'
)}>
{model}
</div>
</div>
) : null}
</>
)}
</div>
<div className="px-3 py-3">
<div className="flex items-start justify-between gap-2">
<div className="px-4 py-3.5">
<div className="flex items-start justify-between gap-3">
<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 className="font-semibold text-gray-900 dark:text-white truncate tracking-tight">
{r.tag}
</div>
{/* ✅ schönere, klare Action-Pills */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<span
role="button"
tabIndex={0}
className={clsx(
'inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold',
'tabular-nums whitespace-nowrap', // <-- verhindert Umbruch im Chip
'overflow-hidden', // <-- nötig für truncate
'bg-indigo-50 text-indigo-700 ring-1 ring-indigo-100',
'dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/20',
'cursor-pointer'
)}
title="FinishedDownloads nach diesem Tag filtern"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); goToFinishedDownloadsWithTag(r.tag) }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); goToFinishedDownloadsWithTag(r.tag) }
}}
>
<span className="shrink-0">{r.downloadsCount}</span>
<span className="min-w-0 truncate font-medium">Downloads</span>
</span>
<span
role="button"
tabIndex={0}
className={clsx(
'inline-flex max-w-full items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold',
'tabular-nums whitespace-nowrap overflow-hidden',
'bg-gray-50 text-gray-700 ring-1 ring-gray-200',
'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10',
'cursor-pointer'
)}
title="Models nach diesem Tag filtern"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); goToModelsWithTag(r.tag) }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); goToModelsWithTag(r.tag) }
}}
>
<span className="shrink-0">{r.modelsCount}</span>
<span className="min-w-0 truncate font-medium">Models</span>
</span>
</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}
{/* ✅ statt doppeltem Count-Badge: nur Chevron als Hinweis */}
<span
aria-hidden="true"
className={clsx(
'shrink-0 mt-0.5 text-gray-400 dark:text-gray-500',
'transition-transform group-hover:translate-x-[1px]'
)}
>
</span>
</div>
</div>

View File

@ -36,6 +36,7 @@ type Props = {
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void>
}
type DownloadRow =
@ -117,6 +118,8 @@ const phaseLabel = (p?: string) => {
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau…'
case 'postwork':
return 'Nacharbeiten werden vorbereitet…'
default:
return ''
}
@ -131,11 +134,48 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
return res.json() as Promise<T>
}
function postWorkLabel(job: RecordJob): string {
const pw = job.postWork
if (!pw) return 'Warte auf Nacharbeiten…'
if (pw.state === 'running') {
const running = typeof pw.running === 'number' ? pw.running : 0
const maxP = typeof pw.maxParallel === 'number' ? pw.maxParallel : 0
return maxP > 0
? `Nacharbeiten laufen… (${running}/${maxP} parallel)`
: 'Nacharbeiten laufen…'
}
if (pw.state === 'queued') {
const pos = typeof pw.position === 'number' ? pw.position : 0
const waiting = typeof pw.waiting === 'number' ? pw.waiting : 0
const running = typeof (pw as any).running === 'number' ? (pw as any).running : 0
// X = grobe Gesamtmenge (wartend + gerade laufend)
const total = Math.max(waiting + running, pos)
// Wunschformat: "64 / X"
return pos > 0 && total > 0
? `Warte auf Nacharbeiten… ${pos} / ${total}`
: 'Warte auf Nacharbeiten…'
}
return 'Warte auf Nacharbeiten…'
}
function StatusCell({ job }: { job: RecordJob }) {
const phaseRaw = String((job as any)?.phase ?? '').trim()
const progress = Number((job as any)?.progress ?? 0)
const phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
// ✅ postwork genauer machen (wartend/running + Position)
if (phaseRaw === 'postwork') {
phaseText = postWorkLabel(job)
}
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
// ✅ ProgressBar unabhängig vom Text
@ -291,19 +331,20 @@ function DownloadsCardRow({
const j = r.job
const name = modelNameFromOutput(j.output)
const file = baseName(j.output || '')
const phase = String((j as any).phase ?? '').trim()
const phaseText = phase ? (phaseLabel(phase) || phase) : ''
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
const rawStatus = String(j.status ?? '').toLowerCase()
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
// ✅ Badge hinter Modelname: IMMER Backend-Status
const statusText = rawStatus || 'unknown'
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
if (phase === 'postwork') {
phaseText = postWorkLabel(j)
}
// ✅ Progressbar Label: Phase (gemappt), fallback auf Status
const statusText = rawStatus || 'unknown'
const progressLabel = phaseText || statusText
const progress = Number((j as any).progress ?? 0)
@ -450,11 +491,6 @@ function DownloadsCardRow({
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
showHot={false}
showKeep={false}
showDelete={false}
showFavorite
showLike
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
@ -542,7 +578,18 @@ const formatBytes = (bytes: number | null): string => {
}
export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, modelsByKey = {}, blurPreviews }: Props) {
export default function Downloads({
jobs,
pending = [],
onOpenPlayer,
onStopJob,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onAddToDownloads,
modelsByKey = {},
blurPreviews
}: Props) {
const [stopAllBusy, setStopAllBusy] = useState(false)
@ -927,14 +974,10 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
showHot={false}
showKeep={false}
showDelete={false}
showFavorite
showLike
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
onAddToDownloads={onAddToDownloads}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>

View File

@ -26,7 +26,6 @@ import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import Button from './Button'
import { useNotify } from './notify'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
import LabeledSwitch from './LabeledSwitch'
import Switch from './Switch'
@ -34,8 +33,6 @@ import Switch from './Switch'
type SortMode =
| 'completed_desc'
| 'completed_asc'
| 'model_asc'
| 'model_desc'
| 'file_asc'
| 'file_desc'
| 'duration_desc'
@ -763,11 +760,17 @@ export default function FinishedDownloads({
if (typeof undoToken === 'string' && undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file })
} else {
// ohne Token kein Restore möglich -> nicht so tun als gäbe es Undo
setLastAction(null)
notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
// optional: nicht als "error" melden, eher info/warn
// notify.error('Undo nicht möglich', 'Delete-Handler liefert kein undoToken zurück.')
}
// ✅ OPTIMISTIK + Pagination refill + count hint
animateRemove(key)
window.dispatchEvent(new CustomEvent('finished-downloads:count-hint', { detail: { delta: -1 } }))
// animateRemove queued already queueRefill(), aber extra ist ok:
// queueRefill()
return true
}
@ -879,6 +882,11 @@ export default function FinishedDownloads({
next[oldFile] = newFile
return next
})
// ✅ Inline/Teaser Keys mitziehen, damit Playback nicht “verloren” geht
setInlinePlay((prev) => (prev?.key === oldFile ? { ...prev, key: newFile } : prev))
setTeaserKey((prev) => (prev === oldFile ? newFile : prev))
setHoverTeaserKey((prev) => (prev === oldFile ? newFile : prev))
// 2) durations-Key mitziehen + Ref/State synchron halten
const cur = durationsRef.current || {}
@ -899,68 +907,80 @@ export default function FinishedDownloads({
const toggleHotVideo = useCallback(
async (job: RecordJob) => {
const file = baseName(job.output || '')
if (!file) {
const currentFile = baseName(job.output || '')
if (!currentFile) {
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden kann nicht HOT togglen.')
return
}
// ✅ HOT-Name berechnen (genau "HOT " Prefix)
// genau "HOT " Prefix
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
// ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen
const applyOptimisticRename = (oldFile: string, newFile: string) => {
applyRename(oldFile, newFile)
}
// ✅ falls Backend andere Namen liefert: Server-Truth nachziehen
const applyServerTruth = (apiOld: string, apiNew: string, optimisticNew: string) => {
if (!apiNew) return
if (apiNew === optimisticNew && apiOld === file) return
// Server-Truth anwenden (inkl. duration-key move via applyRename)
const applyServerTruth = (apiOld: string, apiNew: string) => {
if (!apiOld || !apiNew || apiOld === apiNew) return
applyRename(apiOld, apiNew)
}
const oldFile = currentFile
const optimisticNew = toggledName(oldFile)
// Optimistik sofort anwenden (UI snappy)
applyRename(oldFile, optimisticNew)
try {
await releasePlayingFile(file, { close: true })
await releasePlayingFile(oldFile, { close: true })
const oldFile = file
const optimisticNew = toggledName(oldFile)
// ✅ Wichtig: Optimistik IMMER anwenden
applyOptimisticRename(oldFile, optimisticNew)
// ✅ Undo: HOT ist reversibel => wir merken den aktuellen Dateinamen nach der Aktion
setLastAction({ kind: 'hot', currentFile: optimisticNew })
// ✅ Externer Handler (App) danach Liste auffrischen
// ✅ 1) Wenn du einen externen Handler hast:
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
if (onToggleHot) {
await onToggleHot(job)
const r = (await onToggleHot(job)) as any
// Wenn Handler Server-Truth liefert, übernehmen, sonst Optimistik behalten
const apiOld = typeof r?.oldFile === 'string' ? r.oldFile : ''
const apiNew = typeof r?.newFile === 'string' ? r.newFile : ''
if (apiOld && apiNew) applyServerTruth(apiOld, apiNew)
// ✅ Undo erst jetzt setzen (nach Erfolg)
setLastAction({ kind: 'hot', currentFile: apiNew || optimisticNew })
queueRefill()
return
}
// Fallback: Backend direkt
const res = await fetch(`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' })
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
const res = await fetch(
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
{ method: 'POST' }
)
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
const data = (await res.json().catch(() => null)) as any
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew
applyServerTruth(apiOld, apiNew, optimisticNew)
// Server-Truth über Optimistik drüberziehen (falls der Server anders entschieden hat)
if (apiOld !== apiNew) applyServerTruth(apiOld, apiNew)
// ✅ Undo-Dateiname ggf. auf Server-Truth setzen
if (apiNew) setLastAction({ kind: 'hot', currentFile: apiNew })
// ✅ Undo nach Erfolg
setLastAction({ kind: 'hot', currentFile: apiNew })
queueRefill()
} catch (e: any) {
// ❌ Rollback, weil Optimistik schon angewendet wurde
applyRename(optimisticNew, oldFile)
// und Undo-Action löschen (sonst zeigt Undo auf etwas, das nie passiert ist)
setLastAction(null)
notify.error('HOT umbenennen fehlgeschlagen', String(e?.message || e))
}
},
[ releasePlayingFile, onToggleHot, notify, queueRefill, applyRename, setLastAction ]
[baseName, notify, applyRename, releasePlayingFile, onToggleHot, queueRefill, setLastAction]
)
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
@ -1125,6 +1145,12 @@ export default function FinishedDownloads({
return visibleRows.slice(Math.max(0, start), Math.max(0, end))
}, [globalFilterActive, visibleRows, page, pageSize])
// ✅ "Ordner wirklich leer" -> serverseitiger Count ist am zuverlässigsten
const emptyFolder = !globalFilterActive && totalItemsForPagination === 0
// ✅ "Filter liefert keine Treffer"
const emptyByFilter = globalFilterActive && visibleRows.length === 0
useEffect(() => {
if (!globalFilterActive) return
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
@ -1264,30 +1290,21 @@ export default function FinishedDownloads({
if (canHover) setHoverTeaserKey(null)
}}
>
<LazyMount
// wenn User es gerade “braucht”, sofort mounten:
force={teaserKey === k || hoverTeaserKey === k}
rootMargin="400px"
placeholder={
<div className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10 bg-black/5 dark:bg-white/5 animate-pulse" />
}
>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</LazyMount>
<FinishedVideoPreview
job={j}
getFileName={baseName}
durationSeconds={durations[k]}
muted={previewMuted}
popoverMuted={previewMuted}
onDuration={handleDuration}
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
showPopover={false}
blur={blurPreviews}
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
animatedMode="teaser"
animatedTrigger="always"
assetNonce={assetNonce}
/>
</div>
)
},
@ -1484,7 +1501,7 @@ export default function FinishedDownloads({
onToggleHot={toggleHotVideo}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
order={['watch', 'favorite', 'like', 'hot', 'details', 'add', 'keep', 'delete']}
className="flex items-center justify-end gap-1"
/>
)
@ -1502,6 +1519,7 @@ export default function FinishedDownloads({
if (key === 'completedAt') return asc ? 'completed_asc' : 'completed_desc'
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
if (key === 'size') return asc ? 'size_asc' : 'size_desc'
if (key === 'Model') return asc ? 'file_asc' : 'file_desc'
if (key === 'video') return asc ? 'file_asc' : 'file_desc'
// fallback
@ -1525,8 +1543,67 @@ export default function FinishedDownloads({
}
}, [isSmall])
const emptyFolder = rows.length === 0 && doneTotalPage === 0
const emptyByFilter = !emptyFolder && visibleRows.length === 0
useEffect(() => {
let es: EventSource | null = null
let closed = false
let reconnectTimer: number | null = null
let refillDebounce: number | null = null
const scheduleRefill = () => {
if (refillDebounce != null) return
refillDebounce = window.setTimeout(() => {
refillDebounce = null
// optional: wenn du gerade "leer" bist, auf Seite 1 springen
// (sonst könntest du auf Seite >1 hängen, während wieder Inhalte kommen)
if (emptyFolder && page !== 1) onPageChange(1)
queueRefill()
}, 80)
}
const connect = () => {
if (closed) return
try {
// ✅ NEU: Done-Change Stream (nicht Job-Snapshot Stream)
es = new EventSource('/api/record/done/stream')
es.onmessage = () => {
// Payload ist egal jede Message heißt: done-Liste hat sich geändert
scheduleRefill()
}
// optional: wenn du serverseitig "event: doneChanged" sendest:
// es.addEventListener('doneChanged', scheduleRefill as any)
es.onerror = () => {
try { es?.close() } catch {}
es = null
if (closed) return
// simple reconnect (du kannst hier auch exponentiell machen)
if (reconnectTimer != null) window.clearTimeout(reconnectTimer)
reconnectTimer = window.setTimeout(connect, 1000)
}
} catch {
if (reconnectTimer != null) window.clearTimeout(reconnectTimer)
reconnectTimer = window.setTimeout(connect, 1000)
}
}
connect()
return () => {
closed = true
if (reconnectTimer != null) window.clearTimeout(reconnectTimer)
if (refillDebounce != null) window.clearTimeout(refillDebounce)
try { es?.close() } catch {}
}
}, [queueRefill, emptyFolder, page, onPageChange])
useEffect(() => {
if (emptyFolder && page !== 1) onPageChange(1)
}, [emptyFolder, page, onPageChange])
return (
<>
@ -1616,10 +1693,8 @@ export default function FinishedDownloads({
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="model_asc">Modelname AZ</option>
<option value="model_desc">Modelname ZA</option>
<option value="file_asc">Dateiname AZ</option>
<option value="file_desc">Dateiname ZA</option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>
@ -1780,10 +1855,8 @@ export default function FinishedDownloads({
>
<option value="completed_desc">Fertiggestellt am </option>
<option value="completed_asc">Fertiggestellt am </option>
<option value="model_asc">Modelname AZ</option>
<option value="model_desc">Modelname ZA</option>
<option value="file_asc">Dateiname AZ</option>
<option value="file_desc">Dateiname ZA</option>
<option value="file_asc">Modelname AZ</option>
<option value="file_desc">Modelname ZA</option>
<option value="duration_desc">Dauer </option>
<option value="duration_asc">Dauer </option>
<option value="size_desc">Größe </option>

View File

@ -14,7 +14,6 @@ import {
} from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
function cn(...parts: Array<string | false | null | undefined>) {
@ -179,8 +178,6 @@ export default function FinishedDownloadsCardsView({
if (!exists) setOpenTagsKey(null)
}, [rows, keyFor, openTagsKey])
const mobileRootMargin = isSmall ? '180px' : '500px'
return (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{rows.map((j) => {
@ -226,6 +223,8 @@ export default function FinishedDownloadsCardsView({
tabIndex={0}
className={[
'group',
'content-visibility-auto',
'[contain-intrinsic-size:180px_120px]',
motionCls,
'rounded-xl',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
@ -235,9 +234,7 @@ export default function FinishedDownloadsCardsView({
keepingKeys.has(k) &&
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
]
.filter(Boolean)
.join(' ')}
].filter(Boolean).join(' ')}
onClick={isSmall ? undefined : () => openPlayer(j)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
@ -258,15 +255,10 @@ export default function FinishedDownloadsCardsView({
startInline(k)
}}
>
<LazyMount
force={inlineActive}
rootMargin={mobileRootMargin}
placeholder={<div className="w-full h-full bg-black/5 dark:bg-white/5 animate-pulse" />}
className="absolute inset-0"
>
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
getFileName={baseName}
className="w-full h-full"
showPopover={false}
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
@ -281,7 +273,7 @@ export default function FinishedDownloadsCardsView({
popoverMuted={previewMuted}
assetNonce={assetNonce ?? 0}
/>
</LazyMount>
</div>
{/* Gradient overlay bottom */}
<div
@ -358,11 +350,9 @@ export default function FinishedDownloadsCardsView({
}
: undefined
}
showKeep={!isSmall}
showDelete={!isSmall}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'details', 'keep', 'delete']}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="flex items-center gap-2"
/>
</div>
@ -495,17 +485,41 @@ export default function FinishedDownloadsCardsView({
enabled
disabled={busy}
ignoreFromBottomPx={110}
doubleTapMs={360}
doubleTapMaxMovePx={48}
onTap={() => {
const domId = `inline-prev-${encodeURIComponent(k)}`
startInline(k)
// ✅ nach dem State-Update dem DOM 12 Frames geben
requestAnimationFrame(() => {
if (!tryAutoplayInline(domId)) {
requestAnimationFrame(() => tryAutoplayInline(domId))
}
})
}}
onDoubleTap={
onToggleHot
? async () => {
if (isHot) return false
try {
const file = baseName(j.output || '')
if (file) {
// ✅ NICHT schließen, wenn dieser Teaser gerade inline spielt
const isThisInline = inlinePlay?.key === k
if (!isThisInline) {
await releasePlayingFile(file, { close: true })
await new Promise((r) => setTimeout(r, 150))
}
}
await onToggleHot(j)
return true
} catch {
return false
}
}
: undefined
}
onSwipeLeft={() => deleteVideo(j)}
onSwipeRight={() => keepVideo(j)}
>

View File

@ -1,3 +1,5 @@
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
'use client'
import * as React from 'react'
@ -10,7 +12,6 @@ import {
} from '@heroicons/react/24/solid'
import TagBadge from './TagBadge'
import RecordJobActions from './RecordJobActions'
import LazyMount from './LazyMount'
import { isHotName, stripHotPrefix } from './hotName'
@ -225,12 +226,7 @@ export default function FinishedDownloadsGalleryView({
>
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
<LazyMount
force={teaserKey === k || hoverTeaserKey === k}
rootMargin="500px"
placeholder={<div className="absolute inset-0 bg-black/5 dark:bg-white/5 animate-pulse" />}
className="absolute inset-0"
>
<div className="absolute inset-0">
<FinishedVideoPreview
job={j}
getFileName={(p) => stripHotPrefix(baseName(p))}
@ -247,7 +243,7 @@ export default function FinishedDownloadsGalleryView({
muted={previewMuted}
popoverMuted={previewMuted}
/>
</LazyMount>
</div>
{/* Gradient overlay bottom */}
<div
@ -315,7 +311,7 @@ export default function FinishedDownloadsGalleryView({
onToggleHot={onToggleHot}
onKeep={keepVideo}
onDelete={deleteVideo}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']}
order={['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details', 'add']}
className="w-full justify-end gap-1"
/>
</div>

View File

@ -0,0 +1,245 @@
// frontend/src/components/ui/LoginPage.tsx
import { useEffect, useMemo, useState } from 'react'
import Button from './Button'
type Props = {
onLoggedIn?: () => void | Promise<void>
}
type LoginResp = {
ok?: boolean
totpRequired?: boolean
}
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 getNextFromLocation(): string {
try {
const u = new URL(window.location.href)
const next = u.searchParams.get('next') || '/'
// Sicherheitsgurt: nur relative Pfade zulassen
if (!next.startsWith('/')) return '/'
if (next.startsWith('/login')) return '/'
return next
} catch {
return '/'
}
}
export default function LoginPage({ onLoggedIn }: Props) {
const nextPath = useMemo(() => getNextFromLocation(), [])
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [need2FA, setNeed2FA] = useState(false)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const me = await apiJSON<{ authenticated?: boolean; pending2fa?: boolean }>('/api/auth/me', {
cache: 'no-store' as any,
})
if (cancelled) return
if (me?.authenticated) {
window.location.assign(nextPath || '/')
} else if (me?.pending2fa) {
setNeed2FA(true)
}
} catch {
// ignore
}
}
void check()
return () => {
cancelled = true
}
}, [nextPath])
const submitLogin = async () => {
setBusy(true)
setError(null)
try {
const data = await apiJSON<LoginResp>('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (data?.totpRequired) {
setNeed2FA(true)
return
}
// eingeloggt
if (onLoggedIn) await onLoggedIn()
window.location.assign(nextPath || '/')
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setBusy(false)
}
}
const submit2FA = async () => {
setBusy(true)
setError(null)
try {
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
if (onLoggedIn) await onLoggedIn()
window.location.assign(nextPath || '/')
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setBusy(false)
}
}
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key !== 'Enter') return
ev.preventDefault()
if (busy) return
if (need2FA) void submit2FA()
else void submitLogin()
}
return (
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-28 left-1/2 h-80 w-[52rem] -translate-x-1/2 rounded-full bg-indigo-500/10 blur-3xl dark:bg-indigo-400/10" />
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
</div>
<div className="relative grid min-h-[100dvh] place-items-center px-4">
<div className="w-full max-w-md rounded-2xl border border-gray-200/70 bg-white/80 p-6 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5">
<div className="space-y-1">
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
<p className="text-sm text-gray-600 dark:text-gray-300">
Bitte melde dich an, um fortzufahren.
</p>
</div>
<div className="mt-5 space-y-3">
{!need2FA ? (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
autoComplete="username"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="admin"
disabled={busy}
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
autoComplete="current-password"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="••••••••••"
disabled={busy}
/>
</div>
<Button
variant="primary"
className="w-full rounded-lg"
disabled={busy || !username.trim() || !password}
onClick={() => void submitLogin()}
>
{busy ? 'Login…' : 'Login'}
</Button>
</>
) : (
<>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist aktiv bitte gib den Code aus deiner Authenticator-App ein.
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
inputMode="numeric"
pattern="[0-9]*"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
setNeed2FA(false)
setCode('')
setError(null)
}}
>
Zurück
</Button>
<Button
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6}
onClick={() => void submit2FA()}
>
{busy ? 'Prüfe…' : 'Bestätigen'}
</Button>
</div>
</>
)}
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 break-words">{error}</div>
<button
type="button"
className="shrink-0 rounded px-2 py-1 text-xs font-medium text-red-700 hover:bg-red-100 dark:text-red-200 dark:hover:bg-white/10"
onClick={() => setError(null)}
aria-label="Fehlermeldung schließen"
title="Schließen"
>
</button>
</div>
</div>
) : null}
</div>
</div>
</div>
</div>
)
}

View File

@ -220,10 +220,7 @@ export default function ModelsTab() {
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 [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>()
const refreshVideoCounts = React.useCallback(async () => {
setVideoCountsLoading(true)
@ -281,6 +278,20 @@ export default function ModelsTab() {
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
}, [])
React.useEffect(() => {
try {
const raw = localStorage.getItem('models_pendingTags')
if (!raw) return
const tags = JSON.parse(raw)
if (Array.isArray(tags)) setTagFilter(tags)
localStorage.removeItem('models_pendingTags')
} catch {
// ignore
}
}, [])
const [input, setInput] = React.useState('')
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
const [parseError, setParseError] = React.useState<string | null>(null)
@ -320,11 +331,15 @@ export default function ModelsTab() {
}
}
function jobForDetails(modelKey: string): RecordJob {
// RecordJobActions braucht nur `output`, um modelKeyFromOutput() zu finden.
// Wir geben ein Output, das dem Dateinamen-Schema entspricht: <modelKey>_MM_DD_YYYY__HH-MM-SS.ext
function jobForDetails(m: StoredModel): RecordJob {
const href = modelHref(m) ?? undefined
return {
output: `${modelKey}_01_01_2000__00-00-00.mp4`,
// für "details" (modelKeyFromOutput)
output: `${m.modelKey}_01_01_2000__00-00-00.mp4`,
// ✅ für deinen neuen "add" Button
sourceUrl: href,
} as any
}
@ -614,18 +629,17 @@ export default function ModelsTab() {
)
},
},
{
key: 'actions',
header: '',
align: 'right',
cell: (m) => (
<div className="flex justify-end w-[56px]">
<div className="flex justify-end w-[92px]">
<RecordJobActions
job={jobForDetails(m.modelKey)}
job={jobForDetails(m)}
variant="table"
order={['details']}
className="flex items-center"
order={['add', 'details']}
className="flex items-center gap-2"
/>
</div>
),

View File

@ -1244,9 +1244,6 @@ export default function Player({
isLiked={isLiked}
isWatching={isWatching}
onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
showHot={false}
showKeep={false}
showDelete={false}
onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
order={['watch', 'favorite', 'like', 'details']}

View File

@ -13,20 +13,21 @@ import {
StarIcon as StarOutlineIcon,
HeartIcon as HeartOutlineIcon,
EyeIcon as EyeOutlineIcon,
ArrowDownTrayIcon,
} from '@heroicons/react/24/outline'
import {
FireIcon as FireSolidIcon,
StarIcon as StarSolidIcon,
HeartIcon as HeartSolidIcon,
EyeIcon as EyeSolidIcon,
CheckIcon,
} from '@heroicons/react/24/solid'
import { createPortal } from 'react-dom'
type Variant = 'overlay' | 'table'
type ActionKey = 'details' | 'hot' | 'favorite' | 'like' | 'watch' | 'keep' | 'delete'
type ActionKey = 'details' | 'add' | 'hot' | 'favorite' | 'like' | 'watch' | 'keep' | 'delete'
type ActionResult = void | boolean
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
@ -45,21 +46,13 @@ type Props = {
isLiked?: boolean
isWatching?: boolean
// Buttons gezielt ausblendbar (z.B. Cards auf mobile)
showFavorite?: boolean
showLike?: boolean
showHot?: boolean
showKeep?: boolean
showDelete?: boolean
showWatch?: boolean
showDetails?: boolean
onToggleFavorite?: ActionFn
onToggleLike?: ActionFn
onToggleHot?: ActionFn
onKeep?: ActionFn
onDelete?: ActionFn
onToggleWatch?: ActionFn
onAddToDownloads?: ActionFn
order?: ActionKey[]
@ -102,19 +95,13 @@ export default function RecordJobActions({
isFavorite = false,
isLiked = false,
isWatching = false,
showFavorite,
showLike,
showHot,
showKeep,
showDelete,
showWatch,
showDetails,
onToggleFavorite,
onToggleLike,
onToggleHot,
onKeep,
onDelete,
onToggleWatch,
onAddToDownloads,
order,
className,
}: Props) {
@ -155,16 +142,76 @@ export default function RecordJobActions({
watchOn: 'text-sky-600 dark:text-sky-200',
}
const wantFavorite = showFavorite ?? Boolean(onToggleFavorite)
const wantLike = showLike ?? Boolean(onToggleLike)
const wantHot = showHot ?? Boolean(onToggleHot)
const wantKeep = showKeep ?? Boolean(onKeep)
const wantDelete = showDelete ?? Boolean(onDelete)
const wantWatch = showWatch ?? Boolean(onToggleWatch)
const wantDetails = showDetails ?? true
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']
const inOrder = (k: ActionKey) => actionOrder.includes(k)
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
const detailsKey = modelKeyFromOutput(job.output || '')
const detailsLabel = detailsKey ? `Mehr zu ${detailsKey} anzeigen` : 'Mehr anzeigen'
// Sichtbarkeit NUR über `order`
const wantFavorite = inOrder('favorite')
const wantLike = inOrder('like')
const wantHot = inOrder('hot')
const wantWatch = inOrder('watch')
const wantKeep = inOrder('keep')
const wantDelete = inOrder('delete')
// Details: wenn du ihn auch ohne detailsKey zeigen willst, nimm nur inOrder('details').
// (Aktuell macht Details ohne detailsKey wenig Sinn, daher: nur anzeigen wenn key existiert.)
const wantDetails = inOrder('details') && Boolean(detailsKey)
// Add: nur über order steuern (disabled wenn keine URL/Handler)
const wantAdd = inOrder('add')
const [addState, setAddState] = React.useState<'idle' | 'busy' | 'ok'>('idle')
const addTimerRef = React.useRef<number | null>(null)
React.useEffect(() => {
return () => {
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
}
}, [])
const flashAddOk = () => {
setAddState('ok')
if (addTimerRef.current) window.clearTimeout(addTimerRef.current)
addTimerRef.current = window.setTimeout(() => setAddState('idle'), 800)
}
const doAdd = async (): Promise<boolean> => {
if (busy) return false
if (addState === 'busy') return false
const hasUrl = Boolean(addUrl)
if (!onAddToDownloads && !hasUrl) return false
setAddState('busy')
try {
let ok = true
if (onAddToDownloads) {
const r = await onAddToDownloads(job)
ok = r !== false
} else if (addUrl) {
window.dispatchEvent(new CustomEvent('downloads:add-url', { detail: { url: addUrl } }))
ok = true
} else {
ok = false
}
if (ok) flashAddOk()
else setAddState('idle')
return ok
} catch {
setAddState('idle')
return false
}
}
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
const [rootW, setRootW] = React.useState(0)
const [gapPx, setGapPx] = React.useState(4)
@ -177,7 +224,7 @@ export default function RecordJobActions({
}
const DetailsBtn =
wantDetails && detailsKey ? (
wantDetails ? (
<button
type="button"
className={cn(btnBase)}
@ -198,6 +245,30 @@ export default function RecordJobActions({
</button>
) : null
const AddBtn = wantAdd ? (
<button
type="button"
className={cn(btnBase)}
title={addUrl ? 'URL zu Downloads hinzufügen' : 'Keine URL vorhanden'}
aria-label="Zu Downloads hinzufügen"
disabled={busy || addState === 'busy' || (!onAddToDownloads && !addUrl)}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
await doAdd()
}}
>
<span className={cn('inline-flex items-center justify-center', iconBox)}>
{addState === 'ok' ? (
<CheckIcon className={cn(iconFill, 'text-emerald-600 dark:text-emerald-300')} />
) : (
<ArrowDownTrayIcon className={cn(iconFill, colors.off)} />
)}
</span>
<span className="sr-only">Zu Downloads</span>
</button>
) : null
const FavoriteBtn = wantFavorite ? (
<button
type="button"
@ -263,6 +334,7 @@ export default function RecordJobActions({
const HotBtn = wantHot ? (
<button
type="button"
data-hot-target
className={btnBase}
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
@ -351,12 +423,10 @@ export default function RecordJobActions({
</span>
</button>
) : null
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']
const byKey: Record<ActionKey, React.ReactNode> = {
details: DetailsBtn,
add: AddBtn,
favorite: FavoriteBtn,
like: LikeBtn,
watch: WatchBtn,
@ -530,6 +600,26 @@ export default function RecordJobActions({
)
}
if (k === 'add') {
return (
<button
key="add"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-gray-100/70 dark:hover:bg-white/5 disabled:opacity-50"
disabled={busy || (!onAddToDownloads && !addUrl)}
onClick={async (e) => {
e.preventDefault()
e.stopPropagation()
setMenuOpen(false)
await doAdd()
}}
>
<ArrowDownTrayIcon className={cn('size-4', colors.off)} />
<span className="truncate">Zu Downloads</span>
</button>
)
}
if (k === 'favorite') {
return (
<button

View File

@ -11,12 +11,8 @@ type RecorderSettings = {
recordDir: string
doneDir: string
ffmpegPath?: string
// ✅ neue Optionen
autoAddToDownloadList?: boolean
autoStartAddedDownloads?: boolean
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
useChaturbateApi?: boolean
useMyFreeCamsWatcher?: boolean
autoDeleteSmallDownloads?: boolean
@ -24,25 +20,29 @@ type RecorderSettings = {
blurPreviews?: boolean
teaserPlayback?: 'still' | 'hover' | 'all'
teaserAudio?: boolean
lowDiskPauseBelowGB?: number
}
type DiskStatus = {
emergency: boolean
pauseGB: number
resumeGB: number
freeBytes: number
freeBytesHuman: string
recordPath?: string
}
const DEFAULTS: RecorderSettings = {
// ✅ relativ zur .exe (Backend löst das auf)
recordDir: 'records',
doneDir: 'records/done',
ffmpegPath: '',
// ✅ defaults für switches
autoAddToDownloadList: true,
autoStartAddedDownloads: true,
useChaturbateApi: false,
useMyFreeCamsWatcher: false,
autoDeleteSmallDownloads: false,
autoDeleteSmallDownloadsBelowMB: 50,
autoDeleteSmallDownloads: true,
autoDeleteSmallDownloadsBelowMB: 200,
blurPreviews: false,
teaserPlayback: 'hover',
teaserAudio: false,
@ -60,6 +60,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const [err, setErr] = useState<string | null>(null)
const [diskStatus, setDiskStatus] = useState<DiskStatus | null>(null)
const pauseGB = Number(value.lowDiskPauseBelowGB ?? DEFAULTS.lowDiskPauseBelowGB ?? 5)
const uiPauseGB = diskStatus?.pauseGB ?? pauseGB
const uiResumeGB = diskStatus?.resumeGB ?? (pauseGB + 3)
useEffect(() => {
let alive = true
@ -98,6 +104,27 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}, [])
useEffect(() => {
let alive = true
const load = async () => {
try {
const r = await fetch('/api/status/disk', { cache: 'no-store' })
if (!r.ok) return
const data = (await r.json()) as DiskStatus
if (alive) setDiskStatus(data)
} catch {
// ignorieren
}
}
load()
const t = window.setInterval(load, 5000) // alle 5s aktualisieren
return () => {
alive = false
window.clearInterval(t)
}
}, [])
async function browse(target: 'record' | 'done' | 'ffmpeg') {
setErr(null)
setMsg(null)
@ -211,12 +238,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
return
}
const ok = window.confirm(`Aufräumen: Alle Dateien in "${doneDir}" löschen, die kleiner als ${mb} MB sind? (Ordner "keep" wird übersprungen)`)
const ok = window.confirm(
`Aufräumen:\n` +
`• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` +
`• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` +
`Fortfahren?`
)
if (!ok) return
setCleaning(true)
try {
const res = await fetch('/api/settings/cleanup-small-downloads', {
const res = await fetch('/api/settings/cleanup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
@ -227,9 +259,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
const data = await res.json()
setMsg(
`🧹 Aufräumen fertig: ${data.deletedFiles} Datei(en) gelöscht (${data.deletedBytesHuman}). ` +
`Geprüft: ${data.scannedFiles}. Übersprungen: ${data.skippedFiles}.`
`🧹 Aufräumen fertig:\n` +
`• Gelöscht: ${data.deletedFiles} Datei(en) (${data.deletedBytesHuman})\n` +
`• Geprüft: ${data.scannedFiles} · Übersprungen: ${data.skippedFiles} · Fehler: ${data.errorCount}\n` +
`• Orphans: ${data.orphanIdsRemoved}/${data.orphanIdsScanned} entfernt (Previews/Thumbs/Generated)`
)
} catch (e: any) {
setErr(e?.message ?? String(e))
@ -506,31 +541,49 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Wenn freier Platz darunter fällt: Autostart pausieren + laufende Downloads stoppen. Resume erfolgt automatisch bei +3 GB.
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">Speicherplatz-Notbremse</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Aktiviert automatisch Stop + Autostart-Block bei wenig freiem Speicher (Resume bei +3 GB).
</div>
</div>
<span
className={
'inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium ' +
(diskStatus?.emergency
? 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-200'
: 'bg-green-100 text-green-800 dark:bg-green-500/20 dark:text-green-200')
}
title={diskStatus?.emergency ? 'Notfallbremse greift gerade' : 'OK'}
>
{diskStatus?.emergency ? 'AKTIV' : 'OK'}
</span>
</div>
{/* Pause unter */}
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Pause unter</div>
<div className="text-xs text-gray-600 dark:text-gray-300">Freier Speicher in GB</div>
<div className="mt-3 text-sm text-gray-900 dark:text-gray-200">
<div>
<span className="font-medium">Schwelle:</span>{' '}
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
{' · '}Resume ab{' '}
<span className="tabular-nums">
{uiResumeGB}
</span>{' '}
GB
</div>
<div className="sm:col-span-8 flex items-center gap-3">
<input
type="range"
min={1}
max={500}
step={1}
value={value.lowDiskPauseBelowGB ?? 5}
onChange={(e) => setValue((v) => ({ ...v, lowDiskPauseBelowGB: Number(e.target.value) }))}
className="w-full"
/>
<span className="w-16 text-right text-sm tabular-nums text-gray-900 dark:text-gray-100">
{(value.lowDiskPauseBelowGB ?? 5)} GB
</span>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
{diskStatus
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
: 'Status wird geladen…'}
</div>
{diskStatus?.emergency && (
<div className="mt-2 text-xs text-red-700 dark:text-red-200">
Notfallbremse greift: laufende Downloads werden gestoppt und Autostart bleibt gesperrt, bis wieder genug frei ist.
</div>
)}
</div>
</div>
</div>

View File

@ -65,6 +65,18 @@ export type SwipeCardProps = {
* (z.B. Buttons/Inputs innerhalb der Karte)
*/
tapIgnoreSelector?: string
/** Doppeltippen (z.B. HOT togglen). Rückgabe false => Aktion fehlgeschlagen */
onDoubleTap?: () => boolean | void | Promise<boolean | void>
/** Wohin soll die Flamme fliegen? (Element innerhalb der Card) */
hotTargetSelector?: string
/** Double-Tap Zeitfenster */
doubleTapMs?: number
/** Max. Bewegung zwischen taps (px) */
doubleTapMaxMovePx?: number
}
export type SwipeCardHandle = {
@ -109,11 +121,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
snapMs = 180,
commitMs = 180,
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
onDoubleTap,
hotTargetSelector = '[data-hot-target]',
doubleTapMs = 360,
doubleTapMaxMovePx = 48,
},
ref
) {
const cardRef = React.useRef<HTMLDivElement | null>(null)
const doubleTapBusyRef = React.useRef(false)
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
const dxRef = React.useRef(0)
const rafRef = React.useRef<number | null>(null)
@ -121,6 +139,13 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
const thresholdRef = React.useRef(0)
const outerRef = React.useRef<HTMLDivElement | null>(null)
const tapTimerRef = React.useRef<number | null>(null)
const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null)
const fxLayerRef = React.useRef<HTMLDivElement | null>(null)
const pointer = React.useRef<{
id: number | null
x: number
@ -193,6 +218,145 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
)
const clearTapTimer = React.useCallback(() => {
if (tapTimerRef.current != null) {
window.clearTimeout(tapTimerRef.current)
tapTimerRef.current = null
}
}, [])
const softResetForTap = React.useCallback(() => {
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
setAnimMs(0)
setDx(0)
setArmedDir(null)
try {
const el = cardRef.current
if (el) el.style.touchAction = 'pan-y'
} catch {}
}, [])
const runHotFx = React.useCallback(
(clientX?: number, clientY?: number) => {
const outer = outerRef.current
const card = cardRef.current
if (!outer || !card) return
const layer = fxLayerRef.current
if (!layer) return
const outerRect = outer.getBoundingClientRect()
// ✅ Start: da wo getippt wurde (fallback: Mitte)
let startX = outerRect.width / 2
let startY = outerRect.height / 2
if (typeof clientX === 'number' && typeof clientY === 'number') {
startX = clientX - outerRect.left
startY = clientY - outerRect.top
// optional: innerhalb der Card halten
startX = Math.max(0, Math.min(outerRect.width, startX))
startY = Math.max(0, Math.min(outerRect.height, startY))
}
// Ziel: HOT Button (falls gefunden)
const targetEl = hotTargetSelector
? (card.querySelector(hotTargetSelector) as HTMLElement | null)
: null
let endX = startX
let endY = startY
if (targetEl) {
const tr = targetEl.getBoundingClientRect()
endX = tr.left - outerRect.left + tr.width / 2
endY = tr.top - outerRect.top + tr.height / 2
}
const dx = endX - startX
const dy = endY - startY
// Flame node
const flame = document.createElement('div')
flame.textContent = '🔥'
flame.style.position = 'absolute'
flame.style.left = `${startX}px`
flame.style.top = `${startY}px`
flame.style.transform = 'translate(-50%, -50%)'
flame.style.fontSize = '30px'
flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))'
flame.style.pointerEvents = 'none'
flame.style.willChange = 'transform, opacity'
layer.appendChild(flame)
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
const popMs = 200
const holdMs = 500
const flyMs = 400
const duration = popMs + holdMs + flyMs // 1100
const tPopEnd = popMs / duration
const tHoldEnd = (popMs + holdMs) / duration
const anim = flame.animate(
[
// --- POP am Tap-Punkt ---
{ transform: 'translate(-50%, -50%) scale(0.15)', opacity: 0, offset: 0.0 },
{ transform: 'translate(-50%, -50%) scale(1.25)', opacity: 1, offset: tPopEnd * 0.55 },
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tPopEnd },
// --- HOLD (0.5s stehen bleiben) ---
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tHoldEnd },
// --- FLY zum HOT-Button ---
{
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85)`,
opacity: 0.95,
offset: tHoldEnd + (1 - tHoldEnd) * 0.75,
},
{
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.55)`,
opacity: 0,
offset: 1.0,
},
],
{
duration,
easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
fill: 'forwards',
}
)
// Flash beim Ankommen (am Ende der Fly-Phase)
if (targetEl) {
window.setTimeout(() => {
try {
targetEl.animate(
[
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
{ transform: 'translateZ(0) scale(1.10)', filter: 'brightness(1.25)', offset: 0.35 },
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
],
{ duration: 260, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)' }
)
} catch {}
}, popMs + holdMs + Math.round(flyMs * 0.75))
}
anim.onfinish = () => flame.remove()
},
[hotTargetSelector]
)
React.useEffect(() => {
return () => {
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
}
}, [])
React.useImperativeHandle(
ref,
() => ({
@ -204,7 +368,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
)
return (
<div className={cn('relative overflow-hidden rounded-lg', className)}>
<div
ref={outerRef}
className={cn('relative isolate overflow-hidden rounded-lg', className)}
>
{/* Background actions (100% je Richtung, animiert) */}
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
<div
@ -228,6 +395,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
</div>
</div>
{/* FX Layer (Flame) */}
<div
ref={fxLayerRef}
className="pointer-events-none absolute inset-0 z-50"
/>
{/* Foreground (moves) */}
<div
ref={cardRef}
@ -375,9 +548,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (!wasDragging) {
// ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten
// sonst “stiehlt” SwipeCard den Tap (iOS besonders empfindlich).
if (!wasDragging) {
// Tap auf Video/Controls => NICHT anfassen
if (wasTapIgnored) {
setAnimMs(0)
setDx(0)
@ -385,8 +557,72 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
return
}
reset()
onTap?.()
const now = Date.now()
const last = lastTapRef.current
const isNear =
last &&
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
const isDouble =
Boolean(onDoubleTap) &&
last &&
(now - last.t) <= doubleTapMs &&
isNear
if (isDouble) {
// 2nd tap: Single-Tap abbrechen + DoubleTap ausführen
lastTapRef.current = null
clearTapTimer()
if (doubleTapBusyRef.current) return
doubleTapBusyRef.current = true
// ✅ FX sofort starten (ohne irgendwas am Video zu resetten)
requestAnimationFrame(() => {
try {
runHotFx(e.clientX, e.clientY)
} catch {}
})
;(async () => {
try {
await onDoubleTap?.()
} catch {
// optional: error feedback
} finally {
doubleTapBusyRef.current = false
}
})()
return
}
// ✅ NUR bei SingleTap soft resetten
softResetForTap()
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
onTap?.()
}, onDoubleTap ? doubleTapMs : 0)
return
// kein Double: SingleTap erst nach Delay auslösen
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
clearTapTimer()
tapTimerRef.current = window.setTimeout(() => {
tapTimerRef.current = null
lastTapRef.current = null
onTap?.()
}, onDoubleTap ? doubleTapMs : 0)
return
}

View File

@ -12,9 +12,20 @@ export const apiUrl = (path: string) => {
}
export async function apiFetch(path: string, init?: RequestInit) {
return fetch(apiUrl(path), {
const res = await fetch(apiUrl(path), {
...init,
// falls du Cookies/Sessions brauchst:
credentials: 'include',
headers: {
...(init?.headers ?? {}),
'Content-Type': (init?.headers as any)?.['Content-Type'] ?? 'application/json',
},
})
if (res.status === 401) {
// optional: SPA route
if (!location.pathname.startsWith('/login')) {
location.href = '/login'
}
}
return res
}

View File

@ -1,4 +1,12 @@
// types.ts
// frontend\src\types.ts
export type PostWorkKeyStatus = {
state: 'queued' | 'running' | 'missing'
position?: number // 1..n (nur queued)
waiting?: number // Anzahl wartend
running?: number // Anzahl running
maxParallel?: number // cap(ffmpegSem)
}
export type RecordJob = {
id: string
@ -8,23 +16,24 @@ export type RecordJob = {
startedAt: string
endedAt?: string
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
durationSeconds?: number
sizeBytes?: number
videoWidth?: number
videoHeight?: number
fps?: number
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
phase?: string
progress?: number
// ✅ NEU: Postwork-Queue Status/Position
postWorkKey?: string
postWork?: PostWorkKeyStatus
exitCode?: number
error?: string
logTail?: string
}
export type ParsedModel = {
input: string
isUrl: boolean