From ab3b55bcf8e4a358fd8db274b71afe3a3d9ff1a1 Mon Sep 17 00:00:00 2001 From: Rother Date: Fri, 2 Jan 2026 13:13:03 +0100 Subject: [PATCH] updated --- backend/data/models_store.db-shm | Bin 32768 -> 32768 bytes backend/data/models_store.db-wal | Bin 4157112 -> 4157112 bytes backend/main.go | 789 ++++++++++++++---- backend/models_api.go | 45 +- backend/models_store.go | 107 ++- frontend/src/App.tsx | 265 ++++-- .../src/components/ui/FinishedDownloads.tsx | 389 ++++++--- .../ui/FinishedDownloadsCardsView.tsx | 195 ++--- .../ui/FinishedDownloadsGalleryView.tsx | 247 ++++-- .../ui/FinishedDownloadsTableView.tsx | 3 - .../components/ui/FinishedVideoPreview.tsx | 62 +- .../src/components/ui/GenerateAssetsTask.tsx | 139 +++ frontend/src/components/ui/LiveHlsVideo.tsx | 13 +- frontend/src/components/ui/ModelPreview.tsx | 63 +- frontend/src/components/ui/Player.tsx | 37 +- frontend/src/components/ui/ProgressBar.tsx | 93 +++ .../src/components/ui/RecorderSettings.tsx | 13 +- .../src/components/ui/RunningDownloads.tsx | 112 ++- frontend/src/components/ui/videoPolicy.ts | 17 + 19 files changed, 1970 insertions(+), 619 deletions(-) create mode 100644 frontend/src/components/ui/GenerateAssetsTask.tsx create mode 100644 frontend/src/components/ui/ProgressBar.tsx create mode 100644 frontend/src/components/ui/videoPolicy.ts diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm index f60b8fbaf7414af5368e56548507593cd383ff70..b7a15f0642ae486b68db0b510934bda6f4917ec8 100644 GIT binary patch delta 1759 zcmb7CYfx2H7~P9=FLI5Pyp)(0UMb=7P(v#%1R^oagf90HASNi9qP#^Qx}7U%4#0O+bj45r)_ZA?laqW~HD1YR#qAP~PdDlF8@(_-*h?#;oK2*B?Hd zL7n^8n8#d?5M4*NO}FWjsE28p!dE+q|I(IkD(m8iGP9ORusmE}X|AcyQGS)xxiN&L z&8&-fF3u!6&7$iJ*BaC4u@Qd~R|z-$$wVbdmIBQ7exPD2tm zR98DpgK9_>T6yf1TtCu(8ntz$|1$dUJ9Nu1-a*!vI@pl z6TL(q5y_8=Ce*o|)VqYJ5uci4qqxScf;e-}((3M2aX0fGLfyTkd#Ejc7I`*NN2q<( zL|>R2{kHr?+WR;mUG=}pAzQaB%sGKLQ~iQivP3+@_`Uo(*QOo8N%91FHFqMWk-4yp z@emBgpyo#rv##BY66Qoa!05HF6Zha4S*tvkw*d3VT-sioVIIH-j6nn#{aC}8;^-B} z#LMVL50y*H!4u3LZN0dc*)5xwCu234-%&13iI4gB-$kW5AI4g;6#3 z{0_W9_POyzfe*+f^*Un3P7dq<4pJq^UQZi7BpWb3D~y2V!%B(*`%+Y5vtmZAZxpB? z4a!dfqbMWO@i3!XE-5O)F0w>>FAn29vKAw!_z19*)NACd3<0yl&da3%+(H)IH?j&7 z-o!f?#93<9cq|gh66K}Uspwa3ESLQ@zuaBsLlU*$$Sgk$>?76XhP5HQMCLUzD!#L6 z=@_CYupbm>#DioWd2-_%XCx+K5^lvzRAURC#M5|&drlc$+2)vl8!#C)Xyu9vl2Mln zH;rPxsy50t2VmcgSMeI9YA#b=te&my#{hM^?5HWyT+qoAjHucWv=#8EI{8!G(yAy# zBL-726*F);MI9Q@gcby`4ei*0=kWqwWQAXr wGzYW=xQ|?oBis0)#RvOzG~!t>I*^D9EWk{c%iqn+(-&jX-#Q*>R delta 527 zcmb7AODKd<6u$S)j5|qDER<{qV_3+7vN0hUkBr|RgOEpFG4dKs&0LCwg)&Eq#WYb! z7IY<2QY3^lX_90i3k!R(;oNDIt-Cmn?|kR$J68xM1oNl9 z5dz!-U#0N~R502F3)~O4<@wlNQ$vvKA>gIm7_-6SutJFIyqz49Q}QVn_zi{YsyvkN zz<|}zsqfumOrW7(ymT)fvg0<5)+LRYgMzL6zZY`n;ADrnC$>y<*l+KIL{450aKXiNM6Uqr_k diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal index dc3fbf6019043ed97c20f77b0bbd3f7940dc37e2..b5e8586e4dd22b88b1bb05b08c9577c17d2281f9 100644 GIT binary patch delta 166128 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 YfZ9Pp5C?h=v;?#i)G_98EjzaAUuNcmkN^Mx delta 74049 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 u!)}1hfX#$OV6$MeVRK+}VK>6&!REsjz!t)8g53 stabiler Snapshot fürs JSON + list = append(list, c) + } + jobsMu.Unlock() + + sort.Slice(list, func(i, j int) bool { + return list[i].StartedAt.After(list[j].StartedAt) + }) + + b, _ := json.Marshal(list) + return b +} + +func recordStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Nur GET erlaubt", http.StatusMethodNotAllowed) + return + } + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming nicht unterstützt", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") // hilfreich bei Reverse-Proxies + + // Reconnect-Hinweis für Browser + _, _ = fmt.Fprintf(w, "retry: 3000\n\n") + flusher.Flush() + + ch := make(chan []byte, 16) + recordJobsHub.add(ch) + defer recordJobsHub.remove(ch) + + // Initialer Snapshot sofort + if b := jobsSnapshotJSON(); len(b) > 0 { + _, _ = fmt.Fprintf(w, "event: jobs\ndata: %s\n\n", b) + flusher.Flush() + } + + ping := time.NewTicker(15 * time.Second) + defer ping.Stop() + + for { + select { + case <-r.Context().Done(): + return + + case b := <-ch: + if len(b) == 0 { + continue + } + _, _ = fmt.Fprintf(w, "event: jobs\ndata: %s\n\n", b) + flusher.Flush() + + case <-ping.C: + // Keepalive als Kommentar (stört nicht, hält Verbindungen offen) + _, _ = fmt.Fprintf(w, ": ping %d\n\n", time.Now().Unix()) + flusher.Flush() + } + } +} + // ffmpeg-Binary suchen (env, neben EXE, oder PATH) var ffmpegPath = detectFFmpegPath() @@ -434,10 +575,10 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { // ffmpeg-Pfad nach Änderungen neu bestimmen ffmpegPath = detectFFmpegPath() - fmt.Println("🔍 ffmpegPath (nach Save):", ffmpegPath) + fmt.Println("🔍 ffmpegPath:", ffmpegPath) ffprobePath = detectFFprobePath() - fmt.Println("🔍 ffprobePath (nach Save):", ffprobePath) + fmt.Println("🔍 ffprobePath:", ffprobePath) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(getSettings()) @@ -833,6 +974,20 @@ func extractLastFrameFromPreviewDir(previewDir string) ([]byte, error) { return img, nil } +func stripHotPrefix(s string) string { + s = strings.TrimSpace(s) + // akzeptiere "HOT " auch case-insensitive + if len(s) >= 4 && strings.EqualFold(s[:4], "HOT ") { + return strings.TrimSpace(s[4:]) + } + return s +} + +func generatedRoot() (string, error) { + return resolvePathRelativeToApp("generated") +} + +// Legacy (falls noch alte Assets liegen): func generatedThumbsRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) } @@ -840,22 +995,58 @@ func generatedTeaserRoot() (string, error) { return resolvePathRelativeToApp(filepath.Join("generated", "teaser")) } +// ✅ Neu: /generated//thumbs.jpg + /generated//preview.mp4 +func generatedDirForID(id string) (string, error) { + id, err := sanitizeID(id) + if err != nil { + return "", err + } + root, err := generatedRoot() + if err != nil { + return "", err + } + if strings.TrimSpace(root) == "" { + return "", fmt.Errorf("generated 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 { - thumbs, err := generatedThumbsRoot() + root, err := generatedRoot() if err != nil { return err } - teaser, err := generatedTeaserRoot() - if err != nil { - return err + if strings.TrimSpace(root) == "" { + return fmt.Errorf("generated root ist leer") } - if err := os.MkdirAll(thumbs, 0o755); err != nil { - return err - } - if err := os.MkdirAll(teaser, 0o755); err != nil { - return err - } - return nil + return os.MkdirAll(root, 0o755) } func sanitizeID(id string) (string, error) { @@ -905,11 +1096,23 @@ func findFinishedFileByID(id string) (string, error) { recordAbs, _ := resolvePathRelativeToApp(s.RecordDir) doneAbs, _ := resolvePathRelativeToApp(s.DoneDir) + base := stripHotPrefix(strings.TrimSpace(id)) + if base == "" { + return "", fmt.Errorf("not found") + } + candidates := []string{ - filepath.Join(doneAbs, id+".mp4"), - filepath.Join(doneAbs, id+".ts"), - filepath.Join(recordAbs, id+".mp4"), - filepath.Join(recordAbs, id+".ts"), + // done + filepath.Join(doneAbs, base+".mp4"), + filepath.Join(doneAbs, "HOT "+base+".mp4"), + filepath.Join(doneAbs, base+".ts"), + filepath.Join(doneAbs, "HOT "+base+".ts"), + + // record + filepath.Join(recordAbs, base+".mp4"), + filepath.Join(recordAbs, "HOT "+base+".mp4"), + filepath.Join(recordAbs, base+".ts"), + filepath.Join(recordAbs, "HOT "+base+".ts"), } for _, p := range candidates { @@ -1030,18 +1233,26 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri return } - thumbsRoot, _ := generatedThumbsRoot() - thumbDir := filepath.Join(thumbsRoot, id) - _ = os.MkdirAll(thumbDir, 0o755) + // ✅ Assets immer auf "basename ohne HOT" ablegen + assetID := stripHotPrefix(id) + if assetID == "" { + assetID = id + } - // ✅ Frame-Caching für t=... (für alte "clips" Logik) + assetDir, err := ensureGeneratedDir(assetID) + if err != nil { + http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) + return + } + + // ✅ Frame-Caching für t=... (optional) if tStr := strings.TrimSpace(r.URL.Query().Get("t")); tStr != "" { if sec, err := strconv.ParseFloat(tStr, 64); err == nil && sec >= 0 { - secI := int64(sec + 0.5) // auf ~Sekunden runden + secI := int64(sec + 0.5) if secI < 0 { secI = 0 } - framePath := filepath.Join(thumbDir, fmt.Sprintf("t_%d.jpg", secI)) + framePath := filepath.Join(assetDir, fmt.Sprintf("t_%d.jpg", secI)) if fi, err := os.Stat(framePath); err == nil && !fi.IsDir() && fi.Size() > 0 { servePreviewJPEGFile(w, r, framePath) return @@ -1053,18 +1264,37 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri servePreviewJPEGBytes(w, img) return } - // wenn das scheitert, unten weiter mit preview.jpg } } - // ✅ Statisches Preview (einmalig) -> generated/thumbs//preview.jpg - previewJpg := filepath.Join(thumbDir, "preview.jpg") - if fi, err := os.Stat(previewJpg); err == nil && !fi.IsDir() && fi.Size() > 0 { - servePreviewJPEGFile(w, r, previewJpg) + thumbPath := filepath.Join(assetDir, "thumbs.jpg") + + // 1) Cache hit (neu) + if fi, err := os.Stat(thumbPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + servePreviewJPEGFile(w, r, thumbPath) return } - // Besseres Preview: wenn Duration bekannt, nimm Mitte; sonst fallback + // 2) Legacy-Migration (best effort) + if thumbsLegacy, _ := generatedThumbsRoot(); strings.TrimSpace(thumbsLegacy) != "" { + candidates := []string{ + filepath.Join(thumbsLegacy, assetID, "preview.jpg"), + filepath.Join(thumbsLegacy, id, "preview.jpg"), + filepath.Join(thumbsLegacy, assetID+".jpg"), + filepath.Join(thumbsLegacy, id+".jpg"), + } + for _, c := range candidates { + if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { + if b, rerr := os.ReadFile(c); rerr == nil && len(b) > 0 { + _ = atomicWriteFile(thumbPath, b) + servePreviewJPEGBytes(w, b) + return + } + } + } + } + + // 3) Neu erzeugen genCtx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() @@ -1085,7 +1315,7 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri } } - _ = atomicWriteFile(previewJpg, img) + _ = atomicWriteFile(thumbPath, img) servePreviewJPEGBytes(w, img) } @@ -1108,6 +1338,13 @@ func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { http.ServeContent(w, r, filepath.Base(path), fi.ModTime(), f) } +// tolerante Input-Flags für kaputte/abgeschnittene H264/TS Streams +var ffmpegInputTol = []string{ + "-fflags", "+discardcorrupt+genpts", + "-err_detect", "ignore_err", + "-max_error_rate", "1.0", +} + func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, durSec float64) error { if durSec <= 0 { durSec = 8 @@ -1123,6 +1360,9 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d "-y", "-hide_banner", "-loglevel", "error", + } + args = append(args, ffmpegInputTol...) + args = append(args, "-ss", fmt.Sprintf("%.3f", startSec), "-i", srcPath, "-t", fmt.Sprintf("%.3f", durSec), @@ -1133,12 +1373,9 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d "-crf", "28", "-pix_fmt", "yuv420p", "-movflags", "+faststart", - - // ✅ WICHTIG: Output-Format festnageln, weil tmp auf ".part" endet "-f", "mp4", - tmp, - } + ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) if out, err := cmd.CombinedOutput(); err != nil { @@ -1151,63 +1388,290 @@ func generateTeaserMP4(ctx context.Context, srcPath, outPath string, startSec, d } func generatedTeaser(w http.ResponseWriter, r *http.Request) { - id, err := sanitizeID(r.URL.Query().Get("id")) + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "id fehlt", http.StatusBadRequest) + return + } + + var err error + id, err = sanitizeID(id) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + outPath, err := findFinishedFileByID(id) + if err != nil { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + if err := ensureGeneratedDirs(); err != nil { http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) return } - teaserRoot, _ := generatedTeaserRoot() + assetID := stripHotPrefix(id) + if assetID == "" { + assetID = id + } - // ✅ neuer Name - teaserPath := filepath.Join(teaserRoot, id+"_teaser.mp4") + assetDir, err := ensureGeneratedDir(assetID) + if err != nil { + http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) + return + } - // ✅ optional: Legacy-Name unterstützen (falls bereits welche existieren) - legacyPath := filepath.Join(teaserRoot, id+".mp4") + previewPath := filepath.Join(assetDir, "preview.mp4") // Cache hit (neu) - if fi, err := os.Stat(teaserPath); err == nil && !fi.IsDir() && fi.Size() > 0 { - serveTeaserFile(w, r, teaserPath) + if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + serveTeaserFile(w, r, previewPath) return } - // Cache hit (legacy) - if fi, err := os.Stat(legacyPath); err == nil && !fi.IsDir() && fi.Size() > 0 { - serveTeaserFile(w, r, legacyPath) - return + // Legacy: generated/teaser/_teaser.mp4 oder .mp4 + if teaserLegacy, _ := generatedTeaserRoot(); strings.TrimSpace(teaserLegacy) != "" { + cids := []string{assetID, id} + for _, cid := range cids { + candidates := []string{ + filepath.Join(teaserLegacy, cid+"_teaser.mp4"), + filepath.Join(teaserLegacy, cid+".mp4"), + } + for _, c := range candidates { + if fi, err := os.Stat(c); err == nil && !fi.IsDir() && fi.Size() > 0 { + if _, err2 := os.Stat(previewPath); os.IsNotExist(err2) { + _ = os.MkdirAll(filepath.Dir(previewPath), 0o755) + _ = os.Rename(c, previewPath) + } + if fi2, err2 := os.Stat(previewPath); err2 == nil && !fi2.IsDir() && fi2.Size() > 0 { + serveTeaserFile(w, r, previewPath) + return + } + serveTeaserFile(w, r, c) + return + } + } + } } - // Quelle finden - srcPath, err := findFinishedFileByID(id) - if err != nil { - http.Error(w, "teaser nicht verfügbar", http.StatusNotFound) - return - } - - // Generieren (limitiert parallel) + // Neu erzeugen genSem <- struct{}{} defer func() { <-genSem }() genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) defer cancel() - if err := os.MkdirAll(filepath.Dir(teaserPath), 0o755); err != nil { - http.Error(w, "teaser dir error: "+err.Error(), http.StatusInternalServerError) + if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, 1.0, 18); err != nil { + // Fallback: einzelner kurzer Teaser ab Anfang (trifft seltener kaputte Stellen) + if err2 := generateTeaserMP4(genCtx, outPath, previewPath, 0, 8); err2 != nil { + http.Error(w, "konnte preview nicht erzeugen: "+err.Error()+" (fallback ebenfalls fehlgeschlagen: "+err2.Error()+")", http.StatusInternalServerError) + return + } + } + + serveTeaserFile(w, r, previewPath) +} + +// --------------------------- +// Tasks: Missing Assets erzeugen +// --------------------------- + +type AssetsTaskState struct { + Running bool `json:"running"` + Total int `json:"total"` + Done int `json:"done"` + GeneratedThumbs int `json:"generatedThumbs"` + GeneratedPreviews int `json:"generatedPreviews"` + Skipped int `json:"skipped"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Error string `json:"error,omitempty"` +} + +var assetsTaskMu sync.Mutex +var assetsTaskState AssetsTaskState + +func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + assetsTaskMu.Lock() + st := assetsTaskState + assetsTaskMu.Unlock() + writeJSON(w, http.StatusOK, st) + return + + case http.MethodPost: + assetsTaskMu.Lock() + if assetsTaskState.Running { + st := assetsTaskState + assetsTaskMu.Unlock() + writeJSON(w, http.StatusOK, st) + return + } + + assetsTaskState = AssetsTaskState{ + Running: true, + StartedAt: time.Now(), + } + st := assetsTaskState + assetsTaskMu.Unlock() + + go runGenerateMissingAssets() + + writeJSON(w, http.StatusOK, st) + return + + default: + http.Error(w, "Nur GET/POST", http.StatusMethodNotAllowed) + return + } +} + +func runGenerateMissingAssets() { + finishWithErr := func(err error) { + now := time.Now() + assetsTaskMu.Lock() + assetsTaskState.Running = false + assetsTaskState.FinishedAt = &now + if err != nil { + assetsTaskState.Error = err.Error() + } + assetsTaskMu.Unlock() + } + + s := getSettings() + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil || strings.TrimSpace(doneAbs) == "" { + finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) return } - // ✅ NEU: Teaser aus mehreren 1s-Clips erzeugen - if err := generateTeaserClipsMP4(genCtx, srcPath, teaserPath, 1.0, 18); err != nil { - http.Error(w, "teaser erzeugen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + entries, err := os.ReadDir(doneAbs) + if err != nil { + finishWithErr(fmt.Errorf("doneDir lesen fehlgeschlagen: %v", err)) return } - serveTeaserFile(w, r, teaserPath) + type item struct { + name string + path string + } + items := make([]item, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + low := strings.ToLower(name) + if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { + continue + } + ext := strings.ToLower(filepath.Ext(name)) + if ext != ".mp4" && ext != ".ts" { + continue + } + items = append(items, item{name: name, path: filepath.Join(doneAbs, name)}) + } + + assetsTaskMu.Lock() + assetsTaskState.Total = len(items) + assetsTaskState.Done = 0 + assetsTaskState.GeneratedThumbs = 0 + assetsTaskState.GeneratedPreviews = 0 + assetsTaskState.Skipped = 0 + assetsTaskState.Error = "" + assetsTaskMu.Unlock() + + for i, it := range items { + base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) + id := stripHotPrefix(base) + if strings.TrimSpace(id) == "" { + assetsTaskMu.Lock() + assetsTaskState.Done = i + 1 + assetsTaskMu.Unlock() + continue + } + + assetDir, derr := ensureGeneratedDir(id) + if derr != nil { + assetsTaskMu.Lock() + assetsTaskState.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" + assetsTaskState.Done = i + 1 + assetsTaskMu.Unlock() + fmt.Println("⚠️ ensureGeneratedDir:", derr) + continue + } + + thumbPath := filepath.Join(assetDir, "thumbs.jpg") + previewPath := filepath.Join(assetDir, "preview.mp4") + + thumbOK := func() bool { + fi, err := os.Stat(thumbPath) + return err == nil && !fi.IsDir() && fi.Size() > 0 + }() + previewOK := func() bool { + fi, err := os.Stat(previewPath) + return err == nil && !fi.IsDir() && fi.Size() > 0 + }() + + if thumbOK && previewOK { + assetsTaskMu.Lock() + assetsTaskState.Skipped++ + assetsTaskState.Done = i + 1 + assetsTaskMu.Unlock() + continue + } + + if !thumbOK { + genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + var t float64 = 0 + if dur, derr := durationSecondsCached(genCtx, it.path); derr == nil && dur > 0 { + t = dur * 0.5 + } + cancel() + + img, e1 := extractFrameAtTimeJPEG(it.path, t) + if e1 != nil || len(img) == 0 { + img, e1 = extractLastFrameJPEG(it.path) + if e1 != nil || len(img) == 0 { + img, e1 = extractFirstFrameJPEG(it.path) + } + } + if e1 == nil && len(img) > 0 { + if err := atomicWriteFile(thumbPath, img); err == nil { + assetsTaskMu.Lock() + assetsTaskState.GeneratedThumbs++ + assetsTaskMu.Unlock() + } else { + fmt.Println("⚠️ thumb write:", err) + } + } + } + + if !previewOK { + genSem <- struct{}{} + genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + err := generateTeaserClipsMP4(genCtx, it.path, previewPath, 1.0, 18) + cancel() + <-genSem + + if err == nil { + assetsTaskMu.Lock() + assetsTaskState.GeneratedPreviews++ + assetsTaskMu.Unlock() + } else { + fmt.Println("⚠️ preview clips:", err) + } + } + + assetsTaskMu.Lock() + assetsTaskState.Done = i + 1 + assetsTaskMu.Unlock() + } + + finishWithErr(nil) } func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error { @@ -1262,6 +1726,10 @@ func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLe // Mehrere Inputs: gleiche Datei, aber je Clip mit eigenem -ss/-t for _, t := range starts { + // 1) erst die toleranten Input-Flags + args = append(args, ffmpegInputTol...) + + // 2) dann die normalen Input-Parameter für diesen Clip args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", clipLenSec), @@ -1668,6 +2136,7 @@ func registerRoutes(mux *http.ServeMux) *ModelStore { mux.HandleFunc("/api/record/stop", recordStop) mux.HandleFunc("/api/record/preview", recordPreview) mux.HandleFunc("/api/record/list", recordList) + mux.HandleFunc("/api/record/stream", recordStream) mux.HandleFunc("/api/record/video", recordVideo) mux.HandleFunc("/api/record/done", recordDoneList) mux.HandleFunc("/api/record/done/meta", recordDoneMeta) @@ -1678,6 +2147,8 @@ func registerRoutes(mux *http.ServeMux) *ModelStore { mux.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler) mux.HandleFunc("/api/generated/teaser", generatedTeaser) + // Tasks + mux.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) modelsPath, _ := resolvePathRelativeToApp("data/models_store.db") fmt.Println("📦 Models DB:", modelsPath) @@ -1749,6 +2220,8 @@ func startRecordingInternal(req RecordRequest) (*RecordJob, error) { jobs[jobID] = job jobsMu.Unlock() + notifyJobsChanged() + go runJob(ctx, job, req) return job, nil } @@ -1832,6 +2305,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { jobsMu.Lock() job.Output = outPath jobsMu.Unlock() + notifyJobsChanged() err = RecordStream(ctx, hc, "https://chaturbate.com/", username, outPath, req.Cookie, job) @@ -1923,6 +2397,7 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { finalOut := strings.TrimSpace(job.Output) finalStatus := job.Status jobsMu.Unlock() + notifyJobsChanged() // ---- Nach Abschluss Assets erzeugen (Preview + Teaser) ---- // nur bei Finished/Stopped, und nur wenn die Datei existiert @@ -1933,41 +2408,22 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { return } - // generated-Ordner im EXE-Pfad - genRoot, gerr := resolvePathRelativeToApp(filepath.Join("generated")) - if gerr != nil || strings.TrimSpace(genRoot) == "" { - fmt.Println("⚠️ generated root:", gerr) - return - } - thumbsDir := filepath.Join(genRoot, "thumbs") - teaserDir := filepath.Join(genRoot, "teaser") - _ = os.MkdirAll(thumbsDir, 0o755) - _ = os.MkdirAll(teaserDir, 0o755) - - // ID = Dateiname ohne Endung + // ✅ ID = Dateiname ohne Endung (immer OHNE "HOT " Prefix) base := filepath.Base(videoPath) id := strings.TrimSuffix(base, filepath.Ext(base)) + id = stripHotPrefix(id) if id == "" { return } - // --- Atomic writer (lokal) --- - writeAtomic := func(dst string, data []byte) error { - dir := filepath.Dir(dst) - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - tmp := dst + ".part" - if err := os.WriteFile(tmp, data, 0o644); err != nil { - _ = os.Remove(tmp) - return err - } - _ = os.Remove(dst) - return os.Rename(tmp, dst) + // ✅ /generated//thumbs.jpg + /generated//preview.mp4 + assetDir, gerr := ensureGeneratedDir(id) + if gerr != nil || strings.TrimSpace(assetDir) == "" { + fmt.Println("⚠️ generated dir:", gerr) + return } - // --- 1) Thumb (ein Frame) --- - thumbPath := filepath.Join(thumbsDir, id+".jpg") + thumbPath := filepath.Join(assetDir, "thumbs.jpg") if tfi, err := os.Stat(thumbPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 { genCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() @@ -1986,26 +2442,26 @@ func runJob(ctx context.Context, job *RecordJob, req RecordRequest) { } if e1 == nil && len(img) > 0 { - if err := writeAtomic(thumbPath, img); err != nil { + if err := atomicWriteFile(thumbPath, img); err != nil { fmt.Println("⚠️ thumb write:", err) } } } - // --- 2) Teaser (mp4 aus 1s-clips) --- - teaserPath := filepath.Join(teaserDir, id+"_teaser.mp4") - if tfi, err := os.Stat(teaserPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 { + previewPath := filepath.Join(assetDir, "preview.mp4") + if tfi, err := os.Stat(previewPath); err != nil || tfi.IsDir() || tfi.Size() <= 0 { genSem <- struct{}{} defer func() { <-genSem }() genCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) defer cancel() - if err := generateTeaserClipsMP4(genCtx, videoPath, teaserPath, 1.0, 18); err != nil { - fmt.Println("⚠️ teaser clips:", err) + if err := generateTeaserClipsMP4(genCtx, videoPath, previewPath, 1.0, 18); err != nil { + fmt.Println("⚠️ preview clips:", err) } } }(finalOut) + } } @@ -2578,30 +3034,35 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { // ✅ generated Assets löschen (best effort) base := strings.TrimSuffix(file, filepath.Ext(file)) + canonical := stripHotPrefix(base) - thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) - teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) - - if strings.TrimSpace(thumbsAbs) != "" { - // Falls du thumbs als Ordner pro Video nutzt (thumbs//...) - _ = os.RemoveAll(filepath.Join(thumbsAbs, base)) - - // Falls du thumbs als Datei nutzt (thumbs/.jpg) - _ = os.Remove(filepath.Join(thumbsAbs, base+".jpg")) - - // Falls du zusätzlich frame-files im Root ablegst (thumbs/_*.jpg) - if entries, err := os.ReadDir(thumbsAbs); err == nil { - prefix := base + "_" - for _, e := range entries { - if strings.HasPrefix(e.Name(), prefix) { - _ = os.Remove(filepath.Join(thumbsAbs, e.Name())) - } - } + // Neu: /generated// + if genAbs, _ := generatedRoot(); strings.TrimSpace(genAbs) != "" { + if strings.TrimSpace(canonical) != "" { + _ = os.RemoveAll(filepath.Join(genAbs, canonical)) + } + // falls irgendwo alte Assets mit HOT im Ordnernamen liegen + if strings.TrimSpace(base) != "" && base != canonical { + _ = os.RemoveAll(filepath.Join(genAbs, base)) } } - if strings.TrimSpace(teaserAbs) != "" { - _ = os.Remove(filepath.Join(teaserAbs, base+"_teaser.mp4")) + // Legacy-Cleanup (optional) + thumbsLegacy, _ := generatedThumbsRoot() + teaserLegacy, _ := generatedTeaserRoot() + + if strings.TrimSpace(thumbsLegacy) != "" { + _ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical)) + _ = os.RemoveAll(filepath.Join(thumbsLegacy, base)) + _ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg")) + _ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg")) + } + + if strings.TrimSpace(teaserLegacy) != "" { + _ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, base+".mp4")) } w.Header().Set("Content-Type", "application/json") @@ -2727,25 +3188,35 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { // ✅ generated Assets löschen (best effort) base := strings.TrimSuffix(file, filepath.Ext(file)) + canonical := stripHotPrefix(base) - thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) - teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) - - if strings.TrimSpace(thumbsAbs) != "" { - _ = os.RemoveAll(filepath.Join(thumbsAbs, base)) - _ = os.Remove(filepath.Join(thumbsAbs, base+".jpg")) - if entries, err := os.ReadDir(thumbsAbs); err == nil { - prefix := base + "_" - for _, e := range entries { - if strings.HasPrefix(e.Name(), prefix) { - _ = os.Remove(filepath.Join(thumbsAbs, e.Name())) - } - } + // Neu: /generated// + if genAbs, _ := generatedRoot(); strings.TrimSpace(genAbs) != "" { + if strings.TrimSpace(canonical) != "" { + _ = os.RemoveAll(filepath.Join(genAbs, canonical)) + } + // falls irgendwo alte Assets mit HOT im Ordnernamen liegen + if strings.TrimSpace(base) != "" && base != canonical { + _ = os.RemoveAll(filepath.Join(genAbs, base)) } } - if strings.TrimSpace(teaserAbs) != "" { - _ = os.Remove(filepath.Join(teaserAbs, base+".mp4")) + // Legacy-Cleanup (optional) + thumbsLegacy, _ := generatedThumbsRoot() + teaserLegacy, _ := generatedTeaserRoot() + + if strings.TrimSpace(thumbsLegacy) != "" { + _ = os.RemoveAll(filepath.Join(thumbsLegacy, canonical)) + _ = os.RemoveAll(filepath.Join(thumbsLegacy, base)) + _ = os.Remove(filepath.Join(thumbsLegacy, canonical+".jpg")) + _ = os.Remove(filepath.Join(thumbsLegacy, base+".jpg")) + } + + if strings.TrimSpace(teaserLegacy) != "" { + _ = os.Remove(filepath.Join(teaserLegacy, canonical+"_teaser.mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, base+"_teaser.mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, canonical+".mp4")) + _ = os.Remove(filepath.Join(teaserLegacy, base+".mp4")) } w.Header().Set("Content-Type", "application/json") @@ -2816,6 +3287,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } + // toggle: HOT Prefix newFile := file if strings.HasPrefix(file, "HOT ") { newFile = strings.TrimPrefix(file, "HOT ") @@ -2841,59 +3313,18 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } - // ✅ NEU: generated Assets umbenennen (best effort) - oldBase := strings.TrimSuffix(file, filepath.Ext(file)) - newBase := strings.TrimSuffix(newFile, filepath.Ext(newFile)) + // ✅ KEIN generated-rename mehr! + // Assets bleiben canonical: generated/thumbs/.jpg und generated/teaser/_teaser.mp4 (ohne HOT) - thumbsAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "thumbs")) - teaserAbs, _ := resolvePathRelativeToApp(filepath.Join("generated", "teaser")) - - // thumbs/.jpg - if strings.TrimSpace(thumbsAbs) != "" { - oldThumb := filepath.Join(thumbsAbs, oldBase+".jpg") - newThumb := filepath.Join(thumbsAbs, newBase+".jpg") - - if _, err := os.Stat(oldThumb); err == nil { - if _, err2 := os.Stat(newThumb); os.IsNotExist(err2) { - _ = renameWithRetry(oldThumb, newThumb) - } else { - // wenn Ziel existiert, alten löschen (keine Duplikate) - _ = os.Remove(oldThumb) - } - } - } - - // teaser/_teaser.mp4 - if strings.TrimSpace(teaserAbs) != "" { - oldTeaser := filepath.Join(teaserAbs, oldBase+"_teaser.mp4") - newTeaser := filepath.Join(teaserAbs, newBase+"_teaser.mp4") - - if _, err := os.Stat(oldTeaser); err == nil { - if _, err2 := os.Stat(newTeaser); os.IsNotExist(err2) { - _ = renameWithRetry(oldTeaser, newTeaser) - } else { - _ = os.Remove(oldTeaser) - } - } - - // optional: legacy teaser name ohne Suffix (falls noch vorhanden) - oldLegacy := filepath.Join(teaserAbs, oldBase+".mp4") - newLegacy := filepath.Join(teaserAbs, newBase+".mp4") - if _, err := os.Stat(oldLegacy); err == nil { - if _, err2 := os.Stat(newLegacy); os.IsNotExist(err2) { - _ = renameWithRetry(oldLegacy, newLegacy) - } else { - _ = os.Remove(oldLegacy) - } - } - } + canonicalID := stripHotPrefix(strings.TrimSuffix(file, filepath.Ext(file))) w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") _ = json.NewEncoder(w).Encode(map[string]any{ - "ok": true, - "oldFile": file, - "newFile": newFile, + "ok": true, + "oldFile": file, + "newFile": newFile, + "canonicalID": canonicalID, // optional fürs Frontend }) } @@ -3064,6 +3495,10 @@ func recordStop(w http.ResponseWriter, r *http.Request) { } jobsMu.Unlock() + if ok { + notifyJobsChanged() // ✅ 1) UI sofort updaten (Phase/Progress) + } + if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return @@ -3079,6 +3514,8 @@ func recordStop(w http.ResponseWriter, r *http.Request) { job.cancel() } + notifyJobsChanged() // ✅ 2) optional: nach Cancel/Kill nochmal pushen + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(job) } diff --git a/backend/models_api.go b/backend/models_api.go index 7e8c2f1..b04a0e0 100644 --- a/backend/models_api.go +++ b/backend/models_api.go @@ -227,15 +227,15 @@ func RegisterModelAPI(mux *http.ServeMux, store *ModelStore) { }) mux.HandleFunc("/api/models/meta", func(w http.ResponseWriter, r *http.Request) { - modelsWriteJSON(w, http.StatusOK, store.Meta()) -}) + modelsWriteJSON(w, http.StatusOK, store.Meta()) + }) -mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) { - host := strings.TrimSpace(r.URL.Query().Get("host")) - modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host)) -}) + mux.HandleFunc("/api/models/watched", func(w http.ResponseWriter, r *http.Request) { + host := strings.TrimSpace(r.URL.Query().Get("host")) + modelsWriteJSON(w, http.StatusOK, store.ListWatchedLite(host)) + }) -mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) { modelsWriteJSON(w, http.StatusOK, store.List()) }) @@ -264,6 +264,37 @@ mux.HandleFunc("/api/models/list", func(w http.ResponseWriter, r *http.Request) modelsWriteJSON(w, http.StatusOK, m) }) + // ✅ NEU: Ensure-Endpoint (für QuickActions aus FinishedDownloads) + // Erst versucht er ein bestehendes Model via modelKey zu finden, sonst legt er ein "manual" Model an. + mux.HandleFunc("/api/models/ensure", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + + var req struct { + ModelKey string `json:"modelKey"` + } + if err := modelsReadJSON(r, &req); err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + key := strings.TrimSpace(req.ModelKey) + if key == "" { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": "modelKey fehlt"}) + return + } + + m, err := store.EnsureByModelKey(key) + if err != nil { + modelsWriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + + modelsWriteJSON(w, http.StatusOK, m) + }) + mux.HandleFunc("/api/models/import", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { modelsWriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) diff --git a/backend/models_store.go b/backend/models_store.go index 2d70bb2..21c3024 100644 --- a/backend/models_store.go +++ b/backend/models_store.go @@ -79,6 +79,68 @@ type ModelStore struct { mu sync.Mutex } +// EnsureByModelKey: +// - liefert ein bestehendes Model (best match) wenn vorhanden +// - sonst legt es ein "manual" Model ohne URL an (Input=modelKey, IsURL=false) +// Dadurch funktionieren QuickActions (Like/Favorite) auch bei fertigen Videos, +// bei denen keine SourceURL mehr vorhanden ist. +func (s *ModelStore) EnsureByModelKey(modelKey string) (StoredModel, error) { + if err := s.ensureInit(); err != nil { + return StoredModel{}, err + } + + key := strings.TrimSpace(modelKey) + if key == "" { + return StoredModel{}, errors.New("modelKey fehlt") + } + + // Erst schauen ob es das Model schon gibt (egal welcher Host) + var existingID string + err := s.db.QueryRow(` +SELECT id +FROM models +WHERE lower(model_key) = lower(?) +ORDER BY favorite DESC, updated_at DESC +LIMIT 1; +`, key).Scan(&existingID) + + if err == nil && existingID != "" { + return s.getByID(existingID) + } + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return StoredModel{}, err + } + + // Neu anlegen als "manual" (is_url = 0), input = modelKey (NOT NULL) + now := time.Now().UTC().Format(time.RFC3339Nano) + id := canonicalID("", key) + + s.mu.Lock() + defer s.mu.Unlock() + + _, err = s.db.Exec(` +INSERT INTO models ( + id,input,is_url,host,path,model_key, + tags,last_stream, + watching,favorite,hot,keep,liked, + created_at,updated_at +) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) +ON CONFLICT(id) DO UPDATE SET + model_key=excluded.model_key, + updated_at=excluded.updated_at; +`, + id, key, int64(0), "", "", key, + "", "", + int64(0), int64(0), int64(0), int64(0), nil, + now, now, + ) + if err != nil { + return StoredModel{}, err + } + + return s.getByID(id) +} + // Backwards compatible: // - wenn du ".json" übergibst (wie aktuell in main.go), wird daraus automatisch ".db" // und die JSON-Datei wird als Legacy-Quelle für die 1x Migration genutzt. @@ -379,7 +441,7 @@ func (s *ModelStore) List() []StoredModel { rows, err := s.db.Query(` SELECT id,input,is_url,host,path,model_key, - tags,last_stream, + tags, COALESCE(last_stream,''), watching,favorite,hot,keep,liked, created_at,updated_at FROM models @@ -524,27 +586,28 @@ func (s *ModelStore) UpsertFromParsed(p ParsedModelDTO) (StoredModel, error) { defer s.mu.Unlock() _, err = s.db.Exec(` -INSERT INTO models ( - id,input,is_url,host,path,model_key, - tags,last_stream, - watching,favorite,hot,keep,liked, - created_at,updated_at -) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) -ON CONFLICT(id) DO UPDATE SET - input=excluded.input, - is_url=excluded.is_url, - host=excluded.host, - path=excluded.path, - model_key=excluded.model_key, - updated_at=excluded.updated_at; -`, + INSERT INTO models ( + id,input,is_url,host,path,model_key, + tags,last_stream, + watching,favorite,hot,keep,liked, + created_at,updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET + input=excluded.input, + is_url=excluded.is_url, + host=excluded.host, + path=excluded.path, + model_key=excluded.model_key, + updated_at=excluded.updated_at; + `, id, u.String(), int64(1), host, p.Path, modelKey, - int64(0), int64(0), int64(0), int64(0), nil, // Flags nur bei neuem Insert (Update fasst sie nicht an) + "", "", // ✅ tags, last_stream + int64(0), int64(0), int64(0), int64(0), nil, now, now, ) @@ -592,7 +655,15 @@ func (s *ModelStore) PatchFlags(patch ModelFlagsPatch) (StoredModel, error) { if patch.Keep != nil { keep = boolToInt(*patch.Keep) } - + // ✅ Business-Rule (robust, auch wenn Frontend es mal nicht mitsendet): + // - Liked=true => Favorite=false + // - Favorite=true => Liked wird gelöscht (NULL) + if patch.Liked != nil && *patch.Liked { + favorite = int64(0) + } + if patch.Favorite != nil && *patch.Favorite { + liked = sql.NullInt64{Valid: false} + } if patch.ClearLiked { liked = sql.NullInt64{Valid: false} } else if patch.Liked != nil { @@ -721,7 +792,7 @@ func (s *ModelStore) getByID(id string) (StoredModel, error) { err := s.db.QueryRow(` SELECT input,is_url,host,path,model_key, - tags, lastStream, + tags, COALESCE(last_stream,''), watching,favorite,hot,keep,liked, created_at,updated_at FROM models diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f5aa966..1d0f571 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,9 +7,10 @@ import Tabs, { type TabItem } from './components/ui/Tabs' import RecorderSettings from './components/ui/RecorderSettings' import FinishedDownloads from './components/ui/FinishedDownloads' import Player from './components/ui/Player' -import type { RecordJob, ParsedModel } from './types' +import type { RecordJob } from './types' import RunningDownloads from './components/ui/RunningDownloads' import ModelsTab from './components/ui/ModelsTab' +import ProgressBar from './components/ui/ProgressBar' const COOKIE_STORAGE_KEY = 'record_cookies' @@ -110,8 +111,6 @@ export default function App() { const DONE_PAGE_SIZE = 8 const [sourceUrl, setSourceUrl] = useState('') - const [, setParsed] = useState(null) - const [, setParseError] = useState(null) const [jobs, setJobs] = useState([]) const [doneJobs, setDoneJobs] = useState([]) const [donePage, setDonePage] = useState(1) @@ -120,7 +119,7 @@ export default function App() { const [playerModel, setPlayerModel] = useState(null) const modelsCacheRef = useRef<{ ts: number; list: StoredModel[] } | null>(null) - const [, setError] = useState(null) + const [error, setError] = useState(null) const [busy, setBusy] = useState(false) const [cookieModalOpen, setCookieModalOpen] = useState(false) const [cookies, setCookies] = useState>({}) @@ -129,6 +128,9 @@ export default function App() { const [playerJob, setPlayerJob] = useState(null) const [playerExpanded, setPlayerExpanded] = useState(false) + const [assetNonce, setAssetNonce] = useState(0) + const bumpAssets = useCallback(() => setAssetNonce((n) => n + 1), []) + const [recSettings, setRecSettings] = useState(DEFAULT_RECORDER_SETTINGS) const autoAddEnabled = Boolean(recSettings.autoAddToDownloadList) @@ -317,55 +319,73 @@ export default function App() { }, [doneCount, donePage]) useEffect(() => { - if (sourceUrl.trim() === '') { - setParsed(null) - setParseError(null) - return + let cancelled = false + let es: EventSource | null = null + let fallbackTimer: number | null = null + let inFlight = false + + const applyList = (list: any) => { + const arr = Array.isArray(list) ? (list as RecordJob[]) : [] + if (!cancelled) { + setJobs(arr) + jobsRef.current = arr + } } - const t = setTimeout(async () => { - try { - const p = await apiJSON('/api/models/parse', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: sourceUrl.trim() }), - }) - setParsed(p) - setParseError(null) - } catch (e: any) { - setParsed(null) - setParseError(e?.message ?? String(e)) - } - }, 300) - - return () => clearTimeout(t) - }, [sourceUrl]) - - useEffect(() => { - let cancelled = false - - const loadJobs = async () => { + const loadOnce = async () => { + if (cancelled || inFlight) return + inFlight = true try { const list = await apiJSON('/api/record/list') - if (!cancelled) { - setJobs(Array.isArray(list) ? list : []) - } + applyList(list) } catch { - if (!cancelled) { - // optional: bei Fehler nicht alles leeren, sondern Zustand behalten - // setJobs([]) - } + // ignore + } finally { + inFlight = false } } - // direkt einmal laden - loadJobs() - // dann jede Sekunde - const t = setInterval(loadJobs, 1000) + const startFallbackPolling = () => { + if (fallbackTimer) return + fallbackTimer = window.setInterval(loadOnce, document.hidden ? 15000 : 5000) + } + + // initial einmal laden + void loadOnce() + + // SSE verbinden + es = new EventSource('/api/record/stream') + + const onJobs = (ev: MessageEvent) => { + try { + applyList(JSON.parse(ev.data)) + } catch { + // ignore + } + } + + es.addEventListener('jobs', onJobs as any) + + es.onerror = () => { + // wenn SSE nicht geht (Proxy/Nginx/Browser): fallback polling + startFallbackPolling() + } + + const onVis = () => { + // wenn wieder sichtbar/fokus: einmal nachziehen + if (!document.hidden) void loadOnce() + } + document.addEventListener('visibilitychange', onVis) + window.addEventListener('focus', onVis) return () => { cancelled = true - clearInterval(t) + if (fallbackTimer) window.clearInterval(fallbackTimer) + document.removeEventListener('visibilitychange', onVis) + window.removeEventListener('focus', onVis) + es?.removeEventListener('jobs', onJobs as any) + es?.close() + es = null } }, []) @@ -414,6 +434,40 @@ export default function App() { } }, [selectedTab, donePage]) + // ✅ Sofort-Refresh für Finished-Liste + Count (z.B. nach Delete/Keep), + // damit die Seite direkt wieder mit PAGE_SIZE Items gefüllt wird und + // die Page-Nummern/Counts stimmen. + const refreshDoneNow = useCallback( + async (preferPage?: number) => { + try { + // 1) Meta (Count) + const meta = await apiJSON<{ count?: number }>( + '/api/record/done/meta', + { cache: 'no-store' as any } + ) + const countRaw = typeof meta?.count === 'number' ? meta.count : 0 + const count = Number.isFinite(countRaw) && countRaw >= 0 ? countRaw : 0 + setDoneCount(count) + + // 2) Page clampen + const maxPage = Math.max(1, Math.ceil(count / DONE_PAGE_SIZE)) + const wanted = typeof preferPage === 'number' ? preferPage : donePage + const target = Math.min(Math.max(1, wanted), maxPage) + if (target !== donePage) setDonePage(target) + + // 3) Liste für (ggf. geclampte) Seite laden + const list = await apiJSON( + `/api/record/done?page=${target}&pageSize=${DONE_PAGE_SIZE}`, + { cache: 'no-store' as any } + ) + setDoneJobs(Array.isArray(list) ? list : []) + } catch { + // ignore + } + }, + [donePage] + ) + function isChaturbate(url: string): boolean { try { @@ -491,11 +545,29 @@ export default function App() { } }, []) // arbeitet über refs, daher keine deps nötig - async function resolveModelForJob(job: RecordJob): Promise { + async function resolveModelForJob( + job: RecordJob, + opts?: { ensure?: boolean } + ): Promise { + const wantEnsure = Boolean(opts?.ensure) + + const upsertCache = (m: StoredModel) => { + const now = Date.now() + const cur = modelsCacheRef.current + if (!cur) { + modelsCacheRef.current = { ts: now, list: [m] } + return + } + cur.ts = now + const idx = cur.list.findIndex((x) => x.id === m.id) + if (idx >= 0) cur.list[idx] = m + else cur.list.unshift(m) + } + const urlFromJob = ((job as any).sourceUrl ?? (job as any).SourceURL ?? '') as string const url = extractFirstHttpUrl(urlFromJob) - // 1) Wenn URL da ist: parse + upsert => liefert ID + flags + // 1) Wenn URL da ist: parse + upsert if (url) { const parsed = await apiJSON('/api/models/parse', { method: 'POST', @@ -509,13 +581,15 @@ export default function App() { body: JSON.stringify(parsed), }) + upsertCache(saved) return saved } - // 2) Fallback: aus Dateiname modelKey ableiten und im Store suchen + // 2) Fallback: modelKey aus Dateiname const key = modelKeyFromFilename(job.output || '') if (!key) return null + // Cache laden/auffrischen (nur fürs schnelle Match) const now = Date.now() const cached = modelsCacheRef.current if (!cached || now - cached.ts > 30_000) { @@ -526,12 +600,25 @@ export default function App() { const list = modelsCacheRef.current?.list ?? [] const needle = key.toLowerCase() - // wenn mehrere: nimm Favorite zuerst, dann irgendeins - const hits = list.filter(m => (m.modelKey || '').toLowerCase() === needle) - if (hits.length === 0) return null - return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[0] + const hits = list.filter((m) => (m.modelKey || '').toLowerCase() === needle) + if (hits.length > 0) { + return hits.sort((a, b) => Number(Boolean(b.favorite)) - Number(Boolean(a.favorite)))[0] + } + + // ✅ Wenn QuickAction: Model bei Bedarf anlegen + if (!wantEnsure) return null + + const ensured = await apiJSON('/api/models/ensure', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ modelKey: key }), + }) + + upsertCache(ensured) + return ensured } + useEffect(() => { let cancelled = false if (!playerJob) { @@ -593,6 +680,44 @@ export default function App() { } }, []) + const handleKeepJob = useCallback(async (job: RecordJob) => { + const file = baseName(job.output || '') + if (!file) return + + // 1) gleiche Animation wie Delete (fade-out + Nachrücken) + window.dispatchEvent( + new CustomEvent('finished-downloads:delete', { + detail: { file, phase: 'start' as const }, + }) + ) + + try { + await apiJSON(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST' }) + + window.dispatchEvent( + new CustomEvent('finished-downloads:delete', { + detail: { file, phase: 'success' as const }, + }) + ) + + window.setTimeout(() => { + setDoneJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) + setJobs((prev) => prev.filter((j) => baseName(j.output || '') !== file)) + setPlayerJob((prev) => (prev && baseName(prev.output || '') === file ? null : prev)) + }, 320) + + // Wenn Finished-Tab gerade NICHT offen ist, Counts/Liste trotzdem direkt updaten: + if (selectedTab !== 'finished') void refreshDoneNow() + } catch (e) { + window.dispatchEvent( + new CustomEvent('finished-downloads:delete', { + detail: { file, phase: 'error' as const }, + }) + ) + throw e + } + }, [selectedTab, refreshDoneNow]) + const handleToggleHot = useCallback(async (job: RecordJob) => { const file = baseName(job.output || '') if (!file) return @@ -623,7 +748,7 @@ export default function App() { const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) let m = sameAsPlayer ? playerModel : null - if (!m) m = await resolveModelForJob(job) + if (!m) m = await resolveModelForJob(job, { ensure: true }) if (!m) return const next = !Boolean(m.favorite) @@ -646,7 +771,7 @@ export default function App() { const sameAsPlayer = Boolean(playerJob && baseName(playerJob.output || '') === file) let m = sameAsPlayer ? playerModel : null - if (!m) m = await resolveModelForJob(job) + if (!m) m = await resolveModelForJob(job, { ensure: true }) if (!m) return const curLiked = m.liked === true @@ -732,10 +857,10 @@ export default function App() { async function stopJob(id: string) { try { - await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { - method: 'POST', - }) - } catch {} + await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' }) + } catch (e: any) { + setError(e?.message ?? String(e)) + } } return ( @@ -767,12 +892,36 @@ export default function App() { className="mt-1 block w-full rounded-md px-3 py-2 text-sm bg-white text-gray-900 dark:bg-white/10 dark:text-white" /> + {error ? ( +
+
+
{error}
+ +
+
+ ) : null} + {isChaturbate(sourceUrl) && !hasRequiredChaturbateCookies(cookies) && (
⚠️ Für Chaturbate werden die Cookies cf_clearance und{' '} sessionId benötigt.
)} + + {busy ? ( +
+ +
+ ) : null} + )} - {selectedTab === 'models' && } - {selectedTab === 'settings' && } + {selectedTab === 'settings' && } void + onRefreshDone?: (preferPage?: number) => void | Promise + assetNonce?: number } const norm = (p: string) => (p || '').replaceAll('\\', '/').trim() @@ -157,9 +157,10 @@ export default function FinishedDownloads({ doneTotal, page, pageSize, - onPageChange + onPageChange, + onRefreshDone, + assetNonce, }: Props) { - const [ctx, setCtx] = React.useState<{ x: number; y: number; job: RecordJob } | null>(null) const teaserHostsRef = React.useRef>(new Map()) const [teaserKey, setTeaserKey] = React.useState(null) @@ -168,6 +169,21 @@ export default function FinishedDownloads({ const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) + // 🔥 lokale Optimistik: HOT Rename sofort in der UI spiegeln + const [renamedFiles, setRenamedFiles] = React.useState>({}) + + // 📄 Pagination-Refill: nach Delete/Keep Seite neu laden, damit Items "nachrücken" + const [overrideDoneJobs, setOverrideDoneJobs] = React.useState(null) + const [overrideDoneTotal, setOverrideDoneTotal] = React.useState(null) + const [refillTick, setRefillTick] = React.useState(0) + const refillTimerRef = React.useRef(null) + + const queueRefill = useCallback(() => { + if (refillTimerRef.current) window.clearTimeout(refillTimerRef.current) + // kurz debouncen, damit bei mehreren Aktionen nicht zig Fetches laufen + refillTimerRef.current = window.setTimeout(() => setRefillTick((n) => n + 1), 80) + }, []) + const [sort, setSort] = React.useState(null) type ViewMode = 'table' | 'cards' | 'gallery' @@ -208,6 +224,56 @@ export default function FinishedDownloads({ // ⭐ Models-Flags (Fav/Like) aus Backend-Store const [modelsByKey, setModelsByKey] = React.useState>({}) + // ✅ Seite auffüllen + doneTotal aktualisieren, damit Pagination stimmt + useEffect(() => { + if (refillTick === 0) return + let alive = true + + ;(async () => { + try { + const [metaRes, listRes] = await Promise.all([ + fetch('/api/record/done/meta', { cache: 'no-store' as any }), + fetch(`/api/record/done?page=${page}&pageSize=${pageSize}`, { cache: 'no-store' as any }), + ]) + + if (!alive) return + + if (metaRes.ok) { + const meta = await metaRes.json() + const count = Number(meta?.count ?? 0) + if (Number.isFinite(count) && count >= 0) { + setOverrideDoneTotal(count) + + const totalPages = Math.max(1, Math.ceil(count / pageSize)) + if (page > totalPages) { + // Seite ist nach Delete/Keep "weg" -> auf letzte gültige Seite springen + onPageChange(totalPages) + setOverrideDoneJobs(null) + return + } + } + } + + if (listRes.ok) { + const list = await listRes.json() + setOverrideDoneJobs(Array.isArray(list) ? list : []) + } + } catch { + // optional: console.debug(...) + } + })() + + return () => { + alive = false + } + }, [refillTick, page, pageSize, onPageChange]) + + useEffect(() => { + // Wenn Parent neu geladen hat, brauchen wir Overrides nicht mehr + setOverrideDoneJobs(null) + setOverrideDoneTotal(null) + }, [doneJobs, doneTotal]) + const refreshModelsByKey = useCallback(async () => { try { const res = await fetch('/api/models/list', { cache: 'no-store' as any }) @@ -275,9 +341,8 @@ export default function FinishedDownloads({ const v = host?.querySelector('video') as HTMLVideoElement | null if (!v) return false - v.muted = true - v.playsInline = true - v.setAttribute('playsinline', 'true') + // ✅ zentral + applyInlineVideoPolicy(v, { muted: true }) const p = v.play?.() if (p && typeof (p as any).catch === 'function') (p as Promise).catch(() => {}) @@ -293,16 +358,6 @@ export default function FinishedDownloads({ onOpenPlayer(job) }, [onOpenPlayer]) - const openCtx = (job: RecordJob, e: React.MouseEvent) => { - e.preventDefault() - e.stopPropagation() - setCtx({ x: e.clientX, y: e.clientY, job }) - } - - const openCtxAt = (job: RecordJob, x: number, y: number) => { - setCtx({ x, y, job }) - } - const markDeleting = useCallback((key: string, value: boolean) => { setDeletingKeys((prev) => { const next = new Set(prev) @@ -343,16 +398,25 @@ export default function FinishedDownloads({ }) }, []) - const animateRemove = useCallback((key: string) => { - // 1) rot + fade-out starten - markRemoving(key, true) + const animateRemove = useCallback( + (key: string) => { + // 1) rot + fade-out starten + markRemoving(key, true) - // 2) nach der Animation wirklich ausblenden - window.setTimeout(() => { - markDeleted(key) - markRemoving(key, false) - }, 320) - }, [markDeleted, markRemoving]) + // 2) nach der Animation wirklich ausblenden + Seite auffüllen + window.setTimeout(() => { + markDeleted(key) + markRemoving(key, false) + + // ✅ wichtig: Seite sofort neu laden -> Item rückt nach + queueRefill() + + // optional: Parent sync (kann bleiben, muss aber nicht) + void onRefreshDone?.(page) + }, 320) + }, + [markDeleted, markRemoving, queueRefill, onRefreshDone, page] + ) const releasePlayingFile = useCallback( async (file: string, opts?: { close?: boolean }) => { @@ -384,8 +448,8 @@ export default function FinishedDownloads({ if (onDeleteJob) { await onDeleteJob(job) - // ✅ optional: sofort aus der Liste animieren (fühlt sich besser an) - animateRemove(key) + // ✅ nach erfolgreichem Delete die Page nachziehen + queueRefill() return true } @@ -406,7 +470,7 @@ export default function FinishedDownloads({ markDeleting(key, false) } }, - [deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove] + [deletingKeys, markDeleting, releasePlayingFile, onDeleteJob, animateRemove, queueRefill] ) const keepVideo = useCallback( @@ -442,41 +506,53 @@ export default function FinishedDownloads({ [keepingKeys, deletingKeys, markKeeping, releasePlayingFile, animateRemove] ) - const items = React.useMemo(() => { - if (!ctx) return [] - const j = ctx.job - const model = modelNameFromOutput(j.output) + const toggleHotVideo = useCallback( + async (job: RecordJob) => { + const file = baseName(job.output || '') + if (!file) { + window.alert('Kein Dateiname gefunden – kann nicht HOT togglen.') + return + } - return buildDownloadContextMenu({ - job: j, - modelName: model, - state: { - watching: false, - liked: null, - favorite: false, - hot: false, - keep: false, - }, - actions: { - onPlay: onOpenPlayer, + try { + await releasePlayingFile(file, { close: true }) - onToggleWatch: (job) => console.log('toggle watch', job.id), - onSetLike: (job, liked) => console.log('set like', job.id, liked), - onToggleFavorite: (job) => console.log('toggle favorite', job.id), - onMoreFromModel: (modelName) => console.log('more from', modelName), + // ✅ Wenn du extern einen Handler hast, kannst du den nutzen + // (Wenn du KEINEN hast: läuft der Fallback unten) + if (onToggleHot) { + await onToggleHot(job) + return + } - onRevealInExplorer: (job) => console.log('reveal in explorer', job.output), - onAddToDownloadList: (job) => console.log('add to download list', job.id), - onToggleHot: (job) => console.log('toggle hot', job.id), + // Fallback: Backend direkt + const res = await fetch(`/api/record/toggle-hot?file=${encodeURIComponent(file)}`, { method: 'POST' }) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } - onToggleKeep: (job) => console.log('toggle keep', job.id), - onDelete: (job) => { - setCtx(null) - void deleteVideo(job) - }, - }, - }) - }, [ctx, deleteVideo, onOpenPlayer]) + const data = (await res.json().catch(() => null)) as any + const oldFile = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : file + const newFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : '' + + if (newFile) { + // Optimistisch umbenennen (nicht aufs nächste Polling warten) + setRenamedFiles((prev) => ({ ...prev, [oldFile]: newFile })) + + // Dauer-Key mitziehen (optional) + setDurations((prev) => { + const v = prev[oldFile] + if (typeof v !== 'number') return prev + const { [oldFile]: _omit, ...rest } = prev + return { ...rest, [newFile]: v } + }) + } + } catch (e: any) { + window.alert(`HOT umbenennen fehlgeschlagen: ${String(e?.message || e)}`) + } + }, + [baseName, releasePlayingFile, onToggleHot] + ) const runtimeSecondsForSort = useCallback((job: RecordJob) => { const start = Date.parse(String(job.startedAt || '')) @@ -486,17 +562,37 @@ export default function FinishedDownloads({ return (typeof sec === 'number' && sec > 0) ? sec : Number.POSITIVE_INFINITY }, []) + const applyRenamedOutput = useCallback( + (job: RecordJob): RecordJob => { + const out = norm(job.output || '') + const file = baseName(out) + const override = renamedFiles[file] + if (!override) return job + + const idx = out.lastIndexOf('/') + const dir = idx >= 0 ? out.slice(0, idx + 1) : '' + return { ...job, output: dir + override } + }, + [renamedFiles, baseName] + ) + + const doneJobsPage = overrideDoneJobs ?? doneJobs + const doneTotalPage = overrideDoneTotal ?? doneTotal const rows = useMemo(() => { const map = new Map() // Basis: Files aus dem Done-Ordner - for (const j of doneJobs) map.set(keyFor(j), j) + for (const j of doneJobsPage) { + const jj = applyRenamedOutput(j) + map.set(keyFor(jj), jj) + } - // Jobs aus /list drübermergen (z.B. frisch fertiggewordene) + // Jobs aus /list drübermergen for (const j of jobs) { - const k = keyFor(j) - if (map.has(k)) map.set(k, { ...map.get(k)!, ...j }) + const jj = applyRenamedOutput(j) + const k = keyFor(jj) + if (map.has(k)) map.set(k, { ...map.get(k)!, ...jj }) } const list = Array.from(map.values()).filter((j) => { @@ -506,7 +602,7 @@ export default function FinishedDownloads({ list.sort((a, b) => norm(b.endedAt || '').localeCompare(norm(a.endedAt || ''))) return list - }, [jobs, doneJobs, deletedKeys]) + }, [jobs, doneJobsPage, deletedKeys, applyRenamedOutput]) const endedAtMs = (j: RecordJob) => (j.endedAt ? new Date(j.endedAt).getTime() : 0) @@ -592,16 +688,11 @@ export default function FinishedDownloads({ // ✅ wenn Cards-View: Swipe schon beim Start raus (ohne Aktion, weil App die API schon macht) if (view === 'cards') { - swipeRefs.current.get(key)?.swipeLeft({ runAction: false }) - } - } else if (detail.phase === 'success') { - markDeleting(key, false) - - if (view === 'cards') { - // ✅ nach Swipe-Animation wirklich aus der Liste entfernen - window.setTimeout(() => markDeleted(key), 320) + window.setTimeout(() => { + markDeleted(key) + void onRefreshDone?.(page) // ✅ HIER dazu + }, 320) } else { - // table/gallery: wie bisher ausblenden animateRemove(key) } } else if (detail.phase === 'error') { @@ -611,12 +702,16 @@ export default function FinishedDownloads({ if (view === 'cards') { swipeRefs.current.get(key)?.reset() } + } else if (detail.phase === 'success') { + markDeleting(key, false) + queueRefill() + void onRefreshDone?.(page) } } window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) - }, [animateRemove, markDeleting, markDeleted, view]) + }, [animateRemove, markDeleting, markDeleted, view, onRefreshDone, page, queueRefill]) const viewRows = view === 'table' ? rows : sortedNonTableRows @@ -705,7 +800,6 @@ export default function FinishedDownloads({ { key: 'preview', header: 'Vorschau', - srOnlyHeader: true, widthClassName: 'w-[140px]', cell: (j) => { const k = keyFor(j) @@ -714,11 +808,6 @@ export default function FinishedDownloads({ className="py-1" onClick={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} - onContextMenu={(e) => { - e.preventDefault() - e.stopPropagation() - openCtx(j, e) - }} > ) @@ -755,19 +845,17 @@ export default function FinishedDownloads({ return (
-
-
- {model} -
+
+ + {file || '—'} + + {isHot ? ( HOT ) : null}
-
- {file || '—'} -
) }, @@ -848,15 +936,88 @@ export default function FinishedDownloads({ srOnlyHeader: true, cell: (j) => { const k = keyFor(j) - const busy = deletingKeys.has(k) || keepingKeys.has(k) - + const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const iconBtn = 'inline-flex items-center justify-center rounded-md p-1.5 ' + 'hover:bg-gray-100/70 dark:hover:bg-white/5 ' + - 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500' + 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 dark:focus-visible:outline-indigo-500 ' + + 'disabled:opacity-50 disabled:cursor-not-allowed' + const fileRaw = baseName(j.output || '') + const isHot = fileRaw.startsWith('HOT ') + const modelKey = lower(modelNameFromOutput(j.output)) + const flags = modelsByKey[modelKey] + const isFav = Boolean(flags?.favorite) + const isLiked = flags?.liked === true + return (
+ {/* Favorite */} + + + {/* Like */} + + + {/* HOT */} + + {/* Keep */} - - {/* More */} -
) }, @@ -920,7 +1067,7 @@ export default function FinishedDownloads({ } }, [isSmall]) - if (rows.length === 0) { + if (rows.length === 0 && doneTotalPage === 0) { return (
@@ -1027,13 +1174,12 @@ export default function FinishedDownloads({ handleDuration={handleDuration} deleteVideo={deleteVideo} keepVideo={keepVideo} - openCtx={openCtx} - openCtxAt={openCtxAt} releasePlayingFile={releasePlayingFile} modelsByKey={modelsByKey} - onToggleHot={onToggleHot} + onToggleHot={toggleHotVideo} onToggleFavorite={onToggleFavorite} onToggleLike={onToggleLike} + assetNonce={assetNonce} /> )} @@ -1045,7 +1191,6 @@ export default function FinishedDownloads({ sort={sort} onSortChange={setSort} onRowClick={onOpenPlayer} - onRowContextMenu={(job, e) => openCtx(job, e)} rowClassName={(j) => { const k = keyFor(j) return [ @@ -1079,25 +1224,21 @@ export default function FinishedDownloads({ deletedKeys={deletedKeys} registerTeaserHost={registerTeaserHost} onOpenPlayer={onOpenPlayer} - openCtx={openCtx} - openCtxAt={openCtxAt} deleteVideo={deleteVideo} keepVideo={keepVideo} + onToggleHot={toggleHotVideo} + lower={lower} + modelsByKey={modelsByKey} + onToggleFavorite={onToggleFavorite} + onToggleLike={onToggleLike} + assetNonce={assetNonce} /> )} - setCtx(null)} - /> - { // 1) Inline-Playback + aktiven Teaser sofort stoppen flushSync(() => { diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index a558797..2d9aaae 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -9,7 +9,6 @@ import { flushSync } from 'react-dom' import { TrashIcon, FireIcon, - EllipsisVerticalIcon, BookmarkSquareIcon, StarIcon as StarOutlineIcon, HeartIcon as HeartOutlineIcon, @@ -60,9 +59,6 @@ type Props = { deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise - openCtx: (job: RecordJob, e: React.MouseEvent) => void - openCtxAt: (job: RecordJob, x: number, y: number) => void - releasePlayingFile: (file: string, opts?: { close?: boolean }) => Promise modelsByKey: Record @@ -108,9 +104,6 @@ export default function FinishedDownloadsCardsView({ deleteVideo, keepVideo, - openCtx, - openCtxAt, - releasePlayingFile, modelsByKey, @@ -130,6 +123,20 @@ export default function FinishedDownloadsCardsView({ const model = modelNameFromOutput(j.output) const fileRaw = baseName(j.output || '') + const isHot = fileRaw.startsWith('HOT ') + const flags = modelsByKey[lower(model)] + const isFav = Boolean(flags?.favorite) + const isLiked = flags?.liked === true + + const statusCls = + j.status === 'failed' + ? 'bg-red-500/35' + : j.status === 'finished' + ? 'bg-emerald-500/35' + : j.status === 'stopped' + ? 'bg-amber-500/35' + : 'bg-black/40' + const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) @@ -154,7 +161,6 @@ export default function FinishedDownloadsCardsView({ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} - onContextMenu={(e) => openCtx(j, e)} > {/* Preview */} @@ -171,7 +177,7 @@ export default function FinishedDownloadsCardsView({ > stripHotPrefix(baseName(p))} durationSeconds={durations[k]} onDuration={handleDuration} className="w-full h-full" @@ -195,25 +201,23 @@ export default function FinishedDownloadsCardsView({ ].join(' ')} /> - {/* Overlay bottom */} + {/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
-
-
{model}
-
{stripHotPrefix(fileRaw) || '—'}
-
+
+ + {j.status} + -
- {fileRaw.startsWith('HOT ') ? ( - - HOT - - ) : null} +
+ {dur} + {size} +
@@ -240,71 +244,8 @@ export default function FinishedDownloadsCardsView({ 'inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + 'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500' - const isHot = fileRaw.startsWith('HOT ') - const modelKey = modelNameFromOutput(j.output) - const flags = modelsByKey[lower(modelKey)] - const isFav = Boolean(flags?.favorite) - const isLiked = flags?.liked === true - return ( <> - {!isSmall && ( - <> - {/* Keep */} - - - {/* Delete */} - - - )} - - {/* HOT */} - {/* Favorite */} + - {/* Menu */} + {/* HOT */} + + {!isSmall && ( + <> + {/* Keep */} + + + {/* Delete */} + + + )} ) })()}
- {/* Meta */} -
-
-
- Dauer: {dur} - - Größe: {size} + {/* Footer / Meta */} +
{/* Model + Datei im Footer */} +
+
+ {model} +
+
+ {isLiked ? : null} + {isFav ? : null}
- {j.output ? ( -
- {j.output} -
- ) : null} +
+ {stripHotPrefix(fileRaw) || '—'} + + {isHot ? ( + + HOT + + ) : null} +
diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 25780f7..74524d1 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -3,7 +3,17 @@ import * as React from 'react' import type { RecordJob } from '../../types' import FinishedVideoPreview from './FinishedVideoPreview' -import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline' +import { + TrashIcon, + BookmarkSquareIcon, + FireIcon, + StarIcon as StarOutlineIcon, + HeartIcon as HeartOutlineIcon, +} from '@heroicons/react/24/outline' +import { + StarIcon as StarSolidIcon, + HeartIcon as HeartSolidIcon, +} from '@heroicons/react/24/solid' type Props = { rows: RecordJob[] @@ -27,10 +37,16 @@ type Props = { registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void onOpenPlayer: (job: RecordJob) => void - openCtx: (job: RecordJob, e: React.MouseEvent) => void - openCtxAt: (job: RecordJob, x: number, y: number) => void deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise + onToggleHot: (job: RecordJob) => void | Promise + + lower: (s: string) => string + modelsByKey: Record + onToggleFavorite?: (job: RecordJob) => void | Promise + onToggleLike?: (job: RecordJob) => void | Promise + + } export default function FinishedDownloadsGalleryView({ @@ -55,19 +71,35 @@ export default function FinishedDownloadsGalleryView({ registerTeaserHost, onOpenPlayer, - openCtx, - openCtxAt, deleteVideo, keepVideo, + onToggleHot, + lower, + modelsByKey, + onToggleFavorite, + onToggleLike, }: Props) { return (
{rows.map((j) => { const k = keyFor(j) const model = modelNameFromOutput(j.output) + const modelKey = lower(model) + const flags = modelsByKey[modelKey] + const isFav = Boolean(flags?.favorite) + const isLiked = flags?.liked === true const file = baseName(j.output || '') + const isHot = file.startsWith('HOT ') const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) + const statusCls = + j.status === 'failed' + ? 'bg-red-500/35' + : j.status === 'finished' + ? 'bg-emerald-500/35' + : j.status === 'stopped' + ? 'bg-amber-500/35' + : 'bg-black/40' const busy = deletingKeys.has(k) || keepingKeys.has(k) || removingKeys.has(k) const deleted = deletedKeys.has(k) @@ -94,21 +126,15 @@ export default function FinishedDownloadsGalleryView({ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} - onContextMenu={(e) => openCtx(j, e)} > {/* Thumb */}
{ - e.preventDefault() - e.stopPropagation() - openCtx(j, e) - }} > stripHotPrefix(baseName(p))} durationSeconds={durations[k]} onDuration={handleDuration} variant="fill" @@ -131,7 +157,7 @@ export default function FinishedDownloadsGalleryView({ " /> - {/* Bottom text */} + {/* Bottom overlay meta (Status links, Dauer+Größe rechts) */}
-
{model}
-
- {stripHotPrefix(file) || '—'} +
+ + {j.status} + -
+
{dur} {size}
- {/* Quick keep */} - + {(() => { + const iconBtn = + 'pointer-events-auto inline-flex items-center justify-center rounded-md bg-black/40 p-2 text-white ' + + 'backdrop-blur hover:bg-black/60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ' + + 'disabled:opacity-50 disabled:cursor-not-allowed' - {/* Quick delete */} - + return ( + <> + {/* Favorite */} + {onToggleFavorite ? ( + + ) : null} - {/* More / Context */} - + {/* Like */} + {onToggleLike ? ( + + ) : null} + + + + + + + + ) + })()} +
- {/* status line */} -
-
- - Status: {j.status} - - {baseName(j.output || '').startsWith('HOT ') ? ( - + {/* Footer / Meta (wie CardView) */} +
+ {/* Model + Datei im Footer */} +
+
+ {model} +
+
+ {isLiked ? : null} + {isFav ? : null} +
+
+ +
+ {stripHotPrefix(file) || '—'} + + {isHot ? ( + HOT ) : null} diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index 04a37e4..4a6999c 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -11,7 +11,6 @@ type Props = { sort: SortState onSortChange: (s: SortState) => void onRowClick: (job: RecordJob) => void - onRowContextMenu: (job: RecordJob, e: React.MouseEvent) => void rowClassName?: (job: RecordJob) => string } @@ -22,7 +21,6 @@ export default function FinishedDownloadsTableView({ sort, onSortChange, onRowClick, - onRowContextMenu, rowClassName, }: Props) { return ( @@ -37,7 +35,6 @@ export default function FinishedDownloadsTableView({ sort={sort} onSortChange={onSortChange} onRowClick={onRowClick} - onRowContextMenu={onRowContextMenu} rowClassName={rowClassName} /> ) diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 81d93d4..6f3282a 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState, type SyntheticEvent } from 'react' import type { RecordJob } from '../../types' import HoverPopover from './HoverPopover' +import { DEFAULT_INLINE_MUTED } from './videoPolicy' type Variant = 'thumb' | 'fill' type InlineVideoMode = false | true | 'always' | 'hover' @@ -50,6 +51,14 @@ export type FinishedVideoPreviewProps = { inlineControls?: boolean /** Inline-Playback: loopen? */ inlineLoop?: boolean + + assetNonce?: number + + /** alle Inline/Teaser/Clips muted? (Default: true) */ + muted?: boolean + /** Popover-Video muted? (Default: true) */ + popoverMuted?: boolean + } export default function FinishedVideoPreview({ @@ -79,10 +88,21 @@ export default function FinishedVideoPreview({ inlineNonce = 0, inlineControls = false, inlineLoop = true, + + assetNonce = 0, + + muted = DEFAULT_INLINE_MUTED, + popoverMuted = DEFAULT_INLINE_MUTED, }: FinishedVideoPreviewProps) { const file = getFileName(job.output || '') const blurCls = blur ? 'blur-md' : '' + const commonVideoProps = { + muted, + playsInline: true, + preload: 'metadata' as const, + } + const [thumbOk, setThumbOk] = useState(true) const [videoOk, setVideoOk] = useState(true) const [metaLoaded, setMetaLoaded] = useState(false) @@ -104,12 +124,14 @@ export default function FinishedVideoPreview({ ? 'hover' : 'never' - // ✅ id = Dateiname ohne Endung (genau wie du willst) + const stripHot = (s: string) => (s.startsWith('HOT ') ? s.slice(4) : s) + const previewId = useMemo(() => { + const file = getFileName(job.output || '') if (!file) return '' - const dot = file.lastIndexOf('.') - return dot > 0 ? file.slice(0, dot) : file - }, [file]) + const base = file.replace(/\.[^.]+$/, '') // ext weg + return stripHot(base).trim() + }, [job.output, getFileName]) // Vollvideo (für Inline-Playback + Duration-Metadaten) const videoSrc = useMemo( @@ -120,11 +142,6 @@ export default function FinishedVideoPreview({ // ✅ Teaser-Video (vorgerendert) const isActive = active !== undefined ? Boolean(active) : true - const teaserSrc = useMemo( - () => (previewId ? `/api/generated/teaser?id=${encodeURIComponent(previewId)}` : ''), - [previewId] - ) - const hasDuration = typeof durationSeconds === 'number' && Number.isFinite(durationSeconds) && durationSeconds > 0 @@ -207,14 +224,18 @@ export default function FinishedVideoPreview({ return Math.min(dur - 0.05, Math.max(0.05, t)) }, [animated, animatedMode, hasDuration, durationSeconds, localTick, thumbStepSec, thumbSpread, thumbSamples]) + const v = assetNonce ?? 0 + const thumbSrc = useMemo(() => { if (!previewId) return '' - // static thumb (oder frames: mit t=...) - if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}` - return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${encodeURIComponent( - thumbTimeSec.toFixed(2) - )}&v=${encodeURIComponent(String(localTick))}` - }, [previewId, thumbTimeSec, localTick]) + if (thumbTimeSec == null) return `/api/record/preview?id=${encodeURIComponent(previewId)}&v=${v}` + return `/api/record/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}&v=${v}-${localTick}` + }, [previewId, thumbTimeSec, localTick, v]) + + const teaserSrc = useMemo(() => { + if (!previewId) return '' + return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&v=${v}` + }, [previewId, v]) // ✅ Nur Vollvideo darf onDuration liefern (nicht Teaser!) const handleLoadedMetadata = (e: SyntheticEvent) => { @@ -228,6 +249,11 @@ export default function FinishedVideoPreview({ return
} + useEffect(() => { + setThumbOk(true) + setVideoOk(true) + }, [previewId, assetNonce]) + // --- Inline Video sichtbar? const showingInlineVideo = inlineMode !== 'never' && @@ -347,6 +373,7 @@ export default function FinishedVideoPreview({ {/* 1) Inline Full Video (mit Controls) */} {showingInlineVideo ? (