This commit is contained in:
Linrador 2026-05-11 19:13:54 +02:00
parent e4635bb1f1
commit 485ee91873
64 changed files with 9244 additions and 3308 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

2
backend/.env Normal file
View File

@ -0,0 +1,2 @@
HTTPS_ENABLED=0
AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@ -104,7 +105,7 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
}
func requiredAnalyzeGoals() []string {
return []string{"nsfw", "highlights"}
return []string{"highlights"}
}
func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool {
@ -133,8 +134,6 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
return false, nil
}
// Normaler Asset-/Postwork-Pfad:
// vorhandene Analyse behalten.
if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) {
return false, nil
}
@ -167,45 +166,26 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
return false, appErrorf("videolänge konnte nicht bestimmt werden")
}
// NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren.
nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(
actx,
videoPath,
"all",
)
hits, err := analyzeVideoFromFrames(actx, videoPath)
if err != nil {
return false, err
}
nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw")
nsfwRating := computeNSFWRating(nsfwSegments, durationSec)
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
nsfwAI := &aiAnalysisMeta{
Goal: "nsfw",
Mode: "video",
Hits: nsfwHits,
Segments: nsfwSegments,
Rating: nsfwRating,
AnalyzedAtUnix: time.Now().Unix(),
}
rating := computeHighlightRating(segments, durationSec)
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil {
return false, err
}
highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights")
highlightRating := computeNSFWRating(highlightSegments, durationSec)
highlightAI := &aiAnalysisMeta{
ai := &aiAnalysisMeta{
Goal: "highlights",
Mode: "video",
Hits: highlightHits,
Segments: highlightSegments,
Rating: highlightRating,
Hits: hits,
Segments: segments,
Rating: rating,
AnalyzedAtUnix: time.Now().Unix(),
}
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, highlightAI); err != nil {
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, ai); err != nil {
return false, err
}
@ -241,8 +221,8 @@ func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
truth.TeaserReady = fileExistsNonEmpty(previewPath)
truth.SpritesReady = fileExistsNonEmpty(spritePath)
// Analyse für Standard-Postwork: NSFW-Rating
truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
// Analyse läuft frame-basiert und braucht kein Sprite mehr.
truth.AnalyzeReady = hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
return truth
}
@ -890,10 +870,7 @@ func ensureAnalyzeForVideoCtx(
return false, nil
}
goal = normalizeAIGoal(goal)
if goal == "" {
goal = "nsfw"
}
goal = "highlights"
if hasAIAnalysisForOutputGoal(videoPath, goal) {
return false, nil
@ -914,7 +891,6 @@ func ensureAnalyzeForVideoCtx(
return false, perr
}
// Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr.
if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil {
return false, err
}
@ -948,10 +924,7 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string,
return appErrorf("deferred analyze failed for %s: %w", videoPath, err)
}
goal = normalizeAIGoal(goal)
if goal == "" {
goal = "highlights"
}
goal = "highlights"
if !hasAIAnalysisForOutputGoal(videoPath, goal) {
return appErrorf("Analyse fehlt nach deferred analyze: %s", id)
@ -1008,7 +981,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
}
if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil)
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil)
}
sortBucket, sortName := postWorkSortForVideoPath(outPath)
@ -1046,7 +1019,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
}
if needAnalyze {
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analysen konnten nicht eingeplant werden", enqueueErr)
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr)
}
return false
@ -1381,35 +1354,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
return out, nil
}
func hasAIResultsForOutputGoal(outPath string, goal string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
return false
}
id := assetIDFromVideoPath(outPath)
if id == "" {
return false
}
metaPath, err := generatedMetaFile(id)
if err != nil || strings.TrimSpace(metaPath) == "" {
return false
}
goal = normalizeAIGoal(goal)
aiMap, ok := readVideoMetaAIForGoal(metaPath, goal)
if !ok || aiMap == nil {
return false
}
rawHits, hasHits := aiMap["hits"].([]any)
rawSegs, hasSegs := aiMap["segments"].([]any)
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
}
func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
@ -1426,40 +1370,63 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
return false
}
goal = normalizeAIGoal(goal)
aiMap, ok := readVideoMetaAIForGoal(metaPath, goal)
aiMap, ok := readVideoMetaAIForGoal(metaPath, "highlights")
if !ok || aiMap == nil {
return false
}
// Wichtig:
// Eine Analyse mit 0 Hits / 0 Segments ist trotzdem eine fertige Analyse.
// Sonst zeigt das Frontend fälschlich "Analyse fehlt".
if jsonNumberPositive(aiMap["analyzedAtUnix"]) {
return true
}
if rating, ok := aiMap["rating"].(map[string]any); ok && rating != nil {
return true
}
// Legacy-Fallback für alte meta.json-Dateien.
rawHits, hasHits := aiMap["hits"].([]any)
rawSegs, hasSegs := aiMap["segments"].([]any)
// Wichtig:
// Eine leere Analyse zählt NICHT mehr als fertig.
// Sonst bleibt ein kaputter 0-Treffer-Run für immer gecached.
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
}
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
outPath = strings.TrimSpace(outPath)
if outPath == "" {
func jsonNumberPositive(v any) bool {
switch x := v.(type) {
case json.Number:
i, err := x.Int64()
return err == nil && i > 0
case float64:
return x > 0
case int64:
return x > 0
case int:
return x > 0
default:
return false
}
}
if len(goals) == 0 {
goals = requiredAnalyzeGoals()
func isPositiveJSONNumber(v any) bool {
switch x := v.(type) {
case json.Number:
i, err := x.Int64()
return err == nil && i > 0
case float64:
return x > 0
case int64:
return x > 0
case int:
return x > 0
default:
return false
}
}
for _, goal := range goals {
goal = normalizeAIGoal(goal)
if !hasAIAnalysisForOutputGoal(outPath, goal) {
return false
}
}
return true
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
return hasAIAnalysisForOutputGoal(outPath, "highlights")
}
type PrepareSplitResult struct {
@ -1519,16 +1486,12 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
out.AssetsReady = thumbExists && previewExists
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "highlights"
}
goal = "highlights"
// 2) Für Split brauchen wir zusätzlich Sprite + AI.
// Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen.
// (Im normalen Record-Flow laufen sie separat im Hintergrund.)
// Im normalen Record-Flow laufen sie separat im Hintergrund.
if err := ensureDeferredAssetsAndAI(ctx, videoPath, sourceURL, goal); err != nil {
// best effort: kein harter Fehler, Status unten nochmal aus Dateisystem/meta ableiten
appLogln("⚠️ prepareVideoForSplit deferred enrich:", err)
}
@ -1541,29 +1504,29 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
out.SpriteReady = spriteExists
// 3) AI-Segmente prüfen
if hasAIAnalysisForOutputGoal(videoPath, goal) {
if hasAIAnalysisForOutputGoal(videoPath, "highlights") {
out.AnalyzeReady = true
return out, nil
}
// 4) Fallback: AI direkt berechnen
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal)
if durationSec <= 0 {
return out, nil
}
hits, aerr := analyzeVideoFromFrames(ctx, videoPath)
if aerr != nil {
return out, nil
}
segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal)
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
segments = prepareAIRatingSegments(segments)
var rating *aiRatingMeta
if goal == "nsfw" || goal == "highlights" {
rating = computeNSFWRating(segments, durationSec)
}
rating := computeHighlightRating(segments, durationSec)
ai := &aiAnalysisMeta{
Goal: goal,
Mode: "sprite",
Goal: "highlights",
Mode: "video",
Hits: hits,
Segments: segments,
Rating: rating,
@ -1574,6 +1537,6 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
return out, nil
}
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal)
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights")
return out, nil
}

View File

@ -14,16 +14,33 @@ import (
"sync"
"time"
"github.com/go-webauthn/webauthn/webauthn"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
type passkeyInfo struct {
CredentialID string `json:"credentialId"`
Label string `json:"label"`
CreatedAt string `json:"createdAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
}
type totpInfo struct {
Label string `json:"label,omitempty"`
ConfiguredAt string `json:"configuredAt,omitempty"`
}
type authConfig struct {
Username string `json:"username"`
PasswordHash string `json:"passwordHash"` // bcrypt hash
TOTPEnabled bool `json:"totpEnabled"`
TOTPSecret string `json:"totpSecret"` // base32
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
TOTPEnabled bool `json:"totpEnabled"`
TOTPSecret string `json:"totpSecret"`
TOTPInfo totpInfo `json:"totpInfo,omitempty"`
PasskeyUserID string `json:"passkeyUserId,omitempty"`
Passkeys []webauthn.Credential `json:"passkeys,omitempty"`
PasskeyInfos []passkeyInfo `json:"passkeyInfos,omitempty"`
}
// Session in memory (Restart = logout für alle)
@ -43,6 +60,11 @@ type AuthManager struct {
sessMu sync.Mutex
sess map[string]*session
passkeyMu sync.Mutex
passkeySessions map[string]*webauthn.SessionData
webAuthn *webauthn.WebAuthn
configPath string
}
@ -53,7 +75,8 @@ const (
func NewAuthManager() (*AuthManager, error) {
am := &AuthManager{
sess: make(map[string]*session),
sess: make(map[string]*session),
passkeySessions: make(map[string]*webauthn.SessionData),
}
// optional: persist config in file
@ -75,10 +98,22 @@ func NewAuthManager() (*AuthManager, error) {
// 3) sanity
am.confMu.Lock()
defer am.confMu.Unlock()
if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" {
am.confMu.Unlock()
return nil, errors.New("AUTH_USER/AUTH_PASS_HASH fehlen (oder auth.json unvollständig)")
}
am.confMu.Unlock()
// 4) Passkey User-ID sicherstellen
if err := am.ensurePasskeyUserID(); err != nil {
return nil, err
}
// 5) WebAuthn initialisieren
if err := am.initWebAuthn(); err != nil {
return nil, err
}
return am, nil
}
@ -165,6 +200,51 @@ func atomicWriteFileCompat(path string, data []byte) error {
return os.Rename(tmp, path)
}
func credentialIDString(id []byte) string {
return base64.RawURLEncoding.EncodeToString(id)
}
func shortCredentialID(id string) string {
id = strings.TrimSpace(id)
if len(id) <= 12 {
return id
}
return id[:6] + "…" + id[len(id)-6:]
}
func findPasskeyInfo(infos []passkeyInfo, credentialID string) (passkeyInfo, bool) {
for _, info := range infos {
if strings.TrimSpace(info.CredentialID) == credentialID {
return info, true
}
}
return passkeyInfo{}, false
}
func upsertPasskeyInfo(infos []passkeyInfo, next passkeyInfo) []passkeyInfo {
next.CredentialID = strings.TrimSpace(next.CredentialID)
if next.CredentialID == "" {
return infos
}
for i := range infos {
if strings.TrimSpace(infos[i].CredentialID) == next.CredentialID {
if strings.TrimSpace(next.Label) != "" {
infos[i].Label = next.Label
}
if strings.TrimSpace(next.CreatedAt) != "" {
infos[i].CreatedAt = next.CreatedAt
}
if strings.TrimSpace(next.LastUsedAt) != "" {
infos[i].LastUsedAt = next.LastUsedAt
}
return infos
}
}
return append(infos, next)
}
func generateRandomPassword() (string, error) {
// 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren
b := make([]byte, 24)
@ -379,11 +459,56 @@ func authMeHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, _ := am.getSession(r)
// ✅ Konfiguration atomar lesen
am.confMu.Lock()
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
totpFlag := am.conf.TOTPEnabled
totpEnabled := totpFlag && secretPresent
totpConfigured := totpEnabled
passkeyCount := len(am.conf.Passkeys)
totpDevice := map[string]any(nil)
if totpConfigured {
label := strings.TrimSpace(am.conf.TOTPInfo.Label)
if label == "" {
label = "Authenticator-App"
}
totpDevice = map[string]any{
"id": "totp",
"label": label,
"type": "Authenticator-App",
"configuredAt": strings.TrimSpace(am.conf.TOTPInfo.ConfiguredAt),
"active": totpEnabled,
}
}
passkeys := make([]map[string]any, 0, len(am.conf.Passkeys))
for i, cred := range am.conf.Passkeys {
credentialID := credentialIDString(cred.ID)
info, ok := findPasskeyInfo(am.conf.PasskeyInfos, credentialID)
label := strings.TrimSpace(info.Label)
if label == "" {
label = "Passkey " + shortCredentialID(credentialID)
}
createdAt := strings.TrimSpace(info.CreatedAt)
lastUsedAt := strings.TrimSpace(info.LastUsedAt)
passkeys = append(passkeys, map[string]any{
"id": credentialID,
"label": label,
"type": "Passkey",
"createdAt": createdAt,
"lastUsedAt": lastUsedAt,
"signCount": cred.Authenticator.SignCount,
"backupState": cred.Flags.BackupState,
"backupEligible": cred.Flags.BackupEligible,
"index": i + 1,
"known": ok,
})
}
am.confMu.Unlock()
w.Header().Set("Content-Type", "application/json")
@ -393,15 +518,14 @@ func authMeHandler(am *AuthManager) http.HandlerFunc {
"authenticated": s != nil && s.Authed,
"pending2fa": s != nil && s.Pending2FA,
// ✅ „wirklich aktiv“ (Flag + Secret vorhanden)
"totpEnabled": totpEnabled,
"totpEnabled": totpEnabled,
"totpConfigured": totpConfigured,
"totpFlag": totpFlag,
"totpDevice": totpDevice,
// ✅ Zusatzinfos für UI/Debug:
// Secret existiert schon (Setup gemacht), aber evtl. noch nicht enabled
"totpConfigured": secretPresent,
// reiner Flag-Status (kann true sein, obwohl Secret fehlt)
"totpFlag": totpFlag,
"passkeyConfigured": passkeyCount > 0,
"passkeyCount": passkeyCount,
"passkeys": passkeys,
})
}
}
@ -541,6 +665,10 @@ func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
}
am.conf.TOTPSecret = key.Secret()
am.conf.TOTPEnabled = false // erst nach Enable aktiv
am.conf.TOTPInfo = totpInfo{
Label: "Authenticator-App",
ConfiguredAt: time.Now().Format(time.RFC3339),
}
am.confMu.Unlock()
// 4) persist ohne Lock
@ -641,3 +769,244 @@ func auth2FAEnableHandler(am *AuthManager) http.HandlerFunc {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
type passwordChangeReq struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
func authPasswordChangeHandler(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 || !s.Authed || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req passwordChangeReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
currentPassword := strings.TrimSpace(req.CurrentPassword)
newPassword := strings.TrimSpace(req.NewPassword)
if currentPassword == "" {
http.Error(w, "current password required", http.StatusBadRequest)
return
}
if len(newPassword) < 8 {
http.Error(w, "new password must be at least 8 characters", http.StatusBadRequest)
return
}
am.confMu.Lock()
oldHash := am.conf.PasswordHash
am.confMu.Unlock()
if err := bcrypt.CompareHashAndPassword([]byte(oldHash), []byte(currentPassword)); err != nil {
http.Error(w, "invalid current password", http.StatusUnauthorized)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "bcrypt failed", http.StatusInternalServerError)
return
}
am.confMu.Lock()
am.conf.PasswordHash = string(hash)
am.confMu.Unlock()
go am.persistConfBestEffort()
// Alle anderen Sessions abmelden, aktuelle Session behalten.
am.sessMu.Lock()
cur := am.sess[sid]
am.sess = map[string]*session{}
if cur != nil {
am.sess[sid] = cur
}
am.sessMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}
type disable2FAReq struct {
CurrentPassword string `json:"currentPassword"`
Code string `json:"code"`
}
func auth2FADisableHandler(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, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req disable2FAReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
currentPassword := strings.TrimSpace(req.CurrentPassword)
code := strings.TrimSpace(req.Code)
if currentPassword == "" {
http.Error(w, "current password required", http.StatusBadRequest)
return
}
am.confMu.Lock()
hash := am.conf.PasswordHash
secret := strings.TrimSpace(am.conf.TOTPSecret)
totpEnabled := am.conf.TOTPEnabled && secret != ""
am.confMu.Unlock()
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(currentPassword)); err != nil {
http.Error(w, "invalid current password", http.StatusUnauthorized)
return
}
if totpEnabled {
if code == "" {
http.Error(w, "2fa code required", http.StatusBadRequest)
return
}
if !totp.Validate(code, secret) {
http.Error(w, "invalid code", http.StatusUnauthorized)
return
}
}
am.confMu.Lock()
am.conf.TOTPEnabled = false
am.conf.TOTPSecret = ""
am.conf.TOTPInfo = totpInfo{}
am.confMu.Unlock()
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})
}
}
type passkeyDeleteReq struct {
CredentialID string `json:"credentialId"`
}
func authPasskeyDeleteHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "DELETE required", http.StatusMethodNotAllowed)
return
}
s, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req passkeyDeleteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
credentialID := strings.TrimSpace(req.CredentialID)
if credentialID == "" {
http.Error(w, "credentialId required", http.StatusBadRequest)
return
}
am.confMu.Lock()
nextPasskeys := make([]webauthn.Credential, 0, len(am.conf.Passkeys))
removed := false
for _, cred := range am.conf.Passkeys {
if credentialIDString(cred.ID) == credentialID {
removed = true
continue
}
nextPasskeys = append(nextPasskeys, cred)
}
if !removed {
am.confMu.Unlock()
http.Error(w, "passkey not found", http.StatusNotFound)
return
}
nextInfos := make([]passkeyInfo, 0, len(am.conf.PasskeyInfos))
for _, info := range am.conf.PasskeyInfos {
if strings.TrimSpace(info.CredentialID) == credentialID {
continue
}
nextInfos = append(nextInfos, info)
}
am.conf.Passkeys = nextPasskeys
am.conf.PasskeyInfos = nextInfos
am.confMu.Unlock()
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})
}
}
func auth2FACancelSetupHandler(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, _ := am.getSession(r)
if s == nil || !s.Authed {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
am.confMu.Lock()
// Nur ein unfertiges Setup entfernen.
// Wenn 2FA bereits aktiv ist, darf dieser Endpoint nichts deaktivieren.
if !am.conf.TOTPEnabled {
am.conf.TOTPSecret = ""
am.conf.TOTPInfo = totpInfo{}
}
am.confMu.Unlock()
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})
}
}

578
backend/auth_passkey.go Normal file
View File

@ -0,0 +1,578 @@
// backend\auth_passkey.go
package main
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
)
const passkeyChallengeCookieName = "pk_challenge"
type recorderWebAuthnUser struct {
id []byte
name string
displayName string
credentials []webauthn.Credential
}
func (u recorderWebAuthnUser) WebAuthnID() []byte {
return u.id
}
func (u recorderWebAuthnUser) WebAuthnName() string {
return u.name
}
func (u recorderWebAuthnUser) WebAuthnDisplayName() string {
return u.displayName
}
func (u recorderWebAuthnUser) WebAuthnIcon() string {
return ""
}
func (u recorderWebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
return u.credentials
}
func randomBase64URL(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func (am *AuthManager) ensurePasskeyUserID() error {
am.confMu.Lock()
needsID := strings.TrimSpace(am.conf.PasskeyUserID) == ""
if needsID {
id, err := randomBase64URL(32)
if err != nil {
am.confMu.Unlock()
return err
}
am.conf.PasskeyUserID = id
}
am.confMu.Unlock()
if needsID {
am.persistConfBestEffort()
}
return nil
}
func (am *AuthManager) initWebAuthn() error {
origins := authAllowedRPOrigins()
if len(origins) == 0 {
return errors.New("no WebAuthn RP origins configured")
}
appLogln("🔐 WebAuthn allowed origins:", strings.Join(origins, ", "))
appLogln("🔐 AUTH_RP_ID:", strings.TrimSpace(os.Getenv("AUTH_RP_ID")))
rpID, err := rpIDForOrigin(origins[0])
if err != nil {
return err
}
wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: "Recorder",
RPID: rpID,
RPOrigins: origins,
})
if err != nil {
return err
}
am.webAuthn = wa
return nil
}
func authAllowedRPOrigins() []string {
raw := strings.TrimSpace(os.Getenv("AUTH_RP_ORIGINS"))
// Backward compatible: alter Einzelwert funktioniert weiter.
if raw == "" {
raw = strings.TrimSpace(os.Getenv("AUTH_RP_ORIGIN"))
}
if raw == "" {
raw = strings.Join([]string{
"https://l14pbbk95100006.tegdssd.de:9999",
"https://localhost:9999",
"https://127.0.0.1:9999",
"https://10.0.1.25:9999",
"http://localhost:9999",
"http://127.0.0.1:9999",
"http://10.0.1.25:9999",
"http://l14pbbk95100006.tegdssd.de:5173",
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://10.0.1.25:5173",
}, ",")
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := map[string]bool{}
for _, p := range parts {
origin := strings.TrimSpace(p)
origin = strings.TrimRight(origin, "/")
if origin == "" {
continue
}
if seen[origin] {
continue
}
seen[origin] = true
out = append(out, origin)
}
return out
}
func originFromRequest(r *http.Request) string {
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin != "" {
return strings.TrimRight(origin, "/")
}
proto := "http"
if isSecureRequest(r) {
proto = "https"
}
host := strings.TrimSpace(r.Host)
if host == "" {
return ""
}
return proto + "://" + host
}
func rpIDFromOrigin(origin string) (string, error) {
u, err := url.Parse(strings.TrimSpace(origin))
if err != nil {
return "", err
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "" {
return "", errors.New("origin has no hostname")
}
if net.ParseIP(host) != nil {
return "", errors.New(
"Passkeys/WebAuthn funktionieren nicht mit einer IP-Adresse als RP-ID. " +
"Öffne die App über einen Hostnamen, z. B. https://recorder.home.arpa:9999",
)
}
return host, nil
}
func rpIDIsValidForOrigin(rpID string, origin string) bool {
rpID = strings.ToLower(strings.TrimSpace(rpID))
if rpID == "" {
return false
}
u, err := url.Parse(strings.TrimSpace(origin))
if err != nil {
return false
}
host := strings.ToLower(strings.TrimSpace(u.Hostname()))
if host == "" {
return false
}
// IP-Adressen dürfen bei WebAuthn nur exakt als RP-ID verwendet werden.
if net.ParseIP(host) != nil {
return rpID == host
}
// localhost darf ebenfalls nur exakt localhost sein.
if host == "localhost" {
return rpID == "localhost"
}
// Normale Domains: RP-ID darf Host selbst oder Parent-Domain sein.
return host == rpID || strings.HasSuffix(host, "."+rpID)
}
func rpIDForOrigin(origin string) (string, error) {
configured := strings.TrimSpace(os.Getenv("AUTH_RP_ID"))
if configured != "" {
if rpIDIsValidForOrigin(configured, origin) {
return configured, nil
}
appLogln(
"⚠️ AUTH_RP_ID passt nicht zur aktuellen Origin, nutze Origin-Host:",
"AUTH_RP_ID=", configured,
"origin=", origin,
)
}
return rpIDFromOrigin(origin)
}
func originIsAllowed(origin string, allowed []string) bool {
origin = strings.TrimRight(strings.TrimSpace(origin), "/")
for _, a := range allowed {
if origin == strings.TrimRight(strings.TrimSpace(a), "/") {
return true
}
}
return false
}
func (am *AuthManager) webAuthnForRequest(r *http.Request) (*webauthn.WebAuthn, error) {
origin := originFromRequest(r)
if origin == "" {
return nil, errors.New("request origin missing")
}
allowedOrigins := authAllowedRPOrigins()
if !originIsAllowed(origin, allowedOrigins) {
return nil, errors.New("origin not allowed for WebAuthn: " + origin)
}
rpID, err := rpIDForOrigin(origin)
if err != nil {
return nil, err
}
appLogln("🔐 WebAuthn Request:", "origin=", origin, "rpID=", rpID)
return webauthn.New(&webauthn.Config{
RPDisplayName: "Recorder",
RPID: rpID,
RPOrigins: []string{origin},
})
}
func (am *AuthManager) webAuthnUser() (recorderWebAuthnUser, error) {
am.confMu.Lock()
defer am.confMu.Unlock()
idRaw := strings.TrimSpace(am.conf.PasskeyUserID)
id, err := base64.RawURLEncoding.DecodeString(idRaw)
if err != nil {
return recorderWebAuthnUser{}, err
}
username := strings.TrimSpace(am.conf.Username)
if username == "" {
username = "admin"
}
return recorderWebAuthnUser{
id: id,
name: username,
displayName: username,
credentials: append([]webauthn.Credential(nil), am.conf.Passkeys...),
}, nil
}
func setPasskeyChallengeCookie(w http.ResponseWriter, id string, secure bool) {
http.SetCookie(w, &http.Cookie{
Name: passkeyChallengeCookieName,
Value: id,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: secure,
Expires: time.Now().Add(5 * time.Minute),
})
}
func clearPasskeyChallengeCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: passkeyChallengeCookieName,
Value: "",
Path: "/",
HttpOnly: true,
Expires: time.Unix(0, 0),
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
})
}
func (am *AuthManager) savePasskeySession(key string, data *webauthn.SessionData) {
am.passkeyMu.Lock()
defer am.passkeyMu.Unlock()
am.passkeySessions[key] = data
}
func (am *AuthManager) takePasskeySession(key string) *webauthn.SessionData {
am.passkeyMu.Lock()
defer am.passkeyMu.Unlock()
data := am.passkeySessions[key]
delete(am.passkeySessions, key)
return data
}
func authPasskeyRegisterOptionsHandler(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 || !s.Authed || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, err := am.webAuthnUser()
if err != nil {
http.Error(w, "passkey user error", http.StatusInternalServerError)
return
}
wa, err := am.webAuthnForRequest(r)
if err != nil {
appLogln("passkey registration options config failed:", err)
http.Error(w, "passkey registration options config failed: "+err.Error(), http.StatusBadRequest)
return
}
options, sessionData, err := wa.BeginRegistration(
user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
webauthn.WithExclusions(webauthn.Credentials(user.WebAuthnCredentials()).CredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}),
)
if err != nil {
http.Error(w, "passkey registration options failed", http.StatusInternalServerError)
return
}
am.savePasskeySession("register:"+sid, sessionData)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(options)
}
}
func authPasskeyRegisterVerifyHandler(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 || !s.Authed || sid == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
sessionData := am.takePasskeySession("register:" + sid)
if sessionData == nil {
http.Error(w, "passkey challenge expired", http.StatusBadRequest)
return
}
user, err := am.webAuthnUser()
if err != nil {
http.Error(w, "passkey user error", http.StatusInternalServerError)
return
}
wa, err := am.webAuthnForRequest(r)
if err != nil {
appLogln("passkey registration verify config failed:", err)
http.Error(w, "passkey registration verify config failed: "+err.Error(), http.StatusBadRequest)
return
}
credential, err := wa.FinishRegistration(user, *sessionData, r)
if err != nil {
appLogln("passkey registration verify failed:", err)
http.Error(w, "passkey registration verify failed: "+err.Error(), http.StatusUnauthorized)
return
}
now := time.Now().Format(time.RFC3339)
credentialID := credentialIDString(credential.ID)
am.confMu.Lock()
am.conf.Passkeys = append(am.conf.Passkeys, *credential)
am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{
CredentialID: credentialID,
Label: "Passkey " + shortCredentialID(credentialID),
CreatedAt: now,
})
am.confMu.Unlock()
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})
}
}
func authPasskeyLoginOptionsHandler(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
}
user, err := am.webAuthnUser()
if err != nil {
http.Error(w, "passkey user error", http.StatusInternalServerError)
return
}
if len(user.credentials) == 0 {
http.Error(w, "no passkey configured", http.StatusBadRequest)
return
}
wa, err := am.webAuthnForRequest(r)
if err != nil {
appLogln("passkey login options config failed:", err)
http.Error(w, "passkey login options config failed: "+err.Error(), http.StatusBadRequest)
return
}
options, sessionData, err := wa.BeginLogin(
user,
webauthn.WithUserVerification(protocol.VerificationPreferred),
)
if err != nil {
http.Error(w, "passkey login options failed", http.StatusInternalServerError)
return
}
challengeID, err := randomBase64URL(24)
if err != nil {
http.Error(w, "challenge error", http.StatusInternalServerError)
return
}
am.savePasskeySession("login:"+challengeID, sessionData)
setPasskeyChallengeCookie(w, challengeID, isSecureRequest(r))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(options)
}
}
func authPasskeyLoginVerifyHandler(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
}
c, err := r.Cookie(passkeyChallengeCookieName)
if err != nil || strings.TrimSpace(c.Value) == "" {
http.Error(w, "passkey challenge missing", http.StatusBadRequest)
return
}
challengeID := strings.TrimSpace(c.Value)
clearPasskeyChallengeCookie(w)
sessionData := am.takePasskeySession("login:" + challengeID)
if sessionData == nil {
http.Error(w, "passkey challenge expired", http.StatusBadRequest)
return
}
user, err := am.webAuthnUser()
if err != nil {
http.Error(w, "passkey user error", http.StatusInternalServerError)
return
}
wa, err := am.webAuthnForRequest(r)
if err != nil {
appLogln("passkey login verify config failed:", err)
http.Error(w, "passkey login verify config failed: "+err.Error(), http.StatusBadRequest)
return
}
credential, err := wa.FinishLogin(user, *sessionData, r)
if err != nil {
appLogln("passkey login verify failed:", err)
http.Error(w, "passkey login verify failed: "+err.Error(), http.StatusUnauthorized)
return
}
credentialID := credentialIDString(credential.ID)
nowISO := time.Now().Format(time.RFC3339)
am.confMu.Lock()
for i := range am.conf.Passkeys {
if string(am.conf.Passkeys[i].ID) == string(credential.ID) {
am.conf.Passkeys[i].Authenticator.SignCount = credential.Authenticator.SignCount
break
}
}
am.conf.PasskeyInfos = upsertPasskeyInfo(am.conf.PasskeyInfos, passkeyInfo{
CredentialID: credentialID,
LastUsedAt: nowISO,
})
username := am.conf.Username
am.confMu.Unlock()
go am.persistConfBestEffort()
sid, err := newSessionID()
if err != nil {
http.Error(w, "session error", http.StatusInternalServerError)
return
}
now := time.Now()
s := &session{
User: username,
Authed: true,
Pending2FA: false,
CreatedAt: now,
LastSeenAt: now,
ExpiresAt: now.Add(sessionTTL),
}
am.sessMu.Lock()
am.sess[sid] = s
am.sessMu.Unlock()
setSessionCookie(w, sid, isSecureRequest(r))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
}

View File

@ -3,23 +3,45 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type autoStartItem struct {
userKey string
url string
blind bool
source string // "watched" | "manual" | "resume"
force bool // true = ignoriert Autostart-Pause + Download-Limit
userKey string
url string
blind bool
source string // "watched" | "manual" | "resume"
force bool
ownerUserKey string // nur für manuell hinzugefügte Pending-Einträge
}
var lastChaturbateDownloadLimitLogAt time.Time
func logChaturbateAutoStartError(prefix string, modelURL string, err error) {
if err == nil {
return
}
// Download-Limit nur selten loggen, weil der Autostart-Worker regelmäßig retryt.
if isDownloadLimitReachedError(err) {
now := time.Now()
if !lastChaturbateDownloadLimitLogAt.IsZero() &&
now.Sub(lastChaturbateDownloadLimitLogAt) < 60*time.Second {
return
}
lastChaturbateDownloadLimitLogAt = now
appLogln(prefix, modelURL, err)
return
}
appLogln(prefix, modelURL, err)
}
func normUser(s string) string {
@ -148,13 +170,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
}
jobsMu.RUnlock()
// Job schon weg oder nicht mehr running -> nichts tun
if job == nil || status != JobRunning {
return
}
// echte Output-Datei vorhanden?
if out != "" {
// Wichtig: zuerst prüfen, bevor wir bei "nicht mehr running" zurückkehren.
// Ein Job kann sehr schnell in Postwork/Stopped/Failed wechseln.
if job != nil && out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
// hidden blind-try wird jetzt sichtbar gemacht
if hidden {
@ -175,6 +194,16 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
}
}
// Job schon weg oder nicht mehr running, aber keine Datei entstanden.
// Für resume ist das wichtig: Der Pending-Eintrag wurde beim Start entfernt
// und muss bei Start ohne Output wiederhergestellt werden.
if job == nil || status != JobRunning {
if onNoOutput != nil {
onNoOutput()
}
return
}
time.Sleep(900 * time.Millisecond)
}
@ -229,71 +258,6 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
}
}
func clearAllPendingAutoStartOnStartup() error {
pendingAutoStartMu.Lock()
defer pendingAutoStartMu.Unlock()
dir := filepath.Join("data", "pending-autostart")
entries, err := os.ReadDir(dir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := strings.TrimSpace(entry.Name())
if name == "" {
continue
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
path := filepath.Join(dir, name)
raw, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return err
}
var f pendingAutoStartFile
if err := json.Unmarshal(raw, &f); err != nil {
_ = os.Remove(path)
continue
}
kept := make([]PendingAutoStartItem, 0, len(f.Items))
for _, it := range f.Items {
if normalizePendingSourceServer(it.Source) == "resume" {
kept = append(kept, it)
}
}
if len(kept) == 0 {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
continue
}
if err := savePendingAutoStartItems(strings.TrimSuffix(name, filepath.Ext(name)), kept); err != nil {
return err
}
}
return nil
}
// Startet watched+online(public) automatisch unabhängig vom Frontend
func startChaturbateAutoStartWorker(store *ModelStore) {
if store == nil {
@ -573,6 +537,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
show := normalizePendingShowServer(showByUser[it.userKey])
if it.blind {
// Offline-/Unknown-Blind-Probes nur für manuell hinzugefügte Models.
// Watched/Resume dürfen bei offline nicht in "Wartend" bleiben.
if it.source != "manual" {
continue
}
if show != "offline" && show != "unknown" {
continue
}
@ -590,20 +560,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued = nextQueued
blindQueued := false
selectedBlindUser := hiddenProbeUser
for _, it := range queue {
if it.blind {
blindQueued = true
if selectedBlindUser == "" {
selectedBlindUser = it.userKey
}
break
}
}
offlineCandidates := make([]autoStartItem, 0, len(watchedOrder))
nextPending := make([]PendingAutoStartItem, 0, len(watchedOrder)+len(resumeOrder)+len(runningByUser))
nextWatchedPending := make([]PendingAutoStartItem, 0, len(watchedOrder))
now := time.Now()
resumePendingThisScan := map[string]bool{}
@ -631,14 +597,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
img := strings.TrimSpace(imageByUser[user])
nextPending = append(nextPending, PendingAutoStartItem{
// Wichtig:
// Resume NICHT in nextPending speichern.
// nextPending wird später über saveWatchedPendingAutoStartItemsForProvider()
// gespeichert, und diese Funktion setzt Source hart auf "watched".
// Deshalb Resume separat in der globalen Pending-Datei speichern.
pendingAutoStartMu.Lock()
if err := saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
}); err != nil && verboseLogs() {
appLogln("⚠️ [autostart] save resume pending failed:", user, err)
}
pendingAutoStartMu.Unlock()
resumePendingThisScan[user] = true
@ -679,12 +654,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
})
queued[user] = true
case "private", "hidden", "away", "offline", "unknown":
case "private", "hidden", "away":
if resumePendingThisScan[user] {
continue
}
nextPending = append(nextPending, PendingAutoStartItem{
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
@ -692,6 +668,28 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
case "offline":
pendingAutoStartMu.Lock()
_ = removeResumePendingAutoStartItemForProvider("chaturbate", user)
pendingAutoStartMu.Unlock()
case "unknown":
if resumePendingThisScan[user] {
continue
}
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
CurrentShow: show,
ImageURL: img,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
}
@ -730,7 +728,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queued[user] = true
case "private", "hidden", "away":
nextPending = append(nextPending, PendingAutoStartItem{
// Wenn der User in diesem Scan gerade wegen private/hidden/away
// aus einem laufenden Download gestoppt wurde, existiert bereits
// ein echter resume-Eintrag. Dann keinen normalen watched-Eintrag
// zusätzlich speichern.
if resumePendingThisScan[user] {
continue
}
nextWatchedPending = append(nextWatchedPending, PendingAutoStartItem{
ModelKey: user,
URL: u,
Mode: "wait_public",
@ -739,26 +745,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
Source: "watched",
})
default:
if autostartPaused {
continue
}
if runningByUser[user] != nil {
continue
}
if queued[user] {
continue
}
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
continue
}
case "offline":
// Watched-Model ist wirklich offline:
// nicht als "Wartend" anzeigen und keinen Blind-Probe starten.
continue
offlineCandidates = append(offlineCandidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "watched",
})
default:
// unknown nicht aggressiv in Wartend schieben.
// Wenn kein frischer Online-Status da ist, lieber nichts tun.
continue
}
}
@ -792,10 +787,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
queue = append(queue, autoStartItem{
userKey: user,
url: u,
blind: false,
source: "manual",
userKey: user,
url: u,
blind: false,
source: "manual",
ownerUserKey: itm.OwnerUserKey,
})
queued[user] = true
@ -818,10 +814,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
offlineCandidates = append(offlineCandidates, autoStartItem{
userKey: user,
url: u,
blind: true,
source: "manual",
userKey: user,
url: u,
blind: true,
source: "manual",
ownerUserKey: itm.OwnerUserKey,
})
}
}
@ -848,39 +845,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queue = append(queue, it)
queued[it.userKey] = true
selectedBlindUser = it.userKey
probeCursor = (idx + 1) % n
break
}
}
if selectedBlindUser != "" && !autostartPaused {
if m, ok := watchedByUser[selectedBlindUser]; ok {
u := resolveChaturbateURL(m)
if u != "" {
show := normalizePendingShowServer(showByUser[selectedBlindUser])
img := strings.TrimSpace(imageByUser[selectedBlindUser])
nextProbeAtMs := now.Add(blindRetryCooldown).UnixMilli()
if t, ok := lastBlindTry[selectedBlindUser]; ok {
nextProbeAtMs = t.Add(blindRetryCooldown).UnixMilli()
}
nextPending = append(nextPending, PendingAutoStartItem{
ModelKey: selectedBlindUser,
URL: u,
Mode: "probe_retry",
NextProbeAtMs: nextProbeAtMs,
CurrentShow: show,
ImageURL: img,
Source: "watched",
})
}
}
}
pendingAutoStartMu.Lock()
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending)
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending)
pendingAutoStartMu.Unlock()
case <-startTicker.C:
@ -908,7 +879,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
if u == "" {
continue
}
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
}
paused := isAutostartPaused()
@ -931,8 +902,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
queue = append(queue[:startIdx], queue[startIdx+1:]...)
delete(queued, it.userKey)
show := strings.TrimSpace(showByUser[it.userKey])
isPublic := strings.Contains(show, "public")
show := normalizePendingShowServer(showByUser[it.userKey])
isPublic := show == "public"
// Normale queued Starts dürfen NICHT als Blind-Probe gestartet werden.
// Wenn ein resume/watched/manual Eintrag inzwischen nicht mehr public ist,
// bleibt er wartend bzw. wird im nächsten Scan korrekt neu bewertet.
if !it.blind && !isPublic {
if it.source == "resume" {
pendingAutoStartMu.Lock()
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: show,
Source: "resume",
})
pendingAutoStartMu.Unlock()
}
continue
}
// Nicht-public nur einzeln nacheinander prüfen.
if it.blind && hasHiddenProbeRunningForProvider("chaturbate") {
@ -943,7 +933,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
continue
}
if !it.blind && isPublic {
if !it.blind {
lastTry[it.userKey] = time.Now()
job, err := startRecordingInternal(RecordRequest{
@ -953,7 +943,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
})
if err != nil {
if verboseLogs() {
appLogln("❌ [autostart] start failed:", it.url, err)
logChaturbateAutoStartError("❌ [autostart] start failed:", it.url, err)
}
continue
}
@ -1016,7 +1006,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
})
if err != nil || job == nil {
if verboseLogs() {
appLogln("❌ [autostart] blind start failed:", it.url, err)
if err != nil {
logChaturbateAutoStartError("❌ [autostart] blind start failed:", it.url, err)
} else {
appLogln("❌ [autostart] blind start failed:", it.url, "job is nil")
}
}
continue
}
@ -1026,11 +1020,34 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
}
if it.source == "manual" {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
}, nil)
go chaturbateAbortIfNoOutput(
job.ID,
outputProbeMax,
func() {
// Download läuft wirklich: manuellen Wartend-Eintrag entfernen.
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
pendingAutoStartMu.Unlock()
},
func() {
// Blind-Probe hat keine Datei erzeugt:
// Jetzt erst als "Wartend" speichern.
owner := strings.TrimSpace(it.ownerUserKey)
if owner == "" {
return
}
pendingAutoStartMu.Lock()
_ = saveManualPendingAutoStartItemForUser(owner, PendingAutoStartItem{
ModelKey: it.userKey,
URL: it.url,
Mode: "wait_public",
CurrentShow: "offline",
Source: "manual",
})
pendingAutoStartMu.Unlock()
},
)
} else {
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil)
}

View File

@ -10,7 +10,12 @@ func withCORS(next http.Handler) http.Handler {
origin := strings.TrimSpace(r.Header.Get("Origin"))
// Dev-Origins erlauben
if origin == "http://localhost:5173" || origin == "http://127.0.0.1:5173" {
if origin == "http://localhost:5173" ||
origin == "http://127.0.0.1:5173" ||
origin == "http://10.0.1.25:5173" ||
origin == "https://localhost:5173" ||
origin == "https://127.0.0.1:5173" ||
origin == "https://10.0.1.25:5173" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,HEAD,OPTIONS")

View File

@ -2,5 +2,52 @@
"username": "admin",
"passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC",
"totpEnabled": true,
"totpSecret": "TIZUJ4A5SB2LOJCJN4T4MGOLBMDQ7NGG"
"totpSecret": "GYDAQKCCKJZUNGIB7D6OF53PPTXKCODR",
"totpInfo": {
"label": "Authenticator-App",
"configuredAt": "2026-05-08T11:56:30+02:00"
},
"passkeyUserId": "t0t7ac1H0li39D0bcMKV-fjODVbbPjRE1NBrPNTpPYI",
"passkeys": [
{
"id": "kGSnhcQdT2GlJIpJytlb2A==",
"publicKey": "pQECAyYgASFYIPvUFrzK7BVJeCgvGV0J+3bgIPLQk9dU/G5OH+wqGX5tIlggdYIWIqG/sAUL9uW0cd//8mKqwDg6IGHsJxCcjW99IWk=",
"attestationType": "none",
"attestationFormat": "none",
"transport": [
"internal",
"hybrid"
],
"flags": {
"userPresent": true,
"userVerified": true,
"backupEligible": true,
"backupState": true
},
"authenticator": {
"AAGUID": "1UiCbnm020Cj2BERb36DSQ==",
"attachment": "platform"
},
"attestation": {
"clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiallINXRhSW5yU0pvOVJKUnJJQkI3RnBKNDlXNVE0NTFuNVllbFd5WUFwMCIsIm9yaWdpbiI6Imh0dHBzOi8vbDE0cGJiazk1MTAwMDA2LnRlZ2Rzc2QuZGU6OTk5OSIsImNyb3NzT3JpZ2luIjpmYWxzZX0=",
"clientDataHash": "AmcE/bKJzVx4+KYCV8UKWfRjxKDbb/fHg9M7e5QBzfs=",
"authenticatorData": "bc41HTx8SRDXvoeydjBR56tR4KxD4swPxYLi7Ztan1JdAAAAANVIgm55tNtAo9gREW9+g0kAEJBkp4XEHU9hpSSKScrZW9ilAQIDJiABIVgg+9QWvMrsFUl4KC8ZXQn7duAg8tCT11T8bk4f7CoZfm0iWCB1ghYiob+wBQv25bRx3//yYqrAODogYewnEJyNb30haQ==",
"publicKeyAlgorithm": -7,
"object": "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViUbc41HTx8SRDXvoeydjBR56tR4KxD4swPxYLi7Ztan1JdAAAAANVIgm55tNtAo9gREW9+g0kAEJBkp4XEHU9hpSSKScrZW9ilAQIDJiABIVgg+9QWvMrsFUl4KC8ZXQn7duAg8tCT11T8bk4f7CoZfm0iWCB1ghYiob+wBQv25bRx3//yYqrAODogYewnEJyNb30haQ=="
}
}
],
"passkeyInfos": [
{
"credentialId": "X1lPY4u7SJefAU3TGwSYBw",
"label": "Passkey X1lPY4…GwSYBw",
"createdAt": "2026-05-08T10:42:34+02:00"
},
{
"credentialId": "kGSnhcQdT2GlJIpJytlb2A",
"label": "Passkey kGSnhc…ytlb2A",
"createdAt": "2026-05-08T12:30:03+02:00",
"lastUsedAt": "2026-05-08T12:30:09+02:00"
}
]
}

View File

@ -8,7 +8,7 @@ import (
"path/filepath"
)
//go:embed ml/*.py ml/*.json ai_server.py
//go:embed ml/*.py ml/*.json ai_server.py .env recorder-cert.pem recorder-key.pem
var embeddedMLFiles embed.FS
func trainingEmbeddedMLDir() (string, error) {
@ -74,3 +74,46 @@ func embeddedAIServerDir() (string, error) {
return dir, nil
}
func embeddedDotEnvBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile(".env")
}
func embeddedTLSCertBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile("recorder-cert.pem")
}
func embeddedTLSKeyBytes() ([]byte, error) {
return embeddedMLFiles.ReadFile("recorder-key.pem")
}
func embeddedTLSFilesDir() (string, string, error) {
dir := filepath.Join(os.TempDir(), "nsfwapp-tls")
if err := os.MkdirAll(dir, 0700); err != nil {
return "", "", err
}
certBytes, err := embeddedTLSCertBytes()
if err != nil {
return "", "", err
}
keyBytes, err := embeddedTLSKeyBytes()
if err != nil {
return "", "", err
}
certPath := filepath.Join(dir, "recorder-cert.pem")
keyPath := filepath.Join(dir, "recorder-key.pem")
if err := os.WriteFile(certPath, certBytes, 0600); err != nil {
return "", "", err
}
if err := os.WriteFile(keyPath, keyBytes, 0600); err != nil {
return "", "", err
}
return certPath, keyPath, nil
}

View File

@ -5,17 +5,20 @@ go 1.25.3
require (
github.com/PuerkitoBio/goquery v1.11.0
github.com/getlantern/systray v1.2.2
github.com/go-webauthn/webauthn v0.17.2
github.com/google/uuid v1.6.0
github.com/grafov/m3u8 v0.12.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
github.com/pquerna/otp v1.5.0
github.com/r3labs/sse/v2 v2.10.0
github.com/yalue/onnxruntime_go v1.27.0
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.50.0
)
require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
@ -24,18 +27,25 @@ require (
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
)
@ -45,6 +55,6 @@ require (
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
golang.org/x/image v0.37.0
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.43.0 // indirect
)

View File

@ -9,6 +9,8 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
@ -27,9 +29,21 @@ github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.2 h1:e9YtSZTVnxnMWFezXi6JvnqOSxmH4Er8QDHK2a/mM40=
github.com/go-webauthn/webauthn v0.17.2/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafov/m3u8 v0.12.1 h1:DuP1uA1kvRRmGNAZ0m+ObLv1dvrfNO0TPx0c/enNk0s=
@ -42,12 +56,16 @@ github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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=
@ -70,23 +88,29 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yalue/onnxruntime_go v1.27.0 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@ -104,8 +128,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -131,8 +155,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -151,8 +175,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View File

@ -875,7 +875,7 @@ func remuxTSToMP4WithProgress(
"-nostats",
"-progress", "pipe:1",
"-hide_banner",
"-loglevel", "warning",
"-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,
@ -1677,7 +1677,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
args := []string{
"-y",
"-hide_banner",
"-loglevel", "warning",
"-loglevel", "error",
}
args = append(args, ffmpegInputTol...)
args = append(args,

View File

@ -61,8 +61,6 @@ type previewMeta struct {
}
type analysisMeta struct {
AI *aiAnalysisMeta `json:"ai,omitempty"`
NSFW *aiAnalysisMeta `json:"nsfw,omitempty"`
Highlights *aiAnalysisMeta `json:"highlights,omitempty"`
}
@ -86,11 +84,6 @@ type postworkCompletedMeta struct {
Analyze bool `json:"analyze,omitempty"`
}
type postworkMeta struct {
Completed *postworkCompletedMeta `json:"completed,omitempty"`
History []postworkHistoryEntry `json:"history,omitempty"`
}
type videoMeta struct {
Version int `json:"version"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
@ -99,7 +92,6 @@ type videoMeta struct {
Media videoMediaMeta `json:"media"`
Preview previewMeta `json:"preview,omitempty"`
Analysis analysisMeta `json:"analysis,omitempty"`
Postwork postworkMeta `json:"postwork,omitempty"`
Validation map[string]any `json:"validation,omitempty"`
}
@ -391,40 +383,18 @@ func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
if len(ai.Segments) > 0 {
return true
}
if ai.Rating != nil {
return true
}
if ai.AnalyzedAtUnix > 0 {
return true
}
return false
}
func normalizeAIGoal(goal string) string {
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
return "nsfw"
}
return goal
}
func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta {
goal = normalizeAIGoal(goal)
switch goal {
case "nsfw":
if hasAIAnalysisMeta(a.NSFW) {
return a.NSFW
}
case "highlights":
if hasAIAnalysisMeta(a.Highlights) {
return a.Highlights
}
}
// Legacy-Fallback: alte meta.json hatte nur analysis.ai
if hasAIAnalysisMeta(a.AI) {
storedGoal := normalizeAIGoal(a.AI.Goal)
if storedGoal == goal {
return a.AI
}
if hasAIAnalysisMeta(a.Highlights) {
return a.Highlights
}
return nil
@ -442,7 +412,15 @@ func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
return u, true
}
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
func writeVideoMeta(
metaPath string,
fi os.FileInfo,
dur float64,
w int,
h int,
fps float64,
sourceURL string,
) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
@ -474,7 +452,6 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
if existing != nil {
m.Preview = existing.Preview
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
m.Validation = existing.Validation
if m.File.SourceURL == "" {
@ -494,7 +471,86 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
}
}
buf, err := json.Marshal(m)
buf, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
func writeVideoMetaAI(
metaPath string,
fi os.FileInfo,
dur float64,
w int,
h int,
fps float64,
sourceURL string,
ai *aiAnalysisMeta,
) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
var existing *videoMeta
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
}
if existing != nil {
m.Preview = existing.Preview
m.Validation = existing.Validation
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.DurationSeconds <= 0 {
m.Media.DurationSeconds = existing.Media.DurationSeconds
}
// Wichtig:
// analysis wird NICHT aus existing übernommen.
// Dadurch verschwinden alte analysis.ai / analysis.nsfw beim nächsten Schreiben.
}
if hasAIAnalysisMeta(ai) {
ai.Goal = "highlights"
m.Analysis.Highlights = ai
}
buf, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
@ -538,7 +594,6 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
if existing != nil {
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
m.Preview.Sprite = existing.Preview.Sprite
@ -618,7 +673,6 @@ func writeVideoMetaWithPreviewClipsAndSprite(
if existing != nil {
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
@ -774,103 +828,6 @@ func sanitizeID(id string) (string, error) {
return id, nil
}
func writeVideoMetaAI(
metaPath string,
fi os.FileInfo,
dur float64,
w int,
h int,
fps float64,
sourceURL string,
ai *aiAnalysisMeta,
) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
var existing *videoMeta
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = old
}
m := videoMeta{
Version: 3,
UpdatedAtUnix: time.Now().Unix(),
File: videoFileMeta{
Size: fi.Size(),
ModUnix: fi.ModTime().Unix(),
SourceURL: strings.TrimSpace(sourceURL),
},
Media: videoMediaMeta{
DurationSeconds: dur,
Video: videoStreamMeta{
Width: w,
Height: h,
FPS: fps,
Resolution: formatResolution(w, h),
},
},
}
if existing != nil {
m.Preview = existing.Preview
m.Analysis = existing.Analysis
m.Postwork = existing.Postwork
m.Validation = existing.Validation
if m.Media.Video.Width <= 0 {
m.Media.Video.Width = existing.Media.Video.Width
}
if m.Media.Video.Height <= 0 {
m.Media.Video.Height = existing.Media.Video.Height
}
if m.Media.Video.FPS <= 0 {
m.Media.Video.FPS = existing.Media.Video.FPS
}
if m.Media.Video.Resolution == "" {
m.Media.Video.Resolution = existing.Media.Video.Resolution
}
if m.File.SourceURL == "" {
m.File.SourceURL = existing.File.SourceURL
}
if m.Media.DurationSeconds <= 0 {
m.Media.DurationSeconds = existing.Media.DurationSeconds
}
}
if hasAIAnalysisMeta(ai) {
goal := normalizeAIGoal(ai.Goal)
ai.Goal = goal
switch goal {
case "nsfw":
m.Analysis.NSFW = ai
// Legacy/default für bestehende UI: RatingOverlay liest aktuell analysis.ai.
m.Analysis.AI = ai
case "highlights":
m.Analysis.Highlights = ai
// Wichtig: analysis.ai NICHT mit highlights überschreiben,
// sonst verschwindet dein NSFW-Rating/Stern.
if !hasAIAnalysisMeta(m.Analysis.AI) {
m.Analysis.AI = ai
}
default:
m.Analysis.AI = ai
}
}
buf, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
func writeVideoAIForFile(
ctx context.Context,
fullPath string,
@ -924,94 +881,6 @@ func writeVideoAIForFile(
)
}
func writeVideoMetaPostworkEntry(
metaPath string,
fi os.FileInfo,
entry postworkHistoryEntry,
) error {
if strings.TrimSpace(metaPath) == "" {
return nil
}
var existing videoMeta
if old, ok := readVideoMetaLoose(metaPath); ok && old != nil {
existing = *old
}
// Datei-Metadaten immer aktualisieren
existing.Version = 3
existing.UpdatedAtUnix = time.Now().Unix()
existing.File.Size = fi.Size()
existing.File.ModUnix = fi.ModTime().Unix()
queue := strings.TrimSpace(strings.ToLower(entry.Queue))
phase := strings.TrimSpace(strings.ToLower(entry.Phase))
if queue == "" {
queue = "postwork"
}
if phase == "" {
phase = "postwork"
}
entry.Queue = queue
entry.Phase = phase
if entry.TS <= 0 {
entry.TS = time.Now().Unix()
}
history := existing.Postwork.History
replaced := false
for i := range history {
if strings.EqualFold(history[i].Queue, entry.Queue) &&
strings.EqualFold(history[i].Phase, entry.Phase) {
// nur ersetzen, wenn neuer oder gleich neu
if history[i].TS <= entry.TS {
history[i] = entry
}
replaced = true
break
}
}
if !replaced {
history = append(history, entry)
}
existing.Postwork.History = history
if existing.Postwork.Completed == nil {
existing.Postwork.Completed = &postworkCompletedMeta{}
}
if strings.EqualFold(entry.State, "done") {
switch queue {
case "postwork":
switch phase {
case "postwork", "probe", "remuxing", "moving", "meta":
existing.Postwork.Completed.Meta = true
case "assets", "teaser":
existing.Postwork.Completed.Teaser = true
case "thumb":
existing.Postwork.Completed.Thumb = true
case "sprites", "sprite":
existing.Postwork.Completed.Sprites = true
}
case "enrich":
switch phase {
case "analyze", "analysis", "ai":
existing.Postwork.Completed.Analyze = true
}
}
}
buf, err := json.MarshalIndent(existing, "", " ")
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
@ -1030,82 +899,10 @@ func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool)
return nil, false
}
goal = normalizeAIGoal(goal)
keys := []string{goal}
// Legacy-Fallback: alte meta.json hatte nur analysis.ai.
// Aber nur akzeptieren, wenn goal wirklich passt.
keys = append(keys, "ai")
for _, key := range keys {
ai, ok := rawAnalysis[key].(map[string]any)
if !ok || ai == nil {
continue
}
storedGoal := normalizeAIGoal(fmt.Sprint(ai["goal"]))
if storedGoal != goal {
continue
}
return ai, true
// Neuer einziger Zweig.
if highlights, ok := rawAnalysis["highlights"].(map[string]any); ok && highlights != nil {
return highlights, true
}
return nil, false
}
func writePostworkEntryForFile(
fullPath string,
queue string,
phase string,
state string,
label string,
position int,
waiting int,
running int,
maxParallel int,
) error {
fullPath = strings.TrimSpace(fullPath)
if fullPath == "" {
return appErrorf("fullPath fehlt")
}
fi, err := os.Stat(fullPath)
if err != nil || fi == nil || fi.IsDir() {
return appErrorf("datei nicht gefunden")
}
stem := strings.TrimSuffix(filepath.Base(fullPath), filepath.Ext(fullPath))
assetID := stripHotPrefix(strings.TrimSpace(stem))
if assetID == "" {
return appErrorf("asset id fehlt")
}
assetID, err = sanitizeID(assetID)
if err != nil {
return err
}
metaPath, err := metaJSONPathForAssetID(assetID)
if err != nil {
return err
}
// sorgt dafür, dass meta.json grundsätzlich existiert
if _, ok := ensureVideoMetaForFileBestEffort(context.Background(), fullPath, ""); !ok {
return appErrorf("meta konnte nicht erzeugt werden")
}
return writeVideoMetaPostworkEntry(metaPath, fi, postworkHistoryEntry{
Queue: queue,
Phase: phase,
State: state,
Label: label,
Position: position,
Waiting: waiting,
Running: running,
MaxParallel: maxParallel,
TS: time.Now().Unix(),
})
}

View File

@ -20,8 +20,7 @@
"blowjob",
"toy_play",
"fingering",
"69",
"other"
"69"
],
"bodyParts": [
"anus",

View File

@ -122,7 +122,7 @@ def main():
emit_progress(
"detector",
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
f"Object Detector trainiert… Epoche {epoch}/{total}",
f"Object Detector trainiert…",
epoch=epoch,
epochs=total,
trainSamples=train_count,
@ -140,7 +140,7 @@ def main():
emit_progress(
"detector",
0.04 + 0.90 * (epoch / max(1, total)),
f"Object Detector trainiert… Epoche {epoch}/{total}",
f"Object Detector trainiert…",
epoch=epoch,
epochs=total,
trainSamples=train_count,
@ -173,7 +173,7 @@ def main():
emit_progress(
"detector",
0.04 + 0.90 * (epoch / max(1, total)),
f"Object Detector validiert… Epoche {epoch}/{total}",
f"Object Detector validiert…",
epoch=epoch,
epochs=total,
mAP50=map50,

View File

@ -101,7 +101,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
const scanInterval = 1 * time.Second
const startInterval = 2 * time.Second
const cooldown = 2 * time.Minute
const outputProbeMax = 20 * time.Second
const outputProbeMax = 30 * time.Second
queue := make([]autoStartItem, 0, 64)
queued := map[string]bool{}
@ -428,13 +428,13 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
}
if it.source == "manual" {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() {
go mfcKeepIfOutputGrows(job.ID, outputProbeMax, func() {
pendingAutoStartMu.Lock()
_ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey)
pendingAutoStartMu.Unlock()
})
} else {
go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil)
go mfcKeepIfOutputGrows(job.ID, outputProbeMax, nil)
}
}
}
@ -460,43 +460,66 @@ func isJobRunningForURL(u string) bool {
return false
}
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
// Wenn die Output-Datei innerhalb maxWait sichtbar wächst, behalten wir den Job.
// Wenn sie nicht wächst, stoppen wir den Hidden-Probe und löschen die angelegte Datei.
// Hintergrund: MFC hat keine saubere API. Deshalb wird blind gestartet und anhand echter Dateiaktivität entschieden.
func mfcKeepIfOutputGrows(jobID string, maxWait time.Duration, onKeep func()) {
deadline := time.Now().Add(maxWait)
var lastSize int64 = -1
var sawFile bool
for time.Now().Before(deadline) {
jobsMu.RLock()
job := jobs[jobID]
status := JobStatus("")
out := ""
hidden := false
if job != nil {
status = job.Status
out = strings.TrimSpace(job.Output)
hidden = job.Hidden
}
jobsMu.RUnlock()
// Job schon weg oder nicht mehr running -> nichts tun
// Job schon weg oder nicht mehr running -> nichts tun.
if job == nil || status != JobRunning {
return
}
// Output schon da?
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
if onOutput != nil {
onOutput()
if fi, err := os.Stat(out); err == nil && !fi.IsDir() {
size := fi.Size()
sawFile = true
// Erst behalten, wenn die Datei wirklich wächst.
if lastSize >= 0 && size > lastSize {
if onKeep != nil {
onKeep()
}
// Hidden-Probe wird zu normal sichtbarem Recording.
if hidden {
jobsMu.Lock()
if j := jobs[jobID]; j != nil && j.Status == JobRunning {
j.Hidden = false
}
jobsMu.Unlock()
}
publishJob(jobID)
return
}
publishJob(jobID)
return
lastSize = size
}
}
time.Sleep(900 * time.Millisecond)
time.Sleep(1 * time.Second)
}
// nach Wartezeit immer noch keine Datei => stoppen + löschen
// Nach Wartezeit keine wachsende Datei => stoppen + löschen.
jobsMu.Lock()
job := jobs[jobID]
if job == nil || job.Status != JobRunning {
@ -512,6 +535,7 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
recordCancel := job.cancel
out := strings.TrimSpace(job.Output)
wasVisible := !job.Hidden
jobsMu.Unlock()
@ -525,20 +549,24 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
recordCancel()
}
// 0-Byte Datei ggf. wegwerfen
// Blind-Probe-Datei entfernen, auch wenn sie schon ein paar Bytes hat,
// denn sie ist nicht gewachsen und gilt damit als Fehlstart.
if out != "" {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() == 0 {
if fi, err := os.Stat(out); err == nil && !fi.IsDir() {
_ = os.Remove(out)
}
}
jobsMu.Lock()
j := jobs[jobID]
wasVisible := (j != nil && !j.Hidden)
delete(jobs, jobID)
jobsMu.Unlock()
if wasVisible {
if wasVisible && j != nil {
publishJobRemove(j)
}
if sawFile {
appLogln("🧹 [mfc autostart] Probe ohne Wachstum entfernt:", jobID)
}
}

View File

@ -18,11 +18,13 @@ import (
type PendingAutoStartItem struct {
ModelKey string `json:"modelKey"`
URL string `json:"url"`
Mode string `json:"mode,omitempty"` // wait_public | probe_retry
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry
CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown
Mode string `json:"mode,omitempty"`
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"`
CurrentShow string `json:"currentShow,omitempty"`
ImageURL string `json:"imageUrl,omitempty"`
Source string `json:"source,omitempty"` // manual | watched
Source string `json:"source,omitempty"`
OwnerUserKey string `json:"-"`
}
type PendingAutoStartResponse struct {
@ -198,6 +200,18 @@ func pendingAutoStartFilePath(userKey string) string {
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
}
func clearAllPendingAutoStartOnStartup() error {
pendingAutoStartMu.Lock()
defer pendingAutoStartMu.Unlock()
dir := filepath.Join("data", "pending-autostart")
if err := os.RemoveAll(dir); err != nil {
return err
}
return nil
}
func listPendingAutoStartUserKeys() ([]string, error) {
dir := filepath.Join("data", "pending-autostart")
@ -266,6 +280,9 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS
if pendingProviderFromURL(it.URL) != provider {
continue
}
it.OwnerUserKey = userKey
out = append(out, it)
}
}
@ -273,6 +290,44 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS
return out, nil
}
func saveManualPendingAutoStartItemForUser(userKey string, item PendingAutoStartItem) error {
userKey = strings.ToLower(strings.TrimSpace(userKey))
if userKey == "" {
return errors.New("missing userKey")
}
items, err := loadPendingAutoStartItems(userKey)
if err != nil {
return err
}
item.ModelKey = strings.ToLower(strings.TrimSpace(item.ModelKey))
item.URL = strings.TrimSpace(item.URL)
item.Mode = normalizePendingModeServer(item.Mode)
item.CurrentShow = normalizePendingShowServer(item.CurrentShow)
item.ImageURL = strings.TrimSpace(item.ImageURL)
item.Source = "manual"
if item.ModelKey == "" || item.URL == "" {
return nil
}
replaced := false
for i := range items {
if strings.ToLower(strings.TrimSpace(items[i].ModelKey)) == item.ModelKey {
items[i] = item
replaced = true
break
}
}
if !replaced {
items = append(items, item)
}
return savePendingAutoStartItems(userKey, items)
}
func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error {
provider = strings.ToLower(strings.TrimSpace(provider))
modelKey = strings.ToLower(strings.TrimSpace(modelKey))

File diff suppressed because it is too large Load Diff

View File

@ -20,11 +20,13 @@ import (
"time"
)
var trashCleanupMu sync.Mutex
// ---------------- Types ----------------
type prepareSplitReq struct {
Output string `json:"output"`
Goal string `json:"goal,omitempty"` // z.B. "nsfw"
Goal string `json:"goal,omitempty"` // z.B. "highlights"
}
type prepareSplitResp struct {
@ -90,9 +92,19 @@ type durationItem struct {
}
type undoDeleteToken struct {
Trash string `json:"trash"` // basename in .trash (legacy/optional)
RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model"
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
Trash string `json:"trash"` // basename in .trash (legacy/optional)
RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model"
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
From string `json:"from,omitempty"` // "done" oder "keep" für Undo-Count/UI
}
type trashMeta struct {
Token string `json:"token"`
TrashName string `json:"trashName"`
RelDir string `json:"relDir"`
File string `json:"file"`
From string `json:"from,omitempty"`
DeletedAt int64 `json:"deletedAt,omitempty"`
}
type bulkFilesRequest struct {
@ -777,8 +789,16 @@ func finishedPhaseTruthForID(id string) finishedPhaseTruth {
analysis, _ := m["analysis"].(map[string]any)
if analysis != nil {
if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 {
out.AnalyzeReady = true
if highlights, ok := analysis["highlights"].(map[string]any); ok && highlights != nil {
rawHits, hasHits := highlights["hits"].([]any)
rawSegs, hasSegs := highlights["segments"].([]any)
rawRating, hasRating := highlights["rating"].(map[string]any)
if (hasHits && len(rawHits) > 0) ||
(hasSegs && len(rawSegs) > 0) ||
(hasRating && len(rawRating) > 0) {
out.AnalyzeReady = true
}
}
}
}
@ -2371,7 +2391,7 @@ func recordDoneList(w http.ResponseWriter, r *http.Request) {
c := *base
// vollständiges generated meta.json laden (inkl. analysis.ai.rating)
// vollständiges generated meta.json laden (inkl. analysis.highlights.rating)
applyGeneratedMetaToRecordJobMeta(&c)
// danach gezielte Wahrheiten/Fallbacks drüberlegen
@ -2501,6 +2521,56 @@ func releaseFileForMutation(file string) {
}
}
func cleanupTrashKeepOnlyLatest(trashDir, keepToken, keepTrashName string) {
trashDir = filepath.Clean(strings.TrimSpace(trashDir))
keepToken = strings.TrimSpace(keepToken)
keepTrashName = strings.TrimSpace(keepTrashName)
if trashDir == "" || keepToken == "" || keepTrashName == "" {
return
}
keepMetaName := keepToken + ".json"
entries, err := os.ReadDir(trashDir)
if err != nil {
appLogln("⚠️ trash cleanup read failed:", err)
return
}
for _, e := range entries {
name := e.Name()
// Das zuletzt gelöschte Video behalten
if name == keepTrashName {
continue
}
// Token-Meta für Undo behalten
if name == keepMetaName {
continue
}
// last.json behalten, damit Debug/Komfort weiterhin funktioniert
if name == "last.json" {
continue
}
full := filepath.Join(trashDir, name)
if e.IsDir() {
if err := os.RemoveAll(full); err != nil {
appLogln("⚠️ trash cleanup dir failed:", full, err)
}
continue
}
if err := removeWithRetry(full); err != nil {
appLogln("⚠️ trash cleanup file failed:", full, err)
}
}
}
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
@ -2541,38 +2611,6 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
}
trashDir := filepath.Join(doneAbs, ".trash")
prevBase := ""
prevCanonical := ""
if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 {
var prev struct {
File string `json:"file"`
}
if err := json.Unmarshal(b, &prev); err == nil {
prevFile := strings.TrimSpace(prev.File)
if prevFile != "" {
prevBase = strings.TrimSuffix(prevFile, filepath.Ext(prevFile))
prevCanonical = stripHotPrefix(prevBase)
}
}
}
if err := os.RemoveAll(trashDir); err != nil {
if runtime.GOOS == "windows" && isSharingViolation(err) {
http.Error(w, "konnte .trash nicht leeren (Datei wird gerade verwendet). Bitte Player schließen und erneut versuchen.", http.StatusConflict)
return
}
http.Error(w, "trash leeren fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
if prevCanonical != "" {
removeGeneratedForID(prevCanonical)
if prevBase != "" && prevBase != prevCanonical {
removeGeneratedForID(prevBase)
}
}
if err := os.MkdirAll(trashDir, 0o755); err != nil {
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
@ -2593,12 +2631,14 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
Trash: "",
RelDir: relDir,
File: file,
From: from,
})
if err != nil {
http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
return
}
// Token ist RawURLEncoding-safe. Prefix macht Restore ohne last.json eindeutig.
trashName := tok + "__" + file
trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_")
dst := filepath.Join(trashDir, trashName)
@ -2612,24 +2652,31 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
return
}
type trashMeta struct {
Token string `json:"token"`
TrashName string `json:"trashName"`
RelDir string `json:"relDir"`
File string `json:"file"`
DeletedAt int64 `json:"deletedAt"`
}
meta := trashMeta{
Token: tok,
TrashName: trashName,
RelDir: relDir,
File: file,
From: from,
DeletedAt: time.Now().Unix(),
}
b, _ := json.Marshal(meta)
_ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644)
if b, err := json.Marshal(meta); err == nil {
// Token-spezifische Meta für das aktuelle Undo.
_ = os.WriteFile(filepath.Join(trashDir, tok+".json"), b, 0o644)
// Komfort/Debug.
_ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644)
// .trash sauber halten:
// Nur das zuletzt gelöschte Video + dessen Meta + last.json behalten.
func() {
trashCleanupMu.Lock()
defer trashCleanupMu.Unlock()
cleanupTrashKeepOnlyLatest(trashDir, tok, trashName)
}()
}
purgeDurationCacheForPath(target)
removeJobsByOutputBasename(file)
@ -2656,6 +2703,12 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
return
}
tok, err := decodeUndoDeleteToken(raw)
if err != nil {
http.Error(w, "token ungültig", http.StatusBadRequest)
return
}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
@ -2668,38 +2721,69 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
}
trashDir := filepath.Join(doneAbs, ".trash")
metaPath := filepath.Join(trashDir, "last.json")
b, err := os.ReadFile(metaPath)
if err != nil {
var meta trashMeta
// 1) Neue Variante: token-spezifische Meta.
metaPath := filepath.Join(trashDir, raw+".json")
if b, err := os.ReadFile(metaPath); err == nil && len(b) > 0 {
if err := json.Unmarshal(b, &meta); err != nil {
http.Error(w, "trash meta ungültig", http.StatusInternalServerError)
return
}
} else {
// 2) Fallback für alte/halb geschriebene Einträge:
// Datei anhand Token-Prefix suchen.
entries, rerr := os.ReadDir(trashDir)
if rerr != nil {
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
return
}
prefix := raw + "__"
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, prefix) {
continue
}
meta = trashMeta{
Token: raw,
TrashName: name,
RelDir: tok.RelDir,
File: tok.File,
From: tok.From,
}
break
}
}
if strings.TrimSpace(meta.Token) == "" ||
strings.TrimSpace(meta.TrashName) == "" ||
strings.TrimSpace(meta.File) == "" {
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
return
}
var meta struct {
Token string `json:"token"`
TrashName string `json:"trashName"`
RelDir string `json:"relDir"`
File string `json:"file"`
DeletedAt int64 `json:"deletedAt"`
if strings.TrimSpace(meta.From) == "" {
relLower := strings.ToLower(filepath.ToSlash(strings.TrimSpace(meta.RelDir)))
if relLower == "keep" || strings.HasPrefix(relLower, "keep/") {
meta.From = "keep"
} else {
meta.From = "done"
}
}
if err := json.Unmarshal(b, &meta); err != nil {
http.Error(w, "trash meta ungültig", http.StatusInternalServerError)
return
}
if strings.TrimSpace(meta.Token) == "" || strings.TrimSpace(meta.TrashName) == "" || strings.TrimSpace(meta.File) == "" {
http.Error(w, "trash meta unvollständig", http.StatusInternalServerError)
if meta.Token != raw {
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
return
}
if raw != meta.Token {
http.Error(w, "token ungültig (nicht der letzte)", http.StatusNotFound)
return
}
tok, err := decodeUndoDeleteToken(raw)
if err != nil {
http.Error(w, "token ungültig", http.StatusBadRequest)
if tok.File != meta.File || tok.RelDir != meta.RelDir {
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
return
}
@ -2707,10 +2791,6 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
http.Error(w, "token inhalt ungültig", http.StatusBadRequest)
return
}
if tok.File != meta.File || tok.RelDir != meta.RelDir {
http.Error(w, "token passt nicht zu letzter Löschung", http.StatusNotFound)
return
}
if !isAllowedVideoExt(meta.File) {
http.Error(w, "nicht erlaubt", http.StatusForbidden)
@ -2723,6 +2803,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
if rel == "." {
rel = ""
}
dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel))
dstDirClean := filepath.Clean(dstDir)
doneClean := filepath.Clean(doneAbs)
@ -2756,8 +2837,15 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
now := time.Now()
_ = os.Chtimes(dst, now, now)
_ = os.RemoveAll(trashDir)
_ = os.MkdirAll(trashDir, 0o755)
_ = os.Remove(metaPath)
// last.json nur löschen, wenn es zu genau diesem Token gehört.
if b, err := os.ReadFile(filepath.Join(trashDir, "last.json")); err == nil && len(b) > 0 {
var last trashMeta
if json.Unmarshal(b, &last) == nil && last.Token == raw {
_ = os.Remove(filepath.Join(trashDir, "last.json"))
}
}
purgeDurationCacheForPath(src)
purgeDurationCacheForPath(dst)
@ -2768,6 +2856,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
"ok": true,
"file": meta.File,
"restoredFile": filepath.Base(dst),
"from": meta.From,
})
}

View File

@ -72,15 +72,92 @@ func RecordStream(
return stream, nil
}
stream, err := loadFreshHLS()
if err != nil {
return err
isRetryableFreshHLSError := func(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline exceeded") ||
strings.Contains(msg, "awaiting headers") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "eof") ||
strings.Contains(msg, "tls handshake timeout") ||
strings.Contains(msg, "temporary failure") ||
strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "room dossier nicht gefunden") ||
strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "leere hls url") ||
strings.Contains(msg, "variant-playlist") ||
strings.Contains(msg, "http 401") ||
strings.Contains(msg, "http 403") ||
strings.Contains(msg, "http 429") ||
strings.Contains(msg, "http 5")
}
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
_, _ = ensureGeneratedDir(assetID)
loadFreshHLSWithRetry := func(maxAttempts int, reason string) (*selectedHLSStream, error) {
if maxAttempts < 1 {
maxAttempts = 1
}
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
stream, err := loadFreshHLS()
if err == nil {
return stream, nil
}
lastErr = err
if ctx.Err() != nil {
return nil, ctx.Err()
}
if !isRetryableFreshHLSError(err) {
return nil, err
}
if attempt >= maxAttempts {
break
}
wait := time.Duration(attempt) * 1500 * time.Millisecond
if wait > 8*time.Second {
wait = 8 * time.Second
}
appLogln(
"⚠️ chaturbate hls refresh retry",
reason,
"attempt",
attempt,
"of",
maxAttempts,
"error:",
err,
)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(wait):
}
}
if lastErr == nil {
lastErr = errors.New("hls refresh failed")
}
return nil, lastErr
}
updatePreviewFromStream := func(stream *selectedHLSStream) {
if job == nil || stream == nil {
return
}
jobsMu.Lock()
@ -94,6 +171,23 @@ func RecordStream(
jobsMu.Unlock()
}
stream, err := loadFreshHLSWithRetry(5, "initial")
if err != nil {
return appErrorf(
"chaturbate seite/playlist konnte nach mehreren versuchen nicht geladen werden: %w",
err,
)
}
if job != nil {
assetID := assetIDForJob(job)
if assetID != "" {
_, _ = ensureGeneratedDir(assetID)
}
}
updatePreviewFromStream(stream)
const maxPlaylistRefreshes = 12
refreshes := 0
attempt := 0
@ -134,11 +228,17 @@ func RecordStream(
shouldRefresh :=
strings.Contains(msg, "403") ||
strings.Contains(msg, "401") ||
strings.Contains(msg, "429") ||
strings.Contains(msg, "invalid data found") ||
strings.Contains(msg, "error opening input") ||
strings.Contains(msg, "stream-parsing") ||
strings.Contains(msg, "kein hls-quell-url") ||
strings.Contains(msg, "http ")
strings.Contains(msg, "http ") ||
strings.Contains(msg, "timeout") ||
strings.Contains(msg, "deadline exceeded") ||
strings.Contains(msg, "awaiting headers") ||
strings.Contains(msg, "connection reset") ||
strings.Contains(msg, "eof")
if !shouldRefresh {
return err
@ -155,8 +255,10 @@ func RecordStream(
case <-time.After(1500 * time.Millisecond):
}
newStream, rerr := loadFreshHLS()
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
if rerr != nil {
appLogln("⚠️ chaturbate hls refresh failed:", rerr)
select {
case <-ctx.Done():
return ctx.Err()
@ -166,18 +268,7 @@ func RecordStream(
}
stream = newStream
if job != nil {
jobsMu.Lock()
previewURL := strings.TrimSpace(stream.VideoURL)
if stream.HasSeparateAudio && strings.TrimSpace(stream.MasterURL) != "" {
previewURL = strings.TrimSpace(stream.MasterURL)
}
job.PreviewM3U8 = previewURL
job.PreviewCookie = httpCookie
job.PreviewUA = hc.userAgent
jobsMu.Unlock()
}
updatePreviewFromStream(stream)
}
}

View File

@ -405,6 +405,14 @@ func appendFileAndRemove(dstPath, srcPath string) error {
return nil
}
func isExitStatus(err error, code int) bool {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return exitErr.ExitCode() == code
}
return false
}
func handleM3U8Mode(
ctx context.Context,
stream *selectedHLSStream,
@ -452,7 +460,7 @@ func handleM3U8Mode(
"-y",
"-hide_banner",
"-nostats",
"-loglevel", "warning",
"-loglevel", "error",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
"-http_persistent", "false",
}
@ -560,11 +568,13 @@ func handleM3U8Mode(
close(stopStat)
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg)
// Wenn der User den Recording-Prozess stoppt, beendet ffmpeg HLS unter Windows
// oft mit "exit status 1". Das ist in diesem Fall kein echter Fehler.
if ctx.Err() != nil && isExitStatus(err, 1) {
return nil
}
return appErrorf("ffmpeg m3u8 failed: %w", err)
return hlsFFmpegFailedError(job, referer, err, stderr.String())
}
if job != nil {
@ -578,3 +588,80 @@ func handleM3U8Mode(
return nil
}
func hlsErrorModelName(job *RecordJob, referer string) string {
if job != nil {
// 1) Beste Quelle bei dir: Output-Dateiname
// Beispiel: aurora_natsuki_05_08_2026__12-34-56.mp4 -> aurora_natsuki
if out := strings.TrimSpace(job.Output); out != "" {
stem := strings.TrimSuffix(filepath.Base(out), filepath.Ext(out))
stem = stripHotPrefix(stem)
if s := strings.TrimSpace(modelNameFromFilename(stem)); s != "" {
return s
}
if s := strings.TrimSpace(canonicalAssetIDFromName(stem)); s != "" {
return s
}
}
// 2) Fallback: SourceURL
if s := modelNameFromURL(strings.TrimSpace(job.SourceURL)); s != "" {
return s
}
// 3) Fallback: Job-ID
if s := strings.TrimSpace(job.ID); s != "" {
return s
}
}
// 4) Fallback: Referer
if s := modelNameFromURL(strings.TrimSpace(referer)); s != "" {
return s
}
return ""
}
func modelNameFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
if frag := strings.Trim(strings.TrimSpace(u.Fragment), "/"); frag != "" {
parts := strings.Split(frag, "/")
return strings.TrimSpace(parts[len(parts)-1])
}
path := strings.Trim(u.Path, "/")
if path == "" {
return ""
}
parts := strings.Split(path, "/")
return strings.TrimSpace(parts[len(parts)-1])
}
func hlsFFmpegFailedError(job *RecordJob, referer string, err error, stderr string) error {
model := hlsErrorModelName(job, referer)
msg := strings.TrimSpace(stderr)
prefix := "ffmpeg m3u8 failed"
if model != "" {
prefix = "[" + model + "] " + prefix
}
if msg != "" {
return appErrorf("%s: %w: %s", prefix, err, msg)
}
return appErrorf("%s: %w", prefix, err)
}

View File

@ -30,34 +30,21 @@ func RecordStreamMFC(
) error {
mfc := NewMyFreeCams(username)
// ✅ Statt sofort zu failen: kurz auf PUBLIC warten
const waitPublicMax = 2 * time.Minute
const waitPublicMax = 20 * time.Second
deadline := time.Now().Add(waitPublicMax)
var lastSt *Status
for {
// Context cancel / stop
if err := ctx.Err(); err != nil {
return err
}
st, err := mfc.GetStatus()
if err == nil {
tmp := st
lastSt = &tmp
if st == StatusPublic {
break
}
if err == nil && st == StatusPublic {
break
}
if time.Now().After(deadline) {
if lastSt == nil {
return appErrorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax)
}
return appErrorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt)
return context.DeadlineExceeded
}
time.Sleep(5 * time.Second)
@ -81,17 +68,15 @@ func RecordStreamMFC(
jobsMu.Lock()
job.PreviewM3U8 = previewURL
job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen
job.PreviewCookie = ""
job.PreviewUA = hc.userAgent
jobsMu.Unlock()
}
// ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar)
if job != nil {
_ = publishJob(job.ID)
}
// Aufnahme starten
return handleM3U8Mode(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL())
}

26
backend/recorder-cert.pem Normal file
View File

@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEbjCCAtagAwIBAgIRAKGvq9b44yCGC32q8PwkueowDQYJKoZIhvcNAQELBQAw
gYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwnVEVH
RFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMTcwNQYDVQQDDC5t
a2NlcnQgVEVHRFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMB4X
DTI2MDUwODEwMDQzN1oXDTI4MDgwODEwMDQzN1owWzEnMCUGA1UEChMebWtjZXJ0
IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdURUdEU1NEXFJvdGhl
ckBMMTRQQkJLOTUxMDAwMDYgKFJvdGhlcikwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDDghqU3igC3sNP2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4K
mJ6enU5zrGWI4+T0IHfbo737rvgk6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGs
zIQGJeI4TIeQ7sa7krCpMB/e1YxR1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3N
CC2OgxVYSh/hR9XNIIEPMoioQIYhle41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot
+aBlRjbxgOarLyST3XdtjAfC6CqxnzprX1LfznuULjatoS+pFDBDoy2B13xwa9Br
f+Xy3CswmSO+AzNomWfbeBkJnl6xb3Tb1urrAgMBAAGjfDB6MA4GA1UdDwEB/wQE
AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBR0Zph7hFKq4GpE
XO0y3U+T0iFoQDAyBgNVHREEKzApgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAA
AAAAAAAAAAGHBAoAARkwDQYJKoZIhvcNAQELBQADggGBAJShhgQS+9cwuVOJUIW+
uy9t1ZAjR9qTd31kHdK1vu294GiavrG8RD+92BRujG4Fq1ambvqcu9sUaC8+0HAP
cQvdmnbTxG0ZHkGP7VVj3Poee0jpsVAVtV3aVwKxyO/E7Mv/nEKTqNfnECbEuiqC
tOJ8mI3E9tf2/ljzq2PqvfIPFBBtUT2lihNAFXM/06Nzoa1NPdfYd58N+VBFSOSq
zEM4fmpcqMThZGKGBoWmHwTE5Mw6mZFLyxHJpOFu5wNzyPJGes5o/F0ot5n+ZGah
GbYaGmhEedOxC3/WXRtexJmZ0jrB8Hkx+c/4h6+0HKU/PelrFJS+c2d6h8YGXyhv
pUsfyyoQzx7rSgyLfJqXK+52ZtnWNc0yVVMyKJwjvhv4mcP9l/q3UGR/i7xkD5Pc
2bLgM+wc8nKI7Yd/xejLpQDC0b/P1XibT1T3GRjiC/eu64G/uFOF+m3P5XvmF8JO
0cj8fXwUCq+TVD+22rFmtT+2VYkf8RUsC48Ou+PHccmDyw==
-----END CERTIFICATE-----

28
backend/recorder-key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDDghqU3igC3sNP
2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4KmJ6enU5zrGWI4+T0IHfbo737rvgk
6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGszIQGJeI4TIeQ7sa7krCpMB/e1YxR
1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3NCC2OgxVYSh/hR9XNIIEPMoioQIYh
le41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot+aBlRjbxgOarLyST3XdtjAfC6Cqx
nzprX1LfznuULjatoS+pFDBDoy2B13xwa9Brf+Xy3CswmSO+AzNomWfbeBkJnl6x
b3Tb1urrAgMBAAECggEBAI31EBv72w2KdkKroot+vROCz6quMAdNvgezQif7VcHY
fuBN7EcBXltJWUeAbBEjeIESejUPBcmT2DXlF841CC5lB4VCaeV5lsreE9IsNYBf
CakIwLmZnltgcovydZT063QTJRcUDHAems5pjw76zMLKO+h9GEiMoUxFb3/atv3e
K5HE+uUd6JcPU0q91S6TxqvS9gH30OOkwH6Kl4tzWauQAKg/sVzYafbyTAuEpiMO
jLLLAn/sOmiL4knnRVu1FK4aZSH+t9BN9YuwfucXFn9jFgPtIJ0bF1GuqnwIeDI5
BTNanHfaZRHD0+FbS0KbhARIFgDveoGrdoEtou0eDOECgYEA5dJqUvk96f7zCrxD
eSuUl1MvOjydY3GacfjevZoTxJDqh4Wt2wgLYJc4XB7tI02ci7NncecG8qPfqWq7
aFjQAGAYdR+XvE7R2m3OFK9WbRX4cH6b+l7mSq0eOvcAAajn4RuNr/il+7Opw7zX
eKUB/pnW36B+ethdr5l+kDUQb8MCgYEA2ccanIT2QAxJb5j0F287NGhaTPuWfxcX
2T53I2fIwMiell0YXjwsKwKpckubRB2f5X4b9QJifUTmtxHky/Tx4H9OVhCaxumw
edol5YNzZaP/Tx8m2mqpyN2WPbezIFeA+GNcmDAxEo/NT2B64OVCHWKosndvb+Yk
GFmVb+g9zbkCgYEA1YFJLZRHJJ+pgour02HdRUgOU/gD72KWrNMbeuEtBCvs9cIG
5bjveOiDf3FrtKRhjpc4vuR12+zJ2EZDnIkFk5OypPyYpmRDKL1h+m15yRXkG/5D
QbHwF+gEcZsN8nzMDqDeXGCPMuqSCDnjoz0IQVMB//bGCbIANyZOIgJqJqkCgYEA
vHc+ZG383fjEJLvtoco1JmmYnD6uQ1Ys4WjZmd5bMdtswxvV1tekMaSgF7WurQgm
NGkqsKJbsaVLNOtbYdac7He/x2OfTr02aH2Nhk54M2H1tPd0nFjqjlaVitvLPRX9
GviCTYKHNVUVjLgmHzLIQL382FXcLq6wVhJQ7QPDWKECgYA6ZmdB8waN2SFOJRnj
5zWTgdImicl4yCu8PaEyloO9whYSdYH8zU3K+FKaowXGNInp3BGCmP9TUwoG8Iwx
Ug5d55P2FgwnhCi59Y6dMXih1p94BuQ0vcWPBqztofl7lZbBEnjwzKVycFvcyNFX
QFb/UA4/CfQli9ciDtlqzZt8SA==
-----END PRIVATE KEY-----

View File

@ -425,6 +425,43 @@ func concurrentDownloadsLimitState() (enabled bool, max int, active int, reached
return
}
type downloadLimitReachedError struct {
Active int
Max int
}
func (e downloadLimitReachedError) Error() string {
return fmt.Sprintf("Download-Limit erreicht (%d/%d aktiv)", e.Active, e.Max)
}
func isDownloadLimitReachedError(err error) bool {
var e downloadLimitReachedError
return errors.As(err, &e)
}
var lastDownloadLimitLogAt time.Time
func logDownloadLimitReachedThrottled(err error) {
if err == nil {
return
}
if !isDownloadLimitReachedError(err) {
appLogln("❌", err)
return
}
now := time.Now()
// Nur alle 60 Sekunden ins Log schreiben.
if !lastDownloadLimitLogAt.IsZero() && now.Sub(lastDownloadLimitLogAt) < 60*time.Second {
return
}
lastDownloadLimitLogAt = now
appLogln("❌", err)
}
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
url := strings.TrimSpace(req.URL)
if url == "" {
@ -470,7 +507,10 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
}
if active >= max {
return nil, appErrorf("Download-Limit erreicht (%d/%d aktiv)", active, max)
return nil, downloadLimitReachedError{
Active: active,
Max: max,
}
}
}
@ -1097,9 +1137,12 @@ func enqueuePostworkOrFail(job *RecordJob, out string, postTarget JobStatus) {
jobsMu.Lock()
job.Phase = "postwork"
job.PostWorkKey = postKey
{
s := postWorkQ.StatusForKey(postKey)
job.PostWork = &s
job.PostWork = &PostWorkKeyStatus{
State: "queued",
Position: 0,
Waiting: 0,
Running: 0,
MaxParallel: 1,
}
jobsMu.Unlock()

View File

@ -17,10 +17,21 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
mux.HandleFunc("/api/auth/logout", authLogoutHandler(auth))
mux.HandleFunc("/api/auth/me", authMeHandler(auth))
mux.HandleFunc("/api/auth/password/change", authPasswordChangeHandler(auth))
// 2FA (Authenticator/TOTP)
mux.HandleFunc("/api/auth/2fa/setup", auth2FASetupHandler(auth))
mux.HandleFunc("/api/auth/2fa/enable", auth2FAEnableHandler(auth))
// mux.HandleFunc("/api/auth/2fa/disable", auth2FADisableHandler(auth))
mux.HandleFunc("/api/auth/2fa/disable", auth2FADisableHandler(auth))
mux.HandleFunc("/api/auth/2fa/cancel-setup", auth2FACancelSetupHandler(auth))
// Passkeys / WebAuthn
mux.HandleFunc("/api/auth/passkey/register/options", authPasskeyRegisterOptionsHandler(auth))
mux.HandleFunc("/api/auth/passkey/register/verify", authPasskeyRegisterVerifyHandler(auth))
mux.HandleFunc("/api/auth/passkey/login/options", authPasskeyLoginOptionsHandler(auth))
mux.HandleFunc("/api/auth/passkey/login/verify", authPasskeyLoginVerifyHandler(auth))
mux.HandleFunc("/api/auth/passkey/delete", authPasskeyDeleteHandler(auth))
// --------------------------
// 2) Protected API Mux
@ -77,11 +88,14 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/training/next", trainingNextHandler)
api.HandleFunc("/api/training/frame", trainingFrameHandler)
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
api.HandleFunc("/api/training/feedback/list", trainingFeedbackListHandler)
api.HandleFunc("/api/training/feedback/update", trainingFeedbackUpdateHandler)
api.HandleFunc("/api/training/train", trainingTrainHandler)
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
api.HandleFunc("/api/training/status", trainingStatusHandler)
api.HandleFunc("/api/training/stats", trainingStatsHandler)
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
api.HandleFunc("/api/training/skip", trainingSkipHandler)
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)

View File

@ -2,6 +2,7 @@
package main
import (
"bytes"
"context"
"fmt"
"net"
@ -15,8 +16,23 @@ import (
"sync"
"syscall"
"time"
"github.com/joho/godotenv"
)
var embeddedTLSOnce sync.Once
var embeddedTLSCertPath string
var embeddedTLSKeyPath string
var embeddedTLSErr error
func embeddedTLSPathsCached() (string, string, error) {
embeddedTLSOnce.Do(func() {
embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr = embeddedTLSFilesDir()
})
return embeddedTLSCertPath, embeddedTLSKeyPath, embeddedTLSErr
}
func escapePowerShell(s string) string {
return strings.ReplaceAll(s, "'", "''")
}
@ -174,13 +190,8 @@ func findAIServerScriptDir() (string, error) {
)
}
appLogln("🔎 Suche ai_server.py")
appLogln(" cwd:", cwd)
appLogln(" exeDir:", exeDir)
for _, path := range candidates {
cleanPath := filepath.Clean(path)
appLogln(" prüfe:", cleanPath)
if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() {
appLogln("✅ ai_server.py gefunden:", cleanPath)
@ -305,7 +316,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
"ai_server:app",
"--host", "127.0.0.1",
"--port", port,
"--log-level", "warning",
"--log-level", "error",
)
cmd := exec.CommandContext(ctx, pythonPath, args...)
@ -337,15 +348,6 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
cmd.Env = env
appLogf(
"--- AI Server Start --- python=%s scriptDir=%s url=%s port=%s args=%s",
pythonPath,
scriptDir,
aiServerURL(),
port,
strings.Join(args, " "),
)
logWriter := appLogWriter()
cmd.Stdout = logWriter
cmd.Stderr = logWriter
@ -371,7 +373,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
if err != nil && ctx.Err() == nil {
appLogf("⚠️ AI Server beendet: %v", err)
} else {
appLogln("AI Server beendet.")
appLogln("🛑 AI Server beendet.")
}
}()
@ -394,17 +396,150 @@ func (p *aiServerProcess) Stop() {
}
if p.cmd != nil && p.cmd.Process != nil {
appLogln("🛑 Beende AI Server...")
_ = p.cmd.Process.Kill()
}
}
func setEnvIfMissing(key, value string) {
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
if key == "" {
return
}
if strings.TrimSpace(os.Getenv(key)) != "" {
return
}
_ = os.Setenv(key, value)
}
func loadEmbeddedDotEnv() {
b, err := embeddedDotEnvBytes()
if err != nil {
appLogln(" Keine eingebettete .env gefunden.")
return
}
envMap, err := godotenv.Parse(bytes.NewReader(b))
if err != nil {
appLogln("⚠️ Eingebettete .env konnte nicht gelesen werden:", err)
return
}
for k, v := range envMap {
setEnvIfMissing(k, v)
}
appLogln("✅ Eingebettete .env geladen")
}
func loadExternalDotEnvNextToExe() {
exePath, err := os.Executable()
if err != nil {
if err := godotenv.Overload(); err == nil {
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis")
}
return
}
exeDir := filepath.Dir(exePath)
envPath := filepath.Join(exeDir, ".env")
if err := godotenv.Overload(envPath); err == nil {
appLogln("✅ Externe .env geladen und überschreibt embedded/env:", envPath)
return
}
if err := godotenv.Overload(); err == nil {
appLogln("✅ Externe .env geladen aus aktuellem Arbeitsverzeichnis und überschreibt embedded/env")
}
}
func loadAppEnv() {
loadEmbeddedDotEnv()
loadExternalDotEnvNextToExe()
}
func httpsEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("HTTPS_ENABLED")))
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
}
func resolveMaybeRelativeToExe(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
if filepath.IsAbs(path) {
return path
}
exePath, err := os.Executable()
if err != nil || exePath == "" {
return path
}
return filepath.Join(filepath.Dir(exePath), path)
}
func tlsCertFile() string {
raw := strings.TrimSpace(os.Getenv("TLS_CERT_FILE"))
if raw != "" {
return resolveMaybeRelativeToExe(raw)
}
certPath, _, err := embeddedTLSPathsCached()
if err == nil && certPath != "" {
return certPath
}
return resolveMaybeRelativeToExe("recorder-cert.pem")
}
func tlsKeyFile() string {
raw := strings.TrimSpace(os.Getenv("TLS_KEY_FILE"))
if raw != "" {
return resolveMaybeRelativeToExe(raw)
}
_, keyPath, err := embeddedTLSPathsCached()
if err == nil && keyPath != "" {
return keyPath
}
return resolveMaybeRelativeToExe("recorder-key.pem")
}
func publicAppURL() string {
scheme := "http"
if httpsEnabled() {
scheme = "https"
}
host := strings.TrimSpace(os.Getenv("APP_HOST"))
if host == "" {
host = "localhost"
}
port := strings.TrimSpace(os.Getenv("APP_PORT"))
if port == "" {
port = "9999"
}
return scheme + "://" + host + ":" + port
}
// --- main ---
func main() {
clearAppLog()
initAppLog()
defer closeAppLog()
loadAppEnv()
loadSettings()
appCtx, appCancel := context.WithCancel(context.Background())
@ -489,7 +624,13 @@ func main() {
appLogln("⚠️ covers dir:", err)
}
appLogln("🌐 HTTP-API aktiv: http://localhost:9999")
if httpsEnabled() {
appLogln("🌐 HTTPS-API aktiv:", publicAppURL())
appLogln("🔐 TLS Cert:", tlsCertFile())
appLogln("🔐 TLS Key: ", tlsKeyFile())
} else {
appLogln("🌐 HTTP-API aktiv: http://localhost:9999")
}
handler := withCORS(mux)
srv := &http.Server{
@ -500,8 +641,6 @@ func main() {
var shutdownOnce sync.Once
shutdown := func() {
shutdownOnce.Do(func() {
appLogln("🛑 Shutdown gestartet")
if aiProc != nil {
aiProc.Stop()
}
@ -512,6 +651,7 @@ func main() {
defer cancel()
_ = srv.Shutdown(ctx)
appLogln("🛑 Server beendet.")
closeNSFW()
})
@ -528,10 +668,19 @@ func main() {
serverErrCh := make(chan error, 1)
go func() {
if err := srv.Serve(appLn); err != nil && err != http.ErrServerClosed {
var err error
if httpsEnabled() {
err = srv.ServeTLS(appLn, tlsCertFile(), tlsKeyFile())
} else {
err = srv.Serve(appLn)
}
if err != nil && err != http.ErrServerClosed {
serverErrCh <- err
return
}
serverErrCh <- nil
}()

View File

@ -584,7 +584,7 @@ func splitSingleSegment(
"-nostats",
"-progress", "pipe:1",
"-hide_banner",
"-loglevel", "warning",
"-loglevel", "error",
"-ss", formatFFSec(startSec),
"-i", srcPath,
"-t", formatFFSec(durSec),

View File

@ -83,13 +83,16 @@ type taskStateEvent struct {
}
type analysisProgressEvent struct {
Type string `json:"type"` // "analysis_progress"
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
RequestID string `json:"requestId,omitempty"`
Running bool `json:"running"`
Phase string `json:"phase,omitempty"`
Progress float64 `json:"progress"` // 0..1
Progress float64 `json:"progress"`
Current int `json:"current,omitempty"`
Total int `json:"total,omitempty"`
File string `json:"file,omitempty"`
SourceFile string `json:"sourceFile,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
StartedAtMs int64 `json:"startedAtMs,omitempty"`

View File

@ -915,7 +915,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse")
_, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw")
_, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "highlights")
if skipFile, stopAll := handleFileAbort(aerr); stopAll {
return
} else if skipFile {

View File

@ -90,7 +90,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string)
assetID = strings.TrimSpace(assetID)
goal = strings.ToLower(strings.TrimSpace(goal))
if goal == "" {
goal = "nsfw"
goal = "highlights"
}
if assetID == "" && videoPath != "" {
@ -132,7 +132,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string)
}
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
if !truth.MetaReady {
return errors.New("meta.json fehlt oder ist noch unvollständig")
@ -147,7 +147,7 @@ func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) e
}
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
if !truth.SpritesReady {
return errors.New("preview-sprite.jpg fehlt oder ist leer")
@ -334,7 +334,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
id := job.AssetID
ctx := job.Ctx
requiredGoals := requiredAnalyzeGoals()
primaryGoal := "nsfw"
primaryGoal := "highlights"
setRegenerateAssetsTaskRunning(file, file)
cancelDeferredEnrichForFile(file)

File diff suppressed because it is too large Load Diff

BIN
backend/yolo26n.pt Normal file

Binary file not shown.

Binary file not shown.

26
cert/recorder-cert.pem Normal file
View File

@ -0,0 +1,26 @@
-----BEGIN CERTIFICATE-----
MIIEbjCCAtagAwIBAgIRAKGvq9b44yCGC32q8PwkueowDQYJKoZIhvcNAQELBQAw
gYsxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEwMC4GA1UECwwnVEVH
RFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMTcwNQYDVQQDDC5t
a2NlcnQgVEVHRFNTRFxSb3RoZXJATDE0UEJCSzk1MTAwMDA2IChSb3RoZXIpMB4X
DTI2MDUwODEwMDQzN1oXDTI4MDgwODEwMDQzN1owWzEnMCUGA1UEChMebWtjZXJ0
IGRldmVsb3BtZW50IGNlcnRpZmljYXRlMTAwLgYDVQQLDCdURUdEU1NEXFJvdGhl
ckBMMTRQQkJLOTUxMDAwMDYgKFJvdGhlcikwggEiMA0GCSqGSIb3DQEBAQUAA4IB
DwAwggEKAoIBAQDDghqU3igC3sNP2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4K
mJ6enU5zrGWI4+T0IHfbo737rvgk6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGs
zIQGJeI4TIeQ7sa7krCpMB/e1YxR1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3N
CC2OgxVYSh/hR9XNIIEPMoioQIYhle41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot
+aBlRjbxgOarLyST3XdtjAfC6CqxnzprX1LfznuULjatoS+pFDBDoy2B13xwa9Br
f+Xy3CswmSO+AzNomWfbeBkJnl6xb3Tb1urrAgMBAAGjfDB6MA4GA1UdDwEB/wQE
AwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAfBgNVHSMEGDAWgBR0Zph7hFKq4GpE
XO0y3U+T0iFoQDAyBgNVHREEKzApgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAA
AAAAAAAAAAGHBAoAARkwDQYJKoZIhvcNAQELBQADggGBAJShhgQS+9cwuVOJUIW+
uy9t1ZAjR9qTd31kHdK1vu294GiavrG8RD+92BRujG4Fq1ambvqcu9sUaC8+0HAP
cQvdmnbTxG0ZHkGP7VVj3Poee0jpsVAVtV3aVwKxyO/E7Mv/nEKTqNfnECbEuiqC
tOJ8mI3E9tf2/ljzq2PqvfIPFBBtUT2lihNAFXM/06Nzoa1NPdfYd58N+VBFSOSq
zEM4fmpcqMThZGKGBoWmHwTE5Mw6mZFLyxHJpOFu5wNzyPJGes5o/F0ot5n+ZGah
GbYaGmhEedOxC3/WXRtexJmZ0jrB8Hkx+c/4h6+0HKU/PelrFJS+c2d6h8YGXyhv
pUsfyyoQzx7rSgyLfJqXK+52ZtnWNc0yVVMyKJwjvhv4mcP9l/q3UGR/i7xkD5Pc
2bLgM+wc8nKI7Yd/xejLpQDC0b/P1XibT1T3GRjiC/eu64G/uFOF+m3P5XvmF8JO
0cj8fXwUCq+TVD+22rFmtT+2VYkf8RUsC48Ou+PHccmDyw==
-----END CERTIFICATE-----

28
cert/recorder-key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDDghqU3igC3sNP
2zAJTZbvZcT1xxjUgk3oA2j0WwNPO+xhCH4KmJ6enU5zrGWI4+T0IHfbo737rvgk
6XfTDSvzB9Uags8egBUGxbr9eMBya0hmnYGszIQGJeI4TIeQ7sa7krCpMB/e1YxR
1qYFf1C8oSt43dJCdJP4Ev//BaR7rrFt3D3NCC2OgxVYSh/hR9XNIIEPMoioQIYh
le41mQWjyPFt16Fjg9sWrrnHSz7M4cgVVSot+aBlRjbxgOarLyST3XdtjAfC6Cqx
nzprX1LfznuULjatoS+pFDBDoy2B13xwa9Brf+Xy3CswmSO+AzNomWfbeBkJnl6x
b3Tb1urrAgMBAAECggEBAI31EBv72w2KdkKroot+vROCz6quMAdNvgezQif7VcHY
fuBN7EcBXltJWUeAbBEjeIESejUPBcmT2DXlF841CC5lB4VCaeV5lsreE9IsNYBf
CakIwLmZnltgcovydZT063QTJRcUDHAems5pjw76zMLKO+h9GEiMoUxFb3/atv3e
K5HE+uUd6JcPU0q91S6TxqvS9gH30OOkwH6Kl4tzWauQAKg/sVzYafbyTAuEpiMO
jLLLAn/sOmiL4knnRVu1FK4aZSH+t9BN9YuwfucXFn9jFgPtIJ0bF1GuqnwIeDI5
BTNanHfaZRHD0+FbS0KbhARIFgDveoGrdoEtou0eDOECgYEA5dJqUvk96f7zCrxD
eSuUl1MvOjydY3GacfjevZoTxJDqh4Wt2wgLYJc4XB7tI02ci7NncecG8qPfqWq7
aFjQAGAYdR+XvE7R2m3OFK9WbRX4cH6b+l7mSq0eOvcAAajn4RuNr/il+7Opw7zX
eKUB/pnW36B+ethdr5l+kDUQb8MCgYEA2ccanIT2QAxJb5j0F287NGhaTPuWfxcX
2T53I2fIwMiell0YXjwsKwKpckubRB2f5X4b9QJifUTmtxHky/Tx4H9OVhCaxumw
edol5YNzZaP/Tx8m2mqpyN2WPbezIFeA+GNcmDAxEo/NT2B64OVCHWKosndvb+Yk
GFmVb+g9zbkCgYEA1YFJLZRHJJ+pgour02HdRUgOU/gD72KWrNMbeuEtBCvs9cIG
5bjveOiDf3FrtKRhjpc4vuR12+zJ2EZDnIkFk5OypPyYpmRDKL1h+m15yRXkG/5D
QbHwF+gEcZsN8nzMDqDeXGCPMuqSCDnjoz0IQVMB//bGCbIANyZOIgJqJqkCgYEA
vHc+ZG383fjEJLvtoco1JmmYnD6uQ1Ys4WjZmd5bMdtswxvV1tekMaSgF7WurQgm
NGkqsKJbsaVLNOtbYdac7He/x2OfTr02aH2Nhk54M2H1tPd0nFjqjlaVitvLPRX9
GviCTYKHNVUVjLgmHzLIQL382FXcLq6wVhJQ7QPDWKECgYA6ZmdB8waN2SFOJRnj
5zWTgdImicl4yCu8PaEyloO9whYSdYH8zU3K+FKaowXGNInp3BGCmP9TUwoG8Iwx
Ug5d55P2FgwnhCi59Y6dMXih1p94BuQ0vcWPBqztofl7lZbBEnjwzKVycFvcyNFX
QFb/UA4/CfQli9ciDtlqzZt8SA==
-----END PRIVATE KEY-----

View File

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

View File

@ -1,10 +1,11 @@
<!-- frontend\index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<title>Recorder</title>
</head>
<body>
<div id="root"></div>

View File

@ -10,6 +10,7 @@
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.1.18",
"flag-icons": "^7.5.0",
"prop-types": "^15.8.1",
@ -1466,6 +1467,12 @@
"win32"
]
},
"node_modules/@simplewebauthn/browser": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
"integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",

View File

@ -12,6 +12,7 @@
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.1.18",
"flag-icons": "^7.5.0",
"prop-types": "^15.8.1",

View File

@ -24,6 +24,9 @@ import {
Squares2X2Icon,
Cog6ToothIcon,
BeakerIcon,
CircleStackIcon,
ArrowRightOnRectangleIcon,
PlayIcon,
} from '@heroicons/react/24/solid'
import PerformanceMonitor from './components/ui/PerformanceMonitor'
import { useNotify } from './components/ui/notify'
@ -129,12 +132,16 @@ type TaskStateEvent = {
type AnalysisProgressEvent = {
type?: 'analysis_progress'
scope?: string
requestId?: string
analysisRequestId?: string
running?: boolean
phase?: 'starting' | 'running' | 'done' | 'error' | string
progress?: number
current?: number
total?: number
file?: string
sourceFile?: string
message?: string
error?: string
startedAtMs?: number
@ -658,12 +665,41 @@ function TrainingBeakerIcon(props: React.ComponentProps<typeof BeakerIcon>) {
)
}
export default function App() {
function normalizeTitleProgress(value: unknown): number | null {
const n = Number(value)
if (!Number.isFinite(n)) return null
// Backend-Training kommt als 0..100.
// Analysis-Progress kommt als 0..1, wird aber für den Title nicht mehr benutzt.
const normalized = n > 1 ? n / 100 : n
return Math.max(0, Math.min(1, normalized))
}
export default function App() {
const [trainingTabRunning, setTrainingTabRunning] = useState(false)
const [trainingTitleProgress, setTrainingTitleProgress] = useState<number | null>(null)
const [trainingImageExpanded, setTrainingImageExpanded] = useState(false)
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
useEffect(() => {
const baseTitle = 'Recorder'
if (trainingTabRunning) {
const progress =
typeof trainingTitleProgress === 'number' && Number.isFinite(trainingTitleProgress)
? ` ${Math.round(Math.max(0, Math.min(1, trainingTitleProgress)) * 100)}%`
: ''
document.title = `${baseTitle} · Training${progress}`
return
}
document.title = baseTitle
}, [trainingTabRunning, trainingTitleProgress])
useEffect(() => {
return () => {
for (const t of Object.values(splitProgressClearTimersRef.current)) {
@ -1119,6 +1155,10 @@ export default function App() {
if (cancelled || !res.ok || !data) return
setTrainingTabRunning(Boolean(data?.training?.running))
setTrainingTitleProgress(
normalizeTitleProgress(data?.training?.progress ?? data?.progress)
)
} catch {
// ignore
}
@ -2843,13 +2883,21 @@ export default function App() {
if (msg?.type !== 'analysis_progress') return
window.dispatchEvent(
new CustomEvent('app:sse:analysis', {
detail: msg,
})
)
const scope = String(msg?.scope ?? '').trim()
const file = baseName(String(msg?.file ?? '').trim())
if (scope === 'training') {
// Nur an den TrainingTab weiterreichen.
// Wichtig: Bildanalyse darf den Browser-Title NICHT auf "Training ..." setzen.
window.dispatchEvent(
new CustomEvent('app:sse:analysis', {
detail: msg,
})
)
return
}
const file = baseName(String(msg?.file ?? msg?.sourceFile ?? '').trim())
if (!file) return
const phase = String(msg?.phase ?? '').trim().toLowerCase()
@ -2933,7 +2981,15 @@ export default function App() {
if (data?.type !== 'training_status') return
setTrainingTabRunning(Boolean(data?.training?.running))
const running = Boolean(data?.training?.running)
setTrainingTabRunning(running)
setTrainingTitleProgress(
running
? normalizeTitleProgress(data?.training?.progress ?? data?.progress)
: null
)
window.dispatchEvent(
new CustomEvent('app:sse:training', {
@ -3322,12 +3378,12 @@ export default function App() {
}, [doneCount])
const handleDeleteJobWithUndo = useCallback(
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
async (job: RecordJob): Promise<void | { undoToken?: string; from?: string }> => {
const file = baseName(job.output || '')
if (!file) return
try {
const data = await runFinishedFileAction<{ undoToken?: string }>({
const data = await runFinishedFileAction<{ undoToken?: string; from?: string }>({
kind: 'delete',
file,
run: () =>
@ -3358,7 +3414,9 @@ export default function App() {
})
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
return undoToken ? { undoToken } : {}
const from = typeof data?.from === 'string' ? data.from : undefined
return undoToken ? { undoToken, from } : {}
} catch {
return
}
@ -3848,6 +3906,14 @@ export default function App() {
}
}, [autoAddEnabled, autoStartEnabled, startUrl])
const isTrainingTab = selectedTab === 'training'
useEffect(() => {
if (!isTrainingTab) {
setTrainingImageExpanded(false)
}
}, [isTrainingTab])
if (!authChecked) {
return <div className="min-h-[100dvh] grid place-items-center">Lade</div>
}
@ -3856,17 +3922,38 @@ export default function App() {
return <LoginPage onLoggedIn={checkAuth} />
}
const headerInfoPanelClass =
'rounded-lg border border-gray-200/70 bg-white/45 px-2.5 py-1.5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/5 sm:flex sm:h-[47px] sm:items-center sm:rounded-xl sm:px-3 sm:py-0'
const headerShellClass =
'rounded-2xl border border-gray-200/70 bg-white/65 p-2.5 shadow-sm backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.06] sm:p-3'
const headerStatBadgeClass =
'inline-flex h-6 items-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-2 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100 sm:h-7 sm:gap-1.5 sm:px-2.5 sm:text-[11px]'
const headerStatClass =
'inline-flex h-8 items-center gap-1.5 rounded-xl border border-gray-200/70 bg-white/75 px-2.5 text-[11px] font-semibold text-gray-700 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-200'
const mobileHeaderStatBadgeClass =
'inline-flex h-6 min-w-[3.1rem] items-center justify-center gap-1 rounded-full border border-gray-200/70 bg-white/70 px-1.5 text-[10px] font-semibold text-gray-900 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-gray-100'
const headerActionClass =
'h-9 shrink-0 rounded-xl px-3 text-sm'
const headerStatItems = [
{
label: 'Online',
value: onlineModelsCount,
icon: SignalIcon,
},
{
label: 'Watched',
value: onlineWatchedModelsCount,
icon: EyeIcon,
},
{
label: 'Favoriten',
value: onlineFavCount,
icon: StarIcon,
},
{
label: 'Likes',
value: onlineLikedCount,
icon: HeartIcon,
},
]
const isTrainingTab = selectedTab === 'training'
return (
<div
className={[
@ -3887,85 +3974,43 @@ export default function App() {
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
].join(' ')}
>
<header className="z-[70] shrink-0 bg-white/70 backdrop-blur dark:bg-gray-950/60 sm:sticky sm:top-0 sm:border-b sm:border-gray-200/70 sm:dark:border-white/10">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pt-2.5 sm:py-4 space-y-2 sm:space-y-3">
<div className="flex items-center justify-between gap-3 sm:gap-4">
<div className="min-w-0 flex-1">
{/* Desktop */}
<div className="hidden sm:flex h-[47px] items-stretch">
<div className={`min-w-0 w-full ${headerInfoPanelClass}`}>
<div className="flex min-w-0 w-full items-center gap-2.5">
<h1 className="shrink-0 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
Recorder
</h1>
<header className="z-[70] shrink-0 border-b border-gray-200/70 bg-white/75 backdrop-blur-xl dark:border-white/10 dark:bg-gray-950/70 sm:sticky sm:top-0">
<div className="mx-auto max-w-7xl px-4 pb-2.5 pt-2.5 sm:px-6 sm:py-3 lg:px-8">
<div className={headerShellClass}>
<div className="grid gap-2.5">
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-600 text-white shadow-sm shadow-indigo-600/20">
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
</div>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className={headerStatBadgeClass} title="online">
<SignalIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineModelsCount}</span>
</span>
<span className={headerStatBadgeClass} title="Watched online">
<EyeIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineWatchedModelsCount}</span>
</span>
<span className={headerStatBadgeClass} title="Fav online">
<StarIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineFavCount}</span>
</span>
<span className={headerStatBadgeClass} title="Like online">
<HeartIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineLikedCount}</span>
</span>
</div>
<div className="hidden lg:block h-4 w-px bg-gray-200/70 dark:bg-white/10" />
<div className="hidden lg:block whitespace-nowrap text-[11px] text-gray-500 dark:text-gray-400">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
/>
</div>
</div>
</div>
</div>
{/* Mobile */}
<div className="sm:hidden space-y-1.5">
<div className={headerInfoPanelClass}>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center gap-2">
<h1 className="min-w-0 flex-1 truncate text-base font-semibold tracking-tight text-gray-900 dark:text-white">
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg">
Recorder
</h1>
<div className="flex shrink-0 items-center justify-end gap-1">
<span className={mobileHeaderStatBadgeClass} title="online">
<SignalIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineModelsCount}</span>
</span>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{headerStatItems.map((item) => {
const Icon = item.icon
<span className={mobileHeaderStatBadgeClass} title="Watched online">
<EyeIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineWatchedModelsCount}</span>
</span>
return (
<span
key={item.label}
className={headerStatClass}
>
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
<span className={mobileHeaderStatBadgeClass} title="Fav online">
<StarIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineFavCount}</span>
</span>
<span className={mobileHeaderStatBadgeClass} title="Like online">
<HeartIcon className="size-4 opacity-80" />
<span className="tabular-nums">{onlineLikedCount}</span>
</span>
<span className="tabular-nums text-gray-950 dark:text-white">
{item.value}
</span>
</span>
)
})}
</div>
</div>
<div className="text-[10px] leading-tight text-gray-500 dark:text-gray-400">
<div className="mt-1 text-[10px] leading-tight text-gray-500 dark:text-gray-400 sm:text-[11px]">
<LastUpdatedText
enabled={Boolean(recSettings.useChaturbateApi)}
fetchedAt={cbOnlineFetchedAt}
@ -3974,128 +4019,119 @@ export default function App() {
</div>
</div>
<div className="space-y-1.5">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center lg:justify-end">
{showPerfMon ? (
<PerformanceMonitor
mode="inline"
compact
className="w-full"
className="w-full sm:w-[300px] lg:w-[360px]"
/>
) : null}
<div className="grid grid-cols-2 gap-1.5">
<div className="grid grid-cols-2 gap-1.5 sm:flex sm:items-center sm:gap-2">
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
className="h-9 w-full px-2.5 text-sm"
className={`${headerActionClass} w-full sm:w-auto`}
>
Cookies
<span className="inline-flex items-center justify-center gap-1.5">
<CircleStackIcon className="h-4 w-4" aria-hidden="true" />
Cookies
</span>
</Button>
<Button
variant="soft"
color='red'
color="red"
onClick={logout}
className="h-9 w-full px-2.5 text-sm"
className={`${headerActionClass} w-full sm:w-auto`}
>
Abmelden
<span className="inline-flex items-center justify-center gap-1.5">
<ArrowRightOnRectangleIcon className="h-4 w-4" aria-hidden="true" />
Abmelden
</span>
</Button>
</div>
</div>
</div>
</div>
<div className="hidden sm:flex shrink-0 items-center gap-2 lg:gap-3">
{showPerfMon ? (
<PerformanceMonitor
mode="inline"
compact
className="w-[300px] lg:w-[360px]"
/>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="relative">
<label className="sr-only">Source URL</label>
<TextInput
ref={sourceUrlInputRef}
value={sourceUrl}
size="sm"
onChange={(e: { target: { value: SetStateAction<string> } }) =>
setSourceUrl(e.target.value)
}
selectAllOnFocus
selectAllOnMouseDown
placeholder="Stream-URL einfügen…"
/>
</div>
<Button
variant="primary"
onClick={onStart}
disabled={!canStart || busy}
className="h-9 w-full rounded-xl px-4 sm:w-auto"
>
<span className="inline-flex items-center justify-center gap-1.5">
<PlayIcon className="h-4 w-4" aria-hidden="true" />
{busy ? 'Starte…' : 'Start'}
</span>
</Button>
</div>
{error ? (
<div className="rounded-xl 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-lg 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}
<Button
variant="secondary"
onClick={() => setCookieModalOpen(true)}
className="h-9 px-3 shrink-0"
>
Cookies
</Button>
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
<div className="rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-200">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
</div>
) : null}
<Button
variant="soft"
color='red'
onClick={logout}
className="h-9 px-3 shrink-0"
>
Abmelden
</Button>
</div>
</div>
{busy ? (
<ProgressBar label="Starte Download…" indeterminate />
) : null}
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
<div className="relative">
<label className="sr-only">Source URL</label>
<TextInput
ref={sourceUrlInputRef}
value={sourceUrl}
size='sm'
onChange={(e: { target: { value: SetStateAction<string> } }) => setSourceUrl(e.target.value)}
selectAllOnFocus
selectAllOnMouseDown
placeholder="https://…"
/>
</div>
<Button variant="primary" onClick={onStart} disabled={!canStart} className="w-full sm:w-auto rounded-lg">
Start
</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 className="pt-1">
<Tabs
tabs={tabs}
value={selectedTab}
onChange={setSelectedTab}
ariaLabel="Tabs"
variant="barUnderline"
/>
</div>
</div>
) : null}
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
<div className="text-xs text-amber-700 dark:text-amber-300">
Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
</div>
) : null}
{busy ? (
<div className="pt-1">
<ProgressBar label="Starte Download…" indeterminate />
</div>
) : null}
<div className="hidden sm:block pt-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
</header>
<div className="sm:hidden sticky top-0 z-[65] shrink-0 border-b border-gray-200/70 bg-white/70 backdrop-blur dark:border-white/10 dark:bg-gray-950/60">
<div className="mx-auto max-w-7xl px-4 pt-0 pb-2">
<Tabs tabs={tabs} value={selectedTab} onChange={setSelectedTab} ariaLabel="Tabs" variant="barUnderline" />
</div>
</div>
<main
className={[
'mx-auto w-full max-w-7xl px-4 py-2 sm:px-6 lg:px-8',
'mx-auto w-full py-2',
isTrainingTab && trainingImageExpanded
? 'max-w-none px-2 sm:px-3 lg:px-4'
: 'max-w-7xl px-4 sm:px-6 lg:px-8',
isTrainingTab
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
: 'space-y-2',
@ -4184,7 +4220,10 @@ export default function App() {
) : null}
{selectedTab === 'training' ? (
<TrainingTab onTrainingRunningChange={setTrainingTabRunning} />
<TrainingTab
onTrainingRunningChange={setTrainingTabRunning}
onImageExpandedChange={setTrainingImageExpanded}
/>
) : null}
{selectedTab === 'categories' ? <CategoriesTab /> : null}

View File

@ -26,7 +26,20 @@ function cn(...parts: Array<string | false | null | undefined>) {
}
const base =
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed'
'inline-flex items-center justify-center font-semibold transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 ' +
'disabled:cursor-not-allowed disabled:saturate-0 disabled:opacity-60 disabled:hover:brightness-100'
const disabledPrimary =
'disabled:!bg-gray-300 disabled:!text-gray-600 disabled:shadow-none disabled:hover:!bg-gray-300 ' +
'dark:disabled:!bg-white/10 dark:disabled:!text-white/40 dark:disabled:hover:!bg-white/10'
const disabledSecondary =
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5'
const disabledSoft =
'disabled:bg-gray-100 disabled:text-gray-500 disabled:inset-ring-gray-200 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:hover:bg-white/5'
const roundedMap = {
sm: 'rounded-sm',
@ -44,72 +57,77 @@ const sizeMap: Record<Size, string> = {
const colorMap: Record<Color, Record<Variant, string>> = {
indigo: {
primary:
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500',
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-500 focus-visible:outline-indigo-600 ' +
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' +
disabledPrimary,
secondary:
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' +
disabledSecondary,
soft:
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/24 hover:inset-ring-indigo-500/30 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/30',
'bg-indigo-500/14 text-indigo-700 shadow-xs inset-ring inset-ring-indigo-500/20 hover:bg-indigo-500/20 hover:inset-ring-indigo-500/25 ' +
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' +
disabledSoft,
},
blue: {
primary:
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500',
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-500 focus-visible:outline-blue-600 ' +
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' +
disabledPrimary,
secondary:
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' +
disabledSecondary,
soft:
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/24 hover:inset-ring-blue-500/30 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/30',
'bg-blue-500/14 text-blue-700 shadow-xs inset-ring inset-ring-blue-500/20 hover:bg-blue-500/20 hover:inset-ring-blue-500/25 ' +
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' +
disabledSoft,
},
emerald: {
primary:
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500',
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-500 focus-visible:outline-emerald-600 ' +
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' +
disabledPrimary,
secondary:
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' +
disabledSecondary,
soft:
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/24 hover:inset-ring-emerald-500/30 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/30',
'bg-emerald-500/14 text-emerald-700 shadow-xs inset-ring inset-ring-emerald-500/20 hover:bg-emerald-500/20 hover:inset-ring-emerald-500/25 ' +
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' +
disabledSoft,
},
red: {
primary:
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500',
'!bg-red-600 !text-white shadow-sm hover:!bg-red-500 focus-visible:outline-red-600 ' +
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' +
disabledPrimary,
secondary:
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' +
disabledSecondary,
soft:
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/24 hover:inset-ring-red-500/30 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/30',
'bg-red-500/14 text-red-700 shadow-xs inset-ring inset-ring-red-500/20 hover:bg-red-500/20 hover:inset-ring-red-500/25 ' +
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' +
disabledSoft,
},
amber: {
primary:
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 focus-visible:outline-amber-500 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500',
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-400 focus-visible:outline-amber-500 ' +
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' +
disabledPrimary,
secondary:
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
'disabled:bg-gray-100 disabled:text-gray-500 disabled:ring-gray-300 disabled:shadow-none disabled:hover:bg-gray-100 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' +
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' +
disabledSecondary,
soft:
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/22 hover:inset-ring-amber-500/30 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/30',
'bg-amber-500/12 text-amber-800 shadow-xs inset-ring inset-ring-amber-500/20 hover:bg-amber-500/18 hover:inset-ring-amber-500/25 ' +
'dark:bg-amber-500/20 dark:text-amber-300 dark:shadow-none dark:inset-ring-0 dark:hover:bg-amber-500/25 ' +
disabledSoft,
},
}
@ -179,4 +197,4 @@ export default function Button({
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
</button>
)
}
}

View File

@ -543,19 +543,11 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none
if (!isPostworkJob(job)) return 'none'
if (isTerminalStatus(anyJ?.status)) return 'none'
if (pwState === 'queued') return 'queued'
// 1) Server-Status ist die Wahrheit
if (pwState === 'running') return 'running'
if (pwState === 'queued') return 'queued'
if (
phase === 'postwork' ||
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets'
) {
return 'running'
}
// 2) Queue-Positionsdaten aus dem Backend
if (
typeof pw?.position === 'number' &&
Number.isFinite(pw.position) &&
@ -573,10 +565,28 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none
return 'running'
}
// 3) Wenn ein PostWorkKey existiert, aber noch kein running-Status da ist:
// NICHT als running anzeigen, sondern als queued.
if (hasPwKey) {
return 'queued'
}
// 4) Nur echte konkrete Arbeitsphasen sind running.
// "postwork" alleine bedeutet nur: Job ist im Nacharbeitsbereich.
if (
phase === 'probe' ||
phase === 'remuxing' ||
phase === 'moving' ||
phase === 'assets' ||
phase === 'analyze'
) {
return 'running'
}
if (phase === 'postwork') {
return 'queued'
}
if (anyJ.endedAt || job.endedAt) return 'queued'
return 'none'

View File

@ -72,7 +72,10 @@ type Props = {
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
onDeleteJob?: (
job: RecordJob
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
) =>
| void
| { undoToken?: string; from?: string }
| Promise<void | { undoToken?: string; from?: string }>
onToggleHot?: (
job: RecordJob
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
@ -490,17 +493,45 @@ function mergeMetaPreferDone(
const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {}
const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {}
const incomingHighlights = isPlainObject(incomingAnalysis.highlights)
? incomingAnalysis.highlights
: {}
const existingHighlights = isPlainObject(existingAnalysis.highlights)
? existingAnalysis.highlights
: {}
const incomingAi = isPlainObject(incomingAnalysis.ai)
? incomingAnalysis.ai
: {}
const existingAi = isPlainObject(existingAnalysis.ai)
? existingAnalysis.ai
: {}
merged.analysis = {
...incomingAnalysis,
...existingAnalysis,
ai:
isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0
highlights:
Object.keys(existingHighlights).length > 0
? {
...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}),
...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}),
...incomingHighlights,
...existingHighlights,
}
: isPlainObject(incomingAnalysis.ai)
? incomingAnalysis.ai
: Object.keys(incomingHighlights).length > 0
? incomingHighlights
: undefined,
// Altbestand weiter unterstützen.
ai:
Object.keys(existingAi).length > 0
? {
...incomingAi,
...existingAi,
}
: Object.keys(incomingAi).length > 0
? incomingAi
: undefined,
} as any
@ -924,6 +955,7 @@ function buildCompletedPostworkSummaryFromMeta(
)
const aiObject =
meta?.analysis?.highlights ??
meta?.analysis?.ai ??
meta?.ai
@ -965,7 +997,7 @@ function buildCompletedPostworkSummaryFromMeta(
)
const hasAiAnalysis =
readCompletedFlag(meta, 'analyze', 'analysis', 'ai') ||
readCompletedFlag(meta, 'analyze', 'analysis', 'ai', 'highlights') ||
hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
hasNonEmptyObject(aiObject) ||
hasAiHits ||
@ -2463,7 +2495,15 @@ export default function FinishedDownloads({
showOnlyCorrupt,
])
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
const finishedDownloadsLoading =
allDoneJobsLoading && globalFilterActive && allDoneJobs === null
const totalItemsForPagination =
finishedDownloadsLoading
? 0
: effectiveAllMode
? visibleRows.length
: doneTotalPage
const pageRows = useMemo(() => {
if (effectiveAllMode) {
@ -2479,8 +2519,15 @@ export default function FinishedDownloads({
return visibleRows
}, [visibleRows, page, effectivePageSize, effectiveAllMode, view])
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
const emptyByFilter = globalFilterActive && visibleRows.length === 0
const emptyFolder =
!finishedDownloadsLoading &&
!effectiveAllMode &&
totalItemsForPagination === 0
const emptyByFilter =
!finishedDownloadsLoading &&
globalFilterActive &&
visibleRows.length === 0
const selectedCount = useMemo(() => {
return selectionStore.getCount()
@ -3104,9 +3151,10 @@ export default function FinishedDownloads({
selectionStore.deselect(key)
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
const from = typeof result?.from === 'string' ? result.from : undefined
if (undoToken) {
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
} else {
setLastAction(null)
}
@ -3304,7 +3352,7 @@ export default function FinishedDownloads({
unhideRow(lastAction.originalFile, lastAction.originalFile)
unhideRow(restoredFile, restoredFile)
emitCountHint(+1)
emitCountHint(includeKeep ? 0 : +1)
setLastAction(null)
return
}
@ -3686,29 +3734,6 @@ export default function FinishedDownloads({
// effects
// -----------------------------------------------------------------------------
useEffect(() => {
if (!showOnlyCorrupt) return
for (const job of viewRows) {
const file = baseName(job.output || '')
if (!file) continue
const state = readDownloadIntegrityState((job as any)?.meta)
if (state !== 'unchecked') continue
if (integrityMetaPendingRef.current.has(file)) continue
integrityMetaPendingRef.current.add(file)
void refreshDoneMetaForFile(file).finally(() => {
integrityMetaPendingRef.current.delete(file)
})
}
}, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile])
useEffect(() => {
onPageChange(1)
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
useEffect(() => {
if (!globalFilterActive) {
setAllDoneJobs(null)
@ -3743,6 +3768,29 @@ export default function FinishedDownloads({
}
}, [globalFilterActive, loadAllDoneJobs])
useEffect(() => {
if (!showOnlyCorrupt) return
for (const job of viewRows) {
const file = baseName(job.output || '')
if (!file) continue
const state = readDownloadIntegrityState((job as any)?.meta)
if (state !== 'unchecked') continue
if (integrityMetaPendingRef.current.has(file)) continue
integrityMetaPendingRef.current.add(file)
void refreshDoneMetaForFile(file).finally(() => {
integrityMetaPendingRef.current.delete(file)
})
}
}, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile])
useEffect(() => {
onPageChange(1)
}, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange])
useEffect(() => {
if (view !== 'gallery') return
@ -4845,7 +4893,8 @@ export default function FinishedDownloads({
<LoadingSpinner
size="lg"
className={[
'text-indigo-500 transition-opacity duration-150 opacity-0 pointer-events-none',
'text-indigo-500 transition-opacity duration-150 pointer-events-none',
allDoneJobsLoading ? 'opacity-100' : 'opacity-0',
].join(' ')}
srLabel="Lade Downloads…"
/>
@ -5231,7 +5280,24 @@ export default function FinishedDownloads({
</div>
</div>
{emptyFolder ? (
{finishedDownloadsLoading ? (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<LoadingSpinner size="md" className="text-indigo-500" srLabel="Lade abgeschlossene Downloads…" />
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Abgeschlossene Downloads werden geladen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Bitte kurz warten, die Liste wird vom Backend geladen.
</div>
</div>
</div>
</Card>
) : emptyFolder ? (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">

View File

@ -664,14 +664,19 @@ export function ToyIcon(props: IconProps) {
<IconBase
{...props}
className={iconClassName(props.className, 'origin-center scale-[1.05]')}
viewBox="0 0 256 237"
viewBox="0 0 237 256"
>
<g
transform="translate(0,237) scale(0.1,-0.1)"
transform="translate(0,256) scale(0.1,-0.1)"
fill="currentColor"
stroke="none"
>
<path d="M370 1984 c-67 -57 -74 -110 -34 -261 18 -69 28 -134 31 -218 6 -135 -5 -212 -41 -291 -22 -47 -23 -54 -9 -65 21 -18 46 17 75 106 20 61 23 89 22 215 -1 130 -4 157 -33 263 -37 140 -33 184 22 218 39 24 77 19 124 -17 27 -20 33 -31 29 -49 -4 -15 1 -28 14 -40 17 -16 18 -20 5 -38 -18 -26 -19 -84 -2 -113 17 -31 915 -1051 940 -1069 37 -26 83 -19 126 20 34 31 40 33 51 20 11 -13 7 -22 -24 -54 -23 -24 -39 -51 -43 -74 -5 -36 -3 -40 87 -132 111 -112 157 -138 250 -138 197 0 325 202 240 378 -11 22 -60 80 -110 130 -83 83 -93 90 -130 90 -32 0 -47 -7 -79 -39 -34 -34 -42 -37 -56 -26 -14 12 -13 17 15 45 38 39 49 94 26 129 -12 20 -905 813 -1044 929 -38 31 -86 36 -131 12 -26 -13 -30 -13 -46 5 -12 14 -22 17 -30 10 -8 -7 -20 0 -41 21 -63 66 -148 80 -204 33z m260 -114 c0 -5 -4 -10 -10 -10 -5 0 -10 5 -10 10 0 6 5 10 10 10 6 0 10 -4 10 -10z m445 -400 c31 -32 29 -51 -8 -74 -13 -8 -23 -4 -47 19 -35 34 -36 39 -10 65 26 26 31 25 65 -10z m94 -94 c26 -28 25 -53 -5 -72 -18 -13 -23 -11 -52 19 -30 31 -31 34 -16 55 20 29 45 28 73 -2z m101 -91 c15 -19 6 -61 -16 -69 -42 -16 -82 35 -54 69 16 19 54 19 70 0z m524 -523 c3 -5 -10 -23 -28 -41 -43 -41 -61 -23 -20 20 29 30 39 35 48 21z" />
<path d="M1149 2243 c-18 -21 17 -46 106 -75 60 -20 90 -23 210 -23 124 1 154 4 265 33 147 37 187 34 221 -21 24 -39 19 -77 -17 -124 -20 -27 -31 -33 -49 -29 -15 4 -28 -1 -40 -14 -16 -17 -20 -18 -38 -5 -26 18 -84 19 -113 2 -31 -17 -1051 -915 -1069 -940 -26 -37 -19 -83 20 -126 31 -34 33 -40 20 -51 -13 -11 -22 -7 -54 24 -24 23 -51 39 -74 43 -36 5 -40 3 -132 -87 -112 -111 -138 -157 -138 -250 0 -197 202 -325 378 -240 22 11 80 60 130 110 83 83 90 93 90 130 0 32 -7 47 -39 79 -34 34 -37 42 -26 56 12 14 17 13 45 -15 39 -38 94 -49 130 -26 21 14 845 941 928 1045 31 38 35 86 12 130 -13 26 -13 30 5 46 14 12 17 22 10 30 -7 8 0 20 21 41 66 63 80 148 33 204 -57 67 -110 74 -261 34 -71 -19 -133 -28 -223 -31 -138 -6 -206 4 -287 41 -46 22 -53 23 -64 9z" />
<path d="M1680 1940 c0 -5 -4 -10 -10 -10 -5 0 -10 5 -10 10 0 6 5 10 10 10 6 0 10 -4 10 -10z" />
<path d="M1280 1550 c26 -26 25 -31 -10 -65 -32 -31 -51 -29 -74 8 -8 13 -4 23 19 47 34 35 39 36 65 10z" />
<path d="M1198 1435 c5 -21 -35 -65 -59 -65 -10 0 -25 10 -33 22 -15 21 -14 24 16 55 29 30 34 32 52 19 12 -7 23 -21 24 -31z" />
<path d="M1088 1358 c27 -27 7 -78 -29 -78 -21 0 -49 26 -49 45 0 35 53 58 78 33z" />
<path d="M545 810 c19 -19 25 -34 19 -40 -11 -11 -77 47 -69 61 10 15 20 11 50 -21z" />
</g>
</IconBase>
)
@ -1096,11 +1101,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
text: 'Unbekannt',
icon: UnknownContentIcon,
},
{
match: ['other', 'misc', 'miscellaneous'],
text: 'Andere',
icon: OtherContentIcon,
},
// People
{
match: ['person_female', 'female_person'],
@ -1524,10 +1524,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
return { match: [], text: 'Crop Top', icon: CropTopIcon }
}
if (labelKey.includes('other') || labelKey.includes('misc')) {
return { match: [], text: 'Andere', icon: OtherContentIcon }
}
return null
}

View File

@ -1,6 +1,21 @@
// frontend/src/components/ui/LoginPage.tsx
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import Button from './Button'
import LoginShell from './login/LoginShell'
import ErrorAlert from './login/ErrorAlert'
import LoginForm from './login/LoginForm'
import TotpCodeInput from './login/TotpCodeInput'
import PasskeySetupPrompt from './login/PasskeySetupPrompt'
import { apiJSON, getNextFromLocation } from './login/loginApi'
import {
clearLoginState,
digitsToCode,
loadLoginState,
saveLoginState,
splitCodeToDigits,
type LoginStage,
} from './login/loginStorage'
type Props = {
onLoggedIn?: () => void | Promise<void>
@ -11,11 +26,18 @@ type LoginResp = {
totpRequired?: boolean
}
type PasskeyFeedback = {
kind: 'info' | 'loading' | 'success'
text: string
}
type MeResp = {
authenticated?: boolean
pending2fa?: boolean
totpEnabled?: boolean
totpConfigured?: boolean
passkeyConfigured?: boolean
passkeyCount?: number
}
type SetupResp = {
@ -23,78 +45,14 @@ type SetupResp = {
otpauth?: string
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
function unwrapWebAuthnOptions<T = any>(options: any): T {
const unwrapped = options?.publicKey ?? options
if (!unwrapped?.challenge) {
throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.')
}
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 '/'
}
}
const LOGIN_STATE_KEY = 'recorder_login_state_v1'
type PersistedLoginState = {
stage: 'login' | 'verify' | 'setup'
username: string
code: string
setupAuthUrl: string | null
setupSecret: string | null
setupInfo: string | null
ts: number
}
function loadLoginState(): PersistedLoginState | null {
try {
const raw = sessionStorage.getItem(LOGIN_STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedLoginState
// optional: nach 15 Minuten verwerfen
if (!parsed?.ts || Date.now() - parsed.ts > 15 * 60 * 1000) return null
return parsed
} catch {
return null
}
}
function saveLoginState(s: PersistedLoginState) {
try {
sessionStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(s))
} catch {
// ignore
}
}
function clearLoginState() {
try {
sessionStorage.removeItem(LOGIN_STATE_KEY)
} catch {
// ignore
}
}
function splitCodeToDigits(code: string): string[] {
const only = (code ?? '').replace(/\D/g, '').slice(0, 6)
const arr = only.split('')
while (arr.length < 6) arr.push('')
return arr
}
function digitsToCode(d: string[]) {
return (d ?? []).join('')
return unwrapped as T
}
export default function LoginPage({ onLoggedIn }: Props) {
@ -106,20 +64,185 @@ export default function LoginPage({ onLoggedIn }: Props) {
// 6x inputs
const [codeDigits, setCodeDigits] = useState<string[]>(['', '', '', '', '', ''])
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([])
const lastSubmittedTotpCodeRef = useRef('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stage, setStage] = useState<'login' | 'verify' | 'setup'>('login')
const [stage, setStage] = useState<LoginStage>('login')
const [setupAuthUrl, setSetupAuthUrl] = useState<string | null>(null)
const [setupSecret, setSetupSecret] = useState<string | null>(null)
const [setupInfo, setSetupInfo] = useState<string | null>(null)
const [passkeySupported, setPasskeySupported] = useState(false)
const [passkeyFeedback, setPasskeyFeedback] = useState<PasskeyFeedback | null>(null)
const submittedOnceRef = useRef(false)
const codeStr = useMemo(() => digitsToCode(codeDigits), [codeDigits])
async function finishLogin() {
if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
}
async function continueAfterFullAuth() {
const me = await apiJSON<MeResp>('/api/auth/me', { cache: 'no-store' as any })
if (me?.authenticated && !me?.passkeyConfigured) {
setStage('passkeySetup')
return
}
await finishLogin()
}
useEffect(() => {
setPasskeySupported(
typeof window !== 'undefined' &&
window.isSecureContext &&
typeof window.PublicKeyCredential !== 'undefined'
)
}, [])
function passkeyErrorMessage(e: any) {
const raw = String(e?.message ?? e ?? '').trim()
if (!raw) return 'Passkey konnte nicht eingerichtet werden.'
if (raw.includes('passkey registration verify failed')) {
return (
raw +
'\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.'
)
}
if (
raw.includes('NotAllowedError') ||
raw.toLowerCase().includes('not allowed') ||
raw.toLowerCase().includes('aborted')
) {
return 'Passkey-Einrichtung wurde abgebrochen oder vom Browser/Authenticator nicht erlaubt.'
}
if (
raw.includes('SecurityError') ||
raw.toLowerCase().includes('relying party') ||
raw.toLowerCase().includes('rp id') ||
raw.toLowerCase().includes('origin')
) {
return (
raw +
'\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.'
)
}
if (raw.includes('challenge expired')) {
return 'Die Passkey-Challenge ist abgelaufen. Bitte erneut versuchen.'
}
return raw
}
async function registerPasskey(): Promise<boolean> {
if (busy) return false
setBusy(true)
setError(null)
setPasskeyFeedback({
kind: 'loading',
text: 'Starte Passkey-Einrichtung…',
})
try {
setPasskeyFeedback({
kind: 'loading',
text: 'Hole WebAuthn-Challenge vom Backend…',
})
const options = await apiJSON<any>('/api/auth/passkey/register/options', {
method: 'POST',
})
setPasskeyFeedback({
kind: 'loading',
text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.',
})
const credential = await startRegistration({
optionsJSON: unwrapWebAuthnOptions(options),
})
setPasskeyFeedback({
kind: 'loading',
text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…',
})
await apiJSON<{ ok?: boolean }>('/api/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
})
setError(null)
setSetupInfo('Passkey wurde hinzugefügt.')
setPasskeyFeedback({
kind: 'success',
text: 'Passkey wurde erfolgreich hinzugefügt.',
})
return true
} catch (e: any) {
const message = passkeyErrorMessage(e)
// Fehler immer nur über ErrorAlert anzeigen.
setError(message)
// Fortschrittsbox im PasskeySetupPrompt entfernen,
// damit dort keine zweite rote Fehlermeldung entsteht.
setPasskeyFeedback(null)
return false
} finally {
setBusy(false)
}
}
async function registerPasskeyAndContinue() {
const ok = await registerPasskey()
if (!ok) return
await finishLogin()
}
async function loginWithPasskey() {
setBusy(true)
setError(null)
try {
const options = await apiJSON<any>('/api/auth/passkey/login/options', {
method: 'POST',
})
const credential = await startAuthentication({
optionsJSON: unwrapWebAuthnOptions(options),
})
await apiJSON<{ ok?: boolean }>('/api/auth/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
})
await finishLogin()
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
setBusy(false)
}
}
function focusDigit(i: number) {
const el = codeInputsRef.current[i]
if (el) el.focus()
@ -128,11 +251,18 @@ export default function LoginPage({ onLoggedIn }: Props) {
function clearAllDigits() {
setCodeDigits(['', '', '', '', '', ''])
submittedOnceRef.current = false
lastSubmittedTotpCodeRef.current = ''
setError(null)
window.setTimeout(() => focusDigit(0), 0)
}
function setDigitAt(i: number, val: string) {
const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer
setError(null)
submittedOnceRef.current = false
lastSubmittedTotpCodeRef.current = ''
setCodeDigits((prev) => {
const next = [...prev]
next[i] = v
@ -150,6 +280,10 @@ export default function LoginPage({ onLoggedIn }: Props) {
// falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen
if (only.length > 1) {
setError(null)
submittedOnceRef.current = false
lastSubmittedTotpCodeRef.current = ''
setCodeDigits((prev) => {
const next = [...prev]
let k = i
@ -160,7 +294,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
return
}
@ -207,6 +341,10 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (!only) return
ev.preventDefault()
setError(null)
submittedOnceRef.current = false
lastSubmittedTotpCodeRef.current = ''
setCodeDigits((prev) => {
const next = [...prev]
let k = i
@ -218,7 +356,6 @@ export default function LoginPage({ onLoggedIn }: Props) {
return next
})
submittedOnceRef.current = false
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
}
@ -265,15 +402,18 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (cancelled) return
if (me?.authenticated) {
// ✅ eingeloggt: wenn 2FA noch NICHT konfiguriert → Setup zeigen
if (!me?.totpConfigured) {
setStage('setup')
// Setup-Infos laden (QR/otpauth)
void ensure2FASetup()
} else {
clearLoginState()
window.location.assign(nextPath || '/')
return
}
if (!me?.passkeyConfigured) {
setStage('passkeySetup')
return
}
await finishLogin()
return
}
@ -322,9 +462,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
return
}
if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
await continueAfterFullAuth()
} catch (e: any) {
setError(e?.message ?? String(e))
} finally {
@ -336,8 +474,15 @@ export default function LoginPage({ onLoggedIn }: Props) {
const c = codeStr.trim()
if (!/^\d{6}$/.test(c)) return
// Wichtig gegen Flackern:
// denselben falschen 6-stelligen Code nicht erneut automatisch senden.
if (lastSubmittedTotpCodeRef.current === c) return
lastSubmittedTotpCodeRef.current = c
submittedOnceRef.current = true
setBusy(true)
setError(null)
try {
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
method: 'POST',
@ -345,12 +490,20 @@ export default function LoginPage({ onLoggedIn }: Props) {
body: JSON.stringify({ code: c }),
})
if (onLoggedIn) await onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
lastSubmittedTotpCodeRef.current = ''
await continueAfterFullAuth()
} catch (e: any) {
submittedOnceRef.current = false
setError(e?.message ?? String(e))
const message = String(e?.message ?? e ?? '')
if (message.trim() === 'invalid code') {
setError('Der 2FA-Code ist ungültig.')
} else {
setError(message)
}
// NICHT auf false setzen.
// Sonst triggert der Auto-submit-Effect denselben falschen Code direkt erneut.
submittedOnceRef.current = true
} finally {
setBusy(false)
}
@ -377,6 +530,26 @@ export default function LoginPage({ onLoggedIn }: Props) {
setError(e?.message ?? String(e))
}
}
async function skip2FASetup() {
setBusy(true)
setError(null)
try {
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/cancel-setup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
} catch {
// Ignorieren: Überspringen soll nicht blockieren.
// Falls der Endpoint noch nicht vorhanden ist, geht der Login trotzdem weiter.
} finally {
setBusy(false)
}
await finishLogin()
}
// Auto-submit sobald 6 Ziffern befüllt sind (verify + setup)
useEffect(() => {
@ -384,241 +557,182 @@ export default function LoginPage({ onLoggedIn }: Props) {
if (stage !== 'verify' && stage !== 'setup') return
if (!/^\d{6}$/.test(codeStr)) return
if (submittedOnceRef.current) return
if (lastSubmittedTotpCodeRef.current === codeStr) return
submittedOnceRef.current = true
void submit2FA()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [codeStr, stage, busy])
const Code6Inputs = (
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<div className="flex items-center justify-between gap-2">
{codeDigits.map((d, i) => (
<input
key={i}
ref={(el) => {
codeInputsRef.current[i] = el
}}
value={d}
onChange={(e) => handleDigitChange(i, e.target.value)}
onKeyDown={(e) => handleDigitKeyDown(i, e)}
onPaste={(e) => handleDigitPaste(i, e)}
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
enterKeyHint={i === 5 ? 'done' : 'next'}
autoCapitalize="none"
autoCorrect="off"
disabled={busy}
className="h-12 w-12 rounded-lg text-center text-lg tabular-nums 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"
aria-label={`2FA Ziffer ${i + 1}`}
/>
))}
</div>
</div>
<TotpCodeInput
digits={codeDigits}
busy={busy}
inputRefs={codeInputsRef}
onChangeDigit={handleDigitChange}
onKeyDownDigit={handleDigitKeyDown}
onPasteDigit={handleDigitPaste}
/>
)
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>
<LoginShell>
{stage === 'login' ? (
<LoginForm
username={username}
password={password}
busy={busy}
passkeySupported={passkeySupported}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={() => void submitLogin()}
onPasskeyLogin={() => void loginWithPasskey()}
/>
) : stage === 'verify' ? (
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<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="mt-5 space-y-3">
{stage === 'login' ? (
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submitLogin()
}}
className="space-y-3"
>
<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)}
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>
{Code6Inputs}
<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)}
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>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
setError(null)
setStage('login')
clearAllDigits()
}}
>
Zurück
</Button>
<Button type="submit" variant="primary" className="w-full rounded-lg" disabled={busy || !username.trim() || !password}>
{busy ? 'Login…' : 'Login'}
</Button>
</form>
) : stage === 'verify' ? (
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<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>
<Button
type="submit"
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || !/^\d{6}$/.test(codeStr)}
>
{busy ? 'Prüfe…' : 'Bestätigen'}
</Button>
</div>
</form>
) : stage === 'setup' ? (
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
2FA ist noch nicht eingerichtet bitte richte es jetzt ein.
</div>
{Code6Inputs}
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<div>1) Öffne deine Authenticator-App und füge einen neuen Account hinzu.</div>
<div>2) Scanne den QR-Code oder verwende den Secret-Key.</div>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
setStage('login')
clearAllDigits()
}}
>
Zurück
</Button>
<div className="rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
QR / Setup
</div>
<Button
type="submit"
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || !/^\d{6}$/.test(codeStr)}
>
{busy ? 'Prüfe…' : 'Bestätigen'}
</Button>
</div>
</form>
{setupAuthUrl ? (
<div className="mt-2 flex items-center justify-center">
<img
alt="2FA QR Code"
className="h-44 w-44 rounded bg-white p-2"
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(setupAuthUrl)}`}
/>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
if (busy) return
void submit2FA()
}}
className="space-y-3"
>
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
2FA ist noch nicht eingerichtet bitte richte es jetzt ein (empfohlen).
</div>
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<div>1) Öffne deine Authenticator-App und füge einen neuen Account hinzu.</div>
<div>2) Scanne den QR-Code oder verwende den Secret-Key.</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">QR / Setup</div>
{setupAuthUrl ? (
<div className="mt-2 flex items-center justify-center">
<img
alt="2FA QR Code"
className="h-44 w-44 rounded bg-white p-2"
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(setupAuthUrl)}`}
/>
</div>
) : (
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">QR wird geladen</div>
)}
{setupSecret ? (
<div className="mt-3">
<div className="text-xs text-gray-600 dark:text-gray-300">Secret (manuell):</div>
<div className="mt-1 select-all break-all rounded bg-gray-100 px-2 py-1 text-xs font-mono text-gray-900 dark:bg-white/10 dark:text-gray-100">
{setupSecret}
</div>
</div>
) : null}
{setupInfo ? <div className="mt-3 text-xs text-gray-600 dark:text-gray-300">{setupInfo}</div> : null}
<div className="mt-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-lg"
disabled={busy}
onClick={() => void ensure2FASetup()}
title="Setup-Infos neu laden"
>
{setupAuthUrl ? 'QR/Setup erneut laden' : 'QR/Setup laden'}
</Button>
</div>
</div>
{/* gleiche 6-fach Eingabe auch hier */}
{Code6Inputs}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
// optional: Setup überspringen (nicht empfohlen)
if (onLoggedIn) void onLoggedIn()
clearLoginState()
window.location.assign(nextPath || '/')
}}
title="Ohne 2FA fortfahren (nicht empfohlen)"
>
Später
</Button>
<Button type="submit" variant="primary" className="flex-1 rounded-lg" disabled={busy || !/^\d{6}$/.test(codeStr)}>
{busy ? 'Aktiviere…' : '2FA aktivieren'}
</Button>
</div>
</form>
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
QR wird geladen
</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>
{setupSecret ? (
<div className="mt-3">
<div className="text-xs text-gray-600 dark:text-gray-300">
Secret (manuell):
</div>
<div className="mt-1 select-all break-all rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-900 dark:bg-white/10 dark:text-gray-100">
{setupSecret}
</div>
</div>
) : null}
{setupInfo ? (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-300">
{setupInfo}
</div>
) : null}
<div className="mt-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-lg"
disabled={busy}
onClick={() => void ensure2FASetup()}
title="Setup-Infos neu laden"
>
{setupAuthUrl ? 'QR/Setup erneut laden' : 'QR/Setup laden'}
</Button>
</div>
</div>
</div>
</div>
</div>
{Code6Inputs}
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
void skip2FASetup()
}}
title="Ohne 2FA fortfahren"
>
{busy ? 'Überspringe…' : 'Später'}
</Button>
<Button
type="submit"
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || !/^\d{6}$/.test(codeStr)}
>
{busy ? 'Aktiviere…' : '2FA aktivieren'}
</Button>
</div>
</form>
) : (
<PasskeySetupPrompt
busy={busy}
supported={passkeySupported}
feedback={passkeyFeedback}
onRegisterAndContinue={() => void registerPasskeyAndContinue()}
onSkip={() => void finishLogin()}
/>
)}
<ErrorAlert error={error} onClose={() => setError(null)} />
</LoginShell>
)
}

View File

@ -170,7 +170,7 @@ export default function Modal({
>
<Dialog.Panel
className={cn(
'relative w-full rounded-lg bg-white text-left shadow-xl transition-all',
'relative w-full overflow-hidden rounded-2xl bg-white text-left shadow-xl transition-all',
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
// panel is a flex column so we can create a real scroll area
'flex flex-col min-h-0',
@ -187,7 +187,8 @@ export default function Modal({
{/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
<div
className={cn(
'shrink-0 px-4 pt-4 sm:px-6 sm:pt-6 items-start justify-between gap-3',
'shrink-0 px-4 pt-4 pb-3 sm:px-6 sm:pt-6 sm:pb-4 items-start justify-between gap-3',
'border-b border-gray-200/70 dark:border-white/10',
layout === 'split' ? 'hidden lg:flex' : 'flex'
)}
>

View File

@ -293,6 +293,8 @@ function heroStatusPill(show: string) {
return heroOverlayPill('bg-slate-900/70 ring-white/15')
case 'away':
return heroOverlayPill('bg-amber-500/85 ring-amber-300/25')
case 'offline':
return heroOverlayPill('bg-gray-700/85 ring-white/15')
default:
return heroOverlayPill('bg-black/55 ring-white/15')
}
@ -402,6 +404,10 @@ type BioResp = {
lastError?: string
model?: string
bio?: BioContext | null
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
}
// ------ props ------
@ -414,6 +420,15 @@ type StoredModel = {
tags?: string | null
lastSeenOnline?: boolean | null
lastSeenOnlineAt?: string
roomStatus?: string
isOnline?: boolean
chatRoomUrl?: string
imageUrl?: string
lastOnlineAt?: string
lastOfflineAt?: string
lastRoomSyncAt?: string
favorite?: boolean
watching?: boolean
liked?: boolean | null
@ -849,6 +864,11 @@ export default function ModelDetails({
const effectiveRoom = room ?? storedRoomFromSnap
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
const onlineStatusFetchedAt =
model?.lastRoomSyncAt ||
model?.lastSeenOnlineAt ||
effectiveRoomMeta?.fetchedAt
const doneMatches = done
const runningMatches = useMemo(() => {
@ -875,8 +895,6 @@ export default function ModelDetails({
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
const heroImgFull = effectiveRoom?.image_url || heroImg
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
const profileUsername = useMemo(() => {
const raw = String(
effectiveRoom?.username ||
@ -885,16 +903,29 @@ export default function ModelDetails({
''
).trim()
return raw.replace(/^@/, '')
return raw.replace(/^@/, '').replace(/^\/+|\/+$/g, '')
}, [effectiveRoom?.username, model?.modelKey, key])
const profileHref = useMemo(() => {
if (roomUrl) return roomUrl
if (!profileUsername) return ''
return absCbUrl(`/${profileUsername}/`)
}, [roomUrl, profileUsername])
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
}, [profileUsername])
const showLabel = (effectiveRoom?.current_show || '').trim().toLowerCase()
const currentOnlineState = useMemo(() => {
const roomStatus = String(model?.roomStatus ?? '').trim().toLowerCase()
const isOnline = Boolean(model?.isOnline)
const lastSeenOnline = model?.lastSeenOnline
if (roomStatus === 'offline') return 'offline'
if (lastSeenOnline === false) return 'offline'
if (!isOnline && roomStatus === '') return ''
if (roomStatus) return roomStatus
return (effectiveRoom?.current_show || '').trim().toLowerCase()
}, [model?.roomStatus, model?.isOnline, model?.lastSeenOnline, effectiveRoom?.current_show])
const showLabel = currentOnlineState
const showPill =
showLabel === 'public'
@ -905,7 +936,9 @@ export default function ModelDetails({
? 'Hidden'
: showLabel === 'away'
? 'Away'
: showLabel || ''
: showLabel === 'offline'
? 'Offline'
: showLabel || ''
const bioLocation = (bio?.location || '').trim()
const bioFollowers = bio?.follower_count
@ -2070,9 +2103,9 @@ export default function ModelDetails({
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
{key ? (
<div className="flex flex-wrap gap-x-2 gap-y-1">
{effectiveRoomMeta?.fetchedAt ? (
{onlineStatusFetchedAt ? (
<span className="text-gray-500 dark:text-gray-400">
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
Online-Stand: {fmtDateTime(onlineStatusFetchedAt)}
</span>
) : null}
{bioMeta?.fetchedAt ? (

View File

@ -114,7 +114,6 @@ const AI_LABEL_FALLBACKS: Record<string, string> = {
toy_play: 'Toy Play',
fingering: 'Fingering',
'69': '69',
other: 'Andere',
person: 'Person',
person_unknown: 'Person',
@ -375,7 +374,6 @@ function isKnownFrontendPositionLabel(value: unknown): boolean {
'toy_play',
'fingering',
'69',
'other',
].includes(label)
}
@ -449,11 +447,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
function readAiNode(metaRaw: unknown): Record<string, unknown> | null {
const meta = parseMetaObject(metaRaw) as any
const ai =
meta?.analysis?.ai ??
meta?.ai ??
null
const ai = meta?.analysis?.highlights ?? null
return isPlainObject(ai) ? ai : null
}
@ -731,11 +725,7 @@ function readMetaVideoAspectRatio(metaRaw: unknown): number {
function readPreviewSpriteMeta(metaRaw: unknown): PreviewSpriteMeta | null {
const meta = parseMetaObject(metaRaw) as any
let sprite =
meta?.preview?.sprite ??
meta?.previewSprite ??
meta?.preview_sprite ??
null
let sprite = meta?.preview?.sprite ?? null
if (typeof sprite === 'string') {
try {
@ -1037,48 +1027,25 @@ function segmentTimeLabel(obj: Record<string, unknown>): string | undefined {
function readSegmentArray(metaRaw: unknown): unknown[] {
const meta = parseMetaObject(metaRaw) as any
const ai = readAiNode(metaRaw) as any
const source = meta?.analysis?.highlights
if (!isPlainObject(source)) return []
const arrays: unknown[][] = []
const pushSegmentSources = (node: unknown) => {
if (!isPlainObject(node)) return
const n = node as any
// Bevorzugt echte Segmente. Hits nur als Fallback,
// sonst werden dieselben Stellen doppelt angezeigt.
const preferred = [
n.segments,
n.ratingSegments,
n.matches,
n.hits,
]
for (const value of preferred) {
if (Array.isArray(value) && value.length > 0) {
arrays.push(value)
return
}
}
}
// Wichtig: Highlights zuerst, damit Positionen/Combos oben erscheinen.
const goalNodes = [
meta?.analysis?.highlights,
meta?.analysis?.highlight,
ai?.highlights,
ai?.highlight,
ai?.goals?.highlights,
ai?.goals?.highlight,
meta?.analysis?.ai?.highlights,
meta?.analysis?.ai?.highlight,
const preferred = [
source.segments,
source.ratingSegments,
source.matches,
source.hits,
]
for (const node of goalNodes) {
pushSegmentSources(node)
for (const value of preferred) {
if (Array.isArray(value) && value.length > 0) {
arrays.push(value)
break
}
}
if (arrays.length === 0) return []

View File

@ -1,7 +1,7 @@
// frontend\src\components\ui\RecorderSettings.tsx
'use client'
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import { useEffect, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from 'react'
import Button from './Button'
import Card from './Card'
import LabeledSwitch from './LabeledSwitch'
@ -10,6 +10,7 @@ import TaskList from './TaskList'
import type { TaskItem } from './TaskList'
import PostgresUrlModal from './PostgresUrlModal'
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
import { startRegistration } from '@simplewebauthn/browser'
type RecorderSettings = {
databaseUrl?: string
@ -42,6 +43,29 @@ type RecorderSettings = {
trainingDetectorEpochs?: number
}
type AuthDevice = {
id: string
label: string
type?: string
createdAt?: string
configuredAt?: string
lastUsedAt?: string
active?: boolean
signCount?: number
backupState?: boolean
backupEligible?: boolean
}
type AuthSecurityStatus = {
authenticated?: boolean
totpEnabled?: boolean
totpConfigured?: boolean
passkeyConfigured?: boolean
passkeyCount?: number
totpDevice?: AuthDevice | null
passkeys?: AuthDevice[]
}
type AIServerStatus = {
ok: boolean
running: boolean
@ -68,6 +92,90 @@ type DiskStatus = {
recordPath?: string
}
function unwrapWebAuthnOptions<T = any>(options: any): T {
const unwrapped = options?.publicKey ?? options
if (!unwrapped?.challenge) {
throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.')
}
return unwrapped as T
}
function formatSecurityDate(value?: string) {
const raw = String(value ?? '').trim()
if (!raw) return 'Unbekannt'
const d = new Date(raw)
if (!Number.isFinite(d.getTime())) return 'Unbekannt'
return d.toLocaleString('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
}
function statusPill(active: boolean, activeText = 'Aktiv', inactiveText = 'Aus') {
return (
<span
className={[
'shrink-0 rounded-full px-2 py-1 text-[11px] font-bold ring-1',
active
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')}
>
{active ? activeText : inactiveText}
</span>
)
}
function SecurityDeviceRow(props: {
icon: string
title: string
subtitle: string
meta?: string
badge?: string
actions?: ReactNode
}) {
return (
<div className="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 dark:border-white/10 dark:bg-gray-950/40">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 text-lg dark:bg-white/10">
{props.icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.badge ? (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{props.badge}
</span>
) : null}
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
{props.subtitle}
</div>
{props.meta ? (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{props.meta}
</div>
) : null}
</div>
{props.actions ? (
<div className="shrink-0">
{props.actions}
</div>
) : null}
</div>
)
}
const DEFAULTS: RecorderSettings = {
databaseUrl: '',
@ -315,6 +423,27 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
const saveSucceeded = saveSuccessUntilMs > now
const saveFailed = Boolean(err) && !saving && !saveSucceeded
const [authStatus, setAuthStatus] = useState<AuthSecurityStatus | null>(null)
const [securityBusy, setSecurityBusy] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [newPasswordRepeat, setNewPasswordRepeat] = useState('')
const [disable2FAPassword, setDisable2FAPassword] = useState('')
const [disable2FACode, setDisable2FACode] = useState('')
const [disable2FAFieldError, setDisable2FAFieldError] = useState<{
password?: string
code?: string
} | null>(null)
const [passkeySupported, setPasskeySupported] = useState(false)
const [passkeyFeedback, setPasskeyFeedback] = useState<{
kind: 'loading' | 'success' | 'error'
text: string
} | null>(null)
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
@ -410,6 +539,17 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
}
}, [])
useEffect(() => {
setPasskeySupported(
typeof window !== 'undefined' &&
typeof window.PublicKeyCredential !== 'undefined'
)
}, [])
useEffect(() => {
void loadAuthStatus()
}, [])
useEffect(() => {
let alive = true
fetch('/api/settings', { cache: 'no-store' })
@ -1056,6 +1196,291 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
...cleanupTasks,
].filter((t: TaskItem) => t.status !== 'idle')
async function loadAuthStatus() {
try {
const res = await fetch('/api/auth/me', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (!data) return
setAuthStatus({
authenticated: Boolean(data.authenticated),
totpEnabled: Boolean(data.totpEnabled),
totpConfigured: Boolean(data.totpConfigured),
passkeyConfigured: Boolean(data.passkeyConfigured),
passkeyCount: Number(data.passkeyCount ?? 0),
totpDevice: data.totpDevice ?? null,
passkeys: Array.isArray(data.passkeys) ? data.passkeys : [],
})
} catch {
// ignore
}
}
async function changePassword() {
setErr(null)
setMsg(null)
if (!currentPassword.trim()) {
setErr('Bitte aktuelles Passwort eingeben.')
return
}
if (newPassword.length < 8) {
setErr('Das neue Passwort muss mindestens 8 Zeichen lang sein.')
return
}
if (newPassword !== newPasswordRepeat) {
setErr('Die neuen Passwörter stimmen nicht überein.')
return
}
setSecurityBusy(true)
try {
const res = await fetch('/api/auth/password/change', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
currentPassword,
newPassword,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
setCurrentPassword('')
setNewPassword('')
setNewPasswordRepeat('')
setMsg('Passwort wurde geändert.')
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setSecurityBusy(false)
}
}
async function disable2FA() {
setErr(null)
setMsg(null)
setDisable2FAFieldError(null)
const password = disable2FAPassword.trim()
const code = disable2FACode.trim()
const nextFieldError: {
password?: string
code?: string
} = {}
if (!password) {
nextFieldError.password = 'Gib dein aktuelles Passwort ein, um 2FA zu deaktivieren.'
}
if (authStatus?.totpEnabled && !/^\d{6}$/.test(code)) {
nextFieldError.code = 'Gib den 6-stelligen Code aus deiner Authenticator-App ein.'
}
if (nextFieldError.password || nextFieldError.code) {
setDisable2FAFieldError(nextFieldError)
return
}
const ok = window.confirm('2FA wirklich deaktivieren? Dein Login ist danach weniger geschützt.')
if (!ok) return
setSecurityBusy(true)
try {
const res = await fetch('/api/auth/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
currentPassword: password,
code,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
setDisable2FAPassword('')
setDisable2FACode('')
setDisable2FAFieldError(null)
setMsg('2FA wurde deaktiviert.')
await loadAuthStatus()
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setSecurityBusy(false)
}
}
function passkeyErrorMessage(e: any) {
const raw = String(e?.message ?? e ?? '').trim()
if (!raw) return 'Passkey konnte nicht hinzugefügt werden.'
if (raw.includes('passkey registration verify failed')) {
return (
raw +
'\n\nPrüfe AUTH_RP_ORIGIN und AUTH_RP_ID. Die Werte müssen exakt zur Browser-URL passen.'
)
}
if (
raw.includes('NotAllowedError') ||
raw.toLowerCase().includes('not allowed') ||
raw.toLowerCase().includes('aborted')
) {
return 'Passkey-Einrichtung wurde abgebrochen oder vom Browser/Authenticator nicht erlaubt.'
}
if (
raw.includes('SecurityError') ||
raw.toLowerCase().includes('relying party') ||
raw.toLowerCase().includes('rp id') ||
raw.toLowerCase().includes('origin')
) {
return (
raw +
'\n\nDas sieht nach einem Origin/RP-ID Problem aus. Prüfe AUTH_RP_ORIGIN und AUTH_RP_ID.'
)
}
if (raw.includes('challenge expired')) {
return 'Die Passkey-Challenge ist abgelaufen. Bitte erneut versuchen.'
}
return raw
}
async function registerSettingsPasskey() {
if (securityBusy) return
setErr(null)
setMsg(null)
setPasskeyFeedback({
kind: 'loading',
text: 'Starte Passkey-Einrichtung…',
})
setSecurityBusy(true)
try {
setPasskeyFeedback({
kind: 'loading',
text: 'Hole WebAuthn-Challenge vom Backend…',
})
const optionsRes = await fetch('/api/auth/passkey/register/options', {
method: 'POST',
cache: 'no-store',
})
if (!optionsRes.ok) {
const text = await optionsRes.text().catch(() => '')
throw new Error(text || `HTTP ${optionsRes.status}`)
}
const options = await optionsRes.json()
setPasskeyFeedback({
kind: 'loading',
text: 'Browser-Passkey-Dialog geöffnet. Bitte Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel bestätigen.',
})
const credential = await startRegistration({
optionsJSON: unwrapWebAuthnOptions(options),
})
setPasskeyFeedback({
kind: 'loading',
text: 'Passkey wurde vom Browser erstellt. Verifiziere beim Backend…',
})
const verifyRes = await fetch('/api/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify(credential),
})
if (!verifyRes.ok) {
const text = await verifyRes.text().catch(() => '')
throw new Error(text || `HTTP ${verifyRes.status}`)
}
setMsg('Passkey wurde hinzugefügt.')
setPasskeyFeedback({
kind: 'success',
text: 'Passkey wurde erfolgreich hinzugefügt.',
})
await loadAuthStatus()
} catch (e: any) {
const message = passkeyErrorMessage(e)
setErr(message)
setPasskeyFeedback({
kind: 'error',
text: message,
})
} finally {
setSecurityBusy(false)
}
}
async function deletePasskey(passkey: AuthDevice) {
setErr(null)
setMsg(null)
const credentialId = String(passkey?.id ?? '').trim()
if (!credentialId) {
setErr('Passkey kann nicht gelöscht werden: Credential-ID fehlt.')
return
}
const label = String(passkey?.label || 'diesen Passkey').trim()
const ok = window.confirm(
`"${label}" wirklich löschen? Anmeldung mit diesem Passkey ist danach nicht mehr möglich.`
)
if (!ok) return
setSecurityBusy(true)
try {
const res = await fetch('/api/auth/passkey/delete', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
credentialId,
}),
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
setMsg('Passkey wurde gelöscht.')
await loadAuthStatus()
} catch (e: any) {
setErr(e?.message ?? String(e))
} finally {
setSecurityBusy(false)
}
}
return (
<Card
header={
@ -1610,21 +2035,29 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
/>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">Teaser abspielen</div>
<div className="sm:col-span-8">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Teaser abspielen
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Standbild spart Leistung. Bei Hover (Standard): Desktop spielt bei Hover ab, Mobile im Viewport. Alle kann viel CPU ziehen.
</div>
</div>
<div className="sm:col-span-8">
<label className="sr-only" htmlFor="teaserPlayback">Teaser abspielen</label>
<div className="sm:col-span-4 sm:flex sm:justify-end">
<label className="sr-only" htmlFor="teaserPlayback">
Teaser abspielen
</label>
<select
id="teaserPlayback"
value={value.teaserPlayback ?? 'hover'}
onChange={(e) => setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))}
onChange={(e) =>
setValue((v) => ({ ...v, teaserPlayback: e.target.value as any }))
}
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]"
focus:outline-none focus:ring-2 focus:ring-indigo-500
dark:border-white/10 dark:bg-gray-900 dark:text-gray-100 dark:[color-scheme:dark]
sm:max-w-56"
>
<option value="still">Standbild</option>
<option value="hover">Bei Hover (Standard)</option>
@ -1694,11 +2127,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-950/40">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<button
type="button"
onClick={() => setAppLogOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
className="flex w-full items-start justify-between gap-3 text-left"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
@ -1713,7 +2146,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
) : null}
</div>
<div className="mt-0.5 truncate text-xs text-gray-600 dark:text-gray-300">
<div className="mt-1 truncate text-xs text-gray-600 dark:text-gray-300">
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
</div>
</div>
@ -1730,7 +2163,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</button>
{appLogOpen ? (
<div className="border-t border-gray-200 p-4 dark:border-white/10">
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-white/10">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
{appLog?.path ? (
@ -1775,6 +2208,282 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
</div>
</div>
</div>
{/* Sicherheit */}
<section className="mt-8 rounded-3xl border-2 border-indigo-200 bg-indigo-50/70 p-1 shadow-sm dark:border-indigo-400/20 dark:bg-indigo-500/5">
<div className="rounded-[1.35rem] bg-white p-4 dark:bg-gray-950/70">
<div className="mb-4 flex flex-col gap-3 border-b border-indigo-100 pb-4 dark:border-indigo-400/10 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-2xl bg-indigo-100 text-lg dark:bg-indigo-500/15">
🛡
</div>
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">
Konto-Sicherheit
</div>
<div className="mt-0.5 text-xs text-gray-600 dark:text-gray-300">
Passwort, Zwei-Faktor-Authentifizierung und Passkeys getrennt von den Recorder-Einstellungen verwalten.
</div>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
{statusPill(!!authStatus?.totpEnabled, '2FA aktiv', '2FA aus')}
{statusPill(!!authStatus?.passkeyConfigured, 'Passkey aktiv', 'Kein Passkey')}
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passwort ändern
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Ändert das Login-Passwort. Andere Sessions werden abgemeldet.
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Aktuelles Passwort"
autoComplete="current-password"
disabled={securityBusy}
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 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"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Neues Passwort"
autoComplete="new-password"
disabled={securityBusy}
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 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"
/>
<input
type="password"
value={newPasswordRepeat}
onChange={(e) => setNewPasswordRepeat(e.target.value)}
placeholder="Neues Passwort wiederholen"
autoComplete="new-password"
disabled={securityBusy}
className="rounded-lg bg-white px-3 py-2 text-sm text-gray-900 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"
/>
</div>
<div className="mt-3 flex justify-end">
<Button
variant="primary"
disabled={securityBusy}
onClick={() => void changePassword()}
>
Passwort speichern
</Button>
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Zwei-Faktor-Authentifizierung
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Authenticator-App als zweiter Faktor. Einzelne Geräte können bei TOTP nicht getrennt erkannt werden.
</div>
</div>
{statusPill(!!authStatus?.totpEnabled, 'Aktiv', 'Aus')}
</div>
<div className="mt-3 space-y-2">
{authStatus?.totpDevice ? (
<SecurityDeviceRow
icon="🔐"
title={authStatus.totpDevice.label || 'Authenticator-App'}
subtitle={
authStatus?.totpEnabled
? '2FA ist aktiv und wird beim Passwort-Login abgefragt.'
: '2FA ist eingerichtet, aber aktuell nicht aktiv.'
}
meta={`Eingerichtet: ${formatSecurityDate(authStatus.totpDevice.configuredAt)}`}
badge={authStatus?.totpEnabled ? 'TOTP' : 'Inaktiv'}
/>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist noch nicht eingerichtet. Die Einrichtung erfolgt aktuell beim nächsten Login-Setup.
</div>
)}
</div>
{authStatus?.totpConfigured || authStatus?.totpEnabled ? (
<>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
<input
type="password"
value={disable2FAPassword}
onChange={(e) => {
setDisable2FAPassword(e.target.value)
setDisable2FAFieldError((cur) => ({ ...cur, password: undefined }))
}}
placeholder="Aktuelles Passwort"
autoComplete="current-password"
disabled={securityBusy}
className={[
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
disable2FAFieldError?.password
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
].join(' ')}
/>
<input
value={disable2FACode}
onChange={(e) => {
setDisable2FACode(e.target.value.replace(/\D/g, '').slice(0, 6))
setDisable2FAFieldError((cur) => ({ ...cur, code: undefined }))
}}
placeholder="2FA-Code"
inputMode="numeric"
autoComplete="one-time-code"
disabled={securityBusy}
className={[
'rounded-lg bg-white px-3 py-2 text-sm text-gray-900 ring-1 focus:outline-none focus:ring-2 dark:bg-white/10 dark:text-white',
disable2FAFieldError?.code
? 'ring-red-300 focus:ring-red-500 dark:ring-red-400/50'
: 'ring-gray-200 focus:ring-indigo-500 dark:ring-white/10',
].join(' ')}
/>
</div>
{disable2FAFieldError?.password || disable2FAFieldError?.code ? (
<div className="mt-2 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
{disable2FAFieldError.password || disable2FAFieldError.code}
</div>
) : (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Zum Deaktivieren brauchst du dein aktuelles Passwort
{authStatus?.totpEnabled ? ' und einen gültigen 2FA-Code.' : '.'}
</div>
)}
<div className="mt-3 flex justify-end">
<Button
variant="primary"
color="red"
disabled={securityBusy}
onClick={() => void disable2FA()}
>
2FA deaktivieren
</Button>
</div>
</>
) : null}
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Passkeys
</div>
<div className="mt-1 text-xs text-gray-600 dark:text-gray-300">
Anmeldung ohne Passwort über Face ID, Fingerabdruck, Windows Hello oder Sicherheitsschlüssel.
</div>
</div>
{statusPill(!!authStatus?.passkeyConfigured, 'Aktiv', 'Keine')}
</div>
<div className="mt-3 space-y-2">
{(authStatus?.passkeys ?? []).length > 0 ? (
(authStatus?.passkeys ?? []).map((pk, index) => (
<SecurityDeviceRow
key={pk.id || index}
icon="🔑"
title={pk.label || `Passkey ${index + 1}`}
subtitle={
pk.backupEligible
? pk.backupState
? 'Synchronisierter Passkey'
: 'Passkey kann synchronisiert werden'
: 'Gerätegebundener Passkey oder Sicherheitsschlüssel'
}
meta={[
`Erstellt: ${formatSecurityDate(pk.createdAt)}`,
`Zuletzt genutzt: ${formatSecurityDate(pk.lastUsedAt)}`,
typeof pk.signCount === 'number' ? `SignCount: ${pk.signCount}` : '',
].filter(Boolean).join(' · ')}
badge="Passkey"
actions={
<Button
variant="secondary"
color="red"
size="sm"
disabled={securityBusy}
onClick={() => void deletePasskey(pk)}
>
Löschen
</Button>
}
/>
))
) : (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 text-xs text-gray-600 dark:border-white/10 dark:bg-gray-950/40 dark:text-gray-300">
Noch kein Passkey gespeichert.
</div>
)}
</div>
{!passkeySupported ? (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div>
) : (
<div className="mt-3 space-y-2">
{passkeyFeedback ? (
<div
className={[
'whitespace-pre-wrap rounded-lg border px-3 py-2 text-xs',
passkeyFeedback.kind === 'success'
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
: passkeyFeedback.kind === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200'
: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-500/30 dark:bg-blue-500/10 dark:text-blue-200',
].join(' ')}
>
{passkeyFeedback.kind === 'loading' ? '⏳ ' : null}
{passkeyFeedback.kind === 'success' ? '✅ ' : null}
{passkeyFeedback.kind === 'error' ? '⚠️ ' : null}
{passkeyFeedback.text}
</div>
) : null}
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="secondary"
disabled={securityBusy}
onClick={() => void registerSettingsPasskey()}
>
{securityBusy ? 'Passkey wird eingerichtet…' : 'Passkey hinzufügen'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</section>
</div>
</Card>
)

View File

@ -606,12 +606,9 @@ export function ToastProvider({
loading="lazy"
referrerPolicy="no-referrer"
className={[
// größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
'h-full min-h-[72px] w-16 sm:w-[72px]',
'h-full min-h-[72px] w-20 sm:w-24',
'object-cover object-center',
// nur links runden, damit es in den Toast passt
'rounded-l-xl',
// leichte Trennlinie rechts
'border-r border-black/5 dark:border-white/10',
'bg-gray-100 dark:bg-white/5',
].join(' ')}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -458,10 +458,34 @@ function pickFirstDefined<T = any>(...vals: T[]): T | undefined {
return undefined
}
function parseAiCandidate(candidate: unknown): any {
if (!candidate) return null
if (typeof candidate === 'string') {
try {
const parsed = JSON.parse(candidate)
return parsed && typeof parsed === 'object' ? parsed : null
} catch {
return null
}
}
return typeof candidate === 'object' ? candidate : null
}
function extractAiPayload(data: any): any {
if (!data || typeof data !== 'object') return null
const candidates = [
// ✅ neuer Backend-Pfad
data?.meta?.analysis?.highlights,
data?.meta?.analysis?.Highlights,
data?.analysis?.highlights,
data?.analysis?.Highlights,
data?.highlights,
data?.Highlights,
// Altbestand weiter unterstützen
data?.meta?.analysis?.ai,
data?.meta?.analysis?.AI,
data?.analysis?.ai,
@ -474,15 +498,33 @@ function extractAiPayload(data: any): any {
data?.meta?.AI,
data?.meta?.videoAI,
data?.meta?.videoAi,
// alternative Wrapper, falls API/Meta anders verpackt ist
data?.metaJson?.analysis?.highlights,
data?.metaJson?.analysis?.Highlights,
data?.metaJson?.analysis?.ai,
data?.metaJson?.ai,
data?.metadata?.analysis?.highlights,
data?.metadata?.analysis?.Highlights,
data?.metadata?.analysis?.ai,
data?.metadata?.ai,
]
for (const candidate of candidates) {
if (candidate && typeof candidate === 'object') {
return candidate
const parsed = parseAiCandidate(candidate)
if (parsed && typeof parsed === 'object') {
const hasHits =
Array.isArray(parsed?.hits) ||
Array.isArray(parsed?.Hits)
const hasSegments =
Array.isArray(parsed?.segments) ||
Array.isArray(parsed?.Segments)
if (hasHits || hasSegments) {
return parsed
}
}
}

View File

@ -0,0 +1,26 @@
// frontend\src\components\ui\login\ErrorAlert.tsx
export default function ErrorAlert(props: {
error: string | null
onClose: () => void
}) {
if (!props.error) return null
return (
<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 whitespace-pre-line break-words">{props.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={props.onClose}
aria-label="Fehlermeldung schließen"
title="Schließen"
>
</button>
</div>
</div>
)
}

View File

@ -0,0 +1,73 @@
// frontend\src\components\ui\login\LoginForm.tsx
import Button from '../Button'
import PasskeyPanel from './PasskeyPanel'
export default function LoginForm(props: {
username: string
password: string
busy: boolean
passkeySupported: boolean
onUsernameChange: (value: string) => void
onPasswordChange: (value: string) => void
onSubmit: () => void
onPasskeyLogin: () => void
}) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
if (props.busy) return
props.onSubmit()
}}
className="space-y-3"
>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">
Username
</label>
<input
value={props.username}
onChange={(e) => props.onUsernameChange(e.target.value)}
autoComplete="username webauthn"
className="block w-full rounded-lg bg-white px-3 py-2.5 text-sm 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={props.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={props.password}
onChange={(e) => props.onPasswordChange(e.target.value)}
autoComplete="current-password"
className="block w-full rounded-lg bg-white px-3 py-2.5 text-sm 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={props.busy}
/>
</div>
<Button
type="submit"
variant="primary"
className="w-full rounded-lg"
disabled={props.busy || !props.username.trim() || !props.password}
>
{props.busy ? 'Login…' : 'Login'}
</Button>
<PasskeyPanel
mode="login"
supported={props.passkeySupported}
busy={props.busy}
onLogin={props.onPasskeyLogin}
/>
</form>
)
}

View File

@ -0,0 +1,29 @@
// frontend\src\components\ui\login\LoginShell.tsx
import type { ReactNode } from 'react'
export default function LoginShell(props: { children: ReactNode }) {
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">
{props.children}
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,86 @@
// frontend\src\components\ui\login\PasskeyPanel.tsx
import Button from '../Button'
function passkeyUnsupportedReason() {
if (typeof window === 'undefined') {
return 'Passkeys sind in dieser Umgebung nicht verfügbar.'
}
if (!window.isSecureContext) {
return (
'Passkeys sind nur über HTTPS oder über localhost verfügbar. ' +
'Wenn du die App per IP öffnest, z. B. http://10.0.1.25:9999, blockiert der Browser Passkeys.'
)
}
if (typeof window.PublicKeyCredential === 'undefined') {
return 'Dein Browser unterstützt Passkeys/WebAuthn aktuell nicht.'
}
return 'Passkeys sind aktuell nicht verfügbar.'
}
export default function PasskeyPanel(props: {
supported: boolean
busy: boolean
mode: 'login' | 'register'
onLogin?: () => void
onRegister?: () => void
}) {
if (props.mode === 'login') {
return (
<div className="space-y-2">
<div className="relative flex items-center py-1">
<div className="h-px flex-1 bg-gray-200 dark:bg-white/10" />
<span className="px-3 text-xs text-gray-500 dark:text-gray-400">oder</span>
<div className="h-px flex-1 bg-gray-200 dark:bg-white/10" />
</div>
{props.supported ? (
<Button
type="button"
variant="secondary"
className="w-full rounded-lg"
disabled={props.busy}
onClick={props.onLogin}
>
Mit Passkey anmelden
</Button>
) : (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{passkeyUnsupportedReason()}
</div>
)}
</div>
)
}
return (
<div className="rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Optional: Passkey hinzufügen
</div>
<div className="mt-1 text-xs leading-relaxed text-gray-600 dark:text-gray-300">
Danach kannst du dich mit Fingerabdruck, Face ID, Windows Hello oder einem Sicherheitsschlüssel anmelden.
</div>
{props.supported ? (
<Button
type="button"
variant="secondary"
className="mt-3 w-full rounded-lg"
disabled={props.busy}
onClick={props.onRegister}
>
Passkey hinzufügen
</Button>
) : (
<div className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
{passkeyUnsupportedReason()}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,71 @@
// frontend/src/components/ui/login/PasskeySetupPrompt.tsx
import Button from '../Button'
type PasskeyFeedback = {
kind: 'info' | 'loading' | 'success'
text: string
}
export default function PasskeySetupPrompt(props: {
busy: boolean
supported: boolean
feedback?: PasskeyFeedback | null
onRegisterAndContinue: () => void
onSkip: () => void
}) {
const feedbackClass =
props.feedback?.kind === 'success'
? 'border-green-200 bg-green-50 text-green-800 dark:border-green-500/30 dark:bg-green-500/10 dark:text-green-200'
: 'border-indigo-200 bg-indigo-50 text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200'
return (
<div className="space-y-3">
<div className="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-900 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
Dein Login ist bestätigt. Du kannst jetzt optional einen Passkey einrichten.
</div>
<div className="space-y-2 text-sm text-gray-700 dark:text-gray-200">
<div>
Mit einem Passkey kannst du dich künftig schneller anmelden, z. B. mit Face ID,
Fingerabdruck, Windows Hello oder einem Sicherheitsschlüssel.
</div>
<div>
Du kannst diesen Schritt überspringen und später in den Sicherheitseinstellungen nachholen.
</div>
</div>
{props.feedback ? (
<div className={`whitespace-pre-line rounded-lg border px-3 py-2 text-sm ${feedbackClass}`}>
{props.feedback.text}
</div>
) : null}
{props.supported ? (
<Button
type="button"
variant="primary"
className="w-full rounded-lg"
disabled={props.busy}
onClick={props.onRegisterAndContinue}
>
{props.busy ? 'Richte Passkey ein…' : 'Passkey einrichten und fortfahren'}
</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">
Dein Browser oder diese Verbindung unterstützt Passkeys aktuell nicht.
</div>
)}
<Button
type="button"
variant="secondary"
className="w-full rounded-lg"
disabled={props.busy}
onClick={props.onSkip}
>
Überspringen und fortfahren
</Button>
</div>
)
}

View File

@ -0,0 +1,45 @@
// frontend\src\components\ui\login\TotpCodeInput.tsx
import type { ClipboardEvent, KeyboardEvent } from 'react'
export default function TotpCodeInput(props: {
digits: string[]
busy: boolean
inputRefs: React.MutableRefObject<Array<HTMLInputElement | null>>
onChangeDigit: (index: number, value: string) => void
onKeyDownDigit: (index: number, ev: KeyboardEvent<HTMLInputElement>) => void
onPasteDigit: (index: number, ev: ClipboardEvent<HTMLInputElement>) => void
}) {
return (
<div className="mx-auto w-fit space-y-1">
<label className="block text-xs font-medium text-gray-700 dark:text-gray-200">
2FA Code
</label>
<div className="flex items-center justify-center gap-1.5 sm:gap-2">
{props.digits.map((d, i) => (
<input
key={i}
ref={(el) => {
props.inputRefs.current[i] = el
}}
value={d}
onChange={(e) => props.onChangeDigit(i, e.target.value)}
onKeyDown={(e) => props.onKeyDownDigit(i, e)}
onPaste={(e) => props.onPasteDigit(i, e)}
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
enterKeyHint={i === 5 ? 'done' : 'next'}
autoCapitalize="none"
autoCorrect="off"
disabled={props.busy}
className="h-12 w-11 rounded-lg bg-white text-center text-lg tabular-nums 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 sm:w-12"
aria-label={`2FA Ziffer ${i + 1}`}
/>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,26 @@
// frontend\src\components\ui\login\loginApi.ts
export async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, { credentials: 'include', ...init })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
}
return res.json() as Promise<T>
}
export function getNextFromLocation(): string {
try {
const u = new URL(window.location.href)
const next = u.searchParams.get('next') || '/'
if (!next.startsWith('/')) return '/'
if (next.startsWith('/login')) return '/'
return next
} catch {
return '/'
}
}

View File

@ -0,0 +1,56 @@
// frontend\src\components\ui\login\loginStorage.ts
export const LOGIN_STATE_KEY = 'recorder_login_state_v1'
export type LoginStage = 'login' | 'verify' | 'setup' | 'passkeySetup'
export type PersistedLoginState = {
stage: LoginStage
username: string
code: string
setupAuthUrl: string | null
setupSecret: string | null
setupInfo: string | null
ts: number
}
export function loadLoginState(): PersistedLoginState | null {
try {
const raw = sessionStorage.getItem(LOGIN_STATE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as PersistedLoginState
if (!parsed?.ts || Date.now() - parsed.ts > 15 * 60 * 1000) return null
return parsed
} catch {
return null
}
}
export function saveLoginState(s: PersistedLoginState) {
try {
sessionStorage.setItem(LOGIN_STATE_KEY, JSON.stringify(s))
} catch {
// ignore
}
}
export function clearLoginState() {
try {
sessionStorage.removeItem(LOGIN_STATE_KEY)
} catch {
// ignore
}
}
export function splitCodeToDigits(code: string): string[] {
const only = (code ?? '').replace(/\D/g, '').slice(0, 6)
const arr = only.split('')
while (arr.length < 6) arr.push('')
return arr
}
export function digitsToCode(d: string[]) {
return (d ?? []).join('')
}

View File

@ -109,10 +109,17 @@ export type RecordJobAnalysisAI = {
mode?: string
hits?: Array<unknown>
segments?: Array<unknown>
rating?: unknown
analyzedAtUnix?: number
}
export type RecordJobAnalysisMeta = {
// Neuer Backend-Pfad:
// meta.analysis.highlights
highlights?: RecordJobAnalysisAI
// Altbestand weiter unterstützen:
// meta.analysis.ai
ai?: RecordJobAnalysisAI
}