diff --git a/backend/analyze.go b/backend/analyze.go index b6a5f24..9bfbfe8 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -1007,6 +1007,8 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { return } + autoDeleteLowRatedDownloadAfterAnalysis(ctx, outPath, rating) + appLogf( "✅ [analyze] done file=%q hits=%d segments=%d rating=%.1f stars=%d", file, diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index 9d2f8d8..64c24b0 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -14,20 +14,22 @@ import ( ) const ( - // Weniger Frames als vorher, aber Qualität pro Frame bleibt gut genug - // für Analyse + UI. - previewSpriteCols = 20 - previewSpriteRows = 12 + // 24 x 15 = 360 Sprite-Bilder + // Gesamtgröße bei 320x180: 7680 x 2700 px + previewSpriteCols = 24 + previewSpriteRows = 15 previewSpriteFrameCount = previewSpriteCols * previewSpriteRows - // Nicht kleiner machen, solange die Analyse dieselben Sprites nutzt. - // 320x180 ist für Positionen/Body/Object-Erkennung deutlich sicherer - // als 240x135. - previewSpriteCellW = 320 - previewSpriteCellH = 180 + previewSpriteCellW = 384 + previewSpriteCellH = 216 - // 4 ist kleiner als 3, aber noch gut genug für Analyse. - previewSpriteJpegQ = "4" + // ffmpeg mjpeg: + // kleiner = bessere Qualität, größere Datei. + // 2 = sehr gut, 3 = guter Kompromiss, 4 = kleiner/schwächer. + previewSpriteJpegQ = "3" + + // Besser als fast_bilinear, aber langsamer. + previewSpriteScaleFlags = "lanczos" ) func fixedPreviewSpriteLayout() (cols, rows, count, cellW, cellH int) { @@ -120,12 +122,13 @@ func generatePreviewSpriteJPG( // robustere Filterkette vf := fmt.Sprintf( "fps=fps=1/%f:start_time=0:round=up,"+ - "scale=%d:%d:force_original_aspect_ratio=decrease:flags=fast_bilinear,"+ + "scale=%d:%d:force_original_aspect_ratio=decrease:flags=%s,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "setsar=1,"+ "tile=%dx%d:margin=0:padding=0", stepSec, cellW, cellH, + previewSpriteScaleFlags, cellW, cellH, cols, rows, ) diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 5e26e59..571ff28 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -14,6 +14,8 @@ "autoDeleteSmallDownloads": true, "autoDeleteSmallDownloadsBelowMB": 300, "autoDeleteSmallDownloadsKeepFavorites": true, + "autoDeleteLowRatedDownloads": false, + "autoDeleteLowRatedDownloadsMaxStars": 3, "lowDiskPauseBelowGB": 5, "blurPreviews": false, "teaserPlayback": "all", diff --git a/backend/settings.go b/backend/settings.go index f7c2aec..e7fb8a4 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -34,7 +34,13 @@ type RecorderSettings struct { AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` - LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` + + // Wenn aktiv, werden Downloads nach erfolgreicher Analyse gelöscht, + // wenn ihr Rating <= AutoDeleteLowRatedDownloadsMaxStars ist. + AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"` + AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"` + + LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` // still | hover | all @@ -78,7 +84,11 @@ var ( AutoDeleteSmallDownloads: false, AutoDeleteSmallDownloadsBelowMB: 50, AutoDeleteSmallDownloadsKeepFavorites: true, - LowDiskPauseBelowGB: 5, + + AutoDeleteLowRatedDownloads: false, + AutoDeleteLowRatedDownloadsMaxStars: 3, + + LowDiskPauseBelowGB: 5, BlurPreviews: false, TeaserPlayback: "hover", @@ -150,6 +160,14 @@ func loadSettings() { if s.AutoDeleteSmallDownloadsBelowMB > 100_000 { s.AutoDeleteSmallDownloadsBelowMB = 100_000 } + + if s.AutoDeleteLowRatedDownloadsMaxStars < 1 { + s.AutoDeleteLowRatedDownloadsMaxStars = 3 + } + if s.AutoDeleteLowRatedDownloadsMaxStars > 5 { + s.AutoDeleteLowRatedDownloadsMaxStars = 5 + } + if s.LowDiskPauseBelowGB < 1 { s.LowDiskPauseBelowGB = 1 } @@ -253,7 +271,11 @@ type RecorderSettingsPublic struct { AutoDeleteSmallDownloads bool `json:"autoDeleteSmallDownloads"` AutoDeleteSmallDownloadsBelowMB int `json:"autoDeleteSmallDownloadsBelowMB"` AutoDeleteSmallDownloadsKeepFavorites bool `json:"autoDeleteSmallDownloadsKeepFavorites"` - LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` + + AutoDeleteLowRatedDownloads bool `json:"autoDeleteLowRatedDownloads"` + AutoDeleteLowRatedDownloadsMaxStars int `json:"autoDeleteLowRatedDownloadsMaxStars"` + + LowDiskPauseBelowGB int `json:"lowDiskPauseBelowGB"` BlurPreviews bool `json:"blurPreviews"` TeaserPlayback string `json:"teaserPlayback"` @@ -293,7 +315,11 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { AutoDeleteSmallDownloads: s.AutoDeleteSmallDownloads, AutoDeleteSmallDownloadsBelowMB: s.AutoDeleteSmallDownloadsBelowMB, AutoDeleteSmallDownloadsKeepFavorites: s.AutoDeleteSmallDownloadsKeepFavorites, - LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, + + AutoDeleteLowRatedDownloads: s.AutoDeleteLowRatedDownloads, + AutoDeleteLowRatedDownloadsMaxStars: s.AutoDeleteLowRatedDownloadsMaxStars, + + LowDiskPauseBelowGB: s.LowDiskPauseBelowGB, BlurPreviews: s.BlurPreviews, TeaserPlayback: s.TeaserPlayback, @@ -368,6 +394,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { if in.AutoDeleteSmallDownloadsBelowMB > 100_000 { in.AutoDeleteSmallDownloadsBelowMB = 100_000 } + if in.AutoDeleteLowRatedDownloadsMaxStars < 1 { + in.AutoDeleteLowRatedDownloadsMaxStars = 3 + } + if in.AutoDeleteLowRatedDownloadsMaxStars > 5 { + in.AutoDeleteLowRatedDownloadsMaxStars = 5 + } if in.LowDiskPauseBelowGB < 1 { in.LowDiskPauseBelowGB = 1 } @@ -463,6 +495,8 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.AutoDeleteSmallDownloads = in.AutoDeleteSmallDownloads next.AutoDeleteSmallDownloadsBelowMB = in.AutoDeleteSmallDownloadsBelowMB next.AutoDeleteSmallDownloadsKeepFavorites = in.AutoDeleteSmallDownloadsKeepFavorites + next.AutoDeleteLowRatedDownloads = in.AutoDeleteLowRatedDownloads + next.AutoDeleteLowRatedDownloadsMaxStars = in.AutoDeleteLowRatedDownloadsMaxStars next.LowDiskPauseBelowGB = in.LowDiskPauseBelowGB next.BlurPreviews = in.BlurPreviews diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index 5525592..8956379 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -942,6 +942,9 @@ func runGenerateMissingAssetsJob(jobID string, ctx context.Context) { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") + + // Nach erfolgreicher Hintergrund-Analyse optional nach Rating löschen. + autoDeleteLowRatedDownloadAfterAnalysis(fileCtx, it.path, nil) } else { publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 8042f8c..6e8949f 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -28,6 +28,7 @@ type cleanupResp struct { ScannedFiles int `json:"scannedFiles"` DeletedFiles int `json:"deletedFiles"` DeletedBrokenFiles int `json:"deletedBrokenFiles"` + DeletedLowRatedFiles int `json:"deletedLowRatedFiles"` SkippedFiles int `json:"skippedFiles"` DeletedBytes int64 `json:"deletedBytes"` DeletedBytesHuman string `json:"deletedBytesHuman"` @@ -162,6 +163,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { } mb := int(s.AutoDeleteSmallDownloadsBelowMB) + deleteLowRated := s.AutoDeleteLowRatedDownloads + maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars) var req cleanupReq if r.Body != nil { @@ -200,7 +203,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { publishTaskState() - go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int) { + go func(jobID string, ctx context.Context, doneAbs string, recordAbs string, mb int, deleteLowRated bool, maxStars int) { if err := acquireExclusiveTask(ctx); err != nil { finishCleanupJob(jobID, "", err) return @@ -216,9 +219,9 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { resp := cleanupResp{} - if mb > 0 { + if mb > 0 || deleteLowRated { threshold := int64(mb) * 1024 * 1024 - if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, &resp); err != nil { + if err := cleanupSmallFiles(ctx, jobID, doneAbs, threshold, deleteLowRated, maxStars, &resp); err != nil { finishCleanupJob(jobID, "", err) return } @@ -281,7 +284,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { cleanupProgressText(resp), nil, ) - }(jobID, ctx, doneAbs, recordAbs, mb) + }(jobID, ctx, doneAbs, recordAbs, mb, deleteLowRated, maxStars) writeJSON(w, http.StatusOK, st) return @@ -319,6 +322,10 @@ func cleanupProgressText(resp cleanupResp) string { parts = append(parts, fmt.Sprintf("defekt: %d", resp.DeletedBrokenFiles)) } + if resp.DeletedLowRatedFiles > 0 { + parts = append(parts, fmt.Sprintf("Rating: %d", resp.DeletedLowRatedFiles)) + } + if resp.DeletedRecordOrphans > 0 { parts = append(parts, fmt.Sprintf("Records: %d", resp.DeletedRecordOrphans)) } @@ -635,7 +642,154 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs return nil } -func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, resp *cleanupResp) error { +func clampAutoDeleteRatingStars(v int) int { + if v < 1 { + return 3 + } + if v > 5 { + return 5 + } + return v +} + +func ratingStarsForVideoPath(videoPath string) (int, bool, error) { + videoPath = strings.TrimSpace(videoPath) + if videoPath == "" { + return 0, false, nil + } + + base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath)) + assetID := stripHotPrefix(base) + if strings.TrimSpace(assetID) == "" { + return 0, false, nil + } + + metaPath, err := generatedMetaFile(assetID) + if err != nil { + return 0, false, err + } + + m, ok := readVideoMetaLoose(metaPath) + if !ok || m == nil { + return 0, false, nil + } + + ai := pickAnalysisAIForGoal(m.Analysis, "highlights") + if ai == nil || ai.Rating == nil { + return 0, false, nil + } + + stars := ai.Rating.Stars + if stars <= 0 && ai.Rating.Score > 0 { + stars = starsFromHighlightScore(ai.Rating.Score) + } + if stars < 1 || stars > 5 { + return 0, false, nil + } + + return stars, true, nil +} + +func deleteDoneVideoAndGenerated(videoPath string) (int64, error) { + videoPath = filepath.Clean(strings.TrimSpace(videoPath)) + if videoPath == "" { + return 0, nil + } + + ext := strings.ToLower(filepath.Ext(videoPath)) + if ext != ".mp4" && ext != ".ts" { + return 0, nil + } + + fi, err := os.Stat(videoPath) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + if fi == nil || fi.IsDir() { + return 0, nil + } + + name := filepath.Base(videoPath) + base := strings.TrimSuffix(name, filepath.Ext(name)) + assetID := stripHotPrefix(base) + + // Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei, + // damit Windows-Dateihandles nicht blockieren. + releaseFileForMutation(name) + + if err := removeWithRetry(videoPath); err != nil && !os.IsNotExist(err) { + return 0, err + } + + if strings.TrimSpace(assetID) != "" { + removeGeneratedForID(assetID) + } + + purgeDurationCacheForPath(videoPath) + return fi.Size(), nil +} + +func autoDeleteLowRatedDownloadAfterAnalysis(ctx context.Context, videoPath string, rating *aiRatingMeta) { + select { + case <-ctx.Done(): + return + default: + } + + s := getSettings() + if !s.AutoDeleteLowRatedDownloads { + return + } + + maxStars := clampAutoDeleteRatingStars(s.AutoDeleteLowRatedDownloadsMaxStars) + + stars := 0 + if rating != nil { + stars = rating.Stars + if stars <= 0 && rating.Score > 0 { + stars = starsFromHighlightScore(rating.Score) + } + } + + // Falls kein Rating direkt übergeben wurde, aus der gespeicherten meta.json lesen. + // Das brauchen wir für Asset-/Regenerate-Jobs, weil dort die Analyse erst geprüft + // und danach gelöscht werden soll. + if stars < 1 || stars > 5 { + metaStars, ok, err := ratingStarsForVideoPath(videoPath) + if err != nil { + appLogln("⚠️ [rating-delete] rating aus meta konnte nicht gelesen werden:", filepath.Base(videoPath), err) + return + } + if ok { + stars = metaStars + } + } + + if stars < 1 || stars > 5 || stars > maxStars { + return + } + + deletedBytes, err := deleteDoneVideoAndGenerated(videoPath) + if err != nil { + appLogln("⚠️ [rating-delete] konnte Download nicht löschen:", filepath.Base(videoPath), err) + return + } + + appLogf( + "🗑️ [rating-delete] gelöscht nach Analyse: %s stars=%d threshold<=%d bytes=%s", + filepath.Base(videoPath), + stars, + maxStars, + formatBytesSI(deletedBytes), + ) + + notifyDoneChanged() +} + +func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, threshold int64, deleteLowRated bool, maxStars int, resp *cleanupResp) error { isCandidate := func(name string) bool { low := strings.ToLower(name) if strings.Contains(low, ".part") || strings.Contains(low, ".tmp") { @@ -751,9 +905,24 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh } deleteReason := "" - if c.size < threshold { + if threshold > 0 && c.size < threshold { deleteReason = "small" - } else { + } else if deleteLowRated { + stars, ok, err := ratingStarsForVideoPath(c.path) + if err != nil { + appLogln("⚠️ cleanup rating check failed:", filepath.Base(c.path), err) + } else if ok && stars <= maxStars { + deleteReason = "rating" + appLogf( + "🧹 cleanup deleting low-rated video: %s stars=%d threshold<=%d", + filepath.Base(c.path), + stars, + maxStars, + ) + } + } + + if deleteReason == "" { broken, why := cleanupVideoLooksBroken(ctx, c.path) if broken { deleteReason = "broken" @@ -762,26 +931,21 @@ func cleanupSmallFiles(ctx context.Context, jobID string, doneAbs string, thresh } if deleteReason != "" { - base := strings.TrimSuffix(filepath.Base(c.path), filepath.Ext(c.path)) - id := stripHotPrefix(base) - - // Stoppt ggf. laufende Asset-/Enrich-Tasks für genau diese Datei, - // damit Windows-Dateihandles nicht blockieren. - releaseFileForMutation(c.name) - - if derr := removeWithRetry(c.path); derr == nil || os.IsNotExist(derr) { + deletedBytes, derr := deleteDoneVideoAndGenerated(c.path) + if derr == nil { resp.DeletedFiles++ - resp.DeletedBytes += c.size + if deletedBytes > 0 { + resp.DeletedBytes += deletedBytes + } else { + resp.DeletedBytes += c.size + } - if deleteReason == "broken" { + switch deleteReason { + case "broken": resp.DeletedBrokenFiles++ + case "rating": + resp.DeletedLowRatedFiles++ } - - if strings.TrimSpace(id) != "" { - removeGeneratedForID(id) - } - - purgeDurationCacheForPath(c.path) } else { resp.ErrorCount++ } diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index f976b81..3c7368f 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -511,6 +511,9 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "") + // Nach erfolgreicher Regenerate-Analyse optional nach Rating löschen. + autoDeleteLowRatedDownloadAfterAnalysis(ctx, videoPath, nil) + finishRegenerateAssetsJob(file, "done", "") setRegenerateAssetsTaskDone(file, "Fertig") removeRegenerateAssetsJobLater(file, 3*time.Second) diff --git a/frontend/src/components/ui/DeletingPreviewOverlay.tsx b/frontend/src/components/ui/DeletingPreviewOverlay.tsx new file mode 100644 index 0000000..dedf4a5 --- /dev/null +++ b/frontend/src/components/ui/DeletingPreviewOverlay.tsx @@ -0,0 +1,34 @@ +'use client' + +import LoadingSpinner from './LoadingSpinner' + +export default function DeletingPreviewOverlay({ + label = 'Wird gelöscht', + className = 'rounded-t-lg', +}: { + label?: string + className?: string +}) { + return ( +