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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@ -104,7 +105,7 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m
|
|||||||
}
|
}
|
||||||
|
|
||||||
func requiredAnalyzeGoals() []string {
|
func requiredAnalyzeGoals() []string {
|
||||||
return []string{"nsfw", "highlights"}
|
return []string{"highlights"}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool {
|
func hasAIResultsForAllOutputGoals(outPath string, goals []string) bool {
|
||||||
@ -133,8 +134,6 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normaler Asset-/Postwork-Pfad:
|
|
||||||
// vorhandene Analyse behalten.
|
|
||||||
if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) {
|
if !force && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals()) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@ -167,45 +166,26 @@ func ensureAnalyzeAllGoalsForVideoCtxForce(
|
|||||||
return false, appErrorf("videolänge konnte nicht bestimmt werden")
|
return false, appErrorf("videolänge konnte nicht bestimmt werden")
|
||||||
}
|
}
|
||||||
|
|
||||||
// NSFW + Highlights in EINEM Frame-Extraktionslauf analysieren.
|
hits, err := analyzeVideoFromFrames(actx, videoPath)
|
||||||
nsfwHits, highlightHits, err := analyzeVideoFromFramesForGoal(
|
|
||||||
actx,
|
|
||||||
videoPath,
|
|
||||||
"all",
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
nsfwSegments := buildAnalyzeSegmentsForGoal(nsfwHits, durationSec, "nsfw")
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
||||||
nsfwRating := computeNSFWRating(nsfwSegments, durationSec)
|
segments = prepareAIRatingSegments(segments)
|
||||||
|
|
||||||
nsfwAI := &aiAnalysisMeta{
|
rating := computeHighlightRating(segments, durationSec)
|
||||||
Goal: "nsfw",
|
|
||||||
Mode: "video",
|
|
||||||
Hits: nsfwHits,
|
|
||||||
Segments: nsfwSegments,
|
|
||||||
Rating: nsfwRating,
|
|
||||||
AnalyzedAtUnix: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := writeVideoAIForFile(actx, videoPath, meta.sourceURL, nsfwAI); err != nil {
|
ai := &aiAnalysisMeta{
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
|
|
||||||
highlightSegments := buildAnalyzeSegmentsForGoal(highlightHits, durationSec, "highlights")
|
|
||||||
highlightRating := computeNSFWRating(highlightSegments, durationSec)
|
|
||||||
|
|
||||||
highlightAI := &aiAnalysisMeta{
|
|
||||||
Goal: "highlights",
|
Goal: "highlights",
|
||||||
Mode: "video",
|
Mode: "video",
|
||||||
Hits: highlightHits,
|
Hits: hits,
|
||||||
Segments: highlightSegments,
|
Segments: segments,
|
||||||
Rating: highlightRating,
|
Rating: rating,
|
||||||
AnalyzedAtUnix: time.Now().Unix(),
|
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
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -241,8 +221,8 @@ func assetsTruthForVideo(videoPath string) finishedPhaseTruth {
|
|||||||
truth.TeaserReady = fileExistsNonEmpty(previewPath)
|
truth.TeaserReady = fileExistsNonEmpty(previewPath)
|
||||||
truth.SpritesReady = fileExistsNonEmpty(spritePath)
|
truth.SpritesReady = fileExistsNonEmpty(spritePath)
|
||||||
|
|
||||||
// Analyse für Standard-Postwork: NSFW-Rating
|
// Analyse läuft frame-basiert und braucht kein Sprite mehr.
|
||||||
truth.AnalyzeReady = truth.SpritesReady && hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
|
truth.AnalyzeReady = hasAIAnalysisForAllOutputGoals(videoPath, requiredAnalyzeGoals())
|
||||||
|
|
||||||
return truth
|
return truth
|
||||||
}
|
}
|
||||||
@ -890,10 +870,7 @@ func ensureAnalyzeForVideoCtx(
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
goal = normalizeAIGoal(goal)
|
goal = "highlights"
|
||||||
if goal == "" {
|
|
||||||
goal = "nsfw"
|
|
||||||
}
|
|
||||||
|
|
||||||
if hasAIAnalysisForOutputGoal(videoPath, goal) {
|
if hasAIAnalysisForOutputGoal(videoPath, goal) {
|
||||||
return false, nil
|
return false, nil
|
||||||
@ -914,7 +891,6 @@ func ensureAnalyzeForVideoCtx(
|
|||||||
return false, perr
|
return false, perr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Analyse läuft jetzt frame-basiert und braucht kein preview-sprite.jpg mehr.
|
|
||||||
if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil {
|
if _, err := ensureAnalyzeAllGoalsForVideoCtx(ctx, videoPath, sourceURL); err != nil {
|
||||||
return false, err
|
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)
|
return appErrorf("deferred analyze failed for %s: %w", videoPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
goal = normalizeAIGoal(goal)
|
goal = "highlights"
|
||||||
if goal == "" {
|
|
||||||
goal = "highlights"
|
|
||||||
}
|
|
||||||
|
|
||||||
if !hasAIAnalysisForOutputGoal(videoPath, goal) {
|
if !hasAIAnalysisForOutputGoal(videoPath, goal) {
|
||||||
return appErrorf("Analyse fehlt nach deferred analyze: %s", id)
|
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)
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil)
|
||||||
}
|
}
|
||||||
if needAnalyze {
|
if needAnalyze {
|
||||||
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analysen warten", nil)
|
publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
sortBucket, sortName := postWorkSortForVideoPath(outPath)
|
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)
|
publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr)
|
||||||
}
|
}
|
||||||
if needAnalyze {
|
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
|
return false
|
||||||
@ -1381,35 +1354,6 @@ func ensureAssetsForVideoDetailed(ctx context.Context, videoPath string, sourceU
|
|||||||
return out, nil
|
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 {
|
func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
|
||||||
outPath = strings.TrimSpace(outPath)
|
outPath = strings.TrimSpace(outPath)
|
||||||
if outPath == "" {
|
if outPath == "" {
|
||||||
@ -1426,40 +1370,63 @@ func hasAIAnalysisForOutputGoal(outPath string, goal string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
goal = normalizeAIGoal(goal)
|
aiMap, ok := readVideoMetaAIForGoal(metaPath, "highlights")
|
||||||
|
|
||||||
aiMap, ok := readVideoMetaAIForGoal(metaPath, goal)
|
|
||||||
if !ok || aiMap == nil {
|
if !ok || aiMap == nil {
|
||||||
return false
|
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)
|
rawHits, hasHits := aiMap["hits"].([]any)
|
||||||
rawSegs, hasSegs := aiMap["segments"].([]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)
|
return (hasHits && len(rawHits) > 0) || (hasSegs && len(rawSegs) > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
|
func jsonNumberPositive(v any) bool {
|
||||||
outPath = strings.TrimSpace(outPath)
|
switch x := v.(type) {
|
||||||
if outPath == "" {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(goals) == 0 {
|
func isPositiveJSONNumber(v any) bool {
|
||||||
goals = requiredAnalyzeGoals()
|
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 {
|
func hasAIAnalysisForAllOutputGoals(outPath string, goals []string) bool {
|
||||||
goal = normalizeAIGoal(goal)
|
return hasAIAnalysisForOutputGoal(outPath, "highlights")
|
||||||
if !hasAIAnalysisForOutputGoal(outPath, goal) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PrepareSplitResult struct {
|
type PrepareSplitResult struct {
|
||||||
@ -1519,16 +1486,12 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
|||||||
|
|
||||||
out.AssetsReady = thumbExists && previewExists
|
out.AssetsReady = thumbExists && previewExists
|
||||||
|
|
||||||
goal = strings.ToLower(strings.TrimSpace(goal))
|
goal = "highlights"
|
||||||
if goal == "" {
|
|
||||||
goal = "highlights"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) Für Split brauchen wir zusätzlich Sprite + AI.
|
// 2) Für Split brauchen wir zusätzlich Sprite + AI.
|
||||||
// Deshalb die langsamen Deferred-Schritte hier bei Bedarf synchron nachziehen.
|
// 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 {
|
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)
|
appLogln("⚠️ prepareVideoForSplit deferred enrich:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1541,29 +1504,29 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
|||||||
|
|
||||||
out.SpriteReady = spriteExists
|
out.SpriteReady = spriteExists
|
||||||
|
|
||||||
// 3) AI-Segmente prüfen
|
if hasAIAnalysisForOutputGoal(videoPath, "highlights") {
|
||||||
if hasAIAnalysisForOutputGoal(videoPath, goal) {
|
|
||||||
out.AnalyzeReady = true
|
out.AnalyzeReady = true
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Fallback: AI direkt berechnen
|
|
||||||
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
|
durationSec, _ := durationSecondsForAnalyze(ctx, videoPath)
|
||||||
hits, aerr := analyzeVideoFromFrames(ctx, videoPath, goal)
|
if durationSec <= 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hits, aerr := analyzeVideoFromFrames(ctx, videoPath)
|
||||||
if aerr != nil {
|
if aerr != nil {
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
segments := buildAnalyzeSegmentsForGoal(hits, durationSec, goal)
|
segments := buildAnalyzeSegmentsForGoal(hits, durationSec)
|
||||||
|
segments = prepareAIRatingSegments(segments)
|
||||||
|
|
||||||
var rating *aiRatingMeta
|
rating := computeHighlightRating(segments, durationSec)
|
||||||
if goal == "nsfw" || goal == "highlights" {
|
|
||||||
rating = computeNSFWRating(segments, durationSec)
|
|
||||||
}
|
|
||||||
|
|
||||||
ai := &aiAnalysisMeta{
|
ai := &aiAnalysisMeta{
|
||||||
Goal: goal,
|
Goal: "highlights",
|
||||||
Mode: "sprite",
|
Mode: "video",
|
||||||
Hits: hits,
|
Hits: hits,
|
||||||
Segments: segments,
|
Segments: segments,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
@ -1574,6 +1537,6 @@ func prepareVideoForSplit(ctx context.Context, videoPath, sourceURL, goal string
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, goal)
|
out.AnalyzeReady = hasAIAnalysisForOutputGoal(videoPath, "highlights")
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
399
backend/auth.go
399
backend/auth.go
@ -14,16 +14,33 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-webauthn/webauthn/webauthn"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
"github.com/pquerna/otp/totp"
|
"github.com/pquerna/otp/totp"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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 {
|
type authConfig struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
PasswordHash string `json:"passwordHash"` // bcrypt hash
|
PasswordHash string `json:"passwordHash"`
|
||||||
TOTPEnabled bool `json:"totpEnabled"`
|
TOTPEnabled bool `json:"totpEnabled"`
|
||||||
TOTPSecret string `json:"totpSecret"` // base32
|
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)
|
// Session in memory (Restart = logout für alle)
|
||||||
@ -43,6 +60,11 @@ type AuthManager struct {
|
|||||||
sessMu sync.Mutex
|
sessMu sync.Mutex
|
||||||
sess map[string]*session
|
sess map[string]*session
|
||||||
|
|
||||||
|
passkeyMu sync.Mutex
|
||||||
|
passkeySessions map[string]*webauthn.SessionData
|
||||||
|
|
||||||
|
webAuthn *webauthn.WebAuthn
|
||||||
|
|
||||||
configPath string
|
configPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +75,8 @@ const (
|
|||||||
|
|
||||||
func NewAuthManager() (*AuthManager, error) {
|
func NewAuthManager() (*AuthManager, error) {
|
||||||
am := &AuthManager{
|
am := &AuthManager{
|
||||||
sess: make(map[string]*session),
|
sess: make(map[string]*session),
|
||||||
|
passkeySessions: make(map[string]*webauthn.SessionData),
|
||||||
}
|
}
|
||||||
|
|
||||||
// optional: persist config in file
|
// optional: persist config in file
|
||||||
@ -75,10 +98,22 @@ func NewAuthManager() (*AuthManager, error) {
|
|||||||
|
|
||||||
// 3) sanity
|
// 3) sanity
|
||||||
am.confMu.Lock()
|
am.confMu.Lock()
|
||||||
defer am.confMu.Unlock()
|
|
||||||
if strings.TrimSpace(am.conf.Username) == "" || strings.TrimSpace(am.conf.PasswordHash) == "" {
|
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)")
|
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
|
return am, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +200,51 @@ func atomicWriteFileCompat(path string, data []byte) error {
|
|||||||
return os.Rename(tmp, path)
|
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) {
|
func generateRandomPassword() (string, error) {
|
||||||
// 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren
|
// 24 bytes => 32 chars base64url (ohne =), gut zum Abtippen/Kopieren
|
||||||
b := make([]byte, 24)
|
b := make([]byte, 24)
|
||||||
@ -379,11 +459,56 @@ func authMeHandler(am *AuthManager) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
s, _ := am.getSession(r)
|
s, _ := am.getSession(r)
|
||||||
|
|
||||||
// ✅ Konfiguration atomar lesen
|
|
||||||
am.confMu.Lock()
|
am.confMu.Lock()
|
||||||
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
|
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
|
||||||
totpFlag := am.conf.TOTPEnabled
|
totpFlag := am.conf.TOTPEnabled
|
||||||
totpEnabled := totpFlag && secretPresent
|
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()
|
am.confMu.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
@ -393,15 +518,14 @@ func authMeHandler(am *AuthManager) http.HandlerFunc {
|
|||||||
"authenticated": s != nil && s.Authed,
|
"authenticated": s != nil && s.Authed,
|
||||||
"pending2fa": s != nil && s.Pending2FA,
|
"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:
|
"passkeyConfigured": passkeyCount > 0,
|
||||||
// Secret existiert schon (Setup gemacht), aber evtl. noch nicht enabled
|
"passkeyCount": passkeyCount,
|
||||||
"totpConfigured": secretPresent,
|
"passkeys": passkeys,
|
||||||
|
|
||||||
// reiner Flag-Status (kann true sein, obwohl Secret fehlt)
|
|
||||||
"totpFlag": totpFlag,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -541,6 +665,10 @@ func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
am.conf.TOTPSecret = key.Secret()
|
am.conf.TOTPSecret = key.Secret()
|
||||||
am.conf.TOTPEnabled = false // erst nach Enable aktiv
|
am.conf.TOTPEnabled = false // erst nach Enable aktiv
|
||||||
|
am.conf.TOTPInfo = totpInfo{
|
||||||
|
Label: "Authenticator-App",
|
||||||
|
ConfiguredAt: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
am.confMu.Unlock()
|
am.confMu.Unlock()
|
||||||
|
|
||||||
// 4) persist ohne Lock
|
// 4) persist ohne Lock
|
||||||
@ -641,3 +769,244 @@ func auth2FAEnableHandler(am *AuthManager) http.HandlerFunc {
|
|||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
|
_ = 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type autoStartItem struct {
|
type autoStartItem struct {
|
||||||
userKey string
|
userKey string
|
||||||
url string
|
url string
|
||||||
blind bool
|
blind bool
|
||||||
source string // "watched" | "manual" | "resume"
|
source string // "watched" | "manual" | "resume"
|
||||||
force bool // true = ignoriert Autostart-Pause + Download-Limit
|
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 {
|
func normUser(s string) string {
|
||||||
@ -148,13 +170,10 @@ func chaturbateAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput fun
|
|||||||
}
|
}
|
||||||
jobsMu.RUnlock()
|
jobsMu.RUnlock()
|
||||||
|
|
||||||
// Job schon weg oder nicht mehr running -> nichts tun
|
|
||||||
if job == nil || status != JobRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// echte Output-Datei vorhanden?
|
// 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 {
|
if fi, err := os.Stat(out); err == nil && !fi.IsDir() && fi.Size() > 0 {
|
||||||
// hidden blind-try wird jetzt sichtbar gemacht
|
// hidden blind-try wird jetzt sichtbar gemacht
|
||||||
if hidden {
|
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)
|
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
|
// Startet watched+online(public) automatisch – unabhängig vom Frontend
|
||||||
func startChaturbateAutoStartWorker(store *ModelStore) {
|
func startChaturbateAutoStartWorker(store *ModelStore) {
|
||||||
if store == nil {
|
if store == nil {
|
||||||
@ -573,6 +537,12 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
show := normalizePendingShowServer(showByUser[it.userKey])
|
show := normalizePendingShowServer(showByUser[it.userKey])
|
||||||
|
|
||||||
if it.blind {
|
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" {
|
if show != "offline" && show != "unknown" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -590,20 +560,16 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
queued = nextQueued
|
queued = nextQueued
|
||||||
|
|
||||||
blindQueued := false
|
blindQueued := false
|
||||||
selectedBlindUser := hiddenProbeUser
|
|
||||||
|
|
||||||
for _, it := range queue {
|
for _, it := range queue {
|
||||||
if it.blind {
|
if it.blind {
|
||||||
blindQueued = true
|
blindQueued = true
|
||||||
if selectedBlindUser == "" {
|
|
||||||
selectedBlindUser = it.userKey
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
offlineCandidates := make([]autoStartItem, 0, len(watchedOrder))
|
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()
|
now := time.Now()
|
||||||
|
|
||||||
resumePendingThisScan := map[string]bool{}
|
resumePendingThisScan := map[string]bool{}
|
||||||
@ -631,14 +597,23 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
img := strings.TrimSpace(imageByUser[user])
|
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,
|
ModelKey: user,
|
||||||
URL: u,
|
URL: u,
|
||||||
Mode: "wait_public",
|
Mode: "wait_public",
|
||||||
CurrentShow: show,
|
CurrentShow: show,
|
||||||
ImageURL: img,
|
ImageURL: img,
|
||||||
Source: "resume",
|
Source: "resume",
|
||||||
})
|
}); err != nil && verboseLogs() {
|
||||||
|
appLogln("⚠️ [autostart] save resume pending failed:", user, err)
|
||||||
|
}
|
||||||
|
pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
resumePendingThisScan[user] = true
|
resumePendingThisScan[user] = true
|
||||||
|
|
||||||
@ -679,12 +654,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
})
|
})
|
||||||
queued[user] = true
|
queued[user] = true
|
||||||
|
|
||||||
case "private", "hidden", "away", "offline", "unknown":
|
case "private", "hidden", "away":
|
||||||
if resumePendingThisScan[user] {
|
if resumePendingThisScan[user] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
nextPending = append(nextPending, PendingAutoStartItem{
|
pendingAutoStartMu.Lock()
|
||||||
|
_ = saveResumePendingAutoStartItemForProvider("chaturbate", PendingAutoStartItem{
|
||||||
ModelKey: user,
|
ModelKey: user,
|
||||||
URL: u,
|
URL: u,
|
||||||
Mode: "wait_public",
|
Mode: "wait_public",
|
||||||
@ -692,6 +668,28 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
ImageURL: img,
|
ImageURL: img,
|
||||||
Source: "resume",
|
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
|
queued[user] = true
|
||||||
|
|
||||||
case "private", "hidden", "away":
|
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,
|
ModelKey: user,
|
||||||
URL: u,
|
URL: u,
|
||||||
Mode: "wait_public",
|
Mode: "wait_public",
|
||||||
@ -739,26 +745,15 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
Source: "watched",
|
Source: "watched",
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
case "offline":
|
||||||
if autostartPaused {
|
// Watched-Model ist wirklich offline:
|
||||||
continue
|
// nicht als "Wartend" anzeigen und keinen Blind-Probe starten.
|
||||||
}
|
continue
|
||||||
if runningByUser[user] != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if queued[user] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if t, ok := lastBlindTry[user]; ok && now.Sub(t) < blindRetryCooldown {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
offlineCandidates = append(offlineCandidates, autoStartItem{
|
default:
|
||||||
userKey: user,
|
// unknown nicht aggressiv in Wartend schieben.
|
||||||
url: u,
|
// Wenn kein frischer Online-Status da ist, lieber nichts tun.
|
||||||
blind: true,
|
continue
|
||||||
source: "watched",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -792,10 +787,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queue = append(queue, autoStartItem{
|
queue = append(queue, autoStartItem{
|
||||||
userKey: user,
|
userKey: user,
|
||||||
url: u,
|
url: u,
|
||||||
blind: false,
|
blind: false,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
ownerUserKey: itm.OwnerUserKey,
|
||||||
})
|
})
|
||||||
queued[user] = true
|
queued[user] = true
|
||||||
|
|
||||||
@ -818,10 +814,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
offlineCandidates = append(offlineCandidates, autoStartItem{
|
offlineCandidates = append(offlineCandidates, autoStartItem{
|
||||||
userKey: user,
|
userKey: user,
|
||||||
url: u,
|
url: u,
|
||||||
blind: true,
|
blind: true,
|
||||||
source: "manual",
|
source: "manual",
|
||||||
|
ownerUserKey: itm.OwnerUserKey,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -848,39 +845,13 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
|
|
||||||
queue = append(queue, it)
|
queue = append(queue, it)
|
||||||
queued[it.userKey] = true
|
queued[it.userKey] = true
|
||||||
selectedBlindUser = it.userKey
|
|
||||||
probeCursor = (idx + 1) % n
|
probeCursor = (idx + 1) % n
|
||||||
break
|
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()
|
pendingAutoStartMu.Lock()
|
||||||
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextPending)
|
_ = saveWatchedPendingAutoStartItemsForProvider("chaturbate", nextWatchedPending)
|
||||||
pendingAutoStartMu.Unlock()
|
pendingAutoStartMu.Unlock()
|
||||||
|
|
||||||
case <-startTicker.C:
|
case <-startTicker.C:
|
||||||
@ -908,7 +879,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
if u == "" {
|
if u == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
showByUser[u] = strings.ToLower(strings.TrimSpace(r.CurrentShow))
|
showByUser[u] = normalizePendingShowServer(r.CurrentShow)
|
||||||
}
|
}
|
||||||
|
|
||||||
paused := isAutostartPaused()
|
paused := isAutostartPaused()
|
||||||
@ -931,8 +902,27 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
queue = append(queue[:startIdx], queue[startIdx+1:]...)
|
queue = append(queue[:startIdx], queue[startIdx+1:]...)
|
||||||
delete(queued, it.userKey)
|
delete(queued, it.userKey)
|
||||||
|
|
||||||
show := strings.TrimSpace(showByUser[it.userKey])
|
show := normalizePendingShowServer(showByUser[it.userKey])
|
||||||
isPublic := strings.Contains(show, "public")
|
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.
|
// Nicht-public nur einzeln nacheinander prüfen.
|
||||||
if it.blind && hasHiddenProbeRunningForProvider("chaturbate") {
|
if it.blind && hasHiddenProbeRunningForProvider("chaturbate") {
|
||||||
@ -943,7 +933,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !it.blind && isPublic {
|
if !it.blind {
|
||||||
lastTry[it.userKey] = time.Now()
|
lastTry[it.userKey] = time.Now()
|
||||||
|
|
||||||
job, err := startRecordingInternal(RecordRequest{
|
job, err := startRecordingInternal(RecordRequest{
|
||||||
@ -953,7 +943,7 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if verboseLogs() {
|
if verboseLogs() {
|
||||||
appLogln("❌ [autostart] start failed:", it.url, err)
|
logChaturbateAutoStartError("❌ [autostart] start failed:", it.url, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -1016,7 +1006,11 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
})
|
})
|
||||||
if err != nil || job == nil {
|
if err != nil || job == nil {
|
||||||
if verboseLogs() {
|
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
|
continue
|
||||||
}
|
}
|
||||||
@ -1026,11 +1020,34 @@ func startChaturbateAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if it.source == "manual" {
|
if it.source == "manual" {
|
||||||
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, func() {
|
go chaturbateAbortIfNoOutput(
|
||||||
pendingAutoStartMu.Lock()
|
job.ID,
|
||||||
_ = removeManualPendingAutoStartItemForProvider("chaturbate", it.userKey)
|
outputProbeMax,
|
||||||
pendingAutoStartMu.Unlock()
|
func() {
|
||||||
}, nil)
|
// 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 {
|
} else {
|
||||||
go chaturbateAbortIfNoOutput(job.ID, outputProbeMax, nil, nil)
|
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"))
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||||
|
|
||||||
// Dev-Origins erlauben
|
// 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("Access-Control-Allow-Origin", origin)
|
||||||
w.Header().Set("Vary", "Origin")
|
w.Header().Set("Vary", "Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,HEAD,OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,DELETE,HEAD,OPTIONS")
|
||||||
|
|||||||
@ -2,5 +2,52 @@
|
|||||||
"username": "admin",
|
"username": "admin",
|
||||||
"passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC",
|
"passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC",
|
||||||
"totpEnabled": true,
|
"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"
|
"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
|
var embeddedMLFiles embed.FS
|
||||||
|
|
||||||
func trainingEmbeddedMLDir() (string, error) {
|
func trainingEmbeddedMLDir() (string, error) {
|
||||||
@ -74,3 +74,46 @@ func embeddedAIServerDir() (string, error) {
|
|||||||
|
|
||||||
return dir, nil
|
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 (
|
require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0
|
github.com/PuerkitoBio/goquery v1.11.0
|
||||||
github.com/getlantern/systray v1.2.2
|
github.com/getlantern/systray v1.2.2
|
||||||
|
github.com/go-webauthn/webauthn v0.17.2
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/grafov/m3u8 v0.12.1
|
github.com/grafov/m3u8 v0.12.1
|
||||||
github.com/jackc/pgx/v5 v5.8.0
|
github.com/jackc/pgx/v5 v5.8.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/pquerna/otp v1.5.0
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/r3labs/sse/v2 v2.10.0
|
github.com/r3labs/sse/v2 v2.10.0
|
||||||
github.com/yalue/onnxruntime_go v1.27.0
|
github.com/yalue/onnxruntime_go v1.27.0
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.50.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
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/context v0.0.0-20190109183933-c447772a6520 // indirect
|
||||||
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
|
||||||
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // 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/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
|
||||||
github.com/go-ole/go-ole v1.2.6 // indirect
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
github.com/go-stack/stack v1.8.0 // 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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
github.com/shoenig/go-m1cpu v0.1.6 // 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/go-sysconf v0.3.12 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // 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
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/sync v0.20.0 // 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
|
gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -45,6 +55,6 @@ require (
|
|||||||
github.com/shirou/gopsutil/v3 v3.24.5
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
|
github.com/sqweek/dialog v0.0.0-20240226140203-065105509627
|
||||||
golang.org/x/image v0.37.0
|
golang.org/x/image v0.37.0
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.52.0 // indirect
|
||||||
golang.org/x/sys v0.40.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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
|
||||||
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
|
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=
|
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-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 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
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.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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
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/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/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 h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
|
||||||
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
@ -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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
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 h1:c1YSgDNtpf0WGtxj3YeRIb8VC5LmM1J+Ve3uHdteC1U=
|
||||||
github.com/yalue/onnxruntime_go v1.27.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4=
|
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/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 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
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-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.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.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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
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 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
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=
|
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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
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-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@ -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.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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@ -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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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-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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
|||||||
@ -875,7 +875,7 @@ func remuxTSToMP4WithProgress(
|
|||||||
"-nostats",
|
"-nostats",
|
||||||
"-progress", "pipe:1",
|
"-progress", "pipe:1",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "warning",
|
"-loglevel", "error",
|
||||||
}
|
}
|
||||||
args = append(args, ffmpegInputTol...)
|
args = append(args, ffmpegInputTol...)
|
||||||
args = append(args,
|
args = append(args,
|
||||||
@ -1677,7 +1677,7 @@ func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error {
|
|||||||
args := []string{
|
args := []string{
|
||||||
"-y",
|
"-y",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "warning",
|
"-loglevel", "error",
|
||||||
}
|
}
|
||||||
args = append(args, ffmpegInputTol...)
|
args = append(args, ffmpegInputTol...)
|
||||||
args = append(args,
|
args = append(args,
|
||||||
|
|||||||
397
backend/meta.go
397
backend/meta.go
@ -61,8 +61,6 @@ type previewMeta struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type analysisMeta struct {
|
type analysisMeta struct {
|
||||||
AI *aiAnalysisMeta `json:"ai,omitempty"`
|
|
||||||
NSFW *aiAnalysisMeta `json:"nsfw,omitempty"`
|
|
||||||
Highlights *aiAnalysisMeta `json:"highlights,omitempty"`
|
Highlights *aiAnalysisMeta `json:"highlights,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,11 +84,6 @@ type postworkCompletedMeta struct {
|
|||||||
Analyze bool `json:"analyze,omitempty"`
|
Analyze bool `json:"analyze,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type postworkMeta struct {
|
|
||||||
Completed *postworkCompletedMeta `json:"completed,omitempty"`
|
|
||||||
History []postworkHistoryEntry `json:"history,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type videoMeta struct {
|
type videoMeta struct {
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
UpdatedAtUnix int64 `json:"updatedAtUnix"`
|
||||||
@ -99,7 +92,6 @@ type videoMeta struct {
|
|||||||
Media videoMediaMeta `json:"media"`
|
Media videoMediaMeta `json:"media"`
|
||||||
Preview previewMeta `json:"preview,omitempty"`
|
Preview previewMeta `json:"preview,omitempty"`
|
||||||
Analysis analysisMeta `json:"analysis,omitempty"`
|
Analysis analysisMeta `json:"analysis,omitempty"`
|
||||||
Postwork postworkMeta `json:"postwork,omitempty"`
|
|
||||||
Validation map[string]any `json:"validation,omitempty"`
|
Validation map[string]any `json:"validation,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -391,40 +383,18 @@ func hasAIAnalysisMeta(ai *aiAnalysisMeta) bool {
|
|||||||
if len(ai.Segments) > 0 {
|
if len(ai.Segments) > 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if ai.Rating != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if ai.AnalyzedAtUnix > 0 {
|
if ai.AnalyzedAtUnix > 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
func pickAnalysisAIForGoal(a analysisMeta, goal string) *aiAnalysisMeta {
|
||||||
goal = normalizeAIGoal(goal)
|
if hasAIAnalysisMeta(a.Highlights) {
|
||||||
|
return a.Highlights
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -442,7 +412,15 @@ func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
|
|||||||
return u, true
|
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 {
|
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -474,7 +452,6 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int,
|
|||||||
if existing != nil {
|
if existing != nil {
|
||||||
m.Preview = existing.Preview
|
m.Preview = existing.Preview
|
||||||
m.Analysis = existing.Analysis
|
m.Analysis = existing.Analysis
|
||||||
m.Postwork = existing.Postwork
|
|
||||||
m.Validation = existing.Validation
|
m.Validation = existing.Validation
|
||||||
|
|
||||||
if m.File.SourceURL == "" {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -538,7 +594,6 @@ func writeVideoMetaWithPreviewClips(metaPath string, fi os.FileInfo, dur float64
|
|||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
m.Analysis = existing.Analysis
|
m.Analysis = existing.Analysis
|
||||||
m.Postwork = existing.Postwork
|
|
||||||
|
|
||||||
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
|
if existing.Preview.Sprite != nil && m.Preview.Sprite == nil {
|
||||||
m.Preview.Sprite = existing.Preview.Sprite
|
m.Preview.Sprite = existing.Preview.Sprite
|
||||||
@ -618,7 +673,6 @@ func writeVideoMetaWithPreviewClipsAndSprite(
|
|||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
m.Analysis = existing.Analysis
|
m.Analysis = existing.Analysis
|
||||||
m.Postwork = existing.Postwork
|
|
||||||
|
|
||||||
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
|
m.Preview.Sprite = mergePreviewSpriteMeta(existing.Preview.Sprite, m.Preview.Sprite)
|
||||||
|
|
||||||
@ -774,103 +828,6 @@ func sanitizeID(id string) (string, error) {
|
|||||||
return id, nil
|
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(
|
func writeVideoAIForFile(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
fullPath string,
|
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) {
|
func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool) {
|
||||||
b, err := os.ReadFile(metaPath)
|
b, err := os.ReadFile(metaPath)
|
||||||
if err != nil || len(b) == 0 {
|
if err != nil || len(b) == 0 {
|
||||||
@ -1030,82 +899,10 @@ func readVideoMetaAIForGoal(metaPath string, goal string) (map[string]any, bool)
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
goal = normalizeAIGoal(goal)
|
// Neuer einziger Zweig.
|
||||||
|
if highlights, ok := rawAnalysis["highlights"].(map[string]any); ok && highlights != nil {
|
||||||
keys := []string{goal}
|
return highlights, true
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, false
|
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",
|
"blowjob",
|
||||||
"toy_play",
|
"toy_play",
|
||||||
"fingering",
|
"fingering",
|
||||||
"69",
|
"69"
|
||||||
"other"
|
|
||||||
],
|
],
|
||||||
"bodyParts": [
|
"bodyParts": [
|
||||||
"anus",
|
"anus",
|
||||||
|
|||||||
@ -122,7 +122,7 @@ def main():
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
"detector",
|
"detector",
|
||||||
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
|
0.04 + 0.90 * ((epoch - 1) / max(1, total)),
|
||||||
f"Object Detector trainiert… Epoche {epoch}/{total}",
|
f"Object Detector trainiert…",
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
epochs=total,
|
epochs=total,
|
||||||
trainSamples=train_count,
|
trainSamples=train_count,
|
||||||
@ -140,7 +140,7 @@ def main():
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
"detector",
|
"detector",
|
||||||
0.04 + 0.90 * (epoch / max(1, total)),
|
0.04 + 0.90 * (epoch / max(1, total)),
|
||||||
f"Object Detector trainiert… Epoche {epoch}/{total}",
|
f"Object Detector trainiert…",
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
epochs=total,
|
epochs=total,
|
||||||
trainSamples=train_count,
|
trainSamples=train_count,
|
||||||
@ -173,7 +173,7 @@ def main():
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
"detector",
|
"detector",
|
||||||
0.04 + 0.90 * (epoch / max(1, total)),
|
0.04 + 0.90 * (epoch / max(1, total)),
|
||||||
f"Object Detector validiert… Epoche {epoch}/{total}",
|
f"Object Detector validiert…",
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
epochs=total,
|
epochs=total,
|
||||||
mAP50=map50,
|
mAP50=map50,
|
||||||
|
|||||||
@ -101,7 +101,7 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
const scanInterval = 1 * time.Second
|
const scanInterval = 1 * time.Second
|
||||||
const startInterval = 2 * time.Second
|
const startInterval = 2 * time.Second
|
||||||
const cooldown = 2 * time.Minute
|
const cooldown = 2 * time.Minute
|
||||||
const outputProbeMax = 20 * time.Second
|
const outputProbeMax = 30 * time.Second
|
||||||
|
|
||||||
queue := make([]autoStartItem, 0, 64)
|
queue := make([]autoStartItem, 0, 64)
|
||||||
queued := map[string]bool{}
|
queued := map[string]bool{}
|
||||||
@ -428,13 +428,13 @@ func startMyFreeCamsAutoStartWorker(store *ModelStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if it.source == "manual" {
|
if it.source == "manual" {
|
||||||
go mfcAbortIfNoOutput(job.ID, outputProbeMax, func() {
|
go mfcKeepIfOutputGrows(job.ID, outputProbeMax, func() {
|
||||||
pendingAutoStartMu.Lock()
|
pendingAutoStartMu.Lock()
|
||||||
_ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey)
|
_ = removeManualPendingAutoStartItemForProvider("mfc", it.userKey)
|
||||||
pendingAutoStartMu.Unlock()
|
pendingAutoStartMu.Unlock()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
go mfcAbortIfNoOutput(job.ID, outputProbeMax, nil)
|
go mfcKeepIfOutputGrows(job.ID, outputProbeMax, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -460,43 +460,66 @@ func isJobRunningForURL(u string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wenn nach maxWait keine Output-Datei (>0 Bytes) existiert, stoppen + Job entfernen.
|
// Wenn die Output-Datei innerhalb maxWait sichtbar wächst, behalten wir den Job.
|
||||||
// Hintergrund: bei MFC kann "offline/away/private" sein => keine Ausgabe entsteht.
|
// Wenn sie nicht wächst, stoppen wir den Hidden-Probe und löschen die angelegte Datei.
|
||||||
func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
|
// 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)
|
deadline := time.Now().Add(maxWait)
|
||||||
|
|
||||||
|
var lastSize int64 = -1
|
||||||
|
var sawFile bool
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
jobsMu.RLock()
|
jobsMu.RLock()
|
||||||
job := jobs[jobID]
|
job := jobs[jobID]
|
||||||
status := JobStatus("")
|
status := JobStatus("")
|
||||||
out := ""
|
out := ""
|
||||||
|
hidden := false
|
||||||
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
status = job.Status
|
status = job.Status
|
||||||
out = strings.TrimSpace(job.Output)
|
out = strings.TrimSpace(job.Output)
|
||||||
|
hidden = job.Hidden
|
||||||
}
|
}
|
||||||
jobsMu.RUnlock()
|
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 {
|
if job == nil || status != JobRunning {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output schon da?
|
|
||||||
if out != "" {
|
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() {
|
||||||
if onOutput != nil {
|
size := fi.Size()
|
||||||
onOutput()
|
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)
|
lastSize = size
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
jobsMu.Lock()
|
||||||
job := jobs[jobID]
|
job := jobs[jobID]
|
||||||
if job == nil || job.Status != JobRunning {
|
if job == nil || job.Status != JobRunning {
|
||||||
@ -512,6 +535,7 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
|
|||||||
|
|
||||||
recordCancel := job.cancel
|
recordCancel := job.cancel
|
||||||
out := strings.TrimSpace(job.Output)
|
out := strings.TrimSpace(job.Output)
|
||||||
|
wasVisible := !job.Hidden
|
||||||
|
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
@ -525,20 +549,24 @@ func mfcAbortIfNoOutput(jobID string, maxWait time.Duration, onOutput func()) {
|
|||||||
recordCancel()
|
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 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)
|
_ = os.Remove(out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
j := jobs[jobID]
|
j := jobs[jobID]
|
||||||
wasVisible := (j != nil && !j.Hidden)
|
|
||||||
delete(jobs, jobID)
|
delete(jobs, jobID)
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
|
|
||||||
if wasVisible {
|
if wasVisible && j != nil {
|
||||||
publishJobRemove(j)
|
publishJobRemove(j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sawFile {
|
||||||
|
appLogln("🧹 [mfc autostart] Probe ohne Wachstum entfernt:", jobID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,11 +18,13 @@ import (
|
|||||||
type PendingAutoStartItem struct {
|
type PendingAutoStartItem struct {
|
||||||
ModelKey string `json:"modelKey"`
|
ModelKey string `json:"modelKey"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Mode string `json:"mode,omitempty"` // wait_public | probe_retry
|
Mode string `json:"mode,omitempty"`
|
||||||
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"` // nur bei probe_retry
|
NextProbeAtMs int64 `json:"nextProbeAtMs,omitempty"`
|
||||||
CurrentShow string `json:"currentShow,omitempty"` // public/private/hidden/away/offline/unknown
|
CurrentShow string `json:"currentShow,omitempty"`
|
||||||
ImageURL string `json:"imageUrl,omitempty"`
|
ImageURL string `json:"imageUrl,omitempty"`
|
||||||
Source string `json:"source,omitempty"` // manual | watched
|
Source string `json:"source,omitempty"`
|
||||||
|
|
||||||
|
OwnerUserKey string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PendingAutoStartResponse struct {
|
type PendingAutoStartResponse struct {
|
||||||
@ -198,6 +200,18 @@ func pendingAutoStartFilePath(userKey string) string {
|
|||||||
return filepath.Join("data", "pending-autostart", safeUserKeyForFile(userKey)+".json")
|
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) {
|
func listPendingAutoStartUserKeys() ([]string, error) {
|
||||||
dir := filepath.Join("data", "pending-autostart")
|
dir := filepath.Join("data", "pending-autostart")
|
||||||
|
|
||||||
@ -266,6 +280,9 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS
|
|||||||
if pendingProviderFromURL(it.URL) != provider {
|
if pendingProviderFromURL(it.URL) != provider {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
it.OwnerUserKey = userKey
|
||||||
|
|
||||||
out = append(out, it)
|
out = append(out, it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -273,6 +290,44 @@ func loadManualPendingAutoStartItemsForProvider(provider string) ([]PendingAutoS
|
|||||||
return out, nil
|
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 {
|
func removeManualPendingAutoStartItemForProvider(provider, modelKey string) error {
|
||||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||||
modelKey = strings.ToLower(strings.TrimSpace(modelKey))
|
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"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var trashCleanupMu sync.Mutex
|
||||||
|
|
||||||
// ---------------- Types ----------------
|
// ---------------- Types ----------------
|
||||||
|
|
||||||
type prepareSplitReq struct {
|
type prepareSplitReq struct {
|
||||||
Output string `json:"output"`
|
Output string `json:"output"`
|
||||||
Goal string `json:"goal,omitempty"` // z.B. "nsfw"
|
Goal string `json:"goal,omitempty"` // z.B. "highlights"
|
||||||
}
|
}
|
||||||
|
|
||||||
type prepareSplitResp struct {
|
type prepareSplitResp struct {
|
||||||
@ -90,9 +92,19 @@ type durationItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type undoDeleteToken struct {
|
type undoDeleteToken struct {
|
||||||
Trash string `json:"trash"` // basename in .trash (legacy/optional)
|
Trash string `json:"trash"` // basename in .trash (legacy/optional)
|
||||||
RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model"
|
RelDir string `json:"relDir"` // dir relativ zu doneAbs, z.B. ".", "keep/model", "model"
|
||||||
File string `json:"file"` // original basename, z.B. "HOT xyz.mp4"
|
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 {
|
type bulkFilesRequest struct {
|
||||||
@ -777,8 +789,16 @@ func finishedPhaseTruthForID(id string) finishedPhaseTruth {
|
|||||||
|
|
||||||
analysis, _ := m["analysis"].(map[string]any)
|
analysis, _ := m["analysis"].(map[string]any)
|
||||||
if analysis != nil {
|
if analysis != nil {
|
||||||
if ai, ok := analysis["ai"].(map[string]any); ok && ai != nil && len(ai) > 0 {
|
if highlights, ok := analysis["highlights"].(map[string]any); ok && highlights != nil {
|
||||||
out.AnalyzeReady = true
|
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
|
c := *base
|
||||||
|
|
||||||
// vollständiges generated meta.json laden (inkl. analysis.ai.rating)
|
// vollständiges generated meta.json laden (inkl. analysis.highlights.rating)
|
||||||
applyGeneratedMetaToRecordJobMeta(&c)
|
applyGeneratedMetaToRecordJobMeta(&c)
|
||||||
|
|
||||||
// danach gezielte Wahrheiten/Fallbacks drüberlegen
|
// 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) {
|
func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
if r.Method != http.MethodPost && r.Method != http.MethodDelete {
|
||||||
http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed)
|
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")
|
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 {
|
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||||
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "trash dir erstellen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@ -2593,12 +2631,14 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
Trash: "",
|
Trash: "",
|
||||||
RelDir: relDir,
|
RelDir: relDir,
|
||||||
File: file,
|
File: file,
|
||||||
|
From: from,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "undo token encode fehlgeschlagen: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Token ist RawURLEncoding-safe. Prefix macht Restore ohne last.json eindeutig.
|
||||||
trashName := tok + "__" + file
|
trashName := tok + "__" + file
|
||||||
trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_")
|
trashName = strings.ReplaceAll(trashName, string(os.PathSeparator), "_")
|
||||||
dst := filepath.Join(trashDir, trashName)
|
dst := filepath.Join(trashDir, trashName)
|
||||||
@ -2612,24 +2652,31 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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{
|
meta := trashMeta{
|
||||||
Token: tok,
|
Token: tok,
|
||||||
TrashName: trashName,
|
TrashName: trashName,
|
||||||
RelDir: relDir,
|
RelDir: relDir,
|
||||||
File: file,
|
File: file,
|
||||||
|
From: from,
|
||||||
DeletedAt: time.Now().Unix(),
|
DeletedAt: time.Now().Unix(),
|
||||||
}
|
}
|
||||||
|
|
||||||
b, _ := json.Marshal(meta)
|
if b, err := json.Marshal(meta); err == nil {
|
||||||
_ = os.WriteFile(filepath.Join(trashDir, "last.json"), b, 0o644)
|
// 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)
|
purgeDurationCacheForPath(target)
|
||||||
removeJobsByOutputBasename(file)
|
removeJobsByOutputBasename(file)
|
||||||
@ -2656,6 +2703,12 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tok, err := decodeUndoDeleteToken(raw)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "token ungültig", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s := getSettings()
|
s := getSettings()
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -2668,38 +2721,69 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trashDir := filepath.Join(doneAbs, ".trash")
|
trashDir := filepath.Join(doneAbs, ".trash")
|
||||||
metaPath := filepath.Join(trashDir, "last.json")
|
|
||||||
|
|
||||||
b, err := os.ReadFile(metaPath)
|
var meta trashMeta
|
||||||
if err != nil {
|
|
||||||
|
// 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)
|
http.Error(w, "nichts zum Wiederherstellen", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var meta struct {
|
if strings.TrimSpace(meta.From) == "" {
|
||||||
Token string `json:"token"`
|
relLower := strings.ToLower(filepath.ToSlash(strings.TrimSpace(meta.RelDir)))
|
||||||
TrashName string `json:"trashName"`
|
if relLower == "keep" || strings.HasPrefix(relLower, "keep/") {
|
||||||
RelDir string `json:"relDir"`
|
meta.From = "keep"
|
||||||
File string `json:"file"`
|
} else {
|
||||||
DeletedAt int64 `json:"deletedAt"`
|
meta.From = "done"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(b, &meta); err != nil {
|
|
||||||
http.Error(w, "trash meta ungültig", http.StatusInternalServerError)
|
if meta.Token != raw {
|
||||||
return
|
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
|
||||||
}
|
|
||||||
if strings.TrimSpace(meta.Token) == "" || strings.TrimSpace(meta.TrashName) == "" || strings.TrimSpace(meta.File) == "" {
|
|
||||||
http.Error(w, "trash meta unvollständig", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if raw != meta.Token {
|
if tok.File != meta.File || tok.RelDir != meta.RelDir {
|
||||||
http.Error(w, "token ungültig (nicht der letzte)", http.StatusNotFound)
|
http.Error(w, "token passt nicht zur Trash-Meta", http.StatusNotFound)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tok, err := decodeUndoDeleteToken(raw)
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "token ungültig", http.StatusBadRequest)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2707,10 +2791,6 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "token inhalt ungültig", http.StatusBadRequest)
|
http.Error(w, "token inhalt ungültig", http.StatusBadRequest)
|
||||||
return
|
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) {
|
if !isAllowedVideoExt(meta.File) {
|
||||||
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
http.Error(w, "nicht erlaubt", http.StatusForbidden)
|
||||||
@ -2723,6 +2803,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
if rel == "." {
|
if rel == "." {
|
||||||
rel = ""
|
rel = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel))
|
dstDir := filepath.Join(doneAbs, filepath.FromSlash(rel))
|
||||||
dstDirClean := filepath.Clean(dstDir)
|
dstDirClean := filepath.Clean(dstDir)
|
||||||
doneClean := filepath.Clean(doneAbs)
|
doneClean := filepath.Clean(doneAbs)
|
||||||
@ -2756,8 +2837,15 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
_ = os.Chtimes(dst, now, now)
|
_ = os.Chtimes(dst, now, now)
|
||||||
|
|
||||||
_ = os.RemoveAll(trashDir)
|
_ = os.Remove(metaPath)
|
||||||
_ = os.MkdirAll(trashDir, 0o755)
|
|
||||||
|
// 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(src)
|
||||||
purgeDurationCacheForPath(dst)
|
purgeDurationCacheForPath(dst)
|
||||||
@ -2768,6 +2856,7 @@ func recordRestoreVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
"ok": true,
|
"ok": true,
|
||||||
"file": meta.File,
|
"file": meta.File,
|
||||||
"restoredFile": filepath.Base(dst),
|
"restoredFile": filepath.Base(dst),
|
||||||
|
"from": meta.From,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,15 +72,92 @@ func RecordStream(
|
|||||||
return stream, nil
|
return stream, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := loadFreshHLS()
|
isRetryableFreshHLSError := func(err error) bool {
|
||||||
if err != nil {
|
if err == nil {
|
||||||
return err
|
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 {
|
loadFreshHLSWithRetry := func(maxAttempts int, reason string) (*selectedHLSStream, error) {
|
||||||
assetID := assetIDForJob(job)
|
if maxAttempts < 1 {
|
||||||
if assetID != "" {
|
maxAttempts = 1
|
||||||
_, _ = ensureGeneratedDir(assetID)
|
}
|
||||||
|
|
||||||
|
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()
|
jobsMu.Lock()
|
||||||
@ -94,6 +171,23 @@ func RecordStream(
|
|||||||
jobsMu.Unlock()
|
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
|
const maxPlaylistRefreshes = 12
|
||||||
refreshes := 0
|
refreshes := 0
|
||||||
attempt := 0
|
attempt := 0
|
||||||
@ -134,11 +228,17 @@ func RecordStream(
|
|||||||
shouldRefresh :=
|
shouldRefresh :=
|
||||||
strings.Contains(msg, "403") ||
|
strings.Contains(msg, "403") ||
|
||||||
strings.Contains(msg, "401") ||
|
strings.Contains(msg, "401") ||
|
||||||
|
strings.Contains(msg, "429") ||
|
||||||
strings.Contains(msg, "invalid data found") ||
|
strings.Contains(msg, "invalid data found") ||
|
||||||
strings.Contains(msg, "error opening input") ||
|
strings.Contains(msg, "error opening input") ||
|
||||||
strings.Contains(msg, "stream-parsing") ||
|
strings.Contains(msg, "stream-parsing") ||
|
||||||
strings.Contains(msg, "kein hls-quell-url") ||
|
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 {
|
if !shouldRefresh {
|
||||||
return err
|
return err
|
||||||
@ -155,8 +255,10 @@ func RecordStream(
|
|||||||
case <-time.After(1500 * time.Millisecond):
|
case <-time.After(1500 * time.Millisecond):
|
||||||
}
|
}
|
||||||
|
|
||||||
newStream, rerr := loadFreshHLS()
|
newStream, rerr := loadFreshHLSWithRetry(3, "after-ffmpeg-error")
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
|
appLogln("⚠️ chaturbate hls refresh failed:", rerr)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
@ -166,18 +268,7 @@ func RecordStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
stream = newStream
|
stream = newStream
|
||||||
|
updatePreviewFromStream(stream)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -405,6 +405,14 @@ func appendFileAndRemove(dstPath, srcPath string) error {
|
|||||||
return nil
|
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(
|
func handleM3U8Mode(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
stream *selectedHLSStream,
|
stream *selectedHLSStream,
|
||||||
@ -452,7 +460,7 @@ func handleM3U8Mode(
|
|||||||
"-y",
|
"-y",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-nostats",
|
"-nostats",
|
||||||
"-loglevel", "warning",
|
"-loglevel", "error",
|
||||||
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
|
"-protocol_whitelist", "file,http,https,tcp,tls,crypto",
|
||||||
"-http_persistent", "false",
|
"-http_persistent", "false",
|
||||||
}
|
}
|
||||||
@ -560,11 +568,13 @@ func handleM3U8Mode(
|
|||||||
close(stopStat)
|
close(stopStat)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg := strings.TrimSpace(stderr.String())
|
// Wenn der User den Recording-Prozess stoppt, beendet ffmpeg HLS unter Windows
|
||||||
if msg != "" {
|
// oft mit "exit status 1". Das ist in diesem Fall kein echter Fehler.
|
||||||
return appErrorf("ffmpeg m3u8 failed: %w: %s", err, msg)
|
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 {
|
if job != nil {
|
||||||
@ -578,3 +588,80 @@ func handleM3U8Mode(
|
|||||||
|
|
||||||
return nil
|
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 {
|
) error {
|
||||||
mfc := NewMyFreeCams(username)
|
mfc := NewMyFreeCams(username)
|
||||||
|
|
||||||
// ✅ Statt sofort zu failen: kurz auf PUBLIC warten
|
const waitPublicMax = 20 * time.Second
|
||||||
const waitPublicMax = 2 * time.Minute
|
|
||||||
deadline := time.Now().Add(waitPublicMax)
|
deadline := time.Now().Add(waitPublicMax)
|
||||||
|
|
||||||
var lastSt *Status
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
// Context cancel / stop
|
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
st, err := mfc.GetStatus()
|
st, err := mfc.GetStatus()
|
||||||
if err == nil {
|
if err == nil && st == StatusPublic {
|
||||||
tmp := st
|
break
|
||||||
lastSt = &tmp
|
|
||||||
|
|
||||||
if st == StatusPublic {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
if lastSt == nil {
|
return context.DeadlineExceeded
|
||||||
return appErrorf("mfc: stream wurde nicht public innerhalb %s", waitPublicMax)
|
|
||||||
}
|
|
||||||
return appErrorf("mfc: stream ist nicht public nach %s (letzter Status: %s)", waitPublicMax, *lastSt)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
@ -81,17 +68,15 @@ func RecordStreamMFC(
|
|||||||
|
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
job.PreviewM3U8 = previewURL
|
job.PreviewM3U8 = previewURL
|
||||||
job.PreviewCookie = "" // MFC nutzt i.d.R. keine Cookies; wenn doch: hier setzen
|
job.PreviewCookie = ""
|
||||||
job.PreviewUA = hc.userAgent
|
job.PreviewUA = hc.userAgent
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Job erst jetzt sichtbar machen (Stream wirklich verfügbar)
|
|
||||||
if job != nil {
|
if job != nil {
|
||||||
_ = publishJob(job.ID)
|
_ = publishJob(job.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aufnahme starten
|
|
||||||
return handleM3U8Mode(ctx, stream, outputPath, job, "", hc.userAgent, mfc.GetWebsiteURL())
|
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
|
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) {
|
func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
||||||
url := strings.TrimSpace(req.URL)
|
url := strings.TrimSpace(req.URL)
|
||||||
if url == "" {
|
if url == "" {
|
||||||
@ -470,7 +507,10 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if active >= max {
|
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()
|
jobsMu.Lock()
|
||||||
job.Phase = "postwork"
|
job.Phase = "postwork"
|
||||||
job.PostWorkKey = postKey
|
job.PostWorkKey = postKey
|
||||||
{
|
job.PostWork = &PostWorkKeyStatus{
|
||||||
s := postWorkQ.StatusForKey(postKey)
|
State: "queued",
|
||||||
job.PostWork = &s
|
Position: 0,
|
||||||
|
Waiting: 0,
|
||||||
|
Running: 0,
|
||||||
|
MaxParallel: 1,
|
||||||
}
|
}
|
||||||
jobsMu.Unlock()
|
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/logout", authLogoutHandler(auth))
|
||||||
mux.HandleFunc("/api/auth/me", authMeHandler(auth))
|
mux.HandleFunc("/api/auth/me", authMeHandler(auth))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/auth/password/change", authPasswordChangeHandler(auth))
|
||||||
|
|
||||||
// 2FA (Authenticator/TOTP)
|
// 2FA (Authenticator/TOTP)
|
||||||
|
|
||||||
mux.HandleFunc("/api/auth/2fa/setup", auth2FASetupHandler(auth))
|
mux.HandleFunc("/api/auth/2fa/setup", auth2FASetupHandler(auth))
|
||||||
mux.HandleFunc("/api/auth/2fa/enable", auth2FAEnableHandler(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
|
// 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/next", trainingNextHandler)
|
||||||
api.HandleFunc("/api/training/frame", trainingFrameHandler)
|
api.HandleFunc("/api/training/frame", trainingFrameHandler)
|
||||||
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
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/train", trainingTrainHandler)
|
||||||
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
|
api.HandleFunc("/api/training/cancel", trainingCancelHandler)
|
||||||
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||||
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
api.HandleFunc("/api/training/stats", trainingStatsHandler)
|
||||||
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||||
|
api.HandleFunc("/api/training/skip", trainingSkipHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
@ -15,8 +16,23 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"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 {
|
func escapePowerShell(s string) string {
|
||||||
return strings.ReplaceAll(s, "'", "''")
|
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 {
|
for _, path := range candidates {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
appLogln(" prüfe:", cleanPath)
|
|
||||||
|
|
||||||
if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() {
|
if fi, err := os.Stat(cleanPath); err == nil && fi != nil && !fi.IsDir() {
|
||||||
appLogln("✅ ai_server.py gefunden:", cleanPath)
|
appLogln("✅ ai_server.py gefunden:", cleanPath)
|
||||||
@ -305,7 +316,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
"ai_server:app",
|
"ai_server:app",
|
||||||
"--host", "127.0.0.1",
|
"--host", "127.0.0.1",
|
||||||
"--port", port,
|
"--port", port,
|
||||||
"--log-level", "warning",
|
"--log-level", "error",
|
||||||
)
|
)
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, pythonPath, args...)
|
cmd := exec.CommandContext(ctx, pythonPath, args...)
|
||||||
@ -337,15 +348,6 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
|
|
||||||
cmd.Env = env
|
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()
|
logWriter := appLogWriter()
|
||||||
cmd.Stdout = logWriter
|
cmd.Stdout = logWriter
|
||||||
cmd.Stderr = logWriter
|
cmd.Stderr = logWriter
|
||||||
@ -371,7 +373,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
|
|||||||
if err != nil && ctx.Err() == nil {
|
if err != nil && ctx.Err() == nil {
|
||||||
appLogf("⚠️ AI Server beendet: %v", err)
|
appLogf("⚠️ AI Server beendet: %v", err)
|
||||||
} else {
|
} 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 {
|
if p.cmd != nil && p.cmd.Process != nil {
|
||||||
appLogln("🛑 Beende AI Server...")
|
|
||||||
_ = p.cmd.Process.Kill()
|
_ = 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 ---
|
// --- main ---
|
||||||
func main() {
|
func main() {
|
||||||
clearAppLog()
|
clearAppLog()
|
||||||
initAppLog()
|
initAppLog()
|
||||||
defer closeAppLog()
|
defer closeAppLog()
|
||||||
|
|
||||||
|
loadAppEnv()
|
||||||
|
|
||||||
loadSettings()
|
loadSettings()
|
||||||
|
|
||||||
appCtx, appCancel := context.WithCancel(context.Background())
|
appCtx, appCancel := context.WithCancel(context.Background())
|
||||||
@ -489,7 +624,13 @@ func main() {
|
|||||||
appLogln("⚠️ covers dir:", err)
|
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)
|
handler := withCORS(mux)
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
@ -500,8 +641,6 @@ func main() {
|
|||||||
var shutdownOnce sync.Once
|
var shutdownOnce sync.Once
|
||||||
shutdown := func() {
|
shutdown := func() {
|
||||||
shutdownOnce.Do(func() {
|
shutdownOnce.Do(func() {
|
||||||
appLogln("🛑 Shutdown gestartet")
|
|
||||||
|
|
||||||
if aiProc != nil {
|
if aiProc != nil {
|
||||||
aiProc.Stop()
|
aiProc.Stop()
|
||||||
}
|
}
|
||||||
@ -512,6 +651,7 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
_ = srv.Shutdown(ctx)
|
_ = srv.Shutdown(ctx)
|
||||||
|
appLogln("🛑 Server beendet.")
|
||||||
|
|
||||||
closeNSFW()
|
closeNSFW()
|
||||||
})
|
})
|
||||||
@ -528,10 +668,19 @@ func main() {
|
|||||||
serverErrCh := make(chan error, 1)
|
serverErrCh := make(chan error, 1)
|
||||||
|
|
||||||
go func() {
|
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
|
serverErrCh <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
serverErrCh <- nil
|
serverErrCh <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@ -584,7 +584,7 @@ func splitSingleSegment(
|
|||||||
"-nostats",
|
"-nostats",
|
||||||
"-progress", "pipe:1",
|
"-progress", "pipe:1",
|
||||||
"-hide_banner",
|
"-hide_banner",
|
||||||
"-loglevel", "warning",
|
"-loglevel", "error",
|
||||||
"-ss", formatFFSec(startSec),
|
"-ss", formatFFSec(startSec),
|
||||||
"-i", srcPath,
|
"-i", srcPath,
|
||||||
"-t", formatFFSec(durSec),
|
"-t", formatFFSec(durSec),
|
||||||
|
|||||||
@ -83,13 +83,16 @@ type taskStateEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type analysisProgressEvent 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"`
|
Running bool `json:"running"`
|
||||||
Phase string `json:"phase,omitempty"`
|
Phase string `json:"phase,omitempty"`
|
||||||
Progress float64 `json:"progress"` // 0..1
|
Progress float64 `json:"progress"`
|
||||||
Current int `json:"current,omitempty"`
|
Current int `json:"current,omitempty"`
|
||||||
Total int `json:"total,omitempty"`
|
Total int `json:"total,omitempty"`
|
||||||
File string `json:"file,omitempty"`
|
File string `json:"file,omitempty"`
|
||||||
|
SourceFile string `json:"sourceFile,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
StartedAtMs int64 `json:"startedAtMs,omitempty"`
|
||||||
|
|||||||
@ -915,7 +915,7 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) {
|
|||||||
|
|
||||||
publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse")
|
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 {
|
if skipFile, stopAll := handleFileAbort(aerr); stopAll {
|
||||||
return
|
return
|
||||||
} else if skipFile {
|
} else if skipFile {
|
||||||
|
|||||||
@ -90,7 +90,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string)
|
|||||||
assetID = strings.TrimSpace(assetID)
|
assetID = strings.TrimSpace(assetID)
|
||||||
goal = strings.ToLower(strings.TrimSpace(goal))
|
goal = strings.ToLower(strings.TrimSpace(goal))
|
||||||
if goal == "" {
|
if goal == "" {
|
||||||
goal = "nsfw"
|
goal = "highlights"
|
||||||
}
|
}
|
||||||
|
|
||||||
if assetID == "" && videoPath != "" {
|
if assetID == "" && videoPath != "" {
|
||||||
@ -132,7 +132,7 @@ func regeneratePhaseTruthForVideo(videoPath string, assetID string, goal string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
|
func firstMissingPrimaryRegeneratePhaseError(videoPath string, assetID string) error {
|
||||||
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
|
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
|
||||||
|
|
||||||
if !truth.MetaReady {
|
if !truth.MetaReady {
|
||||||
return errors.New("meta.json fehlt oder ist noch unvollständig")
|
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 {
|
func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) error {
|
||||||
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "nsfw")
|
truth := regeneratePhaseTruthForVideo(videoPath, assetID, "highlights")
|
||||||
|
|
||||||
if !truth.SpritesReady {
|
if !truth.SpritesReady {
|
||||||
return errors.New("preview-sprite.jpg fehlt oder ist leer")
|
return errors.New("preview-sprite.jpg fehlt oder ist leer")
|
||||||
@ -334,7 +334,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) {
|
|||||||
id := job.AssetID
|
id := job.AssetID
|
||||||
ctx := job.Ctx
|
ctx := job.Ctx
|
||||||
requiredGoals := requiredAnalyzeGoals()
|
requiredGoals := requiredAnalyzeGoals()
|
||||||
primaryGoal := "nsfw"
|
primaryGoal := "highlights"
|
||||||
|
|
||||||
setRegenerateAssetsTaskRunning(file, file)
|
setRegenerateAssetsTaskRunning(file, file)
|
||||||
cancelDeferredEnrichForFile(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>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>App</title>
|
<title>Recorder</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
7
frontend/package-lock.json
generated
7
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
@ -1466,6 +1467,12 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.17",
|
"version": "0.5.17",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.2.9",
|
"@headlessui/react": "^2.2.9",
|
||||||
"@heroicons/react": "^2.2.0",
|
"@heroicons/react": "^2.2.0",
|
||||||
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"prop-types": "^15.8.1",
|
"prop-types": "^15.8.1",
|
||||||
|
|||||||
@ -24,6 +24,9 @@ import {
|
|||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
BeakerIcon,
|
BeakerIcon,
|
||||||
|
CircleStackIcon,
|
||||||
|
ArrowRightOnRectangleIcon,
|
||||||
|
PlayIcon,
|
||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
@ -129,12 +132,16 @@ type TaskStateEvent = {
|
|||||||
|
|
||||||
type AnalysisProgressEvent = {
|
type AnalysisProgressEvent = {
|
||||||
type?: 'analysis_progress'
|
type?: 'analysis_progress'
|
||||||
|
scope?: string
|
||||||
|
requestId?: string
|
||||||
|
analysisRequestId?: string
|
||||||
running?: boolean
|
running?: boolean
|
||||||
phase?: 'starting' | 'running' | 'done' | 'error' | string
|
phase?: 'starting' | 'running' | 'done' | 'error' | string
|
||||||
progress?: number
|
progress?: number
|
||||||
current?: number
|
current?: number
|
||||||
total?: number
|
total?: number
|
||||||
file?: string
|
file?: string
|
||||||
|
sourceFile?: string
|
||||||
message?: string
|
message?: string
|
||||||
error?: string
|
error?: string
|
||||||
startedAtMs?: number
|
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 [trainingTabRunning, setTrainingTabRunning] = useState(false)
|
||||||
|
const [trainingTitleProgress, setTrainingTitleProgress] = useState<number | null>(null)
|
||||||
|
const [trainingImageExpanded, setTrainingImageExpanded] = useState(false)
|
||||||
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
|
const [splitProgressByFile, setSplitProgressByFile] = useState<Record<string, BackgroundSplitProgress>>({})
|
||||||
const splitProgressClearTimersRef = useRef<Record<string, number>>({})
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
for (const t of Object.values(splitProgressClearTimersRef.current)) {
|
for (const t of Object.values(splitProgressClearTimersRef.current)) {
|
||||||
@ -1119,6 +1155,10 @@ export default function App() {
|
|||||||
if (cancelled || !res.ok || !data) return
|
if (cancelled || !res.ok || !data) return
|
||||||
|
|
||||||
setTrainingTabRunning(Boolean(data?.training?.running))
|
setTrainingTabRunning(Boolean(data?.training?.running))
|
||||||
|
|
||||||
|
setTrainingTitleProgress(
|
||||||
|
normalizeTitleProgress(data?.training?.progress ?? data?.progress)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -2843,13 +2883,21 @@ export default function App() {
|
|||||||
|
|
||||||
if (msg?.type !== 'analysis_progress') return
|
if (msg?.type !== 'analysis_progress') return
|
||||||
|
|
||||||
window.dispatchEvent(
|
const scope = String(msg?.scope ?? '').trim()
|
||||||
new CustomEvent('app:sse:analysis', {
|
|
||||||
detail: msg,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
if (!file) return
|
||||||
|
|
||||||
const phase = String(msg?.phase ?? '').trim().toLowerCase()
|
const phase = String(msg?.phase ?? '').trim().toLowerCase()
|
||||||
@ -2933,7 +2981,15 @@ export default function App() {
|
|||||||
|
|
||||||
if (data?.type !== 'training_status') return
|
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(
|
window.dispatchEvent(
|
||||||
new CustomEvent('app:sse:training', {
|
new CustomEvent('app:sse:training', {
|
||||||
@ -3322,12 +3378,12 @@ export default function App() {
|
|||||||
}, [doneCount])
|
}, [doneCount])
|
||||||
|
|
||||||
const handleDeleteJobWithUndo = useCallback(
|
const handleDeleteJobWithUndo = useCallback(
|
||||||
async (job: RecordJob): Promise<void | { undoToken?: string }> => {
|
async (job: RecordJob): Promise<void | { undoToken?: string; from?: string }> => {
|
||||||
const file = baseName(job.output || '')
|
const file = baseName(job.output || '')
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await runFinishedFileAction<{ undoToken?: string }>({
|
const data = await runFinishedFileAction<{ undoToken?: string; from?: string }>({
|
||||||
kind: 'delete',
|
kind: 'delete',
|
||||||
file,
|
file,
|
||||||
run: () =>
|
run: () =>
|
||||||
@ -3358,7 +3414,9 @@ export default function App() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const undoToken = typeof data?.undoToken === 'string' ? data.undoToken : ''
|
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 {
|
} catch {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -3848,6 +3906,14 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
}, [autoAddEnabled, autoStartEnabled, startUrl])
|
||||||
|
|
||||||
|
const isTrainingTab = selectedTab === 'training'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTrainingTab) {
|
||||||
|
setTrainingImageExpanded(false)
|
||||||
|
}
|
||||||
|
}, [isTrainingTab])
|
||||||
|
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
return <div className="min-h-[100dvh] grid place-items-center">Lade…</div>
|
||||||
}
|
}
|
||||||
@ -3856,17 +3922,38 @@ export default function App() {
|
|||||||
return <LoginPage onLoggedIn={checkAuth} />
|
return <LoginPage onLoggedIn={checkAuth} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerInfoPanelClass =
|
const headerShellClass =
|
||||||
'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'
|
'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 =
|
const headerStatClass =
|
||||||
'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]'
|
'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 =
|
const headerActionClass =
|
||||||
'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'
|
'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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={[
|
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' : '',
|
isTrainingTab ? 'min-h-[100dvh] lg:flex lg:h-full lg:min-h-0 lg:flex-col' : '',
|
||||||
].join(' ')}
|
].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">
|
<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 sm:px-6 lg:px-8 pt-2.5 sm:py-4 space-y-2 sm:space-y-3">
|
<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="flex items-center justify-between gap-3 sm:gap-4">
|
<div className={headerShellClass}>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="grid gap-2.5">
|
||||||
{/* Desktop */}
|
<div className="flex flex-col gap-2.5 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div className="hidden sm:flex h-[47px] items-stretch">
|
<div className="min-w-0">
|
||||||
<div className={`min-w-0 w-full ${headerInfoPanelClass}`}>
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 w-full items-center gap-2.5">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
<h1 className="shrink-0 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">
|
<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">
|
||||||
Recorder
|
<ArrowDownTrayIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
</h1>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
<h1 className="shrink-0 text-base font-bold tracking-tight text-gray-950 dark:text-white sm:text-lg">
|
||||||
<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">
|
|
||||||
Recorder
|
Recorder
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||||
<span className={mobileHeaderStatBadgeClass} title="online">
|
{headerStatItems.map((item) => {
|
||||||
<SignalIcon className="size-4 opacity-80" />
|
const Icon = item.icon
|
||||||
<span className="tabular-nums">{onlineModelsCount}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={mobileHeaderStatBadgeClass} title="Watched online">
|
return (
|
||||||
<EyeIcon className="size-4 opacity-80" />
|
<span
|
||||||
<span className="tabular-nums">{onlineWatchedModelsCount}</span>
|
key={item.label}
|
||||||
</span>
|
className={headerStatClass}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 opacity-80" aria-hidden="true" />
|
||||||
|
|
||||||
<span className={mobileHeaderStatBadgeClass} title="Fav online">
|
<span className="tabular-nums text-gray-950 dark:text-white">
|
||||||
<StarIcon className="size-4 opacity-80" />
|
{item.value}
|
||||||
<span className="tabular-nums">{onlineFavCount}</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
)
|
||||||
<span className={mobileHeaderStatBadgeClass} title="Like online">
|
})}
|
||||||
<HeartIcon className="size-4 opacity-80" />
|
|
||||||
<span className="tabular-nums">{onlineLikedCount}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
<LastUpdatedText
|
||||||
enabled={Boolean(recSettings.useChaturbateApi)}
|
enabled={Boolean(recSettings.useChaturbateApi)}
|
||||||
fetchedAt={cbOnlineFetchedAt}
|
fetchedAt={cbOnlineFetchedAt}
|
||||||
@ -3974,128 +4019,119 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{showPerfMon ? (
|
||||||
<PerformanceMonitor
|
<PerformanceMonitor
|
||||||
mode="inline"
|
mode="inline"
|
||||||
compact
|
compact
|
||||||
className="w-full"
|
className="w-full sm:w-[300px] lg:w-[360px]"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setCookieModalOpen(true)}
|
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>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="soft"
|
variant="soft"
|
||||||
color='red'
|
color="red"
|
||||||
onClick={logout}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden sm:flex shrink-0 items-center gap-2 lg:gap-3">
|
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
|
||||||
{showPerfMon ? (
|
<div className="relative">
|
||||||
<PerformanceMonitor
|
<label className="sr-only">Source URL</label>
|
||||||
mode="inline"
|
<TextInput
|
||||||
compact
|
ref={sourceUrlInputRef}
|
||||||
className="w-[300px] lg:w-[360px]"
|
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}
|
) : null}
|
||||||
|
|
||||||
<Button
|
{isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) ? (
|
||||||
variant="secondary"
|
<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">
|
||||||
onClick={() => setCookieModalOpen(true)}
|
⚠️ Für Chaturbate werden die Cookies <code>cf_clearance</code> und <code>sessionId</code> benötigt.
|
||||||
className="h-9 px-3 shrink-0"
|
</div>
|
||||||
>
|
) : null}
|
||||||
Cookies
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{busy ? (
|
||||||
variant="soft"
|
<ProgressBar label="Starte Download…" indeterminate />
|
||||||
color='red'
|
) : null}
|
||||||
onClick={logout}
|
|
||||||
className="h-9 px-3 shrink-0"
|
|
||||||
>
|
|
||||||
Abmelden
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2 sm:grid-cols-[1fr_auto] sm:items-stretch">
|
<div className="pt-1">
|
||||||
<div className="relative">
|
<Tabs
|
||||||
<label className="sr-only">Source URL</label>
|
tabs={tabs}
|
||||||
<TextInput
|
value={selectedTab}
|
||||||
ref={sourceUrlInputRef}
|
onChange={setSelectedTab}
|
||||||
value={sourceUrl}
|
ariaLabel="Tabs"
|
||||||
size='sm'
|
variant="barUnderline"
|
||||||
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>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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
|
<main
|
||||||
className={[
|
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
|
isTrainingTab
|
||||||
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
|
? 'min-h-0 pb-4 lg:flex-1 lg:overflow-hidden'
|
||||||
: 'space-y-2',
|
: 'space-y-2',
|
||||||
@ -4184,7 +4220,10 @@ export default function App() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'training' ? (
|
{selectedTab === 'training' ? (
|
||||||
<TrainingTab onTrainingRunningChange={setTrainingTabRunning} />
|
<TrainingTab
|
||||||
|
onTrainingRunningChange={setTrainingTabRunning}
|
||||||
|
onImageExpandedChange={setTrainingImageExpanded}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||||
|
|||||||
@ -26,7 +26,20 @@ function cn(...parts: Array<string | false | null | undefined>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const base =
|
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 = {
|
const roundedMap = {
|
||||||
sm: 'rounded-sm',
|
sm: 'rounded-sm',
|
||||||
@ -44,72 +57,77 @@ const sizeMap: Record<Size, string> = {
|
|||||||
const colorMap: Record<Color, Record<Variant, string>> = {
|
const colorMap: Record<Color, Record<Variant, string>> = {
|
||||||
indigo: {
|
indigo: {
|
||||||
primary:
|
primary:
|
||||||
'!bg-indigo-600 !text-white shadow-sm hover:!bg-indigo-700 focus-visible:outline-indigo-600 ' +
|
'!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',
|
'dark:!bg-indigo-500 dark:hover:!bg-indigo-400 dark:focus-visible:outline-indigo-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 ' +
|
||||||
'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-indigo-500/15 dark:hover:text-indigo-300 dark:hover:ring-indigo-400/20 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
|
disabledSecondary,
|
||||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
|
|
||||||
soft:
|
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 ' +
|
'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/30',
|
'dark:bg-indigo-500/20 dark:text-indigo-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-indigo-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
},
|
},
|
||||||
|
|
||||||
blue: {
|
blue: {
|
||||||
primary:
|
primary:
|
||||||
'!bg-blue-600 !text-white shadow-sm hover:!bg-blue-700 focus-visible:outline-blue-600 ' +
|
'!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',
|
'dark:!bg-blue-500 dark:hover:!bg-blue-400 dark:focus-visible:outline-blue-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-blue-50 hover:text-blue-700 hover:ring-blue-200 ' +
|
||||||
'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-blue-500/15 dark:hover:text-blue-300 dark:hover:ring-blue-400/20 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
|
disabledSecondary,
|
||||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
|
|
||||||
soft:
|
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 ' +
|
'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/30',
|
'dark:bg-blue-500/20 dark:text-blue-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-blue-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
},
|
},
|
||||||
|
|
||||||
emerald: {
|
emerald: {
|
||||||
primary:
|
primary:
|
||||||
'!bg-emerald-600 !text-white shadow-sm hover:!bg-emerald-700 focus-visible:outline-emerald-600 ' +
|
'!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',
|
'dark:!bg-emerald-500 dark:hover:!bg-emerald-400 dark:focus-visible:outline-emerald-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-emerald-50 hover:text-emerald-700 hover:ring-emerald-200 ' +
|
||||||
'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-emerald-500/15 dark:hover:text-emerald-300 dark:hover:ring-emerald-400/20 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
|
disabledSecondary,
|
||||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
|
|
||||||
soft:
|
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 ' +
|
'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/30',
|
'dark:bg-emerald-500/20 dark:text-emerald-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-emerald-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
},
|
},
|
||||||
|
|
||||||
red: {
|
red: {
|
||||||
primary:
|
primary:
|
||||||
'!bg-red-600 !text-white shadow-sm hover:!bg-red-700 focus-visible:outline-red-600 ' +
|
'!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',
|
'dark:!bg-red-500 dark:hover:!bg-red-400 dark:focus-visible:outline-red-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-red-50 hover:text-red-700 hover:ring-red-200 ' +
|
||||||
'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-red-500/15 dark:hover:text-red-300 dark:hover:ring-red-400/20 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
|
disabledSecondary,
|
||||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
|
|
||||||
soft:
|
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 ' +
|
'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/30',
|
'dark:bg-red-500/20 dark:text-red-400 dark:shadow-none dark:inset-ring-0 dark:hover:bg-red-500/25 ' +
|
||||||
|
disabledSoft,
|
||||||
},
|
},
|
||||||
|
|
||||||
amber: {
|
amber: {
|
||||||
primary:
|
primary:
|
||||||
'!bg-amber-500 !text-white shadow-sm hover:!bg-amber-600 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',
|
'dark:!bg-amber-500 dark:hover:!bg-amber-400 dark:focus-visible:outline-amber-500 ' +
|
||||||
|
disabledPrimary,
|
||||||
secondary:
|
secondary:
|
||||||
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-gray-100 ' +
|
'bg-white text-gray-900 shadow-xs ring-1 ring-gray-200 hover:bg-amber-50 hover:text-amber-800 hover:ring-amber-200 ' +
|
||||||
'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-amber-500/15 dark:hover:text-amber-300 dark:hover:ring-amber-400/20 ' +
|
||||||
'dark:bg-white/10 dark:text-white dark:shadow-none dark:ring-white/10 dark:hover:bg-white/20 ' +
|
disabledSecondary,
|
||||||
'dark:disabled:bg-white/5 dark:disabled:text-white/40 dark:disabled:ring-white/10 dark:disabled:hover:bg-white/5',
|
|
||||||
soft:
|
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 ' +
|
'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/30',
|
'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>}
|
{trailingIcon && !isLoading && <span className="-mr-0.5">{trailingIcon}</span>}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -543,19 +543,11 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none
|
|||||||
if (!isPostworkJob(job)) return 'none'
|
if (!isPostworkJob(job)) return 'none'
|
||||||
if (isTerminalStatus(anyJ?.status)) 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 === 'running') return 'running'
|
||||||
|
if (pwState === 'queued') return 'queued'
|
||||||
|
|
||||||
if (
|
// 2) Queue-Positionsdaten aus dem Backend
|
||||||
phase === 'postwork' ||
|
|
||||||
phase === 'probe' ||
|
|
||||||
phase === 'remuxing' ||
|
|
||||||
phase === 'moving' ||
|
|
||||||
phase === 'assets'
|
|
||||||
) {
|
|
||||||
return 'running'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof pw?.position === 'number' &&
|
typeof pw?.position === 'number' &&
|
||||||
Number.isFinite(pw.position) &&
|
Number.isFinite(pw.position) &&
|
||||||
@ -573,10 +565,28 @@ const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none
|
|||||||
return 'running'
|
return 'running'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3) Wenn ein PostWorkKey existiert, aber noch kein running-Status da ist:
|
||||||
|
// NICHT als running anzeigen, sondern als queued.
|
||||||
if (hasPwKey) {
|
if (hasPwKey) {
|
||||||
return 'queued'
|
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'
|
if (anyJ.endedAt || job.endedAt) return 'queued'
|
||||||
|
|
||||||
return 'none'
|
return 'none'
|
||||||
|
|||||||
@ -72,7 +72,10 @@ type Props = {
|
|||||||
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
|
onOpenPlayer: (job: RecordJob, startAtSec?: number) => void
|
||||||
onDeleteJob?: (
|
onDeleteJob?: (
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
) => void | { undoToken?: string } | Promise<void | { undoToken?: string }>
|
) =>
|
||||||
|
| void
|
||||||
|
| { undoToken?: string; from?: string }
|
||||||
|
| Promise<void | { undoToken?: string; from?: string }>
|
||||||
onToggleHot?: (
|
onToggleHot?: (
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise<void | { ok?: boolean; oldFile?: string; newFile?: string }>
|
) => 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 incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {}
|
||||||
const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.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 = {
|
merged.analysis = {
|
||||||
...incomingAnalysis,
|
...incomingAnalysis,
|
||||||
...existingAnalysis,
|
...existingAnalysis,
|
||||||
ai:
|
|
||||||
isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0
|
highlights:
|
||||||
|
Object.keys(existingHighlights).length > 0
|
||||||
? {
|
? {
|
||||||
...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}),
|
...incomingHighlights,
|
||||||
...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}),
|
...existingHighlights,
|
||||||
}
|
}
|
||||||
: isPlainObject(incomingAnalysis.ai)
|
: Object.keys(incomingHighlights).length > 0
|
||||||
? incomingAnalysis.ai
|
? incomingHighlights
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
// Altbestand weiter unterstützen.
|
||||||
|
ai:
|
||||||
|
Object.keys(existingAi).length > 0
|
||||||
|
? {
|
||||||
|
...incomingAi,
|
||||||
|
...existingAi,
|
||||||
|
}
|
||||||
|
: Object.keys(incomingAi).length > 0
|
||||||
|
? incomingAi
|
||||||
: undefined,
|
: undefined,
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
@ -924,6 +955,7 @@ function buildCompletedPostworkSummaryFromMeta(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const aiObject =
|
const aiObject =
|
||||||
|
meta?.analysis?.highlights ??
|
||||||
meta?.analysis?.ai ??
|
meta?.analysis?.ai ??
|
||||||
meta?.ai
|
meta?.ai
|
||||||
|
|
||||||
@ -965,7 +997,7 @@ function buildCompletedPostworkSummaryFromMeta(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const hasAiAnalysis =
|
const hasAiAnalysis =
|
||||||
readCompletedFlag(meta, 'analyze', 'analysis', 'ai') ||
|
readCompletedFlag(meta, 'analyze', 'analysis', 'ai', 'highlights') ||
|
||||||
hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
|
hasDoneHistoryForPhase(meta, 'enrich', 'analyze') ||
|
||||||
hasNonEmptyObject(aiObject) ||
|
hasNonEmptyObject(aiObject) ||
|
||||||
hasAiHits ||
|
hasAiHits ||
|
||||||
@ -2463,7 +2495,15 @@ export default function FinishedDownloads({
|
|||||||
showOnlyCorrupt,
|
showOnlyCorrupt,
|
||||||
])
|
])
|
||||||
|
|
||||||
const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage
|
const finishedDownloadsLoading =
|
||||||
|
allDoneJobsLoading && globalFilterActive && allDoneJobs === null
|
||||||
|
|
||||||
|
const totalItemsForPagination =
|
||||||
|
finishedDownloadsLoading
|
||||||
|
? 0
|
||||||
|
: effectiveAllMode
|
||||||
|
? visibleRows.length
|
||||||
|
: doneTotalPage
|
||||||
|
|
||||||
const pageRows = useMemo(() => {
|
const pageRows = useMemo(() => {
|
||||||
if (effectiveAllMode) {
|
if (effectiveAllMode) {
|
||||||
@ -2479,8 +2519,15 @@ export default function FinishedDownloads({
|
|||||||
return visibleRows
|
return visibleRows
|
||||||
}, [visibleRows, page, effectivePageSize, effectiveAllMode, view])
|
}, [visibleRows, page, effectivePageSize, effectiveAllMode, view])
|
||||||
|
|
||||||
const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0
|
const emptyFolder =
|
||||||
const emptyByFilter = globalFilterActive && visibleRows.length === 0
|
!finishedDownloadsLoading &&
|
||||||
|
!effectiveAllMode &&
|
||||||
|
totalItemsForPagination === 0
|
||||||
|
|
||||||
|
const emptyByFilter =
|
||||||
|
!finishedDownloadsLoading &&
|
||||||
|
globalFilterActive &&
|
||||||
|
visibleRows.length === 0
|
||||||
|
|
||||||
const selectedCount = useMemo(() => {
|
const selectedCount = useMemo(() => {
|
||||||
return selectionStore.getCount()
|
return selectionStore.getCount()
|
||||||
@ -3104,9 +3151,10 @@ export default function FinishedDownloads({
|
|||||||
selectionStore.deselect(key)
|
selectionStore.deselect(key)
|
||||||
|
|
||||||
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : ''
|
||||||
|
const from = typeof result?.from === 'string' ? result.from : undefined
|
||||||
|
|
||||||
if (undoToken) {
|
if (undoToken) {
|
||||||
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key })
|
setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from })
|
||||||
} else {
|
} else {
|
||||||
setLastAction(null)
|
setLastAction(null)
|
||||||
}
|
}
|
||||||
@ -3304,7 +3352,7 @@ export default function FinishedDownloads({
|
|||||||
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
unhideRow(lastAction.originalFile, lastAction.originalFile)
|
||||||
unhideRow(restoredFile, restoredFile)
|
unhideRow(restoredFile, restoredFile)
|
||||||
|
|
||||||
emitCountHint(+1)
|
emitCountHint(includeKeep ? 0 : +1)
|
||||||
setLastAction(null)
|
setLastAction(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -3686,29 +3734,6 @@ export default function FinishedDownloads({
|
|||||||
// effects
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!globalFilterActive) {
|
if (!globalFilterActive) {
|
||||||
setAllDoneJobs(null)
|
setAllDoneJobs(null)
|
||||||
@ -3743,6 +3768,29 @@ export default function FinishedDownloads({
|
|||||||
}
|
}
|
||||||
}, [globalFilterActive, loadAllDoneJobs])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (view !== 'gallery') return
|
if (view !== 'gallery') return
|
||||||
|
|
||||||
@ -4845,7 +4893,8 @@ export default function FinishedDownloads({
|
|||||||
<LoadingSpinner
|
<LoadingSpinner
|
||||||
size="lg"
|
size="lg"
|
||||||
className={[
|
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(' ')}
|
].join(' ')}
|
||||||
srLabel="Lade Downloads…"
|
srLabel="Lade Downloads…"
|
||||||
/>
|
/>
|
||||||
@ -5231,7 +5280,24 @@ export default function FinishedDownloads({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<Card grayBody>
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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
|
<IconBase
|
||||||
{...props}
|
{...props}
|
||||||
className={iconClassName(props.className, 'origin-center scale-[1.05]')}
|
className={iconClassName(props.className, 'origin-center scale-[1.05]')}
|
||||||
viewBox="0 0 256 237"
|
viewBox="0 0 237 256"
|
||||||
>
|
>
|
||||||
<g
|
<g
|
||||||
transform="translate(0,237) scale(0.1,-0.1)"
|
transform="translate(0,256) scale(0.1,-0.1)"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
stroke="none"
|
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>
|
</g>
|
||||||
</IconBase>
|
</IconBase>
|
||||||
)
|
)
|
||||||
@ -1096,11 +1101,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [
|
|||||||
text: 'Unbekannt',
|
text: 'Unbekannt',
|
||||||
icon: UnknownContentIcon,
|
icon: UnknownContentIcon,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
match: ['other', 'misc', 'miscellaneous'],
|
|
||||||
text: 'Andere',
|
|
||||||
icon: OtherContentIcon,
|
|
||||||
},
|
|
||||||
// People
|
// People
|
||||||
{
|
{
|
||||||
match: ['person_female', 'female_person'],
|
match: ['person_female', 'female_person'],
|
||||||
@ -1524,10 +1524,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null {
|
|||||||
return { match: [], text: 'Crop Top', icon: CropTopIcon }
|
return { match: [], text: 'Crop Top', icon: CropTopIcon }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (labelKey.includes('other') || labelKey.includes('misc')) {
|
|
||||||
return { match: [], text: 'Andere', icon: OtherContentIcon }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
// frontend/src/components/ui/LoginPage.tsx
|
// frontend/src/components/ui/LoginPage.tsx
|
||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
|
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type ClipboardEvent } from 'react'
|
||||||
|
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
|
||||||
import Button from './Button'
|
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 = {
|
type Props = {
|
||||||
onLoggedIn?: () => void | Promise<void>
|
onLoggedIn?: () => void | Promise<void>
|
||||||
@ -11,11 +26,18 @@ type LoginResp = {
|
|||||||
totpRequired?: boolean
|
totpRequired?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PasskeyFeedback = {
|
||||||
|
kind: 'info' | 'loading' | 'success'
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
type MeResp = {
|
type MeResp = {
|
||||||
authenticated?: boolean
|
authenticated?: boolean
|
||||||
pending2fa?: boolean
|
pending2fa?: boolean
|
||||||
totpEnabled?: boolean
|
totpEnabled?: boolean
|
||||||
totpConfigured?: boolean
|
totpConfigured?: boolean
|
||||||
|
passkeyConfigured?: boolean
|
||||||
|
passkeyCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type SetupResp = {
|
type SetupResp = {
|
||||||
@ -23,78 +45,14 @@ type SetupResp = {
|
|||||||
otpauth?: string
|
otpauth?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
function unwrapWebAuthnOptions<T = any>(options: any): T {
|
||||||
const res = await fetch(url, { credentials: 'include', ...init })
|
const unwrapped = options?.publicKey ?? options
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '')
|
if (!unwrapped?.challenge) {
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
throw new Error('Passkey Setup fehlgeschlagen: challenge fehlt in den WebAuthn-Options.')
|
||||||
}
|
}
|
||||||
return res.json() as Promise<T>
|
|
||||||
}
|
|
||||||
|
|
||||||
function getNextFromLocation(): string {
|
return unwrapped as T
|
||||||
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('')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LoginPage({ onLoggedIn }: Props) {
|
export default function LoginPage({ onLoggedIn }: Props) {
|
||||||
@ -106,20 +64,185 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
// 6x inputs
|
// 6x inputs
|
||||||
const [codeDigits, setCodeDigits] = useState<string[]>(['', '', '', '', '', ''])
|
const [codeDigits, setCodeDigits] = useState<string[]>(['', '', '', '', '', ''])
|
||||||
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([])
|
const codeInputsRef = useRef<Array<HTMLInputElement | null>>([])
|
||||||
|
const lastSubmittedTotpCodeRef = useRef('')
|
||||||
|
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
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 [setupAuthUrl, setSetupAuthUrl] = useState<string | null>(null)
|
||||||
const [setupSecret, setSetupSecret] = useState<string | null>(null)
|
const [setupSecret, setSetupSecret] = useState<string | null>(null)
|
||||||
const [setupInfo, setSetupInfo] = 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 submittedOnceRef = useRef(false)
|
||||||
|
|
||||||
const codeStr = useMemo(() => digitsToCode(codeDigits), [codeDigits])
|
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) {
|
function focusDigit(i: number) {
|
||||||
const el = codeInputsRef.current[i]
|
const el = codeInputsRef.current[i]
|
||||||
if (el) el.focus()
|
if (el) el.focus()
|
||||||
@ -128,11 +251,18 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
function clearAllDigits() {
|
function clearAllDigits() {
|
||||||
setCodeDigits(['', '', '', '', '', ''])
|
setCodeDigits(['', '', '', '', '', ''])
|
||||||
submittedOnceRef.current = false
|
submittedOnceRef.current = false
|
||||||
|
lastSubmittedTotpCodeRef.current = ''
|
||||||
|
setError(null)
|
||||||
window.setTimeout(() => focusDigit(0), 0)
|
window.setTimeout(() => focusDigit(0), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDigitAt(i: number, val: string) {
|
function setDigitAt(i: number, val: string) {
|
||||||
const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer
|
const v = (val ?? '').replace(/\D/g, '').slice(-1) // genau 1 Ziffer
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
submittedOnceRef.current = false
|
||||||
|
lastSubmittedTotpCodeRef.current = ''
|
||||||
|
|
||||||
setCodeDigits((prev) => {
|
setCodeDigits((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
next[i] = v
|
next[i] = v
|
||||||
@ -150,6 +280,10 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
|
|
||||||
// falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen
|
// falls mehrere Ziffern reinkommen (z.B. AutoFill), verteilen
|
||||||
if (only.length > 1) {
|
if (only.length > 1) {
|
||||||
|
setError(null)
|
||||||
|
submittedOnceRef.current = false
|
||||||
|
lastSubmittedTotpCodeRef.current = ''
|
||||||
|
|
||||||
setCodeDigits((prev) => {
|
setCodeDigits((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
let k = i
|
let k = i
|
||||||
@ -160,7 +294,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
submittedOnceRef.current = false
|
|
||||||
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
|
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -207,6 +341,10 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
if (!only) return
|
if (!only) return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
submittedOnceRef.current = false
|
||||||
|
lastSubmittedTotpCodeRef.current = ''
|
||||||
|
|
||||||
setCodeDigits((prev) => {
|
setCodeDigits((prev) => {
|
||||||
const next = [...prev]
|
const next = [...prev]
|
||||||
let k = i
|
let k = i
|
||||||
@ -218,7 +356,6 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
|
|
||||||
submittedOnceRef.current = false
|
|
||||||
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
|
window.setTimeout(() => focusDigit(Math.min(5, i + only.length)), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,15 +402,18 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
if (me?.authenticated) {
|
if (me?.authenticated) {
|
||||||
// ✅ eingeloggt: wenn 2FA noch NICHT konfiguriert → Setup zeigen
|
|
||||||
if (!me?.totpConfigured) {
|
if (!me?.totpConfigured) {
|
||||||
setStage('setup')
|
setStage('setup')
|
||||||
// Setup-Infos laden (QR/otpauth)
|
|
||||||
void ensure2FASetup()
|
void ensure2FASetup()
|
||||||
} else {
|
return
|
||||||
clearLoginState()
|
|
||||||
window.location.assign(nextPath || '/')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!me?.passkeyConfigured) {
|
||||||
|
setStage('passkeySetup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -322,9 +462,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (onLoggedIn) await onLoggedIn()
|
await continueAfterFullAuth()
|
||||||
clearLoginState()
|
|
||||||
window.location.assign(nextPath || '/')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message ?? String(e))
|
setError(e?.message ?? String(e))
|
||||||
} finally {
|
} finally {
|
||||||
@ -336,8 +474,15 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
const c = codeStr.trim()
|
const c = codeStr.trim()
|
||||||
if (!/^\d{6}$/.test(c)) return
|
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)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
|
await apiJSON<{ ok?: boolean }>('/api/auth/2fa/enable', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -345,12 +490,20 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
body: JSON.stringify({ code: c }),
|
body: JSON.stringify({ code: c }),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (onLoggedIn) await onLoggedIn()
|
lastSubmittedTotpCodeRef.current = ''
|
||||||
clearLoginState()
|
await continueAfterFullAuth()
|
||||||
window.location.assign(nextPath || '/')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
submittedOnceRef.current = false
|
const message = String(e?.message ?? e ?? '')
|
||||||
setError(e?.message ?? String(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 {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@ -377,6 +530,26 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
setError(e?.message ?? String(e))
|
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)
|
// Auto-submit sobald 6 Ziffern befüllt sind (verify + setup)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -384,241 +557,182 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
if (stage !== 'verify' && stage !== 'setup') return
|
if (stage !== 'verify' && stage !== 'setup') return
|
||||||
if (!/^\d{6}$/.test(codeStr)) return
|
if (!/^\d{6}$/.test(codeStr)) return
|
||||||
if (submittedOnceRef.current) return
|
if (submittedOnceRef.current) return
|
||||||
|
if (lastSubmittedTotpCodeRef.current === codeStr) return
|
||||||
|
|
||||||
submittedOnceRef.current = true
|
|
||||||
void submit2FA()
|
void submit2FA()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [codeStr, stage, busy])
|
}, [codeStr, stage, busy])
|
||||||
|
|
||||||
const Code6Inputs = (
|
const Code6Inputs = (
|
||||||
<div className="space-y-1">
|
<TotpCodeInput
|
||||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
|
digits={codeDigits}
|
||||||
|
busy={busy}
|
||||||
<div className="flex items-center justify-between gap-2">
|
inputRefs={codeInputsRef}
|
||||||
{codeDigits.map((d, i) => (
|
onChangeDigit={handleDigitChange}
|
||||||
<input
|
onKeyDownDigit={handleDigitKeyDown}
|
||||||
key={i}
|
onPasteDigit={handleDigitPaste}
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
|
<LoginShell>
|
||||||
<div aria-hidden="true" className="pointer-events-none fixed inset-0 overflow-hidden">
|
{stage === 'login' ? (
|
||||||
<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" />
|
<LoginForm
|
||||||
<div className="absolute -bottom-28 right-[-6rem] h-80 w-[46rem] rounded-full bg-sky-500/10 blur-3xl dark:bg-sky-400/10" />
|
username={username}
|
||||||
</div>
|
password={password}
|
||||||
|
busy={busy}
|
||||||
<div className="relative grid min-h-[100dvh] place-items-center px-4">
|
passkeySupported={passkeySupported}
|
||||||
<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">
|
onUsernameChange={setUsername}
|
||||||
<div className="space-y-1">
|
onPasswordChange={setPassword}
|
||||||
<h1 className="text-lg font-semibold tracking-tight">Recorder Login</h1>
|
onSubmit={() => void submitLogin()}
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">Bitte melde dich an, um fortzufahren.</p>
|
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>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
{Code6Inputs}
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="flex gap-2">
|
||||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Passwort</label>
|
<Button
|
||||||
<input
|
type="button"
|
||||||
type="password"
|
variant="secondary"
|
||||||
value={password}
|
className="flex-1 rounded-lg"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
disabled={busy}
|
||||||
autoComplete="current-password"
|
onClick={() => {
|
||||||
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"
|
setError(null)
|
||||||
placeholder="••••••••••"
|
setStage('login')
|
||||||
disabled={busy}
|
clearAllDigits()
|
||||||
/>
|
}}
|
||||||
</div>
|
>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button type="submit" variant="primary" className="w-full rounded-lg" disabled={busy || !username.trim() || !password}>
|
<Button
|
||||||
{busy ? 'Login…' : 'Login'}
|
type="submit"
|
||||||
</Button>
|
variant="primary"
|
||||||
</form>
|
className="flex-1 rounded-lg"
|
||||||
) : stage === 'verify' ? (
|
disabled={busy || !/^\d{6}$/.test(codeStr)}
|
||||||
<form
|
>
|
||||||
onSubmit={(e) => {
|
{busy ? 'Prüfe…' : 'Bestätigen'}
|
||||||
e.preventDefault()
|
</Button>
|
||||||
if (busy) return
|
</div>
|
||||||
void submit2FA()
|
</form>
|
||||||
}}
|
) : stage === 'setup' ? (
|
||||||
className="space-y-3"
|
<form
|
||||||
>
|
onSubmit={(e) => {
|
||||||
<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">
|
e.preventDefault()
|
||||||
2FA ist aktiv – bitte gib den Code aus deiner Authenticator-App ein.
|
if (busy) return
|
||||||
</div>
|
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">
|
<div className="rounded-lg border border-gray-200 bg-white/70 p-3 dark:border-white/10 dark:bg-white/5">
|
||||||
<Button
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
type="button"
|
QR / Setup
|
||||||
variant="secondary"
|
</div>
|
||||||
className="flex-1 rounded-lg"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={() => {
|
|
||||||
setStage('login')
|
|
||||||
clearAllDigits()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Zurück
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
{setupAuthUrl ? (
|
||||||
type="submit"
|
<div className="mt-2 flex items-center justify-center">
|
||||||
variant="primary"
|
<img
|
||||||
className="flex-1 rounded-lg"
|
alt="2FA QR Code"
|
||||||
disabled={busy || !/^\d{6}$/.test(codeStr)}
|
className="h-44 w-44 rounded bg-white p-2"
|
||||||
>
|
src={`https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(setupAuthUrl)}`}
|
||||||
{busy ? 'Prüfe…' : 'Bestätigen'}
|
/>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
) : (
|
||||||
<form
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
onSubmit={(e) => {
|
QR wird geladen…
|
||||||
e.preventDefault()
|
</div>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{setupSecret ? (
|
||||||
<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="mt-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="text-xs text-gray-600 dark:text-gray-300">
|
||||||
<div className="min-w-0 break-words">{error}</div>
|
Secret (manuell):
|
||||||
<button
|
</div>
|
||||||
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"
|
<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">
|
||||||
onClick={() => setError(null)}
|
{setupSecret}
|
||||||
aria-label="Fehlermeldung schließen"
|
|
||||||
title="Schließen"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
|
||||||
|
<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
|
<Dialog.Panel
|
||||||
className={cn(
|
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)]',
|
'max-h-[calc(100vh-3rem)] sm:max-h-[calc(100vh-4rem)]',
|
||||||
// panel is a flex column so we can create a real scroll area
|
// panel is a flex column so we can create a real scroll area
|
||||||
'flex flex-col min-h-0',
|
'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 */}
|
{/* Header (desktop/tablet). On mobile+split we use our own sticky header inside the scroll area */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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'
|
layout === 'split' ? 'hidden lg:flex' : 'flex'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -293,6 +293,8 @@ function heroStatusPill(show: string) {
|
|||||||
return heroOverlayPill('bg-slate-900/70 ring-white/15')
|
return heroOverlayPill('bg-slate-900/70 ring-white/15')
|
||||||
case 'away':
|
case 'away':
|
||||||
return heroOverlayPill('bg-amber-500/85 ring-amber-300/25')
|
return heroOverlayPill('bg-amber-500/85 ring-amber-300/25')
|
||||||
|
case 'offline':
|
||||||
|
return heroOverlayPill('bg-gray-700/85 ring-white/15')
|
||||||
default:
|
default:
|
||||||
return heroOverlayPill('bg-black/55 ring-white/15')
|
return heroOverlayPill('bg-black/55 ring-white/15')
|
||||||
}
|
}
|
||||||
@ -402,6 +404,10 @@ type BioResp = {
|
|||||||
lastError?: string
|
lastError?: string
|
||||||
model?: string
|
model?: string
|
||||||
bio?: BioContext | null
|
bio?: BioContext | null
|
||||||
|
roomStatus?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
chatRoomUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------ props ------
|
// ------ props ------
|
||||||
@ -414,6 +420,15 @@ type StoredModel = {
|
|||||||
tags?: string | null
|
tags?: string | null
|
||||||
lastSeenOnline?: boolean | null
|
lastSeenOnline?: boolean | null
|
||||||
lastSeenOnlineAt?: string
|
lastSeenOnlineAt?: string
|
||||||
|
|
||||||
|
roomStatus?: string
|
||||||
|
isOnline?: boolean
|
||||||
|
chatRoomUrl?: string
|
||||||
|
imageUrl?: string
|
||||||
|
lastOnlineAt?: string
|
||||||
|
lastOfflineAt?: string
|
||||||
|
lastRoomSyncAt?: string
|
||||||
|
|
||||||
favorite?: boolean
|
favorite?: boolean
|
||||||
watching?: boolean
|
watching?: boolean
|
||||||
liked?: boolean | null
|
liked?: boolean | null
|
||||||
@ -849,6 +864,11 @@ export default function ModelDetails({
|
|||||||
const effectiveRoom = room ?? storedRoomFromSnap
|
const effectiveRoom = room ?? storedRoomFromSnap
|
||||||
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
|
const effectiveRoomMeta = roomMeta ?? storedRoomMeta
|
||||||
|
|
||||||
|
const onlineStatusFetchedAt =
|
||||||
|
model?.lastRoomSyncAt ||
|
||||||
|
model?.lastSeenOnlineAt ||
|
||||||
|
effectiveRoomMeta?.fetchedAt
|
||||||
|
|
||||||
const doneMatches = done
|
const doneMatches = done
|
||||||
|
|
||||||
const runningMatches = useMemo(() => {
|
const runningMatches = useMemo(() => {
|
||||||
@ -875,8 +895,6 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
|
const heroImg = effectiveRoom?.image_url_360x270 || effectiveRoom?.image_url || ''
|
||||||
const heroImgFull = effectiveRoom?.image_url || heroImg
|
const heroImgFull = effectiveRoom?.image_url || heroImg
|
||||||
const roomUrl = effectiveRoom?.chat_room_url_revshare || effectiveRoom?.chat_room_url || ''
|
|
||||||
|
|
||||||
const profileUsername = useMemo(() => {
|
const profileUsername = useMemo(() => {
|
||||||
const raw = String(
|
const raw = String(
|
||||||
effectiveRoom?.username ||
|
effectiveRoom?.username ||
|
||||||
@ -885,16 +903,29 @@ export default function ModelDetails({
|
|||||||
''
|
''
|
||||||
).trim()
|
).trim()
|
||||||
|
|
||||||
return raw.replace(/^@/, '')
|
return raw.replace(/^@/, '').replace(/^\/+|\/+$/g, '')
|
||||||
}, [effectiveRoom?.username, model?.modelKey, key])
|
}, [effectiveRoom?.username, model?.modelKey, key])
|
||||||
|
|
||||||
const profileHref = useMemo(() => {
|
const profileHref = useMemo(() => {
|
||||||
if (roomUrl) return roomUrl
|
|
||||||
if (!profileUsername) return ''
|
if (!profileUsername) return ''
|
||||||
return absCbUrl(`/${profileUsername}/`)
|
return `https://chaturbate.com/${encodeURIComponent(profileUsername)}`
|
||||||
}, [roomUrl, 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 =
|
const showPill =
|
||||||
showLabel === 'public'
|
showLabel === 'public'
|
||||||
@ -905,7 +936,9 @@ export default function ModelDetails({
|
|||||||
? 'Hidden'
|
? 'Hidden'
|
||||||
: showLabel === 'away'
|
: showLabel === 'away'
|
||||||
? 'Away'
|
? 'Away'
|
||||||
: showLabel || ''
|
: showLabel === 'offline'
|
||||||
|
? 'Offline'
|
||||||
|
: showLabel || ''
|
||||||
|
|
||||||
const bioLocation = (bio?.location || '').trim()
|
const bioLocation = (bio?.location || '').trim()
|
||||||
const bioFollowers = bio?.follower_count
|
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">
|
<div className="text-[11px] leading-snug text-gray-600 dark:text-gray-300">
|
||||||
{key ? (
|
{key ? (
|
||||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||||
{effectiveRoomMeta?.fetchedAt ? (
|
{onlineStatusFetchedAt ? (
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
Online-Stand: {fmtDateTime(effectiveRoomMeta.fetchedAt)}
|
Online-Stand: {fmtDateTime(onlineStatusFetchedAt)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{bioMeta?.fetchedAt ? (
|
{bioMeta?.fetchedAt ? (
|
||||||
|
|||||||
@ -114,7 +114,6 @@ const AI_LABEL_FALLBACKS: Record<string, string> = {
|
|||||||
toy_play: 'Toy Play',
|
toy_play: 'Toy Play',
|
||||||
fingering: 'Fingering',
|
fingering: 'Fingering',
|
||||||
'69': '69',
|
'69': '69',
|
||||||
other: 'Andere',
|
|
||||||
|
|
||||||
person: 'Person',
|
person: 'Person',
|
||||||
person_unknown: 'Person',
|
person_unknown: 'Person',
|
||||||
@ -375,7 +374,6 @@ function isKnownFrontendPositionLabel(value: unknown): boolean {
|
|||||||
'toy_play',
|
'toy_play',
|
||||||
'fingering',
|
'fingering',
|
||||||
'69',
|
'69',
|
||||||
'other',
|
|
||||||
].includes(label)
|
].includes(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,11 +447,7 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|||||||
|
|
||||||
function readAiNode(metaRaw: unknown): Record<string, unknown> | null {
|
function readAiNode(metaRaw: unknown): Record<string, unknown> | null {
|
||||||
const meta = parseMetaObject(metaRaw) as any
|
const meta = parseMetaObject(metaRaw) as any
|
||||||
|
const ai = meta?.analysis?.highlights ?? null
|
||||||
const ai =
|
|
||||||
meta?.analysis?.ai ??
|
|
||||||
meta?.ai ??
|
|
||||||
null
|
|
||||||
|
|
||||||
return isPlainObject(ai) ? ai : null
|
return isPlainObject(ai) ? ai : null
|
||||||
}
|
}
|
||||||
@ -731,11 +725,7 @@ function readMetaVideoAspectRatio(metaRaw: unknown): number {
|
|||||||
function readPreviewSpriteMeta(metaRaw: unknown): PreviewSpriteMeta | null {
|
function readPreviewSpriteMeta(metaRaw: unknown): PreviewSpriteMeta | null {
|
||||||
const meta = parseMetaObject(metaRaw) as any
|
const meta = parseMetaObject(metaRaw) as any
|
||||||
|
|
||||||
let sprite =
|
let sprite = meta?.preview?.sprite ?? null
|
||||||
meta?.preview?.sprite ??
|
|
||||||
meta?.previewSprite ??
|
|
||||||
meta?.preview_sprite ??
|
|
||||||
null
|
|
||||||
|
|
||||||
if (typeof sprite === 'string') {
|
if (typeof sprite === 'string') {
|
||||||
try {
|
try {
|
||||||
@ -1037,48 +1027,25 @@ function segmentTimeLabel(obj: Record<string, unknown>): string | undefined {
|
|||||||
|
|
||||||
function readSegmentArray(metaRaw: unknown): unknown[] {
|
function readSegmentArray(metaRaw: unknown): unknown[] {
|
||||||
const meta = parseMetaObject(metaRaw) as any
|
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 arrays: unknown[][] = []
|
||||||
|
|
||||||
|
const preferred = [
|
||||||
const pushSegmentSources = (node: unknown) => {
|
source.segments,
|
||||||
if (!isPlainObject(node)) return
|
source.ratingSegments,
|
||||||
|
source.matches,
|
||||||
const n = node as any
|
source.hits,
|
||||||
|
|
||||||
// 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,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const node of goalNodes) {
|
for (const value of preferred) {
|
||||||
pushSegmentSources(node)
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
arrays.push(value)
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arrays.length === 0) return []
|
if (arrays.length === 0) return []
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// frontend\src\components\ui\RecorderSettings.tsx
|
// frontend\src\components\ui\RecorderSettings.tsx
|
||||||
'use client'
|
'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 Button from './Button'
|
||||||
import Card from './Card'
|
import Card from './Card'
|
||||||
import LabeledSwitch from './LabeledSwitch'
|
import LabeledSwitch from './LabeledSwitch'
|
||||||
@ -10,6 +10,7 @@ import TaskList from './TaskList'
|
|||||||
import type { TaskItem } from './TaskList'
|
import type { TaskItem } from './TaskList'
|
||||||
import PostgresUrlModal from './PostgresUrlModal'
|
import PostgresUrlModal from './PostgresUrlModal'
|
||||||
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
import { CheckIcon, XMarkIcon } from '@heroicons/react/24/solid'
|
||||||
|
import { startRegistration } from '@simplewebauthn/browser'
|
||||||
|
|
||||||
type RecorderSettings = {
|
type RecorderSettings = {
|
||||||
databaseUrl?: string
|
databaseUrl?: string
|
||||||
@ -42,6 +43,29 @@ type RecorderSettings = {
|
|||||||
trainingDetectorEpochs?: number
|
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 = {
|
type AIServerStatus = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
@ -68,6 +92,90 @@ type DiskStatus = {
|
|||||||
recordPath?: string
|
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 = {
|
const DEFAULTS: RecorderSettings = {
|
||||||
databaseUrl: '',
|
databaseUrl: '',
|
||||||
@ -315,6 +423,27 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
const saveSucceeded = saveSuccessUntilMs > now
|
const saveSucceeded = saveSuccessUntilMs > now
|
||||||
const saveFailed = Boolean(err) && !saving && !saveSucceeded
|
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'
|
type SaveUiState = 'idle' | 'saving' | 'success' | 'error'
|
||||||
const saveUiState: SaveUiState = saving ? 'saving' : saveSucceeded ? 'success' : saveFailed ? 'error' : 'idle'
|
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(() => {
|
useEffect(() => {
|
||||||
let alive = true
|
let alive = true
|
||||||
fetch('/api/settings', { cache: 'no-store' })
|
fetch('/api/settings', { cache: 'no-store' })
|
||||||
@ -1056,6 +1196,291 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
...cleanupTasks,
|
...cleanupTasks,
|
||||||
].filter((t: TaskItem) => t.status !== 'idle')
|
].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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
header={
|
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="grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
|
||||||
<div className="sm:col-span-4">
|
<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-sm font-medium text-gray-900 dark:text-gray-200">
|
||||||
|
Teaser abspielen
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-600 dark:text-gray-300">
|
<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.
|
Standbild spart Leistung. „Bei Hover (Standard)“: Desktop spielt bei Hover ab, Mobile im Viewport. „Alle“ kann viel CPU ziehen.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-8">
|
<div className="sm:col-span-4 sm:flex sm:justify-end">
|
||||||
<label className="sr-only" htmlFor="teaserPlayback">Teaser abspielen</label>
|
<label className="sr-only" htmlFor="teaserPlayback">
|
||||||
|
Teaser abspielen
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
id="teaserPlayback"
|
id="teaserPlayback"
|
||||||
value={value.teaserPlayback ?? 'hover'}
|
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
|
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="still">Standbild</option>
|
||||||
<option value="hover">Bei Hover (Standard)</option>
|
<option value="hover">Bei Hover (Standard)</option>
|
||||||
@ -1694,11 +2127,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAppLogOpen((v) => !v)}
|
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="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@ -1713,7 +2146,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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.
|
Zeigt die aktuelle recorder.log aus dem App-Verzeichnis.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1730,7 +2163,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{appLogOpen ? (
|
{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="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">
|
<div className="min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||||||
{appLog?.path ? (
|
{appLog?.path ? (
|
||||||
@ -1775,6 +2208,282 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -606,12 +606,9 @@ export function ToastProvider({
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
className={[
|
className={[
|
||||||
// größer + volle Höhe des Toast-Inhaltsbereichs visuell anliegend
|
'h-full min-h-[72px] w-20 sm:w-24',
|
||||||
'h-full min-h-[72px] w-16 sm:w-[72px]',
|
|
||||||
'object-cover object-center',
|
'object-cover object-center',
|
||||||
// nur links runden, damit es in den Toast passt
|
|
||||||
'rounded-l-xl',
|
'rounded-l-xl',
|
||||||
// leichte Trennlinie rechts
|
|
||||||
'border-r border-black/5 dark:border-white/10',
|
'border-r border-black/5 dark:border-white/10',
|
||||||
'bg-gray-100 dark:bg-white/5',
|
'bg-gray-100 dark:bg-white/5',
|
||||||
].join(' ')}
|
].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
|
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 {
|
function extractAiPayload(data: any): any {
|
||||||
if (!data || typeof data !== 'object') return null
|
if (!data || typeof data !== 'object') return null
|
||||||
|
|
||||||
const candidates = [
|
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?.meta?.analysis?.AI,
|
data?.meta?.analysis?.AI,
|
||||||
data?.analysis?.ai,
|
data?.analysis?.ai,
|
||||||
@ -474,15 +498,33 @@ function extractAiPayload(data: any): any {
|
|||||||
data?.meta?.AI,
|
data?.meta?.AI,
|
||||||
data?.meta?.videoAI,
|
data?.meta?.videoAI,
|
||||||
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?.analysis?.ai,
|
||||||
data?.metaJson?.ai,
|
data?.metaJson?.ai,
|
||||||
|
data?.metadata?.analysis?.highlights,
|
||||||
|
data?.metadata?.analysis?.Highlights,
|
||||||
data?.metadata?.analysis?.ai,
|
data?.metadata?.analysis?.ai,
|
||||||
data?.metadata?.ai,
|
data?.metadata?.ai,
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (candidate && typeof candidate === 'object') {
|
const parsed = parseAiCandidate(candidate)
|
||||||
return 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
|
mode?: string
|
||||||
hits?: Array<unknown>
|
hits?: Array<unknown>
|
||||||
segments?: Array<unknown>
|
segments?: Array<unknown>
|
||||||
|
rating?: unknown
|
||||||
analyzedAtUnix?: number
|
analyzedAtUnix?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecordJobAnalysisMeta = {
|
export type RecordJobAnalysisMeta = {
|
||||||
|
// Neuer Backend-Pfad:
|
||||||
|
// meta.analysis.highlights
|
||||||
|
highlights?: RecordJobAnalysisAI
|
||||||
|
|
||||||
|
// Altbestand weiter unterstützen:
|
||||||
|
// meta.analysis.ai
|
||||||
ai?: RecordJobAnalysisAI
|
ai?: RecordJobAnalysisAI
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user