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 (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // 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/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@ -28,8 +31,9 @@ require (
|
|||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect
|
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/image v0.35.0
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
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/libc v1.66.10 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/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 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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=
|
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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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=
|
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/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 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ=
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw=
|
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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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 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/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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
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-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.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/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.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 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
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" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>App</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BqjSaPox.js"></script>
|
<script type="module" crossorigin src="/assets/index-DeJUGWfr.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CRe6vAJq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-s1ZflJsu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
|||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller'
|
||||||
import CategoriesTab from './components/ui/CategoriesTab'
|
import CategoriesTab from './components/ui/CategoriesTab'
|
||||||
|
import LoginPage from './components/ui/LoginPage'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -186,6 +187,24 @@ function modelKeyFromFilename(fileOrPath: string): string | null {
|
|||||||
|
|
||||||
export default function App() {
|
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()
|
const notify = useNotify()
|
||||||
|
|
||||||
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
|
// ✅ Perf: PerformanceMonitor erst nach initialer Render/Hydration anzeigen
|
||||||
@ -209,8 +228,6 @@ export default function App() {
|
|||||||
type DoneSortMode =
|
type DoneSortMode =
|
||||||
| 'completed_desc'
|
| 'completed_desc'
|
||||||
| 'completed_asc'
|
| 'completed_asc'
|
||||||
| 'model_asc'
|
|
||||||
| 'model_desc'
|
|
||||||
| 'file_asc'
|
| 'file_asc'
|
||||||
| 'file_desc'
|
| 'file_desc'
|
||||||
| 'duration_desc'
|
| 'duration_desc'
|
||||||
@ -635,7 +652,10 @@ export default function App() {
|
|||||||
setPlayerExpanded(false)
|
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
|
// ✅ Anzahl Watched Models (aus Store), die online sind
|
||||||
const onlineWatchedModelsCount = useMemo(() => {
|
const onlineWatchedModelsCount = useMemo(() => {
|
||||||
@ -798,16 +818,31 @@ export default function App() {
|
|||||||
const prev = jobsRef.current
|
const prev = jobsRef.current
|
||||||
const prevById = new Map(prev.map((j) => [j.id, j.status]))
|
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)
|
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)
|
setJobs(arr)
|
||||||
jobsRef.current = arr
|
jobsRef.current = arr
|
||||||
setLastHeaderUpdateAtMs(Date.now())
|
|
||||||
|
|
||||||
if (endedNow) bumpAssetsTwice()
|
|
||||||
|
|
||||||
setPlayerJob((prevJob) => {
|
setPlayerJob((prevJob) => {
|
||||||
if (!prevJob) return prevJob
|
if (!prevJob) return prevJob
|
||||||
@ -1065,6 +1100,24 @@ export default function App() {
|
|||||||
return startUrl(sourceUrl)
|
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(
|
const handleDeleteJobWithUndo = useCallback(
|
||||||
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
@ -2029,6 +2082,14 @@ export default function App() {
|
|||||||
return () => stop()
|
return () => stop()
|
||||||
}, [recSettings.useChaturbateApi])
|
}, [recSettings.useChaturbateApi])
|
||||||
|
|
||||||
|
if (!authChecked) {
|
||||||
|
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authed) {
|
||||||
|
return <LoginPage onLoggedIn={checkAuth} />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
<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 aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
@ -2182,6 +2243,7 @@ export default function App() {
|
|||||||
onToggleFavorite={handleToggleFavorite}
|
onToggleFavorite={handleToggleFavorite}
|
||||||
onToggleLike={handleToggleLike}
|
onToggleLike={handleToggleLike}
|
||||||
onToggleWatch={handleToggleWatch}
|
onToggleWatch={handleToggleWatch}
|
||||||
|
onAddToDownloads={handleAddToDownloads}
|
||||||
blurPreviews={Boolean(recSettings.blurPreviews)}
|
blurPreviews={Boolean(recSettings.blurPreviews)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@ -18,11 +18,12 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
return res.json() as 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 base = `/api/generated/cover?category=${encodeURIComponent(category)}`
|
||||||
const v = token ? `&v=${token}` : ''
|
const v = token ? `&v=${token}` : ''
|
||||||
const r = refresh ? `&refresh=1` : ''
|
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[] {
|
function splitTags(raw?: string): string[] {
|
||||||
@ -68,10 +69,12 @@ function thumbUrlFromOutput(output: string): string | null {
|
|||||||
return `/generated/meta/${encodeURIComponent(id)}/thumbs.jpg`
|
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 =
|
const url =
|
||||||
`/api/generated/cover?category=${encodeURIComponent(category)}` +
|
`/api/generated/cover?category=${encodeURIComponent(category)}` +
|
||||||
`&src=${encodeURIComponent(thumbPath)}` +
|
`&src=${encodeURIComponent(thumbPath)}` +
|
||||||
|
(m ? `&model=${encodeURIComponent(m)}` : ``) +
|
||||||
(refresh ? `&refresh=1` : ``)
|
(refresh ? `&refresh=1` : ``)
|
||||||
|
|
||||||
await fetch(url, { method: 'GET', cache: 'no-store' as any })
|
await fetch(url, { method: 'GET', cache: 'no-store' as any })
|
||||||
@ -83,12 +86,29 @@ type TagRow = {
|
|||||||
downloadsCount: number
|
downloadsCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoverInfoListItem = {
|
||||||
|
category: string
|
||||||
|
model?: string
|
||||||
|
generatedAt?: string
|
||||||
|
hasCover: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function CategoriesTab() {
|
export default function CategoriesTab() {
|
||||||
|
|
||||||
const [rows, setRows] = React.useState<TagRow[]>([])
|
const [rows, setRows] = React.useState<TagRow[]>([])
|
||||||
const [loading, setLoading] = React.useState(false)
|
const [loading, setLoading] = React.useState(false)
|
||||||
const [err, setErr] = React.useState<string | null>(null)
|
const [err, setErr] = React.useState<string | null>(null)
|
||||||
const [coverBust, setCoverBust] = React.useState<number>(() => Date.now())
|
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[]
|
// TagLower -> outputs[]
|
||||||
const candidatesRef = React.useRef<Record<string, string[]>>({})
|
const candidatesRef = React.useRef<Record<string, string[]>>({})
|
||||||
@ -152,21 +172,54 @@ export default function CategoriesTab() {
|
|||||||
detail: { tags: [clean], mode: 'replace' },
|
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 () => {
|
const refresh = React.useCallback(async () => {
|
||||||
|
// laufenden refresh abbrechen
|
||||||
|
refreshAbortRef.current?.abort()
|
||||||
|
|
||||||
|
const ac = new AbortController()
|
||||||
|
refreshAbortRef.current = ac
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setErr(null)
|
setErr(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Models (für Tags)
|
// parallel laden
|
||||||
const models = await apiJSON<StoredModel[]>('/api/models/list', { cache: 'no-store' as any })
|
const [models, doneResp] = await Promise.all([
|
||||||
|
apiJSON<StoredModel[]>('/api/models/list', {
|
||||||
// Done-Jobs (für Downloads pro Tag + optional Seeding)
|
cache: 'no-store' as any,
|
||||||
const doneResp = await apiJSON<any>(
|
signal: ac.signal as any,
|
||||||
`/api/record/done?page=1&pageSize=2000&sort=completed_desc`,
|
}),
|
||||||
{ cache: 'no-store' 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)
|
const doneJobs: RecordJob[] = Array.isArray(doneResp?.items)
|
||||||
? (doneResp.items as RecordJob[])
|
? (doneResp.items as RecordJob[])
|
||||||
@ -176,7 +229,7 @@ export default function CategoriesTab() {
|
|||||||
|
|
||||||
buildCandidates(Array.isArray(models) ? models : [], doneJobs)
|
buildCandidates(Array.isArray(models) ? models : [], doneJobs)
|
||||||
|
|
||||||
// modelsCount pro Tag (über models)
|
// modelsCount pro Tag
|
||||||
const modelCountByTag = new Map<string, number>()
|
const modelCountByTag = new Map<string, number>()
|
||||||
for (const m of Array.isArray(models) ? models : []) {
|
for (const m of Array.isArray(models) ? models : []) {
|
||||||
for (const t of splitTags((m as any)?.tags)) {
|
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' }))
|
.sort((a, b) => a.tag.localeCompare(b.tag, undefined, { sensitivity: 'base' }))
|
||||||
|
|
||||||
setRows(outRows)
|
let coverInfoByTag = new Map<string, CoverInfoListItem>()
|
||||||
|
try {
|
||||||
// Optional: Seed Covers (limitiert)
|
const infos = await apiJSON<CoverInfoListItem[]>('/api/generated/coverinfo/list', {
|
||||||
const SEED_LIMIT = 60
|
cache: 'no-store' as any,
|
||||||
let seeded = 0
|
signal: ac.signal as any,
|
||||||
for (const r of outRows) {
|
})
|
||||||
if (seeded >= SEED_LIMIT) break
|
for (const i of Array.isArray(infos) ? infos : []) {
|
||||||
const list = candMap[r.tag] || []
|
const k = String(i?.category ?? '').trim().toLowerCase()
|
||||||
if (list.length === 0) continue
|
if (k) coverInfoByTag.set(k, i)
|
||||||
|
}
|
||||||
const pick = list[Math.floor(Math.random() * list.length)]
|
} catch {
|
||||||
const thumb = pick ? thumbUrlFromOutput(pick) : null
|
// optional: still weiterlaufen ohne Fallback
|
||||||
if (!thumb) continue
|
|
||||||
|
|
||||||
seeded++
|
|
||||||
void ensureCover(r.tag, thumb, false).catch(() => {})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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())
|
setCoverBust(Date.now())
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
if (e?.name === 'AbortError') return
|
||||||
|
|
||||||
setErr(e?.message ?? String(e))
|
setErr(e?.message ?? String(e))
|
||||||
setRows([])
|
setRows([])
|
||||||
candidatesRef.current = {}
|
candidatesRef.current = {}
|
||||||
|
setCoverModelByTag({})
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
// nur "aus" schalten, wenn dieser refresh noch der aktuelle ist
|
||||||
|
if (refreshAbortRef.current === ac) {
|
||||||
|
refreshAbortRef.current = null
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [buildCandidates])
|
}, [buildCandidates])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void refresh()
|
void refresh()
|
||||||
|
return () => {
|
||||||
|
refreshAbortRef.current?.abort()
|
||||||
|
}
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
||||||
const renewCovers = React.useCallback(async () => {
|
const renewCovers = React.useCallback(async () => {
|
||||||
|
if (renewing) return
|
||||||
|
setRenewing(true)
|
||||||
|
setErr(null)
|
||||||
|
setRenewProgress({ done: 0, total: rows.length })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const candMap = candidatesRef.current || {}
|
const candMap = candidatesRef.current || {}
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
rows.map(async (r) => {
|
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 {
|
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) {
|
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: '' }
|
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',
|
method: 'GET',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
})
|
})
|
||||||
@ -252,6 +354,8 @@ export default function CategoriesTab() {
|
|||||||
return { tag: r.tag, ok, status: res.status, text }
|
return { tag: r.tag, ok, status: res.status, text }
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return { tag: r.tag, ok: false, status: 0, text: e?.message ?? String(e) }
|
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 {
|
} finally {
|
||||||
setCoverBust(Date.now())
|
setCoverBust(Date.now())
|
||||||
|
setRenewing(false)
|
||||||
|
// optional: kurz stehen lassen, dann ausblenden
|
||||||
|
setTimeout(() => setRenewProgress(null), 400)
|
||||||
}
|
}
|
||||||
}, [rows])
|
}, [rows, renewing])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -278,10 +385,30 @@ export default function CategoriesTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<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
|
Cover erneuern
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" size="md" onClick={refresh} disabled={loading}>
|
<Button variant="secondary" size="md" onClick={refresh} disabled={loading || renewing}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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">
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{rows.map((r) => {
|
{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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -305,33 +435,170 @@ export default function CategoriesTab() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => goToFinishedDownloadsWithTag(r.tag)}
|
onClick={() => goToFinishedDownloadsWithTag(r.tag)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group text-left rounded-xl ring-1 ring-gray-200/70 dark:ring-white/10',
|
'group text-left rounded-2xl overflow-hidden transition',
|
||||||
'bg-white/70 hover:bg-white dark:bg-white/5 dark:hover:bg-white/10',
|
// surface
|
||||||
'overflow-hidden transition',
|
'bg-white/70 dark:bg-white/[0.06]',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-indigo-500'
|
// 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)"
|
title="In FinishedDownloads öffnen (Tag-Filter setzen)"
|
||||||
>
|
>
|
||||||
<div className="relative h-24">
|
<div className="relative w-full overflow-hidden aspect-[16/9] bg-gray-100/70 dark:bg-white/5">
|
||||||
<img
|
{/* Wenn Fehler: hübscher Placeholder statt broken image */}
|
||||||
src={img}
|
{isErr ? (
|
||||||
alt={r.tag}
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
<div className="absolute inset-0 opacity-70"
|
||||||
loading="lazy"
|
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>
|
||||||
|
|
||||||
<div className="px-3 py-3">
|
<div className="px-4 py-3.5">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="font-semibold text-gray-900 dark:text-white truncate">{r.tag}</div>
|
<div className="font-semibold text-gray-900 dark:text-white truncate tracking-tight">
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
{r.tag}
|
||||||
{r.downloadsCount} Downloads • {r.modelsCount} Models
|
</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>
|
||||||
</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">
|
{/* ✅ statt doppeltem Count-Badge: nur Chevron als Hinweis */}
|
||||||
{r.downloadsCount}
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -36,6 +36,7 @@ type Props = {
|
|||||||
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
onToggleLike?: (job: RecordJob) => void | Promise<void>
|
||||||
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
onToggleWatch?: (job: RecordJob) => void | Promise<void>
|
||||||
|
onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type DownloadRow =
|
type DownloadRow =
|
||||||
@ -117,6 +118,8 @@ const phaseLabel = (p?: string) => {
|
|||||||
return 'Verschiebe nach Done…'
|
return 'Verschiebe nach Done…'
|
||||||
case 'assets':
|
case 'assets':
|
||||||
return 'Erstelle Vorschau…'
|
return 'Erstelle Vorschau…'
|
||||||
|
case 'postwork':
|
||||||
|
return 'Nacharbeiten werden vorbereitet…'
|
||||||
default:
|
default:
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
@ -131,11 +134,48 @@ async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
return res.json() as 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 }) {
|
function StatusCell({ job }: { job: RecordJob }) {
|
||||||
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
||||||
const progress = Number((job as any)?.progress ?? 0)
|
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()
|
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
// ✅ ProgressBar unabhängig vom Text
|
// ✅ ProgressBar unabhängig vom Text
|
||||||
@ -293,17 +333,18 @@ function DownloadsCardRow({
|
|||||||
const file = baseName(j.output || '')
|
const file = baseName(j.output || '')
|
||||||
|
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
const phase = String((j as any).phase ?? '').trim()
|
||||||
const phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
|
||||||
|
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
|
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
|
||||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
const rawStatus = String(j.status ?? '').toLowerCase()
|
||||||
|
|
||||||
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
||||||
|
|
||||||
// ✅ Badge hinter Modelname: IMMER Backend-Status
|
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||||
const statusText = rawStatus || 'unknown'
|
if (phase === 'postwork') {
|
||||||
|
phaseText = postWorkLabel(j)
|
||||||
|
}
|
||||||
|
|
||||||
// ✅ Progressbar Label: Phase (gemappt), fallback auf Status
|
const statusText = rawStatus || 'unknown'
|
||||||
const progressLabel = phaseText || statusText
|
const progressLabel = phaseText || statusText
|
||||||
|
|
||||||
const progress = Number((j as any).progress ?? 0)
|
const progress = Number((j as any).progress ?? 0)
|
||||||
@ -450,11 +491,6 @@ function DownloadsCardRow({
|
|||||||
isFavorite={isFav}
|
isFavorite={isFav}
|
||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
showHot={false}
|
|
||||||
showKeep={false}
|
|
||||||
showDelete={false}
|
|
||||||
showFavorite
|
|
||||||
showLike
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
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)
|
const [stopAllBusy, setStopAllBusy] = useState(false)
|
||||||
|
|
||||||
@ -927,14 +974,10 @@ export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob,
|
|||||||
isFavorite={isFav}
|
isFavorite={isFav}
|
||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
showHot={false}
|
|
||||||
showKeep={false}
|
|
||||||
showDelete={false}
|
|
||||||
showFavorite
|
|
||||||
showLike
|
|
||||||
onToggleFavorite={onToggleFavorite}
|
onToggleFavorite={onToggleFavorite}
|
||||||
onToggleLike={onToggleLike}
|
onToggleLike={onToggleLike}
|
||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
|
onAddToDownloads={onAddToDownloads}
|
||||||
order={['watch', 'favorite', 'like', 'details']}
|
order={['watch', 'favorite', 'like', 'details']}
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import TagBadge from './TagBadge'
|
|||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { useNotify } from './notify'
|
import { useNotify } from './notify'
|
||||||
import LazyMount from './LazyMount'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
import LabeledSwitch from './LabeledSwitch'
|
import LabeledSwitch from './LabeledSwitch'
|
||||||
import Switch from './Switch'
|
import Switch from './Switch'
|
||||||
@ -34,8 +33,6 @@ import Switch from './Switch'
|
|||||||
type SortMode =
|
type SortMode =
|
||||||
| 'completed_desc'
|
| 'completed_desc'
|
||||||
| 'completed_asc'
|
| 'completed_asc'
|
||||||
| 'model_asc'
|
|
||||||
| 'model_desc'
|
|
||||||
| 'file_asc'
|
| 'file_asc'
|
||||||
| 'file_desc'
|
| 'file_desc'
|
||||||
| 'duration_desc'
|
| 'duration_desc'
|
||||||
@ -763,11 +760,17 @@ export default function FinishedDownloads({
|
|||||||
if (typeof undoToken === 'string' && undoToken) {
|
if (typeof undoToken === 'string' && undoToken) {
|
||||||
setLastAction({ kind: 'delete', undoToken, originalFile: file })
|
setLastAction({ kind: 'delete', undoToken, originalFile: file })
|
||||||
} else {
|
} else {
|
||||||
// ohne Token kein Restore möglich -> nicht so tun als gäbe es Undo
|
|
||||||
setLastAction(null)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -880,6 +883,11 @@ export default function FinishedDownloads({
|
|||||||
return next
|
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
|
// 2) durations-Key mitziehen + Ref/State synchron halten
|
||||||
const cur = durationsRef.current || {}
|
const cur = durationsRef.current || {}
|
||||||
const v = (cur as any)[oldFile]
|
const v = (cur as any)[oldFile]
|
||||||
@ -899,68 +907,80 @@ export default function FinishedDownloads({
|
|||||||
|
|
||||||
const toggleHotVideo = useCallback(
|
const toggleHotVideo = useCallback(
|
||||||
async (job: RecordJob) => {
|
async (job: RecordJob) => {
|
||||||
const file = baseName(job.output || '')
|
const currentFile = baseName(job.output || '')
|
||||||
if (!file) {
|
if (!currentFile) {
|
||||||
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden – kann nicht HOT togglen.')
|
notify.error('HOT nicht möglich', 'Kein Dateiname gefunden – kann nicht HOT togglen.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ HOT-Name berechnen (genau "HOT " Prefix)
|
// genau "HOT " Prefix
|
||||||
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
|
const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`)
|
||||||
|
|
||||||
// ✅ UI-optimistisch umbenennen + Dauer-Key mitziehen
|
// Server-Truth anwenden (inkl. duration-key move via applyRename)
|
||||||
const applyOptimisticRename = (oldFile: string, newFile: string) => {
|
const applyServerTruth = (apiOld: string, apiNew: string) => {
|
||||||
applyRename(oldFile, newFile)
|
if (!apiOld || !apiNew || apiOld === apiNew) return
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 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
|
|
||||||
applyRename(apiOld, apiNew)
|
applyRename(apiOld, apiNew)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldFile = currentFile
|
||||||
|
const optimisticNew = toggledName(oldFile)
|
||||||
|
|
||||||
|
// Optimistik sofort anwenden (UI snappy)
|
||||||
|
applyRename(oldFile, optimisticNew)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await releasePlayingFile(file, { close: true })
|
await releasePlayingFile(oldFile, { close: true })
|
||||||
|
|
||||||
const oldFile = file
|
// ✅ 1) Wenn du einen externen Handler hast:
|
||||||
const optimisticNew = toggledName(oldFile)
|
// -> ideal: er gibt {oldFile,newFile} zurück (optional)
|
||||||
|
|
||||||
// ✅ 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
|
|
||||||
if (onToggleHot) {
|
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()
|
queueRefill()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Backend direkt
|
// ✅ 2) Fallback: Backend direkt (wichtig: oldFile verwenden!)
|
||||||
const res = await fetch(`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' })
|
const res = await fetch(
|
||||||
|
`/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json().catch(() => null)) as any
|
const data = (await res.json().catch(() => null)) as any
|
||||||
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file
|
const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile
|
||||||
const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : ''
|
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
|
// ✅ Undo nach Erfolg
|
||||||
if (apiNew) setLastAction({ kind: 'hot', currentFile: apiNew })
|
setLastAction({ kind: 'hot', currentFile: apiNew })
|
||||||
|
|
||||||
queueRefill()
|
queueRefill()
|
||||||
} catch (e: any) {
|
} 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))
|
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) => {
|
const runtimeSecondsForSort = useCallback((job: RecordJob) => {
|
||||||
@ -1125,6 +1145,12 @@ export default function FinishedDownloads({
|
|||||||
return visibleRows.slice(Math.max(0, start), Math.max(0, end))
|
return visibleRows.slice(Math.max(0, start), Math.max(0, end))
|
||||||
}, [globalFilterActive, visibleRows, page, pageSize])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!globalFilterActive) return
|
if (!globalFilterActive) return
|
||||||
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
|
const totalPages = Math.max(1, Math.ceil(visibleRows.length / pageSize))
|
||||||
@ -1264,30 +1290,21 @@ export default function FinishedDownloads({
|
|||||||
if (canHover) setHoverTeaserKey(null)
|
if (canHover) setHoverTeaserKey(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LazyMount
|
<FinishedVideoPreview
|
||||||
// wenn User es gerade “braucht”, sofort mounten:
|
job={j}
|
||||||
force={teaserKey === k || hoverTeaserKey === k}
|
getFileName={baseName}
|
||||||
rootMargin="400px"
|
durationSeconds={durations[k]}
|
||||||
placeholder={
|
muted={previewMuted}
|
||||||
<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" />
|
popoverMuted={previewMuted}
|
||||||
}
|
onDuration={handleDuration}
|
||||||
>
|
className="w-28 h-16 rounded-md ring-1 ring-black/5 dark:ring-white/10"
|
||||||
<FinishedVideoPreview
|
showPopover={false}
|
||||||
job={j}
|
blur={blurPreviews}
|
||||||
getFileName={baseName}
|
animated={teaserPlayback === 'all' ? true : teaserPlayback === 'hover' ? teaserKey === k : false}
|
||||||
durationSeconds={durations[k]}
|
animatedMode="teaser"
|
||||||
muted={previewMuted}
|
animatedTrigger="always"
|
||||||
popoverMuted={previewMuted}
|
assetNonce={assetNonce}
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -1484,7 +1501,7 @@ export default function FinishedDownloads({
|
|||||||
onToggleHot={toggleHotVideo}
|
onToggleHot={toggleHotVideo}
|
||||||
onKeep={keepVideo}
|
onKeep={keepVideo}
|
||||||
onDelete={deleteVideo}
|
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"
|
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 === 'completedAt') return asc ? 'completed_asc' : 'completed_desc'
|
||||||
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
|
if (key === 'runtime') return asc ? 'duration_asc' : 'duration_desc'
|
||||||
if (key === 'size') return asc ? 'size_asc' : 'size_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'
|
if (key === 'video') return asc ? 'file_asc' : 'file_desc'
|
||||||
|
|
||||||
// fallback
|
// fallback
|
||||||
@ -1525,8 +1543,67 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [isSmall])
|
}, [isSmall])
|
||||||
|
|
||||||
const emptyFolder = rows.length === 0 && doneTotalPage === 0
|
useEffect(() => {
|
||||||
const emptyByFilter = !emptyFolder && visibleRows.length === 0
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -1616,10 +1693,8 @@ export default function FinishedDownloads({
|
|||||||
>
|
>
|
||||||
<option value="completed_desc">Fertiggestellt am ↓</option>
|
<option value="completed_desc">Fertiggestellt am ↓</option>
|
||||||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||||||
<option value="model_asc">Modelname A→Z</option>
|
<option value="file_asc">Modelname A→Z</option>
|
||||||
<option value="model_desc">Modelname Z→A</option>
|
<option value="file_desc">Modelname Z→A</option>
|
||||||
<option value="file_asc">Dateiname A→Z</option>
|
|
||||||
<option value="file_desc">Dateiname Z→A</option>
|
|
||||||
<option value="duration_desc">Dauer ↓</option>
|
<option value="duration_desc">Dauer ↓</option>
|
||||||
<option value="duration_asc">Dauer ↑</option>
|
<option value="duration_asc">Dauer ↑</option>
|
||||||
<option value="size_desc">Größe ↓</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_desc">Fertiggestellt am ↓</option>
|
||||||
<option value="completed_asc">Fertiggestellt am ↑</option>
|
<option value="completed_asc">Fertiggestellt am ↑</option>
|
||||||
<option value="model_asc">Modelname A→Z</option>
|
<option value="file_asc">Modelname A→Z</option>
|
||||||
<option value="model_desc">Modelname Z→A</option>
|
<option value="file_desc">Modelname Z→A</option>
|
||||||
<option value="file_asc">Dateiname A→Z</option>
|
|
||||||
<option value="file_desc">Dateiname Z→A</option>
|
|
||||||
<option value="duration_desc">Dauer ↓</option>
|
<option value="duration_desc">Dauer ↓</option>
|
||||||
<option value="duration_asc">Dauer ↑</option>
|
<option value="duration_asc">Dauer ↑</option>
|
||||||
<option value="size_desc">Größe ↓</option>
|
<option value="size_desc">Größe ↓</option>
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import LazyMount from './LazyMount'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
|
||||||
function cn(...parts: Array<string | false | null | undefined>) {
|
function cn(...parts: Array<string | false | null | undefined>) {
|
||||||
@ -179,8 +178,6 @@ export default function FinishedDownloadsCardsView({
|
|||||||
if (!exists) setOpenTagsKey(null)
|
if (!exists) setOpenTagsKey(null)
|
||||||
}, [rows, keyFor, openTagsKey])
|
}, [rows, keyFor, openTagsKey])
|
||||||
|
|
||||||
const mobileRootMargin = isSmall ? '180px' : '500px'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{rows.map((j) => {
|
{rows.map((j) => {
|
||||||
@ -226,6 +223,8 @@ export default function FinishedDownloadsCardsView({
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={[
|
className={[
|
||||||
'group',
|
'group',
|
||||||
|
'content-visibility-auto',
|
||||||
|
'[contain-intrinsic-size:180px_120px]',
|
||||||
motionCls,
|
motionCls,
|
||||||
'rounded-xl',
|
'rounded-xl',
|
||||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500',
|
'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) &&
|
keepingKeys.has(k) &&
|
||||||
'ring-1 ring-emerald-300 bg-emerald-50/60 dark:bg-emerald-500/10 dark:ring-emerald-500/30 animate-pulse',
|
'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]',
|
removingKeys.has(k) && 'opacity-0 translate-y-2 scale-[0.98]',
|
||||||
]
|
].filter(Boolean).join(' ')}
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
onClick={isSmall ? undefined : () => openPlayer(j)}
|
onClick={isSmall ? undefined : () => openPlayer(j)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j)
|
||||||
@ -258,15 +255,10 @@ export default function FinishedDownloadsCardsView({
|
|||||||
startInline(k)
|
startInline(k)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LazyMount
|
<div className="absolute inset-0">
|
||||||
force={inlineActive}
|
|
||||||
rootMargin={mobileRootMargin}
|
|
||||||
placeholder={<div className="w-full h-full bg-black/5 dark:bg-white/5 animate-pulse" />}
|
|
||||||
className="absolute inset-0"
|
|
||||||
>
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={baseName}
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
showPopover={false}
|
showPopover={false}
|
||||||
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
|
blur={isSmall ? false : (inlineActive ? false : blurPreviews)}
|
||||||
@ -281,7 +273,7 @@ export default function FinishedDownloadsCardsView({
|
|||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
assetNonce={assetNonce ?? 0}
|
assetNonce={assetNonce ?? 0}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</div>
|
||||||
|
|
||||||
{/* Gradient overlay bottom */}
|
{/* Gradient overlay bottom */}
|
||||||
<div
|
<div
|
||||||
@ -358,11 +350,9 @@ export default function FinishedDownloadsCardsView({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
showKeep={!isSmall}
|
|
||||||
showDelete={!isSmall}
|
|
||||||
onKeep={keepVideo}
|
onKeep={keepVideo}
|
||||||
onDelete={deleteVideo}
|
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"
|
className="flex items-center gap-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -495,17 +485,41 @@ export default function FinishedDownloadsCardsView({
|
|||||||
enabled
|
enabled
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
ignoreFromBottomPx={110}
|
ignoreFromBottomPx={110}
|
||||||
|
doubleTapMs={360}
|
||||||
|
doubleTapMaxMovePx={48}
|
||||||
onTap={() => {
|
onTap={() => {
|
||||||
const domId = `inline-prev-${encodeURIComponent(k)}`
|
const domId = `inline-prev-${encodeURIComponent(k)}`
|
||||||
startInline(k)
|
startInline(k)
|
||||||
|
|
||||||
// ✅ nach dem State-Update dem DOM 1–2 Frames geben
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (!tryAutoplayInline(domId)) {
|
if (!tryAutoplayInline(domId)) {
|
||||||
requestAnimationFrame(() => 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)}
|
onSwipeLeft={() => deleteVideo(j)}
|
||||||
onSwipeRight={() => keepVideo(j)}
|
onSwipeRight={() => keepVideo(j)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// frontend\src\components\ui\FinishedDownloadsGalleryView.tsx
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
@ -10,7 +12,6 @@ import {
|
|||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import TagBadge from './TagBadge'
|
import TagBadge from './TagBadge'
|
||||||
import RecordJobActions from './RecordJobActions'
|
import RecordJobActions from './RecordJobActions'
|
||||||
import LazyMount from './LazyMount'
|
|
||||||
import { isHotName, stripHotPrefix } from './hotName'
|
import { isHotName, stripHotPrefix } from './hotName'
|
||||||
|
|
||||||
|
|
||||||
@ -225,12 +226,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
>
|
>
|
||||||
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
{/* ✅ Clip nur Media + Bottom-Overlays (nicht das Menü) */}
|
||||||
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
<div className="absolute inset-0 overflow-hidden rounded-t-lg">
|
||||||
<LazyMount
|
<div className="absolute inset-0">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<FinishedVideoPreview
|
<FinishedVideoPreview
|
||||||
job={j}
|
job={j}
|
||||||
getFileName={(p) => stripHotPrefix(baseName(p))}
|
getFileName={(p) => stripHotPrefix(baseName(p))}
|
||||||
@ -247,7 +243,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
muted={previewMuted}
|
muted={previewMuted}
|
||||||
popoverMuted={previewMuted}
|
popoverMuted={previewMuted}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</div>
|
||||||
|
|
||||||
{/* Gradient overlay bottom */}
|
{/* Gradient overlay bottom */}
|
||||||
<div
|
<div
|
||||||
@ -315,7 +311,7 @@ export default function FinishedDownloadsGalleryView({
|
|||||||
onToggleHot={onToggleHot}
|
onToggleHot={onToggleHot}
|
||||||
onKeep={keepVideo}
|
onKeep={keepVideo}
|
||||||
onDelete={deleteVideo}
|
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"
|
className="w-full justify-end gap-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)
|
const [videoCountsLoading, setVideoCountsLoading] = React.useState(false)
|
||||||
|
|
||||||
// 🔽 Table sorting (global über alle filtered Einträge)
|
// 🔽 Table sorting (global über alle filtered Einträge)
|
||||||
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>({
|
const [sort, setSort] = React.useState<{ key: string; direction: 'asc' | 'desc' } | null>()
|
||||||
key: 'videos',
|
|
||||||
direction: 'desc',
|
|
||||||
})
|
|
||||||
|
|
||||||
const refreshVideoCounts = React.useCallback(async () => {
|
const refreshVideoCounts = React.useCallback(async () => {
|
||||||
setVideoCountsLoading(true)
|
setVideoCountsLoading(true)
|
||||||
@ -281,6 +278,20 @@ export default function ModelsTab() {
|
|||||||
return () => window.removeEventListener('models:set-tag-filter', onSet as any)
|
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 [input, setInput] = React.useState('')
|
||||||
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
const [parsed, setParsed] = React.useState<ParsedModel | null>(null)
|
||||||
const [parseError, setParseError] = React.useState<string | null>(null)
|
const [parseError, setParseError] = React.useState<string | null>(null)
|
||||||
@ -320,11 +331,15 @@ export default function ModelsTab() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobForDetails(modelKey: string): RecordJob {
|
function jobForDetails(m: StoredModel): RecordJob {
|
||||||
// RecordJobActions braucht nur `output`, um modelKeyFromOutput() zu finden.
|
const href = modelHref(m) ?? undefined
|
||||||
// Wir geben ein Output, das dem Dateinamen-Schema entspricht: <modelKey>_MM_DD_YYYY__HH-MM-SS.ext
|
|
||||||
return {
|
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
|
} as any
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -614,18 +629,17 @@ export default function ModelsTab() {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
header: '',
|
header: '',
|
||||||
align: 'right',
|
align: 'right',
|
||||||
cell: (m) => (
|
cell: (m) => (
|
||||||
<div className="flex justify-end w-[56px]">
|
<div className="flex justify-end w-[92px]">
|
||||||
<RecordJobActions
|
<RecordJobActions
|
||||||
job={jobForDetails(m.modelKey)}
|
job={jobForDetails(m)}
|
||||||
variant="table"
|
variant="table"
|
||||||
order={['details']}
|
order={['add', 'details']}
|
||||||
className="flex items-center"
|
className="flex items-center gap-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1244,9 +1244,6 @@ export default function Player({
|
|||||||
isLiked={isLiked}
|
isLiked={isLiked}
|
||||||
isWatching={isWatching}
|
isWatching={isWatching}
|
||||||
onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
|
onToggleWatch={onToggleWatch ? (j) => onToggleWatch(j) : undefined}
|
||||||
showHot={false}
|
|
||||||
showKeep={false}
|
|
||||||
showDelete={false}
|
|
||||||
onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
|
onToggleFavorite={onToggleFavorite ? (j) => onToggleFavorite(j) : undefined}
|
||||||
onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
|
onToggleLike={onToggleLike ? (j) => onToggleLike(j) : undefined}
|
||||||
order={['watch', 'favorite', 'like', 'details']}
|
order={['watch', 'favorite', 'like', 'details']}
|
||||||
|
|||||||
@ -13,20 +13,21 @@ import {
|
|||||||
StarIcon as StarOutlineIcon,
|
StarIcon as StarOutlineIcon,
|
||||||
HeartIcon as HeartOutlineIcon,
|
HeartIcon as HeartOutlineIcon,
|
||||||
EyeIcon as EyeOutlineIcon,
|
EyeIcon as EyeOutlineIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FireIcon as FireSolidIcon,
|
FireIcon as FireSolidIcon,
|
||||||
StarIcon as StarSolidIcon,
|
StarIcon as StarSolidIcon,
|
||||||
HeartIcon as HeartSolidIcon,
|
HeartIcon as HeartSolidIcon,
|
||||||
EyeIcon as EyeSolidIcon,
|
EyeIcon as EyeSolidIcon,
|
||||||
|
CheckIcon,
|
||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
|
||||||
type Variant = 'overlay' | 'table'
|
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 ActionResult = void | boolean
|
||||||
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
|
type ActionFn = (job: RecordJob) => ActionResult | Promise<ActionResult>
|
||||||
@ -45,21 +46,13 @@ type Props = {
|
|||||||
isLiked?: boolean
|
isLiked?: boolean
|
||||||
isWatching?: 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
|
onToggleFavorite?: ActionFn
|
||||||
onToggleLike?: ActionFn
|
onToggleLike?: ActionFn
|
||||||
onToggleHot?: ActionFn
|
onToggleHot?: ActionFn
|
||||||
onKeep?: ActionFn
|
onKeep?: ActionFn
|
||||||
onDelete?: ActionFn
|
onDelete?: ActionFn
|
||||||
onToggleWatch?: ActionFn
|
onToggleWatch?: ActionFn
|
||||||
|
onAddToDownloads?: ActionFn
|
||||||
|
|
||||||
order?: ActionKey[]
|
order?: ActionKey[]
|
||||||
|
|
||||||
@ -102,19 +95,13 @@ export default function RecordJobActions({
|
|||||||
isFavorite = false,
|
isFavorite = false,
|
||||||
isLiked = false,
|
isLiked = false,
|
||||||
isWatching = false,
|
isWatching = false,
|
||||||
showFavorite,
|
|
||||||
showLike,
|
|
||||||
showHot,
|
|
||||||
showKeep,
|
|
||||||
showDelete,
|
|
||||||
showWatch,
|
|
||||||
showDetails,
|
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
onToggleLike,
|
onToggleLike,
|
||||||
onToggleHot,
|
onToggleHot,
|
||||||
onKeep,
|
onKeep,
|
||||||
onDelete,
|
onDelete,
|
||||||
onToggleWatch,
|
onToggleWatch,
|
||||||
|
onAddToDownloads,
|
||||||
order,
|
order,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@ -155,16 +142,76 @@ export default function RecordJobActions({
|
|||||||
watchOn: 'text-sky-600 dark:text-sky-200',
|
watchOn: 'text-sky-600 dark:text-sky-200',
|
||||||
}
|
}
|
||||||
|
|
||||||
const wantFavorite = showFavorite ?? Boolean(onToggleFavorite)
|
|
||||||
const wantLike = showLike ?? Boolean(onToggleLike)
|
// ✅ Reihenfolge strikt nach `order` (wenn gesetzt). Keys die nicht im order stehen: niemals anzeigen.
|
||||||
const wantHot = showHot ?? Boolean(onToggleHot)
|
const actionOrder: ActionKey[] = order ?? ['watch', 'favorite', 'like', 'hot', 'keep', 'delete', 'details']
|
||||||
const wantKeep = showKeep ?? Boolean(onKeep)
|
const inOrder = (k: ActionKey) => actionOrder.includes(k)
|
||||||
const wantDelete = showDelete ?? Boolean(onDelete)
|
|
||||||
const wantWatch = showWatch ?? Boolean(onToggleWatch)
|
const addUrl = String((job as any)?.sourceUrl ?? '').trim()
|
||||||
const wantDetails = showDetails ?? true
|
|
||||||
const detailsKey = modelKeyFromOutput(job.output || '')
|
const detailsKey = modelKeyFromOutput(job.output || '')
|
||||||
const detailsLabel = detailsKey ? `Mehr zu ${detailsKey} anzeigen` : 'Mehr anzeigen'
|
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/…)
|
// ✅ Auto-Fit: verfügbare Breite + tatsächlicher gap (Tailwind gap-1/gap-2/…)
|
||||||
const [rootW, setRootW] = React.useState(0)
|
const [rootW, setRootW] = React.useState(0)
|
||||||
const [gapPx, setGapPx] = React.useState(4)
|
const [gapPx, setGapPx] = React.useState(4)
|
||||||
@ -177,7 +224,7 @@ export default function RecordJobActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DetailsBtn =
|
const DetailsBtn =
|
||||||
wantDetails && detailsKey ? (
|
wantDetails ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(btnBase)}
|
className={cn(btnBase)}
|
||||||
@ -198,6 +245,30 @@ export default function RecordJobActions({
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : 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 ? (
|
const FavoriteBtn = wantFavorite ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -263,6 +334,7 @@ export default function RecordJobActions({
|
|||||||
const HotBtn = wantHot ? (
|
const HotBtn = wantHot ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
data-hot-target
|
||||||
className={btnBase}
|
className={btnBase}
|
||||||
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
title={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
aria-label={isHot ? 'HOT entfernen' : 'Als HOT markieren'}
|
||||||
@ -352,11 +424,9 @@ export default function RecordJobActions({
|
|||||||
</button>
|
</button>
|
||||||
) : null
|
) : 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> = {
|
const byKey: Record<ActionKey, React.ReactNode> = {
|
||||||
details: DetailsBtn,
|
details: DetailsBtn,
|
||||||
|
add: AddBtn,
|
||||||
favorite: FavoriteBtn,
|
favorite: FavoriteBtn,
|
||||||
like: LikeBtn,
|
like: LikeBtn,
|
||||||
watch: WatchBtn,
|
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') {
|
if (k === 'favorite') {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -11,12 +11,8 @@ type RecorderSettings = {
|
|||||||
recordDir: string
|
recordDir: string
|
||||||
doneDir: string
|
doneDir: string
|
||||||
ffmpegPath?: string
|
ffmpegPath?: string
|
||||||
|
|
||||||
// ✅ neue Optionen
|
|
||||||
autoAddToDownloadList?: boolean
|
autoAddToDownloadList?: boolean
|
||||||
autoStartAddedDownloads?: boolean
|
autoStartAddedDownloads?: boolean
|
||||||
|
|
||||||
// ✅ Chaturbate Online-Rooms API (Backend pollt, sobald aktiviert)
|
|
||||||
useChaturbateApi?: boolean
|
useChaturbateApi?: boolean
|
||||||
useMyFreeCamsWatcher?: boolean
|
useMyFreeCamsWatcher?: boolean
|
||||||
autoDeleteSmallDownloads?: boolean
|
autoDeleteSmallDownloads?: boolean
|
||||||
@ -24,25 +20,29 @@ type RecorderSettings = {
|
|||||||
blurPreviews?: boolean
|
blurPreviews?: boolean
|
||||||
teaserPlayback?: 'still' | 'hover' | 'all'
|
teaserPlayback?: 'still' | 'hover' | 'all'
|
||||||
teaserAudio?: boolean
|
teaserAudio?: boolean
|
||||||
|
|
||||||
lowDiskPauseBelowGB?: number
|
lowDiskPauseBelowGB?: number
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiskStatus = {
|
||||||
|
emergency: boolean
|
||||||
|
pauseGB: number
|
||||||
|
resumeGB: number
|
||||||
|
freeBytes: number
|
||||||
|
freeBytesHuman: string
|
||||||
|
recordPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const DEFAULTS: RecorderSettings = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
// ✅ relativ zur .exe (Backend löst das auf)
|
|
||||||
recordDir: 'records',
|
recordDir: 'records',
|
||||||
doneDir: 'records/done',
|
doneDir: 'records/done',
|
||||||
ffmpegPath: '',
|
ffmpegPath: '',
|
||||||
|
|
||||||
// ✅ defaults für switches
|
|
||||||
autoAddToDownloadList: true,
|
autoAddToDownloadList: true,
|
||||||
autoStartAddedDownloads: true,
|
|
||||||
|
|
||||||
useChaturbateApi: false,
|
useChaturbateApi: false,
|
||||||
useMyFreeCamsWatcher: false,
|
useMyFreeCamsWatcher: false,
|
||||||
autoDeleteSmallDownloads: false,
|
autoDeleteSmallDownloads: true,
|
||||||
autoDeleteSmallDownloadsBelowMB: 50,
|
autoDeleteSmallDownloadsBelowMB: 200,
|
||||||
blurPreviews: false,
|
blurPreviews: false,
|
||||||
teaserPlayback: 'hover',
|
teaserPlayback: 'hover',
|
||||||
teaserAudio: false,
|
teaserAudio: false,
|
||||||
@ -60,6 +60,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
const [browsing, setBrowsing] = useState<'record' | 'done' | 'ffmpeg' | null>(null)
|
||||||
const [msg, setMsg] = useState<string | null>(null)
|
const [msg, setMsg] = useState<string | null>(null)
|
||||||
const [err, setErr] = 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(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
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') {
|
async function browse(target: 'record' | 'done' | 'ffmpeg') {
|
||||||
setErr(null)
|
setErr(null)
|
||||||
setMsg(null)
|
setMsg(null)
|
||||||
@ -211,12 +238,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
return
|
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
|
if (!ok) return
|
||||||
|
|
||||||
setCleaning(true)
|
setCleaning(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/cleanup-small-downloads', {
|
const res = await fetch('/api/settings/cleanup', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
@ -227,9 +259,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
setMsg(
|
setMsg(
|
||||||
`🧹 Aufräumen fertig: ${data.deletedFiles} Datei(en) gelöscht (${data.deletedBytesHuman}). ` +
|
`🧹 Aufräumen fertig:\n` +
|
||||||
`Geprüft: ${data.scannedFiles}. Übersprungen: ${data.skippedFiles}.`
|
`• 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) {
|
} catch (e: any) {
|
||||||
setErr(e?.message ?? String(e))
|
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="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="flex items-start justify-between gap-3">
|
||||||
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
<div>
|
||||||
Wenn freier Platz darunter fällt: Autostart pausieren + laufende Downloads stoppen. Resume erfolgt automatisch bei +3 GB.
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Pause unter */}
|
<div className="mt-3 text-sm text-gray-900 dark:text-gray-200">
|
||||||
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
<div>
|
||||||
<div className="sm:col-span-4">
|
<span className="font-medium">Schwelle:</span>{' '}
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Pause unter</div>
|
Pause unter <span className="tabular-nums">{uiPauseGB}</span> GB
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300">Freier Speicher in GB</div>
|
{' · '}Resume ab{' '}
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{uiResumeGB}
|
||||||
|
</span>{' '}
|
||||||
|
GB
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-8 flex items-center gap-3">
|
|
||||||
<input
|
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
type="range"
|
{diskStatus
|
||||||
min={1}
|
? `Frei: ${diskStatus.freeBytesHuman}${diskStatus.recordPath ? ` (Pfad: ${diskStatus.recordPath})` : ''}`
|
||||||
max={500}
|
: 'Status wird geladen…'}
|
||||||
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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -65,6 +65,18 @@ export type SwipeCardProps = {
|
|||||||
* (z.B. Buttons/Inputs innerhalb der Karte)
|
* (z.B. Buttons/Inputs innerhalb der Karte)
|
||||||
*/
|
*/
|
||||||
tapIgnoreSelector?: string
|
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 = {
|
export type SwipeCardHandle = {
|
||||||
@ -109,11 +121,17 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
snapMs = 180,
|
snapMs = 180,
|
||||||
commitMs = 180,
|
commitMs = 180,
|
||||||
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]',
|
||||||
|
onDoubleTap,
|
||||||
|
hotTargetSelector = '[data-hot-target]',
|
||||||
|
doubleTapMs = 360,
|
||||||
|
doubleTapMaxMovePx = 48,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const doubleTapBusyRef = React.useRef(false)
|
||||||
|
|
||||||
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
|
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
|
||||||
const dxRef = React.useRef(0)
|
const dxRef = React.useRef(0)
|
||||||
const rafRef = React.useRef<number | null>(null)
|
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)
|
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
|
||||||
const thresholdRef = React.useRef(0)
|
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<{
|
const pointer = React.useRef<{
|
||||||
id: number | null
|
id: number | null
|
||||||
x: number
|
x: number
|
||||||
@ -193,6 +218,145 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
[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(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@ -204,7 +368,10 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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) */}
|
{/* Background actions (100% je Richtung, animiert) */}
|
||||||
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
||||||
<div
|
<div
|
||||||
@ -228,6 +395,12 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* FX Layer (Flame) */}
|
||||||
|
<div
|
||||||
|
ref={fxLayerRef}
|
||||||
|
className="pointer-events-none absolute inset-0 z-50"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Foreground (moves) */}
|
{/* Foreground (moves) */}
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
@ -375,9 +548,8 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
|
|
||||||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||||||
|
|
||||||
if (!wasDragging) {
|
if (!wasDragging) {
|
||||||
// ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten
|
// Tap auf Video/Controls => NICHT anfassen
|
||||||
// sonst “stiehlt” SwipeCard den Tap (iOS besonders empfindlich).
|
|
||||||
if (wasTapIgnored) {
|
if (wasTapIgnored) {
|
||||||
setAnimMs(0)
|
setAnimMs(0)
|
||||||
setDx(0)
|
setDx(0)
|
||||||
@ -385,8 +557,72 @@ const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function Swi
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
reset()
|
const now = Date.now()
|
||||||
onTap?.()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,20 @@ export const apiUrl = (path: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiFetch(path: string, init?: RequestInit) {
|
export async function apiFetch(path: string, init?: RequestInit) {
|
||||||
return fetch(apiUrl(path), {
|
const res = await fetch(apiUrl(path), {
|
||||||
...init,
|
...init,
|
||||||
// falls du Cookies/Sessions brauchst:
|
|
||||||
credentials: 'include',
|
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 = {
|
export type RecordJob = {
|
||||||
id: string
|
id: string
|
||||||
@ -8,23 +16,24 @@ export type RecordJob = {
|
|||||||
startedAt: string
|
startedAt: string
|
||||||
endedAt?: string
|
endedAt?: string
|
||||||
|
|
||||||
// ✅ kommt aus dem Backend bei done-list (und ggf. später auch live)
|
|
||||||
durationSeconds?: number
|
durationSeconds?: number
|
||||||
sizeBytes?: number
|
sizeBytes?: number
|
||||||
videoWidth?: number
|
videoWidth?: number
|
||||||
videoHeight?: number
|
videoHeight?: number
|
||||||
fps?: number
|
fps?: number
|
||||||
|
|
||||||
// ✅ wird fürs UI genutzt (Stop/Finalize Fortschritt)
|
|
||||||
phase?: string
|
phase?: string
|
||||||
progress?: number
|
progress?: number
|
||||||
|
|
||||||
|
// ✅ NEU: Postwork-Queue Status/Position
|
||||||
|
postWorkKey?: string
|
||||||
|
postWork?: PostWorkKeyStatus
|
||||||
|
|
||||||
exitCode?: number
|
exitCode?: number
|
||||||
error?: string
|
error?: string
|
||||||
logTail?: string
|
logTail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export type ParsedModel = {
|
export type ParsedModel = {
|
||||||
input: string
|
input: string
|
||||||
isUrl: boolean
|
isUrl: boolean
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user