updated
This commit is contained in:
parent
ce68074a5a
commit
c830b3bf15
625
backend/auth.go
Normal file
625
backend/auth.go
Normal 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
303
backend/cleanup.go
Normal 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
6
backend/data/auth.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"username": "admin",
|
||||
"passwordHash": "$2a$10$ujxgEV/riwyxEfQKdG3hruUGljg/ts3bDETFAPhZb07N0TBY5LRNq",
|
||||
"totpEnabled": false,
|
||||
"totpSecret": ""
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
2225
backend/main.go
2225
backend/main.go
File diff suppressed because it is too large
Load Diff
Binary file not shown.
202
backend/postwork_queue.go
Normal file
202
backend/postwork_queue.go
Normal 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
|
||||
40
backend/postwork_refresh.go
Normal file
40
backend/postwork_refresh.go
Normal 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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
345
backend/web/dist/assets/index-BqjSaPox.js
vendored
345
backend/web/dist/assets/index-BqjSaPox.js
vendored
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-CRe6vAJq.css
vendored
1
backend/web/dist/assets/index-CRe6vAJq.css
vendored
File diff suppressed because one or more lines are too long
353
backend/web/dist/assets/index-DeJUGWfr.js
vendored
Normal file
353
backend/web/dist/assets/index-DeJUGWfr.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-s1ZflJsu.css
vendored
Normal file
1
backend/web/dist/assets/index-s1ZflJsu.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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 A→Z</option>
|
||||
<option value="model_desc">Modelname Z→A</option>
|
||||
<option value="file_asc">Dateiname A→Z</option>
|
||||
<option value="file_desc">Dateiname Z→A</option>
|
||||
<option value="file_asc">Modelname A→Z</option>
|
||||
<option value="file_desc">Modelname Z→A</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 A→Z</option>
|
||||
<option value="model_desc">Modelname Z→A</option>
|
||||
<option value="file_asc">Dateiname A→Z</option>
|
||||
<option value="file_desc">Dateiname Z→A</option>
|
||||
<option value="file_asc">Modelname A→Z</option>
|
||||
<option value="file_desc">Modelname Z→A</option>
|
||||
<option value="duration_desc">Dauer ↓</option>
|
||||
<option value="duration_asc">Dauer ↑</option>
|
||||
<option value="size_desc">Größe ↓</option>
|
||||
|
||||
@ -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 1–2 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)}
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
245
frontend/src/components/ui/LoginPage.tsx
Normal file
245
frontend/src/components/ui/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
|
||||
@ -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']}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user