This commit is contained in:
Linrador 2026-02-09 12:29:19 +01:00
parent f8b6dae669
commit cb4ecfb889
33 changed files with 2811 additions and 874 deletions

View File

@ -1,3 +1,5 @@
// backend\assets_generate.go
package main
import (
@ -75,30 +77,51 @@ func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRati
metaPath := filepath.Join(assetDir, "meta.json")
// ---- Meta / Duration ----
// ---- Meta / Duration + Props (Width/Height/FPS/Resolution) ----
durSec := 0.0
if d, ok := readVideoMetaDuration(metaPath, fi); ok {
durSec = d
vw, vh := 0, 0
fps := 0.0
// 1) Try cache (Meta) first (inkl. w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, fi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Duration berechnen
dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
d, derr := durationSecondsCached(dctx, videoPath)
cancel()
if derr == nil && d > 0 {
durSec = d
// ✅ Duration-only meta schreiben (inkl. sourceURL)
_ = writeVideoMetaDuration(metaPath, fi, durSec, sourceURL)
}
}
// ✅ Wenn Duration aus Meta kam, aber SourceURL jetzt neu vorhanden ist,
// dann Meta "anreichern" (ohne ffprobe).
if durSec > 0 && strings.TrimSpace(sourceURL) != "" {
if u, ok := readVideoMetaSourceURL(metaPath, fi); !ok || strings.TrimSpace(u) == "" {
_ = writeVideoMetaDuration(metaPath, fi, durSec, sourceURL)
// 3) Wenn wir Duration haben, aber Props fehlen -> ffprobe holen und Voll-Meta schreiben
// (damit resolution wirklich in meta.json landet)
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
// optional: durSem für ffprobe begrenzen (du hast es global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
durSem.Release()
} else {
// wenn Acquire fehlschlägt, best-effort ohne props
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, videoPath)
}
}
// 4) Meta schreiben/aktualisieren:
// - schreibt resolution (über formatResolution) nur wenn vw/vh > 0
// - schreibt sourceURL wenn vorhanden
if durSec > 0 {
_ = writeVideoMeta(metaPath, fi, durSec, vw, vh, fps, sourceURL)
}
// Gewichte: thumbs klein, preview groß
const (
thumbsW = 0.25

View File

@ -380,15 +380,29 @@ func authMeHandler(am *AuthManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s, _ := am.getSession(r)
// ✅ Konfiguration atomar lesen
am.confMu.Lock()
totpEnabled := am.conf.TOTPEnabled && strings.TrimSpace(am.conf.TOTPSecret) != ""
secretPresent := strings.TrimSpace(am.conf.TOTPSecret) != ""
totpFlag := am.conf.TOTPEnabled
totpEnabled := totpFlag && secretPresent
am.confMu.Unlock()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(map[string]any{
"authenticated": s != nil && s.Authed,
"pending2fa": s != nil && s.Pending2FA,
"totpEnabled": totpEnabled,
// ✅ „wirklich aktiv“ (Flag + Secret vorhanden)
"totpEnabled": totpEnabled,
// ✅ Zusatzinfos für UI/Debug:
// Secret existiert schon (Setup gemacht), aber evtl. noch nicht enabled
"totpConfigured": secretPresent,
// reiner Flag-Status (kann true sein, obwohl Secret fehlt)
"totpFlag": totpFlag,
})
}
}
@ -485,6 +499,11 @@ type codeReq struct {
// /api/auth/2fa/setup -> secret + otpauth (nur wenn bereits authed)
func auth2FASetupHandler(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)

View File

@ -1,3 +1,5 @@
// backend\cleanup.go
package main
import (
@ -73,10 +75,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
// 2) Orphans entfernen (immer sinnvoll, unabhängig von mb)
cleanupOrphanAssets(doneAbs, &resp)
// ✅ Wenn wir irgendwas gelöscht haben: generated GC nachziehen
if resp.DeletedFiles > 0 || resp.OrphanIDsRemoved > 0 {
triggerGeneratedGarbageCollectorAsync()
}
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
// damit die Zahlen in der JSON-Response landen.
gcStats := triggerGeneratedGarbageCollectorSync()
resp.OrphanIDsScanned += gcStats.Checked
resp.OrphanIDsRemoved += gcStats.Removed
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
writeJSON(w, http.StatusOK, resp)

View File

@ -1,6 +1,6 @@
{
"username": "admin",
"passwordHash": "$2a$10$ujxgEV/riwyxEfQKdG3hruUGljg/ts3bDETFAPhZb07N0TBY5LRNq",
"totpEnabled": false,
"totpSecret": ""
"passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC",
"totpEnabled": true,
"totpSecret": "TIZUJ4A5SB2LOJCJN4T4MGOLBMDQ7NGG"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -14,36 +14,47 @@ import (
var generatedGCRunning int32
// Startet den GC im Hintergrund, aber nur wenn nicht schon einer läuft.
func triggerGeneratedGarbageCollectorAsync() {
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
return
}
type generatedGCStats struct {
Checked int
Removed int
}
// Läuft synchron und liefert Zahlen zurück (für /api/settings/cleanup Response).
func triggerGeneratedGarbageCollectorSync() generatedGCStats {
// gleiches "nur 1 GC gleichzeitig" Verhalten wie async
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
fmt.Println("🧹 [gc] skip: already running")
return generatedGCStats{}
}
defer atomic.StoreInt32(&generatedGCRunning, 0)
stats := runGeneratedGarbageCollector()
return stats
}
// Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Orphans.
func startGeneratedGarbageCollector() {
go func() {
defer atomic.StoreInt32(&generatedGCRunning, 0)
runGeneratedGarbageCollector() // ohne Sleep
time.Sleep(3 * time.Second)
triggerGeneratedGarbageCollectorSync()
}()
}
// Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Ordner, für die es kein Video in /done mehr gibt.
func startGeneratedGarbageCollector() {
time.Sleep(3 * time.Second)
runGeneratedGarbageCollector()
}
// Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup)
func runGeneratedGarbageCollector() {
// Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann.
func runGeneratedGarbageCollector() generatedGCStats {
stats := generatedGCStats{}
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil {
fmt.Println("🧹 [gc] resolve doneDir failed:", err)
return
return stats
}
doneAbs = strings.TrimSpace(doneAbs)
if doneAbs == "" {
return
return stats
}
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren
@ -89,7 +100,7 @@ func runGeneratedGarbageCollector() {
metaRoot = strings.TrimSpace(metaRoot)
}
if err != nil || metaRoot == "" {
return
return stats
}
removedMeta := 0
@ -116,6 +127,8 @@ func runGeneratedGarbageCollector() {
}
fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
stats.Checked += checkedMeta
stats.Removed += removedMeta
// 3) Optional: legacy /generated/<id>
genRoot, err := generatedRoot()
@ -123,7 +136,7 @@ func runGeneratedGarbageCollector() {
genRoot = strings.TrimSpace(genRoot)
}
if err != nil || genRoot == "" {
return
return stats
}
reserved := map[string]struct{}{
@ -165,4 +178,8 @@ func runGeneratedGarbageCollector() {
if checkedLegacy > 0 || removedLegacy > 0 {
fmt.Printf("🧹 [gc] generated legacy checked=%d removed_orphans=%d\n", checkedLegacy, removedLegacy)
}
stats.Checked += checkedLegacy
stats.Removed += removedLegacy
return stats
}

View File

@ -19,6 +19,7 @@ require (
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/sync v0.19.0 // indirect
)
require (

View File

@ -82,6 +82,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -1091,6 +1091,44 @@ func stopAllStoppableJobs() int {
return len(stoppable)
}
func shouldAutoDeleteSmallDownload(filePath string) (bool, int64, int64) {
// returns: (shouldDelete, sizeBytes, thresholdBytes)
s := getSettings()
if !s.AutoDeleteSmallDownloads {
return false, 0, 0
}
mb := s.AutoDeleteSmallDownloadsBelowMB
if mb <= 0 {
return false, 0, 0
}
p := strings.TrimSpace(filePath)
if p == "" {
return false, 0, 0
}
// relativ -> absolut versuchen (best effort)
if !filepath.IsAbs(p) {
if abs, err := resolvePathRelativeToApp(p); err == nil && strings.TrimSpace(abs) != "" {
p = abs
}
}
fi, err := os.Stat(p)
if err != nil || fi.IsDir() {
return false, 0, int64(mb) * 1024 * 1024
}
size := fi.Size()
thr := int64(mb) * 1024 * 1024
if size > 0 && size < thr {
return true, size, thr
}
return false, size, thr
}
func sizeOfPathBestEffort(p string) uint64 {
p = strings.TrimSpace(p)
if p == "" {
@ -1335,8 +1373,6 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
return sec, nil
}
// main.go
type RecorderSettings struct {
RecordDir string `json:"recordDir"`
DoneDir string `json:"doneDir"`
@ -3536,196 +3572,6 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(raw)
}
// --------------------------
// generated/meta/<id>/meta.json
// --------------------------
type videoMeta struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
SourceURL string `json:"sourceUrl,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime)
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return 0, 0, 0, 0, false
}
// 1) Neues Format (oder v1 mit gleichen Feldern)
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true
}
// 2) Fallback: ganz altes v1-Format (nur Duration etc.)
var m1 struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
if err := json.Unmarshal(b, &m1); err != nil {
return 0, 0, 0, 0, false
}
if m1.Version != 1 {
return 0, 0, 0, 0, false
}
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m1.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m1.DurationSeconds, 0, 0, 0, true
}
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
d, _, _, _, ok := readVideoMeta(metaPath, fi)
return d, ok
}
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return "", false
}
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return "", false
}
u := strings.TrimSpace(m.SourceURL)
if u == "" {
return "", false
}
return u, true
}
// altes v1 ohne SourceURL -> keine URL
return "", false
}
// Voll-Write (wenn du dur + props schon hast)
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
m := videoMeta{
Version: 2, // du kannst 2 lassen; nur "v2"-Name ist weg
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
}
buf, err := json.Marshal(m)
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
}
func generatedMetaFile(id string) (string, error) {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
if err != nil {
return "", err
}
return filepath.Join(dir, "meta.json"), nil
}
// ✅ Neu: /generated/meta/<id>/...
func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id)
if err != nil {
return "", err
}
root, err := generatedMetaRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated meta root ist leer")
}
return filepath.Join(root, id), nil
}
func ensureGeneratedDir(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func generatedThumbFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "thumbs.jpg"), nil
}
func generatedPreviewFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "preview.mp4"), nil
}
func ensureGeneratedDirs() error {
root, err := generatedMetaRoot()
if err != nil {
return err
}
if strings.TrimSpace(root) == "" {
return fmt.Errorf("generated meta root ist leer")
}
return os.MkdirAll(root, 0o755)
}
func sanitizeID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("id fehlt")
}
if strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("ungültige id")
}
return id, nil
}
func sanitizeModelKey(k string) string {
k = stripHotPrefix(strings.TrimSpace(k))
if k == "" || k == "—" || strings.ContainsAny(k, `/\`) {

210
backend/meta.go Normal file
View File

@ -0,0 +1,210 @@
// backend/meta.go
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// --------------------------
// generated/meta/<id>/meta.json
// --------------------------
type videoMeta struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
VideoWidth int `json:"videoWidth,omitempty"`
VideoHeight int `json:"videoHeight,omitempty"`
FPS float64 `json:"fps,omitempty"`
Resolution string `json:"resolution,omitempty"` // z.B. "1920x1080"
SourceURL string `json:"sourceUrl,omitempty"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
// liest Meta (v2 ODER altes v1) und validiert gegen fi (Size/ModTime)
func readVideoMeta(metaPath string, fi os.FileInfo) (dur float64, w int, h int, fps float64, ok bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return 0, 0, 0, 0, false
}
// 1) Neues Format (oder v1 mit gleichen Feldern)
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m.DurationSeconds, m.VideoWidth, m.VideoHeight, m.FPS, true
}
// 2) Fallback: ganz altes v1-Format (nur Duration etc.)
var m1 struct {
Version int `json:"version"`
DurationSeconds float64 `json:"durationSeconds"`
FileSize int64 `json:"fileSize"`
FileModUnix int64 `json:"fileModUnix"`
UpdatedAtUnix int64 `json:"updatedAtUnix"`
}
if err := json.Unmarshal(b, &m1); err != nil {
return 0, 0, 0, 0, false
}
if m1.Version != 1 {
return 0, 0, 0, 0, false
}
if m1.FileSize != fi.Size() || m1.FileModUnix != fi.ModTime().Unix() {
return 0, 0, 0, 0, false
}
if m1.DurationSeconds <= 0 {
return 0, 0, 0, 0, false
}
return m1.DurationSeconds, 0, 0, 0, true
}
func readVideoMetaDuration(metaPath string, fi os.FileInfo) (float64, bool) {
d, _, _, _, ok := readVideoMeta(metaPath, fi)
return d, ok
}
func readVideoMetaSourceURL(metaPath string, fi os.FileInfo) (string, bool) {
b, err := os.ReadFile(metaPath)
if err != nil || len(b) == 0 {
return "", false
}
var m videoMeta
if err := json.Unmarshal(b, &m); err == nil && (m.Version == 2 || m.Version == 1) {
if m.FileSize != fi.Size() || m.FileModUnix != fi.ModTime().Unix() {
return "", false
}
u := strings.TrimSpace(m.SourceURL)
if u == "" {
return "", false
}
return u, true
}
// altes v1 ohne SourceURL -> keine URL
return "", false
}
// Voll-Write (wenn du dur + props schon hast)
func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, fps float64, sourceURL string) error {
if strings.TrimSpace(metaPath) == "" || dur <= 0 {
return nil
}
m := videoMeta{
Version: 2,
DurationSeconds: dur,
FileSize: fi.Size(),
FileModUnix: fi.ModTime().Unix(),
VideoWidth: w,
VideoHeight: h,
FPS: fps,
Resolution: formatResolution(w, h),
SourceURL: strings.TrimSpace(sourceURL),
UpdatedAtUnix: time.Now().Unix(),
}
buf, err := json.Marshal(m)
if err != nil {
return err
}
buf = append(buf, '\n')
return atomicWriteFile(metaPath, buf)
}
// Duration-only Write (ohne props)
func writeVideoMetaDuration(metaPath string, fi os.FileInfo, dur float64, sourceURL string) error {
return writeVideoMeta(metaPath, fi, dur, 0, 0, 0, sourceURL)
}
func generatedMetaFile(id string) (string, error) {
dir, err := generatedDirForID(id) // erzeugt KEIN Verzeichnis
if err != nil {
return "", err
}
return filepath.Join(dir, "meta.json"), nil
}
// ✅ Neu: /generated/meta/<id>/...
func generatedDirForID(id string) (string, error) {
id, err := sanitizeID(id)
if err != nil {
return "", err
}
root, err := generatedMetaRoot()
if err != nil {
return "", err
}
if strings.TrimSpace(root) == "" {
return "", fmt.Errorf("generated meta root ist leer")
}
return filepath.Join(root, id), nil
}
func ensureGeneratedDir(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
return dir, nil
}
func formatResolution(w, h int) string {
if w <= 0 || h <= 0 {
return ""
}
return fmt.Sprintf("%dx%d", w, h)
}
func generatedThumbFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "thumbs.jpg"), nil
}
func generatedPreviewFile(id string) (string, error) {
dir, err := generatedDirForID(id)
if err != nil {
return "", err
}
return filepath.Join(dir, "preview.mp4"), nil
}
func ensureGeneratedDirs() error {
root, err := generatedMetaRoot()
if err != nil {
return err
}
if strings.TrimSpace(root) == "" {
return fmt.Errorf("generated meta root ist leer")
}
return os.MkdirAll(root, 0o755)
}
func sanitizeID(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("id fehlt")
}
if strings.ContainsAny(id, `/\`) {
return "", fmt.Errorf("ungültige id")
}
return id, nil
}

Binary file not shown.

View File

@ -199,4 +199,4 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
}
// global (oder in deinem app struct halten)
var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 2
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 2

View File

@ -1,3 +1,5 @@
// backend\postwork_refresh.go
package main
import (

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
// backend\record_helpers_paths.go
package main
import (

View File

@ -0,0 +1,39 @@
// backend\record_job_progress.go
package main
func setJobProgress(job *RecordJob, phase string, pct int) {
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
jobsMu.Lock()
defer jobsMu.Unlock()
// ✅ Sobald Postwork/Phase läuft oder Aufnahme beendet ist:
// keine "alten" Recorder-Updates mehr akzeptieren
if job.EndedAt != nil || (job.Phase != "" && job.Phase != "recording") {
// optional: trotzdem Phase aktualisieren, aber Progress nicht senken
if phase != "" {
job.Phase = phase
}
if pct < job.Progress {
pct = job.Progress
}
job.Progress = pct
return
}
// Recording-Phase:
if phase != "" {
job.Phase = phase
}
// ✅ niemals rückwärts
if pct < job.Progress {
pct = job.Progress
}
job.Progress = pct
}

View File

@ -1,3 +1,5 @@
// backend\record_start.go
package main
import (
@ -22,7 +24,9 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
// Duplicate-running guard (identische URL)
jobsMu.Lock()
for _, j := range jobs {
if j != nil && j.Status == JobRunning && strings.TrimSpace(j.SourceURL) == url {
// ✅ Nur blocken, solange wirklich noch aufgenommen wird.
// Sobald EndedAt gesetzt ist (Postwork/Queue läuft), darf ein neuer Download starten.
if j != nil && j.Status == JobRunning && j.EndedAt == nil && strings.TrimSpace(j.SourceURL) == url {
// ✅ Wenn ein versteckter Auto-Check-Job läuft und der User manuell startet -> sofort sichtbar machen
if j.Hidden && !req.Hidden {
j.Hidden = false
@ -102,6 +106,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
now = time.Now()
}
// ✅ Phase für Recording explizit setzen (damit spätere Progress-Writer das erkennen können)
jobsMu.Lock()
job.Phase = "recording"
if job.Progress < 1 {
job.Progress = 1
}
jobsMu.Unlock()
notifyJobsChanged()
// ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ----
switch provider {
case "chaturbate":
@ -189,6 +202,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
if errText != "" {
job.Error = errText
}
// ✅ WICHTIG: sofort Phase wechseln, damit Recorder-Progress danach nichts mehr “zurücksetzt”
job.Phase = "postwork"
// ✅ Progress darf ab jetzt nicht mehr runtergehen (mind. Einstieg in Postwork)
if job.Progress < 70 {
job.Progress = 70
}
out := strings.TrimSpace(job.Output)
jobsMu.Unlock()
notifyJobsChanged()
@ -207,6 +229,49 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
return
}
// ✅ NEU: Bevor Postwork queued wird -> kleine Downloads direkt löschen
// (spart Remux/Move/ffprobe/assets komplett)
{
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
if s.AutoDeleteSmallDownloads && minMB > 0 {
threshold := int64(minMB) * 1024 * 1024
// out ist i.d.R. absolut; Stat ist cheap
if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() {
// Size auch ins Job-JSON schreiben (nice fürs UI, selbst wenn wir danach löschen)
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
notifyJobsChanged()
if fi.Size() > 0 && fi.Size() < threshold {
base := filepath.Base(out)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) {
removeGeneratedForID(id)
purgeDurationCacheForPath(out)
// Job komplett entfernen (wie dein späterer Auto-Delete-Block)
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
fmt.Println("🧹 auto-deleted (pre-queue):", base, "| size:", formatBytesSI(fi.Size()))
return
} else {
fmt.Println("⚠️ auto-delete (pre-queue) failed:", derr)
// wenn delete fehlschlägt -> normal weiter in Postwork
}
}
}
}
}
// ✅ Postwork: remux/move/ffprobe/assets begrenzen -> in Queue
postOut := out
postTarget := target
@ -301,51 +366,11 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
job.Output = out
jobsMu.Unlock()
notifyJobsChanged()
// ✅ erst JETZT ist done wirklich betroffen
notifyDoneChanged()
}
// 3) Optional: kleine Downloads automatisch löschen
setPhase("postwork", 82)
if fi, serr := os.Stat(out); serr == nil && fi != nil && !fi.IsDir() {
jobsMu.Lock()
job.SizeBytes = fi.Size()
jobsMu.Unlock()
notifyJobsChanged()
s := getSettings()
minMB := s.AutoDeleteSmallDownloadsBelowMB
if s.AutoDeleteSmallDownloads && minMB > 0 {
threshold := int64(minMB) * 1024 * 1024
if fi.Size() > 0 && fi.Size() < threshold {
base := filepath.Base(out)
id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base)))
if derr := removeWithRetry(out); derr == nil || os.IsNotExist(derr) {
removeGeneratedForID(id)
if doneAbs, rerr := resolvePathRelativeToApp(getSettings().DoneDir); rerr == nil && strings.TrimSpace(doneAbs) != "" {
_ = os.RemoveAll(filepath.Join(doneAbs, "preview", id))
_ = os.RemoveAll(filepath.Join(doneAbs, "thumbs", id))
}
purgeDurationCacheForPath(out)
jobsMu.Lock()
delete(jobs, job.ID)
jobsMu.Unlock()
notifyJobsChanged()
notifyDoneChanged()
fmt.Println("🧹 auto-deleted:", base, "size:", formatBytesSI(fi.Size()))
return nil
} else {
fmt.Println("⚠️ auto-delete failed:", derr)
}
}
}
}
// 4) Dauer (ffprobe)
setPhase("ffprobe", 84)
// 3) Dauer (ffprobe)
setPhase("probe", 84)
{
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {

View File

@ -1,3 +1,5 @@
// backend\routes.go
package main
import (

View File

@ -1,3 +1,5 @@
// backend\server.go
package main
import (

View File

@ -253,35 +253,52 @@ func runGenerateMissingAssets(ctx context.Context) {
sourceURL = u
}
// ✅ Dauer zuerst aus meta.json, sonst 1× ffprobe & meta.json schreiben
// ✅ Meta: Duration + Props (w/h/fps) => damit Resolution in meta.json landet
durSec := 0.0
vw, vh := 0, 0
fps := 0.0
// Wir wollen nicht nur "Duration ok", sondern auch Props ok.
// Sonst wird später fälschlich "skipped" und Resolution bleibt für immer leer.
metaOK := false
if d, ok := readVideoMetaDuration(metaPath, vfi); ok {
durSec = d
metaOK = true
// meta ist valide (Duration ok), aber falls wir (irgendwoher) eine SourceURL hätten
// und sie in meta noch fehlt -> meta anreichern ohne ffprobe.
if strings.TrimSpace(sourceURL) != "" {
if u, ok := readVideoMetaSourceURL(metaPath, vfi); !ok || strings.TrimSpace(u) == "" {
_ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL)
}
}
// 1) Versuch: komplette Meta lesen (Duration + w/h/fps)
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
durSec, vw, vh, fps = d, mw, mh, mfps
} else {
// 2) Fallback: Duration berechnen
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
d, derr := durationSecondsCached(dctx, it.path)
cancel()
if derr == nil && d > 0 {
durSec = d
// ✅ HIER: nicht writeVideoMeta(metaPath, fi, dur, sourceURL) !!
// sondern Duration-only writer nutzen
_ = writeVideoMetaDuration(metaPath, vfi, durSec, sourceURL)
metaOK = true
}
}
// 3) Wenn wir Duration haben, aber Props fehlen: einmal ffprobe für Props
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
pctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
// optional: Semaphore verwenden (du hast durSem global)
if durSem != nil {
if err := durSem.Acquire(pctx); err == nil {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
durSem.Release()
}
} else {
vw, vh, fps, _ = probeVideoProps(pctx, it.path)
}
}
// 4) Jetzt voll schreiben (inkl. Resolution via formatResolution)
if durSec > 0 {
_ = writeVideoMeta(metaPath, vfi, durSec, vw, vh, fps, sourceURL)
}
// Meta gilt nur als "OK", wenn Duration + Auflösung vorhanden ist
metaOK = durSec > 0 && vw > 0 && vh > 0
if thumbOK && previewOK && metaOK {
assetsTaskMu.Lock()
assetsTaskState.Skipped++

455
backend/transcode.go Normal file
View File

@ -0,0 +1,455 @@
// backend\transcode.go
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
// -------------------------
// Transcode config / globals
// -------------------------
// max parallel ffmpeg jobs
var transcodeSem = make(chan struct{}, 2)
// de-dupe concurrent requests for same output
var transcodeSF singleflight.Group
type heightCacheEntry struct {
mtime time.Time
size int64
height int
}
var heightCacheMu sync.Mutex
var heightCache = map[string]heightCacheEntry{}
func probeVideoHeight(ctx context.Context, inPath string) (int, error) {
// ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 <file>
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=height",
"-of", "csv=p=0",
inPath,
)
out, err := cmd.Output()
if err != nil {
return 0, err
}
s := strings.TrimSpace(string(out))
if s == "" {
return 0, fmt.Errorf("ffprobe returned empty height")
}
h, err := strconv.Atoi(s)
if err != nil || h <= 0 {
return 0, fmt.Errorf("bad height %q", s)
}
return h, nil
}
func getVideoHeightCached(ctx context.Context, inPath string) (int, error) {
fi, err := os.Stat(inPath)
if err != nil || fi.IsDir() || fi.Size() <= 0 {
return 0, fmt.Errorf("input not usable")
}
heightCacheMu.Lock()
if e, ok := heightCache[inPath]; ok {
if e.size == fi.Size() && e.mtime.Equal(fi.ModTime()) && e.height > 0 {
h := e.height
heightCacheMu.Unlock()
return h, nil
}
}
heightCacheMu.Unlock()
h, err := probeVideoHeight(ctx, inPath)
if err != nil {
return 0, err
}
heightCacheMu.Lock()
heightCache[inPath] = heightCacheEntry{mtime: fi.ModTime(), size: fi.Size(), height: h}
heightCacheMu.Unlock()
return h, nil
}
type TranscodeProfile struct {
Name string // "1080p" | "720p" | "480p"
Height int
}
func profileFromQuality(q string) (TranscodeProfile, bool) {
switch strings.ToLower(strings.TrimSpace(q)) {
case "", "auto":
return TranscodeProfile{Name: "auto", Height: 0}, true
case "2160p":
return TranscodeProfile{Name: "2160p", Height: 2160}, true
case "1080p":
return TranscodeProfile{Name: "1080p", Height: 1080}, true
case "720p":
return TranscodeProfile{Name: "720p", Height: 720}, true
case "480p":
return TranscodeProfile{Name: "480p", Height: 480}, true
default:
return TranscodeProfile{}, false
}
}
// Cache layout: <doneAbs>/.transcodes/<canonicalID>/<quality>.mp4
func transcodeCachePath(doneAbs, canonicalID, quality string) string {
const v = "v1"
return filepath.Join(doneAbs, ".transcodes", canonicalID, v, quality+".mp4")
}
func ensureFFmpegAvailable() error {
_, err := exec.LookPath("ffmpeg")
if err != nil {
return fmt.Errorf("ffmpeg not found in PATH")
}
return nil
}
func ensureFFprobeAvailable() error {
_, err := exec.LookPath("ffprobe")
if err != nil {
return fmt.Errorf("ffprobe not found in PATH")
}
return nil
}
func fileUsable(p string) (os.FileInfo, bool) {
fi, err := os.Stat(p)
if err != nil {
return nil, false
}
if fi.IsDir() || fi.Size() <= 0 {
return nil, false
}
return fi, true
}
func isCacheFresh(inPath, outPath string) bool {
inFi, err := os.Stat(inPath)
if err != nil || inFi.IsDir() || inFi.Size() <= 0 {
return false
}
outFi, ok := fileUsable(outPath)
if !ok {
return false
}
// if out is not older than input -> ok
return !outFi.ModTime().Before(inFi.ModTime())
}
func acquireTranscodeSlot(ctx context.Context) error {
select {
case transcodeSem <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func releaseTranscodeSlot() {
select {
case <-transcodeSem:
default:
}
}
func tailString(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[len(s)-max:]
}
func runFFmpeg(ctx context.Context, args []string) error {
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err == nil {
return nil
}
// Wenn ctx abgebrochen wurde (Timeout oder Cancel), gib Output trotzdem mit aus.
if ctx.Err() != nil {
return fmt.Errorf("ffmpeg aborted: %v (output=%s)", ctx.Err(), tailString(buf.String(), 4000))
}
return fmt.Errorf("ffmpeg failed: %w (output=%s)", err, tailString(buf.String(), 4000))
}
// -------------------------
// Public entry used by recordVideo
// -------------------------
// maybeTranscodeForRequest inspects "quality" query param.
// If quality is "auto" (or empty), it returns original outPath unchanged.
// Otherwise it ensures cached transcode exists & is fresh, and returns the cached path.
func maybeTranscodeForRequest(rctx context.Context, originalPath string, quality string) (string, error) {
prof, ok := profileFromQuality(quality)
if !ok {
return "", fmt.Errorf("bad quality %q", quality)
}
if prof.Name == "auto" {
return originalPath, nil
}
// ensure ffmpeg is present
if err := ensureFFmpegAvailable(); err != nil {
return "", err
}
// optional: skip transcode if source is already <= requested height (prevents upscaling)
if prof.Height > 0 {
// ffprobe is needed only for this optimization
if err := ensureFFprobeAvailable(); err == nil {
// short timeout for probing
pctx, cancel := context.WithTimeout(rctx, 5*time.Second)
defer cancel()
if srcH, err := getVideoHeightCached(pctx, originalPath); err == nil && srcH > 0 {
// if source is already at/below requested (with tiny tolerance), don't transcode
if srcH <= prof.Height+8 {
return originalPath, nil
}
}
}
}
// Need doneAbs for cache root
s := getSettings()
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
if err != nil || strings.TrimSpace(doneAbs) == "" {
return "", fmt.Errorf("doneDir missing or invalid")
}
// canonicalID = basename stem without ext and without "HOT "
base := filepath.Base(originalPath)
stem := strings.TrimSuffix(base, filepath.Ext(base))
canonicalID := stripHotPrefix(stem)
canonicalID = strings.TrimSpace(canonicalID)
if canonicalID == "" {
return "", fmt.Errorf("canonical id empty")
}
cacheOut := transcodeCachePath(doneAbs, canonicalID, prof.Name)
// fast path: already exists & fresh
if isCacheFresh(originalPath, cacheOut) {
return cacheOut, nil
}
// singleflight key: input + cacheOut
key := originalPath + "|" + cacheOut
_, err, _ = transcodeSF.Do(key, func() (any, error) {
// check again inside singleflight
if isCacheFresh(originalPath, cacheOut) {
return nil, nil
}
// If stale exists, remove (best-effort)
_ = os.Remove(cacheOut)
// ensure dir
if err := os.MkdirAll(filepath.Dir(cacheOut), 0o755); err != nil {
return nil, err
}
// timeout for transcode
// ✅ NICHT an rctx hängen, sonst killt Client-Abbruch ffmpeg beim Quality-Wechsel
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
defer cancel()
if err := acquireTranscodeSlot(ctx); err != nil {
return nil, err
}
defer releaseTranscodeSlot()
// ✅ Temp muss eine "echte" Video-Endung haben, sonst kann ffmpeg das Format nicht wählen
tmp := cacheOut + ".part.mp4"
_ = os.Remove(tmp)
// ffmpeg args
args := buildFFmpegArgs(originalPath, tmp, prof)
if err := runFFmpeg(ctx, args); err != nil {
_ = os.Remove(tmp)
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return nil, fmt.Errorf("transcode timeout: %w", err)
}
return nil, err
}
// validate tmp
if _, ok := fileUsable(tmp); !ok {
_ = os.Remove(tmp)
return nil, fmt.Errorf("transcode output invalid")
}
// atomic replace
_ = os.Remove(cacheOut)
if err := os.Rename(tmp, cacheOut); err != nil {
_ = os.Remove(tmp)
return nil, err
}
return nil, nil
})
if err != nil {
return "", err
}
// final validate
if _, ok := fileUsable(cacheOut); !ok {
return "", fmt.Errorf("transcode cache missing after build")
}
return cacheOut, nil
}
// -------------------------
// ffmpeg profiles
// -------------------------
func buildFFmpegArgs(inPath, outPath string, prof TranscodeProfile) []string {
// You can tune these defaults:
// - CRF: lower => better quality, bigger file (1080p ~22, 720p ~23, 480p ~24/25)
// - preset: veryfast is good for on-demand
crf := "23"
switch prof.Name {
case "1080p":
crf = "22"
case "720p":
crf = "23"
case "480p":
crf = "25"
}
// Keyframes: choose a stable value; if you want dynamic based on fps you can extend later.
gop := "60"
// ✅ Für fertige MP4-Dateien: NICHT fragmentieren.
// faststart reicht, damit "moov" vorne liegt.
movflags := "+faststart"
// scale keeps aspect ratio, ensures even width
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
return []string{
"-hide_banner",
"-loglevel", "error",
"-nostdin",
"-y",
"-i", inPath,
// ✅ robust: falls Audio fehlt, trotzdem kein Fehler
"-map", "0:v:0?",
"-map", "0:a:0?",
"-sn",
"-vf", vf,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", crf,
"-pix_fmt", "yuv420p",
"-max_muxing_queue_size", "1024",
"-g", gop,
"-keyint_min", gop,
"-sc_threshold", "0",
// Audio nur wenn vorhanden (wegen "-map 0:a:0?")
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-movflags", movflags,
outPath,
}
}
func buildFFmpegStreamArgs(inPath string, prof TranscodeProfile) []string {
crf := "23"
switch prof.Name {
case "1080p":
crf = "22"
case "720p":
crf = "23"
case "480p":
crf = "25"
}
gop := "60"
vf := fmt.Sprintf("scale=-2:%d", prof.Height)
// ✅ Fragmented MP4: spielbar bevor der File “fertig” ist
// empty_moov + moof fragments => Browser kann früh starten
movflags := "frag_keyframe+empty_moov+default_base_moof"
return []string{
"-hide_banner",
"-loglevel", "error",
"-y",
"-i", inPath,
"-vf", vf,
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", crf,
"-pix_fmt", "yuv420p",
"-max_muxing_queue_size", "1024",
"-g", gop,
"-keyint_min", gop,
"-sc_threshold", "0",
"-c:a", "aac",
"-b:a", "128k",
"-ac", "2",
"-movflags", movflags,
// ✅ wichtig: Format explizit + Ausgabe in stdout
"-f", "mp4",
"pipe:1",
}
}
// -------------------------
// Cleanup helper
// -------------------------
func removeTranscodesForID(doneAbs, canonicalID string) {
_ = os.RemoveAll(filepath.Join(doneAbs, ".transcodes", canonicalID))
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>App</title>
<script type="module" crossorigin src="/assets/index-B0V3ux9Z.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-s1ZflJsu.css">
<script type="module" crossorigin src="/assets/index-DV6ZfOPf.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BRCxVTHL.css">
</head>
<body>
<div id="root"></div>

View File

@ -560,7 +560,11 @@ export default function App() {
// Duplicate-running guard (normalisiert vergleichen)
const alreadyRunning = jobsRef.current.some((j) => {
if (j.status !== 'running') return false
if (String(j.status || '').toLowerCase() !== 'running') return false
// ✅ Wenn endedAt existiert: Aufnahme ist fertig -> Postwork/Queue -> NICHT blocken
if ((j as any).endedAt) return false
const jNorm = normalizeHttpUrl(String((j as any).sourceUrl || ''))
return jNorm === norm
})
@ -1055,6 +1059,35 @@ export default function App() {
[donePage, doneSort]
)
useEffect(() => {
let es: EventSource | null = null
try {
es = new EventSource('/api/record/done/stream')
} catch {
return
}
const onDone = () => {
// wenn finished tab offen: liste aktualisieren
if (selectedTabRef.current === 'finished') {
void refreshDoneNow()
} else {
// sonst nur count aktualisieren (leicht)
// optional: void loadDoneCount() wenn du es aus dem Scope verfügbar machst
}
}
es.addEventListener('doneChanged', onDone as any)
es.onerror = () => {
// fallback: dein bestehendes polling bleibt als sicherheit
}
return () => {
es?.removeEventListener('doneChanged', onDone as any)
es?.close()
}
}, [refreshDoneNow])
function isChaturbate(raw: string): boolean {
const norm = normalizeHttpUrl(raw)
if (!norm) return false

View File

@ -109,22 +109,32 @@ const addedAtMsOf = (r: DownloadRow): number => {
}
const phaseLabel = (p?: string) => {
switch (p) {
switch ((p ?? '').toLowerCase()) {
case 'stopping':
return 'Stop wird angefordert…'
case 'probe':
return 'Analysiere Datei (Dauer/Streams)…'
case 'remuxing':
return 'Remux zu MP4…'
return 'Konvertiere Container zu MP4…' // oder: 'Remux zu MP4…'
case 'moving':
return 'Verschiebe nach Done…'
case 'assets':
return 'Erstelle Vorschau…'
return 'Erstelle Vorschau/Thumbnails…'
case 'postwork':
return 'Nacharbeiten werden vorbereitet…'
return 'Nacharbeiten laufen…'
default:
// optional: lieber etwas anzeigen als leer
// return p ? `Arbeite… (${p})` : ''
return ''
}
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
if (!res.ok) {
@ -169,21 +179,31 @@ function StatusCell({ job }: { job: RecordJob }) {
const phaseRaw = String((job as any)?.phase ?? '').trim()
const progress = Number((job as any)?.progress ?? 0)
const phase = phaseRaw.toLowerCase()
const isRecording = phase === 'recording'
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
// ✅ postwork genauer machen (wartend/running + Position)
if (phaseRaw === 'postwork') {
if (phase === 'postwork') {
phaseText = postWorkLabel(job)
}
if (isRecording) {
phaseText = 'Recording läuft…'
}
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
// ✅ ProgressBar unabhängig vom Text
// ✅ determinate nur wenn sinnvoll (0..100)
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
// ✅ Balken NICHT während recording anzeigen
const showBar =
!isRecording &&
Number.isFinite(progress) &&
progress > 0 &&
progress < 100
// ✅ wenn wir in einer Phase sind, aber noch kein Progress da ist -> indeterminate
const showIndeterminate =
!isRecording &&
!showBar &&
Boolean(phaseRaw) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
@ -334,13 +354,20 @@ function DownloadsCardRow({
const phase = String((j as any).phase ?? '').trim()
const phaseLower = phase.toLowerCase()
const isRecording = phaseLower === 'recording'
const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
const rawStatus = String(j.status ?? '').toLowerCase()
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
if (phase === 'postwork') {
if (phaseLower === 'recording') {
phaseText = 'Recording läuft…'
} else if (phaseLower === 'postwork') {
phaseText = postWorkLabel(j)
}
@ -348,8 +375,14 @@ function DownloadsCardRow({
const progressLabel = phaseText || statusText
const progress = Number((j as any).progress ?? 0)
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
const showBar =
!isRecording &&
Number.isFinite(progress) &&
progress > 0 &&
progress < 100
const showIndeterminate =
!isRecording &&
!showBar &&
Boolean(phase) &&
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
@ -577,6 +610,25 @@ const formatBytes = (bytes: number | null): string => {
return `${s} ${units[i]}`
}
const isPostworkJob = (job: RecordJob): boolean => {
const anyJ = job as any
const phase = String(anyJ.phase ?? '').trim()
const pw = anyJ.postWork
const pwKey = String(anyJ.postWorkKey ?? '').trim()
// ✅ Postwork/Queue erkennen:
// - postWorkKey gesetzt
// - postWork.state queued/running
// - Aufnahme ist fertig (endedAt) und wir sind in einer Phase (remuxing/moving/assets/...)
if (pwKey) return true
if (pw && (pw.state === 'queued' || pw.state === 'running')) return true
if (job.endedAt && phase) return true
// explizit
if (phase === 'postwork') return true
return false
}
export default function Downloads({
jobs,
@ -693,8 +745,10 @@ export default function Downloads({
for (const id of keys) {
const j = jobs.find((x) => x.id === id)
if (!j) continue
const phase = String((j as any).phase ?? '').trim()
const isStopping = Boolean(phase) || j.status !== 'running'
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running'
if (!isStopping) next[id] = true
}
return next
@ -718,9 +772,14 @@ export default function Downloads({
const stoppableIds = useMemo(() => {
return jobs
.filter((j) => {
if (isPostworkJob(j)) return false
if ((j as any).endedAt) return false
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
const phaseLower = phase.trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
return !isStopping
})
.map((j) => j.id)
@ -956,7 +1015,10 @@ export default function Downloads({
const j = r.job
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
const phaseLower = phase.trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const key = modelNameFromOutput(j.output || '')
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
@ -985,7 +1047,9 @@ export default function Downloads({
{(() => {
const phase = String((j as any).phase ?? '').trim()
const isStopRequested = Boolean(stopRequestedIds[j.id])
const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested
const phaseLower = phase.trim().toLowerCase()
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
return (
<Button
@ -1011,19 +1075,31 @@ export default function Downloads({
]
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds])
const hasAnyPending = pending.length > 0
const hasJobs = jobs.length > 0
const downloadJobRows = useMemo<DownloadRow[]>(() => {
const list = jobs
.filter((j) => !isPostworkJob(j))
.map((job) => ({ kind: 'job', job }) as const)
const rows = useMemo<DownloadRow[]>(() => {
const list: DownloadRow[] = [
...jobs.map((job) => ({ kind: 'job', job }) as const),
...pending.map((p) => ({ kind: 'pending', pending: p }) as const),
]
// ✅ Neueste zuerst (Hinzugefügt am DESC)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs, pending])
}, [jobs])
const postworkRows = useMemo<DownloadRow[]>(() => {
const list = jobs
.filter((j) => isPostworkJob(j))
.map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs])
const pendingRows = useMemo<DownloadRow[]>(() => {
const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [pending])
const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length
const stopAll = useCallback(async () => {
if (stopAllBusy) return
@ -1059,7 +1135,7 @@ export default function Downloads({
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{rows.length}
{totalCount}
</span>
</div>
@ -1105,62 +1181,163 @@ export default function Downloads({
</div>
{/* ✅ Content abhängig von Jobs/Pending */}
{(hasAnyPending || hasJobs) ? (
<>
{/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden">
{rows.map((r) => (
<DownloadsCardRow
key={r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`}
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
))}
</div>
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
<>
{/* Mobile: Cards */}
<div className="mt-3 grid gap-4 sm:hidden">
{downloadJobRows.length > 0 ? (
<>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
Downloads ({downloadJobRows.length})
</div>
{downloadJobRows.map((r) => (
<DownloadsCardRow
key={`dl:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
))}
</>
) : null}
{/* Tablet/Desktop: Tabelle */}
<div className="mt-3 hidden sm:block overflow-x-auto">
<Table
rows={rows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
</>
) : (
<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">
<span className="text-lg"></span>
{postworkRows.length > 0 ? (
<>
<div className="mt-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
Nacharbeiten ({postworkRows.length})
</div>
{postworkRows.map((r) => (
<DownloadsCardRow
key={`pw:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
))}
</>
) : null}
{pendingRows.length > 0 ? (
<>
<div className="mt-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
Wartend ({pendingRows.length})
</div>
{pendingRows.map((r) => (
<DownloadsCardRow
key={`wa:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
stopRequestedIds={stopRequestedIds}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
))}
</>
) : null}
</div>
{/* Desktop: Tabellen */}
<div className="mt-3 hidden sm:block space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Downloads ({downloadJobRows.length})
</div>
<Table
rows={downloadJobRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine laufenden Downloads
) : null}
{postworkRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Starte oben eine URL hier siehst du Live-Status und kannst Jobs stoppen.
<Table
rows={postworkRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `pw:job:${r.job.id}` : `pw:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
onRowClick={(r) => {
if (r.kind === 'job') onOpenPlayer(r.job)
}}
/>
</div>
) : null}
{pendingRows.length > 0 ? (
<div className="overflow-x-auto">
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Wartend ({pendingRows.length})
</div>
<Table
rows={pendingRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `wa:job:${r.job.id}` : `wa:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
/>
</div>
) : null}
</div>
</>
) : (
<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">
<span className="text-lg"></span>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine laufenden Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Starte oben eine URL hier siehst du Live-Status und kannst Jobs stoppen.
</div>
</div>
</Card>
)}
</div>
</Card>
)}
</div>
)
}

View File

@ -11,8 +11,20 @@ type LoginResp = {
totpRequired?: boolean
}
type MeResp = {
authenticated?: boolean
pending2fa?: boolean
totpEnabled?: boolean
totpConfigured?: boolean
}
type SetupResp = {
secret?: string
otpauth?: string
}
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init)
const res = await fetch(url, { credentials: 'include', ...init })
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(text || `HTTP ${res.status}`)
@ -39,26 +51,44 @@ export default function LoginPage({ onLoggedIn }: Props) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [need2FA, setNeed2FA] = useState(false)
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [stage, setStage] = useState<'login' | 'verify' | 'setup'>('login')
const [setupAuthUrl, setSetupAuthUrl] = useState<string | null>(null)
const [setupSecret, setSetupSecret] = useState<string | null>(null)
const [setupInfo, setSetupInfo] = useState<string | null>(null)
// Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter
useEffect(() => {
useEffect(() => {
let cancelled = false
const check = async () => {
try {
const me = await apiJSON<{ authenticated?: boolean; pending2fa?: boolean }>('/api/auth/me', {
cache: 'no-store' as any,
})
const me = await apiJSON<MeResp>('/api/auth/me', { cache: 'no-store' as any })
if (cancelled) return
if (me?.authenticated) {
window.location.assign(nextPath || '/')
} else if (me?.pending2fa) {
setNeed2FA(true)
// ✅ eingeloggt: wenn 2FA noch NICHT konfiguriert → Setup zeigen
if (!me?.totpConfigured) {
setStage('setup')
// Setup-Infos laden (QR/otpauth)
void ensure2FASetup()
} else {
window.location.assign(nextPath || '/')
}
return
}
if (me?.pending2fa) {
setStage('verify')
return
}
setStage('login')
} catch {
// ignore
}
@ -69,9 +99,10 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}, [nextPath])
const submitLogin = async () => {
const submitLogin = async () => {
setBusy(true)
setError(null)
try {
const data = await apiJSON<LoginResp>('/api/auth/login', {
method: 'POST',
@ -79,12 +110,22 @@ export default function LoginPage({ onLoggedIn }: Props) {
body: JSON.stringify({ username, password }),
})
// 2FA ist aktiv → Code-Abfrage
if (data?.totpRequired) {
setNeed2FA(true)
setStage('verify')
return
}
// eingeloggt
// ✅ eingeloggt, aber evtl. Setup nötig
const me = await apiJSON<MeResp>('/api/auth/me', { cache: 'no-store' as any })
if (me?.authenticated && !me?.totpConfigured) {
setStage('setup')
await ensure2FASetup()
return
}
// normaler Fall: eingeloggt + entweder 2FA schon configured oder bewusst nicht erzwingen
if (onLoggedIn) await onLoggedIn()
window.location.assign(nextPath || '/')
} catch (e: any) {
@ -94,6 +135,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}
const submit2FA = async () => {
setBusy(true)
setError(null)
@ -113,11 +155,34 @@ export default function LoginPage({ onLoggedIn }: Props) {
}
}
const ensure2FASetup = async () => {
setError(null)
setSetupInfo(null)
try {
const data = await apiJSON<SetupResp>('/api/auth/2fa/setup', {
method: 'POST', // ✅ dein Backend prüft Method nicht, aber POST ist sauber
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}), // optional leer
})
const otpauth = (data?.otpauth ?? '').trim()
if (!otpauth) throw new Error('2FA Setup fehlgeschlagen (keine otpauth URL).')
setSetupAuthUrl(otpauth)
setSetupSecret((data?.secret ?? '').trim() || null)
setSetupInfo('Scan den QR-Code in deiner Authenticator-App und bestätige danach mit dem 6-stelligen Code.')
} catch (e: any) {
setError(e?.message ?? String(e))
}
}
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key !== 'Enter') return
ev.preventDefault()
if (busy) return
if (need2FA) void submit2FA()
if (stage === 'verify' || stage === 'setup') void submit2FA()
else void submitLogin()
}
@ -138,7 +203,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
</div>
<div className="mt-5 space-y-3">
{!need2FA ? (
{stage === 'login' ? (
<>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
@ -176,20 +241,29 @@ export default function LoginPage({ onLoggedIn }: Props) {
{busy ? 'Login…' : 'Login'}
</Button>
</>
) : (
) : stage === 'verify' ? (
<>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
2FA ist aktiv bitte gib den Code aus deiner Authenticator-App ein.
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code</label>
<input
id="totp"
name="totp"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
@ -202,9 +276,9 @@ export default function LoginPage({ onLoggedIn }: Props) {
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
setNeed2FA(false)
setCode('')
setError(null)
setSetupAuthUrl(null)
setSetupSecret(null)
setSetupInfo(null)
}}
>
Zurück
@ -219,6 +293,106 @@ export default function LoginPage({ onLoggedIn }: Props) {
</Button>
</div>
</>
) : (
<>
<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
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 className="space-y-1">
<label htmlFor="totp" className="text-xs font-medium text-gray-700 dark:text-gray-200">2FA Code (zum Aktivieren)</label>
<input
id="totp-setup"
name="totp"
aria-label="totp"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={onEnter}
autoComplete="one-time-code"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
enterKeyHint="done"
autoCapitalize="none"
autoCorrect="off"
className="block w-full rounded-lg px-3 py-2.5 text-sm bg-white text-gray-900 shadow-sm ring-1 ring-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-white/10 dark:text-white dark:ring-white/10"
placeholder="123456"
disabled={busy}
/>
</div>
<div className="flex gap-2">
<Button
variant="secondary"
className="flex-1 rounded-lg"
disabled={busy}
onClick={() => {
// optional: Setup überspringen (nicht empfohlen)
if (onLoggedIn) void onLoggedIn()
window.location.assign(nextPath || '/')
}}
title="Ohne 2FA fortfahren (nicht empfohlen)"
>
Später
</Button>
<Button
variant="primary"
className="flex-1 rounded-lg"
disabled={busy || code.trim().length < 6}
onClick={() => void submit2FA()}
>
{busy ? 'Aktiviere…' : '2FA aktivieren'}
</Button>
</div>
</>
)}
{error ? (

View File

@ -379,6 +379,8 @@ export default function ModelDetails({
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
const [doneTotalCount, setDoneTotalCount] = React.useState(0)
const refetchModels = React.useCallback(async () => {
try {
const r = await fetch('/api/models/list', { cache: 'no-store' })
@ -545,30 +547,29 @@ export default function ModelDetails({
// Done downloads (inkl. done + keep/<model>/) -> ALLES laden, Pagination client-side
React.useEffect(() => {
if (!open) return
if (!open || !key) return
let alive = true
setDoneLoading(true)
const url = `/api/record/done?all=1&sort=completed_desc&includeKeep=1`
const url =
`/api/record/done?model=${encodeURIComponent(key)}` +
`&page=${donePage}&pageSize=${DONE_PAGE_SIZE}` +
`&sort=completed_desc&includeKeep=1&withCount=1`
fetch(url, { cache: 'no-store' })
.then((r) => r.json())
.then((data: any) => {
if (!alive) return
// ✅ robust: Backend kann Array ODER {items:[...]} liefern
const items = Array.isArray(data)
? (data as RecordJob[])
: Array.isArray(data?.items)
? (data.items as RecordJob[])
: []
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
const count = Number(data?.count ?? items.length)
setDone(items)
setDoneTotalCount(Number.isFinite(count) ? count : items.length)
})
.catch(() => {
if (!alive) return
setDone([])
setDoneTotalCount(0)
})
.finally(() => {
if (!alive) return
@ -578,7 +579,7 @@ export default function ModelDetails({
return () => {
alive = false
}
}, [open])
}, [open, key, donePage])
// Running jobs
React.useEffect(() => {
@ -630,8 +631,8 @@ export default function ModelDetails({
}, [done, key])
const doneTotalPages = React.useMemo(() => {
return Math.max(1, Math.ceil(doneMatches.length / DONE_PAGE_SIZE))
}, [doneMatches.length])
return Math.max(1, Math.ceil(doneTotalCount / DONE_PAGE_SIZE))
}, [doneTotalCount])
const doneMatchesPage = React.useMemo(() => {
const start = (donePage - 1) * DONE_PAGE_SIZE
@ -921,6 +922,44 @@ export default function ModelDetails({
{/* Local flags icons (unten rechts im Hero) */}
<div className="absolute bottom-3 right-3 flex items-center gap-2">
{/* Watched = Eye */}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleWatchModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.watching
? 'bg-sky-500/25 ring-sky-200/30'
: 'bg-black/20 ring-white/15'
)}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
>
<span className="relative inline-block size-4">
<EyeOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-white/70'
)}
/>
<EyeSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-sky-200'
)}
/>
</span>
</button>
{/* Favorite = Star */}
<button
type="button"
@ -994,43 +1033,6 @@ export default function ModelDetails({
/>
</span>
</button>
{/* Watched = Eye */}
<button
type="button"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleToggleWatchModel()
}}
className={cn(
'inline-flex items-center justify-center rounded-full p-2 ring-1 ring-inset backdrop-blur',
'cursor-pointer hover:scale-[1.03] active:scale-[0.98] transition',
model?.watching
? 'bg-sky-500/25 ring-sky-200/30'
: 'bg-black/20 ring-white/15'
)}
title={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
aria-pressed={Boolean(model?.watching)}
aria-label={model?.watching ? 'Watch entfernen' : 'Auf Watch setzen'}
>
<span className="relative inline-block size-4">
<EyeOutlineIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-0 scale-75 rotate-12' : 'opacity-100 scale-100 rotate-0',
'text-white/70'
)}
/>
<EyeSolidIcon
className={cn(
'absolute inset-0 size-4 transition-all duration-200 ease-out motion-reduce:transition-none',
model?.watching ? 'opacity-100 scale-110 rotate-0' : 'opacity-0 scale-75 -rotate-12',
'text-sky-200'
)}
/>
</span>
</button>
</div>
</div>

View File

@ -19,6 +19,165 @@ import Button from './Button'
import { apiUrl, apiFetch } from '../../lib/api'
import LiveHlsVideo from './LiveHlsVideo'
// ✅ Video.js Gear Menu (nur Quality)
function ensureGearControlRegistered() {
const vjsAny = videojs as any
if (vjsAny.__gearControlRegistered) return
vjsAny.__gearControlRegistered = true
const MenuButton = videojs.getComponent('MenuButton')
const MenuItem = videojs.getComponent('MenuItem')
class GearMenuItem extends (MenuItem as any) {
private _onSelect?: () => void
constructor(player: any, options: any) {
super(player, options)
this._onSelect = options?.onSelect
this.on('click', () => this._onSelect?.())
}
}
class GearMenuButton extends (MenuButton as any) {
constructor(player: any, options: any) {
super(player, options)
this.controlText('Settings')
// Icon ersetzen: ⚙️ SVG in den Placeholder
const el = this.el() as HTMLElement
const ph = el.querySelector('.vjs-icon-placeholder') as HTMLElement | null
if (ph) {
ph.innerHTML = `
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M19.14,12.94c0.04-0.31,0.06-0.63,0.06-0.94s-0.02-0.63-0.06-0.94l2.03-1.58
c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.11-0.2-0.36-0.28-0.57-0.2l-2.39,0.96c-0.5-0.38-1.04-0.7-1.64-0.94
L14.4,2.81C14.37,2.59,14.18,2.42,13.95,2.42h-3.9c-0.23,0-0.42,0.17-0.45,0.39L9.27,5.37
C8.67,5.61,8.13,5.93,7.63,6.31L5.24,5.35c-0.21-0.08-0.46,0-0.57,0.2L2.75,8.87
C2.64,9.07,2.69,9.34,2.87,9.48l2.03,1.58C4.86,11.37,4.84,11.69,4.84,12s0.02,0.63,0.06,0.94L2.87,14.52
c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.11,0.2,0.36,0.28,0.57,0.2l2.39-0.96c0.5,0.38,1.04,0.7,1.64,0.94
l0.33,2.56c0.03,0.22,0.22,0.39,0.45,0.39h3.9c0.23,0,0.42-0.17,0.45-0.39l0.33-2.56c0.6-0.24,1.14-0.56,1.64-0.94
l2.39,0.96c0.21,0.08,0.46,0,0.57-0.2l1.92-3.32c0.11-0.2,0.06-0.47-0.12-0.61L19.14,12.94z M12,15.5
c-1.93,0-3.5-1.57-3.5-3.5s1.57-3.5,3.5-3.5s3.5,1.57,3.5,3.5S13.93,15.5,12,15.5z"/>
</svg>
`
}
// ✅ Nur Quality refreshen
const p: any = this.player()
p.on('gear:refresh', () => this.update())
}
update() {
try { super.update?.() } catch {}
const p: any = this.player()
const curQ = String(p.options_?.__gearQuality ?? 'auto')
const items = (this.items || []) as any[]
for (const it of items) {
const kind = it?.options_?.__kind
const val = it?.options_?.__value
if (kind === 'quality') it.selected?.(String(val) === curQ)
}
}
createItems() {
const player: any = this.player()
const items: any[] = []
// ✅ KEIN "Quality" Header mehr
const qualities = (player.options_?.gearQualities || ['auto', '1080p', '720p', '480p']) as string[]
const currentQ = String(player.options_?.__gearQuality ?? 'auto')
for (const q of qualities) {
items.push(
new GearMenuItem(player, {
label: q === 'auto' ? 'Auto' : q,
selectable: true,
selected: currentQ === q,
__kind: 'quality',
__value: q,
onSelect: () => {
player.options_.__gearQuality = q
player.trigger({ type: 'gear:quality', quality: q })
player.trigger('gear:refresh')
},
})
)
}
return items
}
}
// ✅ Typing-Fix
const VjsComponent = videojs.getComponent('Component') as any
videojs.registerComponent('GearMenuButton', GearMenuButton as unknown as typeof VjsComponent)
// ✅ CSS nur 1x injizieren
if (!vjsAny.__gearControlCssInjected) {
vjsAny.__gearControlCssInjected = true
const css = document.createElement('style')
css.textContent = `
#player-root .vjs-gear-menu .vjs-icon-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ✅ Menübreite nicht aufblasen */
#player-root .vjs-gear-menu .vjs-menu {
min-width: 0 !important;
width: fit-content !important;
}
/* ✅ die UL auch nicht breit ziehen */
#player-root .vjs-gear-menu .vjs-menu-content {
width: fit-content !important;
min-width: 0 !important;
padding: 2px 0 !important;
}
/* ✅ Items kompakter */
#player-root .vjs-gear-menu .vjs-menu-content .vjs-menu-item {
padding: 4px 10px !important;
line-height: 1.1 !important;
}
/* ✅ Gear-Button als Anker */
#player-root .vjs-gear-menu {
position: relative !important;
}
/* ✅ Popup wirklich über dem Gear-Icon zentrieren */
#player-root .vjs-gear-menu .vjs-menu {
position: absolute !important;
left: 0% !important;
right: 0% !important;
transform: translateX(-50%) !important;
transform-origin: 50% 100% !important;
z-index: 9999 !important;
}
/* ✅ Manche Skins setzen am UL noch Layout/Width neutralisieren */
#player-root .vjs-gear-menu .vjs-menu-content {
width: max-content !important;
min-width: 0 !important;
}
/* ✅ Menü horizontal zentrieren über dem Gear-Icon */
#player-root .vjs-gear-menu .vjs-menu {
z-index: 9999 !important;
}
`
document.head.appendChild(css)
}
}
const baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
const lower = (s: string) => (s || '').trim().toLowerCase()
@ -179,6 +338,49 @@ function useMediaQuery(query: string) {
return matches
}
function gearQualitiesForHeight(
h?: number | null,
supported: string[] = ['1080p', '720p', '480p'] // <-- nur was dein Backend wirklich kann
): string[] {
const maxH = typeof h === 'number' && Number.isFinite(h) && h > 0 ? h : 0
const qToH = (q: string): number => {
const m = String(q).match(/(\d{3,4})p/i)
return m ? Number(m[1]) : 0
}
// nur Qualitäten <= Videohöhe (mit etwas Toleranz)
const filtered = supported
.filter((q) => {
const qh = qToH(q)
if (!qh) return false
if (!maxH) return true // wenn unbekannt: zeig alle supported
return qh <= maxH + 8 // kleine Toleranz
})
.sort((a, b) => qToH(b) - qToH(a)) // absteigend
// immer Auto vorne
const out = ['auto', ...filtered]
// dedupe
return Array.from(new Set(out))
}
function qualityFromHeight(h?: number | null): string {
const hh = typeof h === 'number' && Number.isFinite(h) && h > 0 ? Math.round(h) : 0
if (!hh) return 'auto'
// grob auf gängige Stufen mappen
if (hh >= 1000) return '1080p'
if (hh >= 700) return '720p'
if (hh >= 460) return '480p'
// falls kleiner: trotzdem was sinnvolles
return `${hh}p`
}
export type PlayerProps = {
job: RecordJob
expanded: boolean
@ -397,6 +599,24 @@ export default function Player({
}
}, [isRunning, hlsIndexUrl])
const buildVideoSrc = React.useCallback((params: { file?: string; id?: string; quality?: string }) => {
const q = String(params.quality || 'auto')
// ✅ query params sauber bauen
const qp = new URLSearchParams()
if (params.file) qp.set('file', params.file)
if (params.id) qp.set('id', params.id)
if (q && q !== 'auto') {
qp.set('quality', q)
qp.set('stream', '1') // ✅ HIER stream aktivieren (nur wenn quality gewählt)
}
return apiUrl(`/api/record/video?${qp.toString()}`)
}, [])
const [selectedQuality, setSelectedQuality] = React.useState<string>('auto')
const media = React.useMemo(() => {
// ✅ Live wird NICHT mehr über Video.js gespielt
if (isRunning) return { src: '', type: '' }
@ -408,11 +628,11 @@ export default function Player({
ext === 'mp4' ? 'video/mp4' :
ext === 'ts' ? 'video/mp2t' :
'application/octet-stream'
return { src: apiUrl(`/api/record/video?file=${encodeURIComponent(file)}`), type }
return { src: buildVideoSrc({ file, quality: selectedQuality }), type }
}
return { src: apiUrl(`/api/record/video?id=${encodeURIComponent(job.id)}`), type: 'video/mp4' }
}, [isRunning, job.output, job.id])
return { src: buildVideoSrc({ id: job.id, quality: selectedQuality }), type: 'video/mp4' }
}, [isRunning, job.output, job.id, selectedQuality, buildVideoSrc])
const containerRef = React.useRef<HTMLDivElement | null>(null)
const playerRef = React.useRef<VideoJsPlayer | null>(null)
@ -420,7 +640,33 @@ export default function Player({
const [mounted, setMounted] = React.useState(false)
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [playerReadyTick, setPlayerReadyTick] = React.useState(0)
// pro Datei einmal default setzen (damit beim nächsten Video wieder sinnvoll startet)
const playbackKey = React.useMemo(() => {
// finished: Dateiname; running ist eh LiveHlsVideo, also egal
return baseName(job.output?.trim() || '') || job.id
}, [job.output, job.id])
const supportedQualities = React.useMemo(
() => gearQualitiesForHeight(videoH, ['1080p', '720p', '480p']),
[videoH]
)
const defaultQuality = React.useMemo(() => {
const metaQ = qualityFromHeight(videoH)
return supportedQualities.includes(metaQ) ? metaQ : 'auto'
}, [supportedQualities, videoH])
const lastPlaybackKeyRef = React.useRef<string>('')
React.useEffect(() => {
if (lastPlaybackKeyRef.current !== playbackKey) {
lastPlaybackKeyRef.current = playbackKey
setSelectedQuality(defaultQuality)
}
}, [playbackKey, defaultQuality])
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
const [, setVvTick] = React.useState(0)
React.useEffect(() => {
@ -496,6 +742,50 @@ export default function Player({
}
}, [mounted, expanded])
React.useEffect(() => {
const p = playerRef.current
if (!p || (p as any).isDisposed?.()) return
const onQ = (ev: any) => {
const q = String(ev?.quality ?? 'auto')
// nur für finished (Video.js läuft da)
if (isRunning) return
// ✅ Beispiel: Backend liefert je nach quality ein anderes File/Transcode
// Du musst backendseitig unterstützen: /api/record/video?file=...&quality=720p
const fileName = baseName(job.output?.trim() || '')
if (!fileName) return
const ct = p.currentTime?.() || 0
const wasPaused = p.paused?.() ?? false
setSelectedQuality(q)
const nextSrc = buildVideoSrc({ file: fileName, quality: q })
// src wechseln
try { p.src({ src: nextSrc, type: 'video/mp4' }) } catch {}
p.one('loadedmetadata', () => {
console.log('[Player] loadedmetadata after switch', {
h: typeof (p as any).videoHeight === 'function' ? (p as any).videoHeight() : null,
w: typeof (p as any).videoWidth === 'function' ? (p as any).videoWidth() : null,
})
try { if (ct > 0) p.currentTime(ct) } catch {}
if (!wasPaused) {
const ret = p.play?.()
if (ret && typeof (ret as any).catch === 'function') (ret as any).catch(() => {})
}
})
}
p.on('gear:quality', onQ)
return () => {
try { p.off('gear:quality', onQ) } catch {}
}
}, [playerReadyTick, job.output, isRunning, buildVideoSrc])
React.useEffect(() => setMounted(true), [])
React.useEffect(() => {
@ -633,6 +923,14 @@ export default function Player({
containerRef.current.appendChild(videoEl)
videoNodeRef.current = videoEl
ensureGearControlRegistered()
const initialGearQualities = gearQualitiesForHeight(videoH, ['1080p', '720p', '480p'])
const metaQuality = qualityFromHeight(videoH)
const initialSelectedQuality = initialGearQualities.includes(metaQuality) ? metaQuality : 'auto'
setSelectedQuality(initialSelectedQuality)
const p = videojs(videoEl, {
autoplay: true,
muted: startMuted,
@ -644,7 +942,7 @@ export default function Player({
fill: true,
// ✅ Live UI (wir verstecken zwar die Seekbar, aber LiveTracker ist nützlich)
liveui: true,
liveui: false,
// ✅ optional: VHS Low-Latency (wenn deine Video.js-Version es unterstützt)
html5: {
@ -654,6 +952,9 @@ export default function Player({
},
inactivityTimeout: 0,
gearQualities: initialGearQualities,
__gearQuality: initialSelectedQuality,
controlBar: {
skipButtons: { backward: 10, forward: 10 },
@ -664,11 +965,12 @@ export default function Player({
'skipForward',
'volumePanel',
'currentTimeDisplay',
'durationDisplay',
'timeDivider',
'durationDisplay',
'progressControl',
'spacer',
'playbackRateMenuButton',
'GearMenuButton',
'fullscreenToggle',
],
},
@ -677,6 +979,30 @@ export default function Player({
playerRef.current = p
setPlayerReadyTick((x) => x + 1)
p.one('loadedmetadata', () => {
try {
const h =
typeof (p as any).videoHeight === 'function'
? (p as any).videoHeight()
: 0
const next = gearQualitiesForHeight(h, ['1080p', '720p', '480p'])
;(p as any).options_.gearQualities = next
const metaQ = qualityFromHeight(h)
;(p as any).options_.__gearQuality = next.includes(metaQ) ? metaQ : 'auto'
p.trigger('gear:refresh')
} catch {}
})
try {
const gear = (p.getChild('controlBar') as any)?.getChild('GearMenuButton')
if (gear?.el) gear.el().classList.add('vjs-gear-menu')
} catch {}
p.userActive(true)
p.on('userinactive', () => p.userActive(true))
@ -693,7 +1019,7 @@ export default function Player({
}
}
}
}, [mounted, startMuted, isRunning])
}, [mounted, startMuted, isRunning, videoH])
React.useEffect(() => {
const p = playerRef.current

View File

@ -15,6 +15,65 @@
-moz-osx-font-smoothing: grayscale;
}
/* Video.js Popups (Playback-Rate Menü, Volume-Slider) über eigene Overlays legen */
.video-js {
position: relative; /* schafft eine stabile Basis */
}
.video-js .vjs-control-bar {
position: relative;
z-index: 60;
}
.video-js .vjs-menu-button-popup .vjs-menu {
z-index: 9999 !important;
}
.video-js .vjs-volume-panel .vjs-volume-control {
z-index: 9999 !important;
}
/* Zeiten im Mini-Player wieder einblenden */
.vjs-mini .video-js .vjs-current-time,
.vjs-mini .video-js .vjs-time-divider,
.vjs-mini .video-js .vjs-duration {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
}
/* Text sicher sichtbar */
.vjs-mini .video-js .vjs-current-time-display,
.vjs-mini .video-js .vjs-duration-display {
display: inline !important;
}
/* Kompaktere Zeit-Anzeige (Padding/Min-Width reduzieren) */
.video-js .vjs-time-control {
padding-left: .35em !important;
padding-right: .35em !important;
min-width: 0 !important;
width: auto !important;
}
/* Divider noch enger */
.video-js .vjs-time-divider {
padding-left: .15em !important;
padding-right: .15em !important;
}
.video-js .vjs-time-divider > div {
padding: 0 !important;
}
/* Optional: Ziffern stabil (weniger “springen”), etwas kleiner */
.video-js .vjs-current-time-display,
.video-js .vjs-duration-display {
font-variant-numeric: tabular-nums;
font-size: 0.95em;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;