bugfixes
This commit is contained in:
parent
e4635bb1f1
commit
485ee91873
BIN
Icons/toy.png
BIN
Icons/toy.png
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.7 KiB |
2
backend/.env
Normal file
2
backend/.env
Normal 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
@ -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
|
||||
}
|
||||
|
||||
399
backend/auth.go
399
backend/auth.go
@ -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
578
backend/auth_passkey.go
Normal 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})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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,
|
||||
|
||||
397
backend/meta.go
397
backend/meta.go
@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -20,8 +20,7 @@
|
||||
"blowjob",
|
||||
"toy_play",
|
||||
"fingering",
|
||||
"69",
|
||||
"other"
|
||||
"69"
|
||||
],
|
||||
"bodyParts": [
|
||||
"anus",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
1170
backend/rating.go
1170
backend/rating.go
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
26
backend/recorder-cert.pem
Normal 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
28
backend/recorder-key.pem
Normal 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-----
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}()
|
||||
|
||||
|
||||
@ -584,7 +584,7 @@ func splitSingleSegment(
|
||||
"-nostats",
|
||||
"-progress", "pipe:1",
|
||||
"-hide_banner",
|
||||
"-loglevel", "warning",
|
||||
"-loglevel", "error",
|
||||
"-ss", formatFFSec(startSec),
|
||||
"-i", srcPath,
|
||||
"-t", formatFFSec(durSec),
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
1075
backend/training.go
1075
backend/training.go
File diff suppressed because it is too large
Load Diff
BIN
backend/yolo26n.pt
Normal file
BIN
backend/yolo26n.pt
Normal file
Binary file not shown.
BIN
cert/mkcert-v1.4.4-windows-amd64.exe
Normal file
BIN
cert/mkcert-v1.4.4-windows-amd64.exe
Normal file
Binary file not shown.
26
cert/recorder-cert.pem
Normal file
26
cert/recorder-cert.pem
Normal 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
28
cert/recorder-key.pem
Normal 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-----
|
||||
@ -1 +0,0 @@
|
||||
DATABASE_URL="file:./prisma/models.db"
|
||||
@ -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>
|
||||
|
||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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'
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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'
|
||||
)}
|
||||
>
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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 []
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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(' ')}
|
||||
|
||||
1278
frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx
Normal file
1278
frontend/src/components/ui/TrainingFeedbackHistoryModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
frontend/src/components/ui/login/ErrorAlert.tsx
Normal file
26
frontend/src/components/ui/login/ErrorAlert.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
frontend/src/components/ui/login/LoginForm.tsx
Normal file
73
frontend/src/components/ui/login/LoginForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
frontend/src/components/ui/login/LoginShell.tsx
Normal file
29
frontend/src/components/ui/login/LoginShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/components/ui/login/PasskeyPanel.tsx
Normal file
86
frontend/src/components/ui/login/PasskeyPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
71
frontend/src/components/ui/login/PasskeySetupPrompt.tsx
Normal file
71
frontend/src/components/ui/login/PasskeySetupPrompt.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/ui/login/TotpCodeInput.tsx
Normal file
45
frontend/src/components/ui/login/TotpCodeInput.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
frontend/src/components/ui/login/loginApi.ts
Normal file
26
frontend/src/components/ui/login/loginApi.ts
Normal 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 '/'
|
||||
}
|
||||
}
|
||||
56
frontend/src/components/ui/login/loginStorage.ts
Normal file
56
frontend/src/components/ui/login/loginStorage.ts
Normal 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('')
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user