updated
This commit is contained in:
parent
f8b6dae669
commit
cb4ecfb889
@ -1,3 +1,5 @@
|
|||||||
|
// backend\assets_generate.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -75,28 +77,49 @@ func ensureAssetsForVideoWithProgress(videoPath string, sourceURL string, onRati
|
|||||||
|
|
||||||
metaPath := filepath.Join(assetDir, "meta.json")
|
metaPath := filepath.Join(assetDir, "meta.json")
|
||||||
|
|
||||||
// ---- Meta / Duration ----
|
// ---- Meta / Duration + Props (Width/Height/FPS/Resolution) ----
|
||||||
durSec := 0.0
|
durSec := 0.0
|
||||||
if d, ok := readVideoMetaDuration(metaPath, fi); ok {
|
vw, vh := 0, 0
|
||||||
durSec = d
|
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 {
|
} else {
|
||||||
|
// 2) Duration berechnen
|
||||||
dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
dctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||||
d, derr := durationSecondsCached(dctx, videoPath)
|
d, derr := durationSecondsCached(dctx, videoPath)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if derr == nil && d > 0 {
|
if derr == nil && d > 0 {
|
||||||
durSec = d
|
durSec = d
|
||||||
// ✅ Duration-only meta schreiben (inkl. sourceURL)
|
|
||||||
_ = writeVideoMetaDuration(metaPath, fi, durSec, sourceURL)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ Wenn Duration aus Meta kam, aber SourceURL jetzt neu vorhanden ist,
|
// 3) Wenn wir Duration haben, aber Props fehlen -> ffprobe holen und Voll-Meta schreiben
|
||||||
// dann Meta "anreichern" (ohne ffprobe).
|
// (damit resolution wirklich in meta.json landet)
|
||||||
if durSec > 0 && strings.TrimSpace(sourceURL) != "" {
|
if durSec > 0 && (vw <= 0 || vh <= 0 || fps <= 0) {
|
||||||
if u, ok := readVideoMetaSourceURL(metaPath, fi); !ok || strings.TrimSpace(u) == "" {
|
pctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||||
_ = writeVideoMetaDuration(metaPath, fi, durSec, sourceURL)
|
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ß
|
// Gewichte: thumbs klein, preview groß
|
||||||
|
|||||||
@ -380,15 +380,29 @@ 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()
|
||||||
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()
|
am.confMu.Unlock()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
"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,
|
||||||
|
|
||||||
|
// ✅ 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)
|
// /api/auth/2fa/setup -> secret + otpauth (nur wenn bereits authed)
|
||||||
func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
|
func auth2FASetupHandler(am *AuthManager) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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)
|
s, _ := am.getSession(r)
|
||||||
if s == nil || !s.Authed {
|
if s == nil || !s.Authed {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\cleanup.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -73,10 +75,11 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// 2) Orphans entfernen (immer sinnvoll, unabhängig von mb)
|
// 2) Orphans entfernen (immer sinnvoll, unabhängig von mb)
|
||||||
cleanupOrphanAssets(doneAbs, &resp)
|
cleanupOrphanAssets(doneAbs, &resp)
|
||||||
|
|
||||||
// ✅ Wenn wir irgendwas gelöscht haben: generated GC nachziehen
|
// ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen,
|
||||||
if resp.DeletedFiles > 0 || resp.OrphanIDsRemoved > 0 {
|
// damit die Zahlen in der JSON-Response landen.
|
||||||
triggerGeneratedGarbageCollectorAsync()
|
gcStats := triggerGeneratedGarbageCollectorSync()
|
||||||
}
|
resp.OrphanIDsScanned += gcStats.Checked
|
||||||
|
resp.OrphanIDsRemoved += gcStats.Removed
|
||||||
|
|
||||||
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes)
|
||||||
writeJSON(w, http.StatusOK, resp)
|
writeJSON(w, http.StatusOK, resp)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"passwordHash": "$2a$10$ujxgEV/riwyxEfQKdG3hruUGljg/ts3bDETFAPhZb07N0TBY5LRNq",
|
"passwordHash": "$2a$10$2aiY8R4G5pFmK3sZ/x9EXewMt/G4zt2cMz.dDXWIntSbd6Hoa9oYC",
|
||||||
"totpEnabled": false,
|
"totpEnabled": true,
|
||||||
"totpSecret": ""
|
"totpSecret": "TIZUJ4A5SB2LOJCJN4T4MGOLBMDQ7NGG"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -14,36 +14,47 @@ import (
|
|||||||
|
|
||||||
var generatedGCRunning int32
|
var generatedGCRunning int32
|
||||||
|
|
||||||
// Startet den GC im Hintergrund, aber nur wenn nicht schon einer läuft.
|
type generatedGCStats struct {
|
||||||
func triggerGeneratedGarbageCollectorAsync() {
|
Checked int
|
||||||
if !atomic.CompareAndSwapInt32(&generatedGCRunning, 0, 1) {
|
Removed int
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
// 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)
|
defer atomic.StoreInt32(&generatedGCRunning, 0)
|
||||||
runGeneratedGarbageCollector() // ohne Sleep
|
|
||||||
|
stats := runGeneratedGarbageCollector()
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Läuft 1× nach Serverstart (mit Delay), löscht /generated/* Orphans.
|
||||||
|
func startGeneratedGarbageCollector() {
|
||||||
|
go func() {
|
||||||
|
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)
|
// 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()
|
s := getSettings()
|
||||||
|
|
||||||
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
doneAbs, err := resolvePathRelativeToApp(s.DoneDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("🧹 [gc] resolve doneDir failed:", err)
|
fmt.Println("🧹 [gc] resolve doneDir failed:", err)
|
||||||
return
|
return stats
|
||||||
}
|
}
|
||||||
doneAbs = strings.TrimSpace(doneAbs)
|
doneAbs = strings.TrimSpace(doneAbs)
|
||||||
if doneAbs == "" {
|
if doneAbs == "" {
|
||||||
return
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren
|
// 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren
|
||||||
@ -89,7 +100,7 @@ func runGeneratedGarbageCollector() {
|
|||||||
metaRoot = strings.TrimSpace(metaRoot)
|
metaRoot = strings.TrimSpace(metaRoot)
|
||||||
}
|
}
|
||||||
if err != nil || metaRoot == "" {
|
if err != nil || metaRoot == "" {
|
||||||
return
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
removedMeta := 0
|
removedMeta := 0
|
||||||
@ -116,6 +127,8 @@ func runGeneratedGarbageCollector() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
|
fmt.Printf("🧹 [gc] generated/meta checked=%d removed_orphans=%d\n", checkedMeta, removedMeta)
|
||||||
|
stats.Checked += checkedMeta
|
||||||
|
stats.Removed += removedMeta
|
||||||
|
|
||||||
// 3) Optional: legacy /generated/<id>
|
// 3) Optional: legacy /generated/<id>
|
||||||
genRoot, err := generatedRoot()
|
genRoot, err := generatedRoot()
|
||||||
@ -123,7 +136,7 @@ func runGeneratedGarbageCollector() {
|
|||||||
genRoot = strings.TrimSpace(genRoot)
|
genRoot = strings.TrimSpace(genRoot)
|
||||||
}
|
}
|
||||||
if err != nil || genRoot == "" {
|
if err != nil || genRoot == "" {
|
||||||
return
|
return stats
|
||||||
}
|
}
|
||||||
|
|
||||||
reserved := map[string]struct{}{
|
reserved := map[string]struct{}{
|
||||||
@ -165,4 +178,8 @@ func runGeneratedGarbageCollector() {
|
|||||||
if checkedLegacy > 0 || removedLegacy > 0 {
|
if checkedLegacy > 0 || removedLegacy > 0 {
|
||||||
fmt.Printf("🧹 [gc] generated legacy checked=%d removed_orphans=%d\n", checkedLegacy, removedLegacy)
|
fmt.Printf("🧹 [gc] generated legacy checked=%d removed_orphans=%d\n", checkedLegacy, removedLegacy)
|
||||||
}
|
}
|
||||||
|
stats.Checked += checkedLegacy
|
||||||
|
stats.Removed += removedLegacy
|
||||||
|
|
||||||
|
return stats
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ require (
|
|||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/crypto v0.47.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@ -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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.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-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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
|||||||
230
backend/main.go
230
backend/main.go
@ -1091,6 +1091,44 @@ func stopAllStoppableJobs() int {
|
|||||||
return len(stoppable)
|
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 {
|
func sizeOfPathBestEffort(p string) uint64 {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
@ -1335,8 +1373,6 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) {
|
|||||||
return sec, nil
|
return sec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// main.go
|
|
||||||
|
|
||||||
type RecorderSettings struct {
|
type RecorderSettings struct {
|
||||||
RecordDir string `json:"recordDir"`
|
RecordDir string `json:"recordDir"`
|
||||||
DoneDir string `json:"doneDir"`
|
DoneDir string `json:"doneDir"`
|
||||||
@ -3536,196 +3572,6 @@ func generatedCover(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write(raw)
|
_, _ = 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 {
|
func sanitizeModelKey(k string) string {
|
||||||
k = stripHotPrefix(strings.TrimSpace(k))
|
k = stripHotPrefix(strings.TrimSpace(k))
|
||||||
if k == "" || k == "—" || strings.ContainsAny(k, `/\`) {
|
if k == "" || k == "—" || strings.ContainsAny(k, `/\`) {
|
||||||
|
|||||||
210
backend/meta.go
Normal file
210
backend/meta.go
Normal 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.
@ -199,4 +199,4 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// global (oder in deinem app struct halten)
|
// global (oder in deinem app struct halten)
|
||||||
var postWorkQ = NewPostWorkQueue(512, 2) // maxParallelFFmpeg = 2
|
var postWorkQ = NewPostWorkQueue(512, 4) // maxParallelFFmpeg = 2
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\postwork_refresh.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,5 @@
|
|||||||
|
// backend\record_helpers_paths.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
39
backend/record_job_progress.go
Normal file
39
backend/record_job_progress.go
Normal 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
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\record_start.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -22,7 +24,9 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) {
|
|||||||
// Duplicate-running guard (identische URL)
|
// Duplicate-running guard (identische URL)
|
||||||
jobsMu.Lock()
|
jobsMu.Lock()
|
||||||
for _, j := range jobs {
|
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
|
// ✅ Wenn ein versteckter Auto-Check-Job läuft und der User manuell startet -> sofort sichtbar machen
|
||||||
if j.Hidden && !req.Hidden {
|
if j.Hidden && !req.Hidden {
|
||||||
j.Hidden = false
|
j.Hidden = false
|
||||||
@ -102,6 +106,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
now = time.Now()
|
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) ----
|
// ---- Aufnahme starten (Output-Pfad sauber relativ zur EXE auflösen) ----
|
||||||
switch provider {
|
switch provider {
|
||||||
case "chaturbate":
|
case "chaturbate":
|
||||||
@ -189,6 +202,15 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
if errText != "" {
|
if errText != "" {
|
||||||
job.Error = 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)
|
out := strings.TrimSpace(job.Output)
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
notifyJobsChanged()
|
notifyJobsChanged()
|
||||||
@ -207,6 +229,49 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
return
|
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
|
// ✅ Postwork: remux/move/ffprobe/assets begrenzen -> in Queue
|
||||||
postOut := out
|
postOut := out
|
||||||
postTarget := target
|
postTarget := target
|
||||||
@ -301,51 +366,11 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) {
|
|||||||
job.Output = out
|
job.Output = out
|
||||||
jobsMu.Unlock()
|
jobsMu.Unlock()
|
||||||
notifyJobsChanged()
|
notifyJobsChanged()
|
||||||
// ✅ erst JETZT ist done wirklich betroffen
|
|
||||||
notifyDoneChanged()
|
notifyDoneChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Optional: kleine Downloads automatisch löschen
|
// 3) Dauer (ffprobe)
|
||||||
setPhase("postwork", 82)
|
setPhase("probe", 84)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
|
if sec, derr := durationSecondsCached(dctx, out); derr == nil && sec > 0 {
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\routes.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
// backend\server.go
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@ -253,35 +253,52 @@ func runGenerateMissingAssets(ctx context.Context) {
|
|||||||
sourceURL = u
|
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
|
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
|
metaOK := false
|
||||||
|
|
||||||
if d, ok := readVideoMetaDuration(metaPath, vfi); ok {
|
// 1) Versuch: komplette Meta lesen (Duration + w/h/fps)
|
||||||
durSec = d
|
if d, mw, mh, mfps, ok := readVideoMeta(metaPath, vfi); ok {
|
||||||
metaOK = true
|
durSec, vw, vh, fps = d, mw, mh, mfps
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
|
// 2) Fallback: Duration berechnen
|
||||||
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
dctx, cancel := context.WithTimeout(ctx, 6*time.Second)
|
||||||
d, derr := durationSecondsCached(dctx, it.path)
|
d, derr := durationSecondsCached(dctx, it.path)
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
if derr == nil && d > 0 {
|
if derr == nil && d > 0 {
|
||||||
durSec = d
|
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 {
|
if thumbOK && previewOK && metaOK {
|
||||||
assetsTaskMu.Lock()
|
assetsTaskMu.Lock()
|
||||||
assetsTaskState.Skipped++
|
assetsTaskState.Skipped++
|
||||||
|
|||||||
455
backend/transcode.go
Normal file
455
backend/transcode.go
Normal 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))
|
||||||
|
}
|
||||||
1
backend/web/dist/assets/index-BRCxVTHL.css
vendored
Normal file
1
backend/web/dist/assets/index-BRCxVTHL.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
backend/web/dist/assets/index-s1ZflJsu.css
vendored
1
backend/web/dist/assets/index-s1ZflJsu.css
vendored
File diff suppressed because one or more lines are too long
4
backend/web/dist/index.html
vendored
4
backend/web/dist/index.html
vendored
@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<title>App</title>
|
<title>App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B0V3ux9Z.js"></script>
|
<script type="module" crossorigin src="/assets/index-DV6ZfOPf.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-s1ZflJsu.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BRCxVTHL.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@ -560,7 +560,11 @@ export default function App() {
|
|||||||
|
|
||||||
// Duplicate-running guard (normalisiert vergleichen)
|
// Duplicate-running guard (normalisiert vergleichen)
|
||||||
const alreadyRunning = jobsRef.current.some((j) => {
|
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 || ''))
|
const jNorm = normalizeHttpUrl(String((j as any).sourceUrl || ''))
|
||||||
return jNorm === norm
|
return jNorm === norm
|
||||||
})
|
})
|
||||||
@ -1055,6 +1059,35 @@ export default function App() {
|
|||||||
[donePage, doneSort]
|
[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 {
|
function isChaturbate(raw: string): boolean {
|
||||||
const norm = normalizeHttpUrl(raw)
|
const norm = normalizeHttpUrl(raw)
|
||||||
if (!norm) return false
|
if (!norm) return false
|
||||||
|
|||||||
@ -109,22 +109,32 @@ const addedAtMsOf = (r: DownloadRow): number => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const phaseLabel = (p?: string) => {
|
const phaseLabel = (p?: string) => {
|
||||||
switch (p) {
|
switch ((p ?? '').toLowerCase()) {
|
||||||
case 'stopping':
|
case 'stopping':
|
||||||
return 'Stop wird angefordert…'
|
return 'Stop wird angefordert…'
|
||||||
|
case 'probe':
|
||||||
|
return 'Analysiere Datei (Dauer/Streams)…'
|
||||||
|
|
||||||
case 'remuxing':
|
case 'remuxing':
|
||||||
return 'Remux zu MP4…'
|
return 'Konvertiere Container zu MP4…' // oder: 'Remux zu MP4…'
|
||||||
|
|
||||||
case 'moving':
|
case 'moving':
|
||||||
return 'Verschiebe nach Done…'
|
return 'Verschiebe nach Done…'
|
||||||
|
|
||||||
case 'assets':
|
case 'assets':
|
||||||
return 'Erstelle Vorschau…'
|
return 'Erstelle Vorschau/Thumbnails…'
|
||||||
|
|
||||||
case 'postwork':
|
case 'postwork':
|
||||||
return 'Nacharbeiten werden vorbereitet…'
|
return 'Nacharbeiten laufen…'
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// optional: lieber etwas anzeigen als leer
|
||||||
|
// return p ? `Arbeite… (${p})` : ''
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
async function apiJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(url, init)
|
const res = await fetch(url, init)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@ -169,21 +179,31 @@ function StatusCell({ job }: { job: RecordJob }) {
|
|||||||
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
const phaseRaw = String((job as any)?.phase ?? '').trim()
|
||||||
const progress = Number((job as any)?.progress ?? 0)
|
const progress = Number((job as any)?.progress ?? 0)
|
||||||
|
|
||||||
|
const phase = phaseRaw.toLowerCase()
|
||||||
|
const isRecording = phase === 'recording'
|
||||||
|
|
||||||
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
|
let phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : ''
|
||||||
|
|
||||||
// ✅ postwork genauer machen (wartend/running + Position)
|
// ✅ postwork genauer machen (wartend/running + Position)
|
||||||
if (phaseRaw === 'postwork') {
|
if (phase === 'postwork') {
|
||||||
phaseText = postWorkLabel(job)
|
phaseText = postWorkLabel(job)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRecording) {
|
||||||
|
phaseText = 'Recording läuft…'
|
||||||
|
}
|
||||||
|
|
||||||
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
|
const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase()
|
||||||
|
|
||||||
// ✅ ProgressBar unabhängig vom Text
|
// ✅ Balken NICHT während recording anzeigen
|
||||||
// ✅ determinate nur wenn sinnvoll (0..100)
|
const showBar =
|
||||||
const showBar = Number.isFinite(progress) && progress > 0 && progress < 100
|
!isRecording &&
|
||||||
|
Number.isFinite(progress) &&
|
||||||
|
progress > 0 &&
|
||||||
|
progress < 100
|
||||||
|
|
||||||
// ✅ wenn wir in einer Phase sind, aber noch kein Progress da ist -> indeterminate
|
|
||||||
const showIndeterminate =
|
const showIndeterminate =
|
||||||
|
!isRecording &&
|
||||||
!showBar &&
|
!showBar &&
|
||||||
Boolean(phaseRaw) &&
|
Boolean(phaseRaw) &&
|
||||||
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
||||||
@ -334,13 +354,20 @@ function DownloadsCardRow({
|
|||||||
|
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
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 isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand
|
||||||
const rawStatus = String(j.status ?? '').toLowerCase()
|
const rawStatus = String(j.status ?? '').toLowerCase()
|
||||||
|
|
||||||
const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
|
const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested
|
||||||
|
|
||||||
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
let phaseText = phase ? (phaseLabel(phase) || phase) : ''
|
||||||
if (phase === 'postwork') {
|
|
||||||
|
if (phaseLower === 'recording') {
|
||||||
|
phaseText = 'Recording läuft…'
|
||||||
|
} else if (phaseLower === 'postwork') {
|
||||||
phaseText = postWorkLabel(j)
|
phaseText = postWorkLabel(j)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -348,8 +375,14 @@ function DownloadsCardRow({
|
|||||||
const progressLabel = phaseText || statusText
|
const progressLabel = phaseText || statusText
|
||||||
|
|
||||||
const progress = Number((j as any).progress ?? 0)
|
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 =
|
const showIndeterminate =
|
||||||
|
!isRecording &&
|
||||||
!showBar &&
|
!showBar &&
|
||||||
Boolean(phase) &&
|
Boolean(phase) &&
|
||||||
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
(!Number.isFinite(progress) || progress <= 0 || progress >= 100)
|
||||||
@ -577,6 +610,25 @@ const formatBytes = (bytes: number | null): string => {
|
|||||||
return `${s} ${units[i]}`
|
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({
|
export default function Downloads({
|
||||||
jobs,
|
jobs,
|
||||||
@ -693,8 +745,10 @@ export default function Downloads({
|
|||||||
for (const id of keys) {
|
for (const id of keys) {
|
||||||
const j = jobs.find((x) => x.id === id)
|
const j = jobs.find((x) => x.id === id)
|
||||||
if (!j) continue
|
if (!j) continue
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
const phaseLower = String((j as any).phase ?? '').trim().toLowerCase()
|
||||||
const isStopping = Boolean(phase) || j.status !== 'running'
|
const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
|
||||||
|
const isStopping = isBusyPhase || j.status !== 'running'
|
||||||
|
|
||||||
if (!isStopping) next[id] = true
|
if (!isStopping) next[id] = true
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@ -718,9 +772,14 @@ export default function Downloads({
|
|||||||
const stoppableIds = useMemo(() => {
|
const stoppableIds = useMemo(() => {
|
||||||
return jobs
|
return jobs
|
||||||
.filter((j) => {
|
.filter((j) => {
|
||||||
|
if (isPostworkJob(j)) return false
|
||||||
|
if ((j as any).endedAt) return false
|
||||||
|
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
const phase = String((j as any).phase ?? '').trim()
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
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
|
return !isStopping
|
||||||
})
|
})
|
||||||
.map((j) => j.id)
|
.map((j) => j.id)
|
||||||
@ -956,7 +1015,10 @@ export default function Downloads({
|
|||||||
const j = r.job
|
const j = r.job
|
||||||
const phase = String((j as any).phase ?? '').trim()
|
const phase = String((j as any).phase ?? '').trim()
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
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 key = modelNameFromOutput(j.output || '')
|
||||||
const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined
|
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 phase = String((j as any).phase ?? '').trim()
|
||||||
const isStopRequested = Boolean(stopRequestedIds[j.id])
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@ -1011,19 +1075,31 @@ export default function Downloads({
|
|||||||
]
|
]
|
||||||
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds])
|
}, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds])
|
||||||
|
|
||||||
const hasAnyPending = pending.length > 0
|
const downloadJobRows = useMemo<DownloadRow[]>(() => {
|
||||||
const hasJobs = jobs.length > 0
|
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))
|
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
|
||||||
return list
|
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 () => {
|
const stopAll = useCallback(async () => {
|
||||||
if (stopAllBusy) return
|
if (stopAllBusy) return
|
||||||
@ -1059,7 +1135,7 @@ export default function Downloads({
|
|||||||
Downloads
|
Downloads
|
||||||
</div>
|
</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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1105,13 +1181,18 @@ export default function Downloads({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ Content abhängig von Jobs/Pending */}
|
{/* ✅ Content abhängig von Jobs/Pending */}
|
||||||
{(hasAnyPending || hasJobs) ? (
|
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
|
||||||
<>
|
<>
|
||||||
{/* Mobile: Cards */}
|
{/* Mobile: Cards */}
|
||||||
<div className="mt-3 grid gap-4 sm:hidden">
|
<div className="mt-3 grid gap-4 sm:hidden">
|
||||||
{rows.map((r) => (
|
{downloadJobRows.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Downloads ({downloadJobRows.length})
|
||||||
|
</div>
|
||||||
|
{downloadJobRows.map((r) => (
|
||||||
<DownloadsCardRow
|
<DownloadsCardRow
|
||||||
key={r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`}
|
key={`dl:${r.kind === 'job' ? r.job.id : pendingRowKey(r.pending)}`}
|
||||||
r={r}
|
r={r}
|
||||||
nowMs={nowMs}
|
nowMs={nowMs}
|
||||||
blurPreviews={blurPreviews}
|
blurPreviews={blurPreviews}
|
||||||
@ -1125,14 +1206,69 @@ export default function Downloads({
|
|||||||
onToggleWatch={onToggleWatch}
|
onToggleWatch={onToggleWatch}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Tablet/Desktop: Tabelle */}
|
{/* Desktop: Tabellen */}
|
||||||
<div className="mt-3 hidden sm:block overflow-x-auto">
|
<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
|
<Table
|
||||||
rows={rows}
|
rows={downloadJobRows}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
getRowKey={(r) => (r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`)}
|
getRowKey={(r) => (r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)}
|
||||||
striped
|
striped
|
||||||
fullWidth
|
fullWidth
|
||||||
stickyHeader
|
stickyHeader
|
||||||
@ -1143,6 +1279,47 @@ export default function Downloads({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
) : 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>
|
||||||
|
<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>
|
<Card grayBody>
|
||||||
|
|||||||
@ -11,8 +11,20 @@ type LoginResp = {
|
|||||||
totpRequired?: boolean
|
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> {
|
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) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '')
|
const text = await res.text().catch(() => '')
|
||||||
throw new Error(text || `HTTP ${res.status}`)
|
throw new Error(text || `HTTP ${res.status}`)
|
||||||
@ -39,26 +51,44 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
const [username, setUsername] = useState('')
|
const [username, setUsername] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
|
||||||
const [need2FA, setNeed2FA] = useState(false)
|
|
||||||
const [code, setCode] = useState('')
|
const [code, setCode] = useState('')
|
||||||
|
|
||||||
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 [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
|
// Wenn Backend schon eingeloggt ist (z.B. Cookie vorhanden), direkt weiter
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const check = async () => {
|
const check = async () => {
|
||||||
try {
|
try {
|
||||||
const me = await apiJSON<{ authenticated?: boolean; pending2fa?: boolean }>('/api/auth/me', {
|
const me = await apiJSON<MeResp>('/api/auth/me', { cache: 'no-store' as any })
|
||||||
cache: 'no-store' as any,
|
|
||||||
})
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
if (me?.authenticated) {
|
if (me?.authenticated) {
|
||||||
|
// ✅ 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 || '/')
|
window.location.assign(nextPath || '/')
|
||||||
} else if (me?.pending2fa) {
|
|
||||||
setNeed2FA(true)
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me?.pending2fa) {
|
||||||
|
setStage('verify')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setStage('login')
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -72,6 +102,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
const submitLogin = async () => {
|
const submitLogin = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiJSON<LoginResp>('/api/auth/login', {
|
const data = await apiJSON<LoginResp>('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -79,12 +110,22 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2FA ist aktiv → Code-Abfrage
|
||||||
if (data?.totpRequired) {
|
if (data?.totpRequired) {
|
||||||
setNeed2FA(true)
|
setStage('verify')
|
||||||
return
|
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()
|
if (onLoggedIn) await onLoggedIn()
|
||||||
window.location.assign(nextPath || '/')
|
window.location.assign(nextPath || '/')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@ -94,6 +135,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const submit2FA = async () => {
|
const submit2FA = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
setError(null)
|
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>) => {
|
const onEnter = (ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (ev.key !== 'Enter') return
|
if (ev.key !== 'Enter') return
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
if (busy) return
|
if (busy) return
|
||||||
if (need2FA) void submit2FA()
|
|
||||||
|
if (stage === 'verify' || stage === 'setup') void submit2FA()
|
||||||
else void submitLogin()
|
else void submitLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +203,7 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 space-y-3">
|
<div className="mt-5 space-y-3">
|
||||||
{!need2FA ? (
|
{stage === 'login' ? (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">Username</label>
|
<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'}
|
{busy ? 'Login…' : 'Login'}
|
||||||
</Button>
|
</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">
|
<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.
|
2FA ist aktiv – bitte gib den Code aus deiner Authenticator-App ein.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<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
|
<input
|
||||||
|
id="totp"
|
||||||
|
name="totp"
|
||||||
|
aria-label="totp"
|
||||||
|
type="text"
|
||||||
value={code}
|
value={code}
|
||||||
onChange={(e) => setCode(e.target.value)}
|
onChange={(e) => setCode(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
|
autoComplete="one-time-code"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
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"
|
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"
|
placeholder="123456"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@ -202,9 +276,9 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
className="flex-1 rounded-lg"
|
className="flex-1 rounded-lg"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNeed2FA(false)
|
setSetupAuthUrl(null)
|
||||||
setCode('')
|
setSetupSecret(null)
|
||||||
setError(null)
|
setSetupInfo(null)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurück
|
Zurück
|
||||||
@ -219,6 +293,106 @@ export default function LoginPage({ onLoggedIn }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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 ? (
|
{error ? (
|
||||||
|
|||||||
@ -379,6 +379,8 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
const [imgViewer, setImgViewer] = React.useState<{ src: string; alt?: string } | null>(null)
|
||||||
|
|
||||||
|
const [doneTotalCount, setDoneTotalCount] = React.useState(0)
|
||||||
|
|
||||||
const refetchModels = React.useCallback(async () => {
|
const refetchModels = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/api/models/list', { cache: 'no-store' })
|
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
|
// Done downloads (inkl. done + keep/<model>/) -> ALLES laden, Pagination client-side
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return
|
if (!open || !key) return
|
||||||
let alive = true
|
let alive = true
|
||||||
|
|
||||||
setDoneLoading(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' })
|
fetch(url, { cache: 'no-store' })
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: any) => {
|
.then((data: any) => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
|
const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : []
|
||||||
// ✅ robust: Backend kann Array ODER {items:[...]} liefern
|
const count = Number(data?.count ?? items.length)
|
||||||
const items = Array.isArray(data)
|
|
||||||
? (data as RecordJob[])
|
|
||||||
: Array.isArray(data?.items)
|
|
||||||
? (data.items as RecordJob[])
|
|
||||||
: []
|
|
||||||
|
|
||||||
setDone(items)
|
setDone(items)
|
||||||
|
setDoneTotalCount(Number.isFinite(count) ? count : items.length)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
setDone([])
|
setDone([])
|
||||||
|
setDoneTotalCount(0)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!alive) return
|
if (!alive) return
|
||||||
@ -578,7 +579,7 @@ export default function ModelDetails({
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false
|
alive = false
|
||||||
}
|
}
|
||||||
}, [open])
|
}, [open, key, donePage])
|
||||||
|
|
||||||
// Running jobs
|
// Running jobs
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -630,8 +631,8 @@ export default function ModelDetails({
|
|||||||
}, [done, key])
|
}, [done, key])
|
||||||
|
|
||||||
const doneTotalPages = React.useMemo(() => {
|
const doneTotalPages = React.useMemo(() => {
|
||||||
return Math.max(1, Math.ceil(doneMatches.length / DONE_PAGE_SIZE))
|
return Math.max(1, Math.ceil(doneTotalCount / DONE_PAGE_SIZE))
|
||||||
}, [doneMatches.length])
|
}, [doneTotalCount])
|
||||||
|
|
||||||
const doneMatchesPage = React.useMemo(() => {
|
const doneMatchesPage = React.useMemo(() => {
|
||||||
const start = (donePage - 1) * DONE_PAGE_SIZE
|
const start = (donePage - 1) * DONE_PAGE_SIZE
|
||||||
@ -921,6 +922,44 @@ export default function ModelDetails({
|
|||||||
|
|
||||||
{/* Local flags icons (unten rechts im Hero) */}
|
{/* Local flags icons (unten rechts im Hero) */}
|
||||||
<div className="absolute bottom-3 right-3 flex items-center gap-2">
|
<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 */}
|
{/* Favorite = Star */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -994,43 +1033,6 @@ export default function ModelDetails({
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,165 @@ import Button from './Button'
|
|||||||
import { apiUrl, apiFetch } from '../../lib/api'
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
import LiveHlsVideo from './LiveHlsVideo'
|
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 baseName = (p: string) => (p || '').replaceAll('\\', '/').split('/').pop() || ''
|
||||||
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
const stripHotPrefix = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s)
|
||||||
const lower = (s: string) => (s || '').trim().toLowerCase()
|
const lower = (s: string) => (s || '').trim().toLowerCase()
|
||||||
@ -179,6 +338,49 @@ function useMediaQuery(query: string) {
|
|||||||
return matches
|
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 = {
|
export type PlayerProps = {
|
||||||
job: RecordJob
|
job: RecordJob
|
||||||
expanded: boolean
|
expanded: boolean
|
||||||
@ -397,6 +599,24 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [isRunning, hlsIndexUrl])
|
}, [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(() => {
|
const media = React.useMemo(() => {
|
||||||
// ✅ Live wird NICHT mehr über Video.js gespielt
|
// ✅ Live wird NICHT mehr über Video.js gespielt
|
||||||
if (isRunning) return { src: '', type: '' }
|
if (isRunning) return { src: '', type: '' }
|
||||||
@ -408,11 +628,11 @@ export default function Player({
|
|||||||
ext === 'mp4' ? 'video/mp4' :
|
ext === 'mp4' ? 'video/mp4' :
|
||||||
ext === 'ts' ? 'video/mp2t' :
|
ext === 'ts' ? 'video/mp2t' :
|
||||||
'application/octet-stream'
|
'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' }
|
return { src: buildVideoSrc({ id: job.id, quality: selectedQuality }), type: 'video/mp4' }
|
||||||
}, [isRunning, job.output, job.id])
|
}, [isRunning, job.output, job.id, selectedQuality, buildVideoSrc])
|
||||||
|
|
||||||
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
const containerRef = React.useRef<HTMLDivElement | null>(null)
|
||||||
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
const playerRef = React.useRef<VideoJsPlayer | null>(null)
|
||||||
@ -420,6 +640,32 @@ export default function Player({
|
|||||||
|
|
||||||
const [mounted, setMounted] = React.useState(false)
|
const [mounted, setMounted] = React.useState(false)
|
||||||
|
|
||||||
|
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
|
// ✅ iOS Safari: visualViewport changes (address bar / bottom bar / keyboard) need a rerender
|
||||||
const [, setVvTick] = React.useState(0)
|
const [, setVvTick] = React.useState(0)
|
||||||
|
|
||||||
@ -496,6 +742,50 @@ export default function Player({
|
|||||||
}
|
}
|
||||||
}, [mounted, expanded])
|
}, [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(() => setMounted(true), [])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -633,6 +923,14 @@ export default function Player({
|
|||||||
containerRef.current.appendChild(videoEl)
|
containerRef.current.appendChild(videoEl)
|
||||||
videoNodeRef.current = 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, {
|
const p = videojs(videoEl, {
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
muted: startMuted,
|
muted: startMuted,
|
||||||
@ -644,7 +942,7 @@ export default function Player({
|
|||||||
fill: true,
|
fill: true,
|
||||||
|
|
||||||
// ✅ Live UI (wir verstecken zwar die Seekbar, aber LiveTracker ist nützlich)
|
// ✅ 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)
|
// ✅ optional: VHS Low-Latency (wenn deine Video.js-Version es unterstützt)
|
||||||
html5: {
|
html5: {
|
||||||
@ -655,6 +953,9 @@ export default function Player({
|
|||||||
|
|
||||||
inactivityTimeout: 0,
|
inactivityTimeout: 0,
|
||||||
|
|
||||||
|
gearQualities: initialGearQualities,
|
||||||
|
__gearQuality: initialSelectedQuality,
|
||||||
|
|
||||||
controlBar: {
|
controlBar: {
|
||||||
skipButtons: { backward: 10, forward: 10 },
|
skipButtons: { backward: 10, forward: 10 },
|
||||||
volumePanel: { inline: false },
|
volumePanel: { inline: false },
|
||||||
@ -664,11 +965,12 @@ export default function Player({
|
|||||||
'skipForward',
|
'skipForward',
|
||||||
'volumePanel',
|
'volumePanel',
|
||||||
'currentTimeDisplay',
|
'currentTimeDisplay',
|
||||||
'durationDisplay',
|
|
||||||
'timeDivider',
|
'timeDivider',
|
||||||
|
'durationDisplay',
|
||||||
'progressControl',
|
'progressControl',
|
||||||
'spacer',
|
'spacer',
|
||||||
'playbackRateMenuButton',
|
'playbackRateMenuButton',
|
||||||
|
'GearMenuButton',
|
||||||
'fullscreenToggle',
|
'fullscreenToggle',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -677,6 +979,30 @@ export default function Player({
|
|||||||
|
|
||||||
playerRef.current = p
|
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.userActive(true)
|
||||||
p.on('userinactive', () => 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(() => {
|
React.useEffect(() => {
|
||||||
const p = playerRef.current
|
const p = playerRef.current
|
||||||
|
|||||||
@ -15,6 +15,65 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-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) {
|
@media (prefers-color-scheme: light) {
|
||||||
:root {
|
:root {
|
||||||
color: #213547;
|
color: #213547;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user