diff --git a/backend/data/models_store.db-shm b/backend/data/models_store.db-shm index f60b8fb..b7a15f0 100644 Binary files a/backend/data/models_store.db-shm and b/backend/data/models_store.db-shm differ diff --git a/backend/data/models_store.db-wal b/backend/data/models_store.db-wal index dc3fbf6..b5e8586 100644 Binary files a/backend/data/models_store.db-wal and b/backend/data/models_store.db-wal differ diff --git a/backend/main.go b/backend/main.go index 35af620..b62f7eb 100644 --- a/backend/main.go +++ b/backend/main.go @@ -89,6 +89,147 @@ var ( jobsMu = sync.Mutex{} ) +// -------------------- SSE: /api/record/stream -------------------- + +type sseHub struct { + mu sync.Mutex + clients map[chan []byte]struct{} +} + +func newSSEHub() *sseHub { + return &sseHub{clients: map[chan []byte]struct{}{}} +} + +func (h *sseHub) add(ch chan []byte) { + h.mu.Lock() + h.clients[ch] = struct{}{} + h.mu.Unlock() +} + +func (h *sseHub) remove(ch chan []byte) { + h.mu.Lock() + delete(h.clients, ch) + h.mu.Unlock() + close(ch) +} + +func (h *sseHub) broadcast(b []byte) { + h.mu.Lock() + defer h.mu.Unlock() + for ch := range h.clients { + // Non-blocking: langsame Clients droppen Updates (holen sich beim nächsten Update wieder ein) + select { + case ch <- b: + default: + } + } +} + +var recordJobsHub = newSSEHub() +var recordJobsNotify = make(chan struct{}, 1) + +func init() { + // Debounced broadcaster + go func() { + for range recordJobsNotify { + // kleine Debounce-Phase, um Burst-Updates zusammenzufassen + time.Sleep(40 * time.Millisecond) + + // Kanal drainen (falls mehrere Notifies in kurzer Zeit kamen) + for { + select { + case <-recordJobsNotify: + default: + goto SEND + } + } + SEND: + recordJobsHub.broadcast(jobsSnapshotJSON()) + } + }() +} + +func notifyJobsChanged() { + select { + case recordJobsNotify <- struct{}{}: + default: + } +} + +func jobsSnapshotJSON() []byte { + jobsMu.Lock() + list := make([]RecordJob, 0, len(jobs)) + for _, j := range jobs { + if j == nil { + continue + } + c := *j // copy => 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 ? (