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 ( +
+
+ + +
+ {label} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 43d32a9..9782b5d 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -46,6 +46,7 @@ import SpritePreloader from './SpritePreloader' import FinishedDownloadsDesktopCard from './FinishedDownloadsDesktopCard' import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' +import DeletingPreviewOverlay from './DeletingPreviewOverlay' type SwipeRefs = { @@ -837,6 +838,10 @@ function FinishedDownloadsCardsView({ /> ) : null} + + {deletingKeys.has(k) ? ( + + ) : null} @@ -1277,6 +1282,16 @@ function FinishedDownloadsCardsView({ renderRole: 'top', }) + const selectionModeActive = selectionStore.hasAnySelection() + + const canSpeedHold = + !inlineActive && + !busy && + ( + allowTeaserAnimation || + (selectionModeActive && teaserState.mode !== 'still') + ) + return (
{ if (isHot) return await onToggleHot?.(j) @@ -1331,6 +1346,11 @@ function FinishedDownloadsCardsView({ }} onPressStart={(info) => { if (info.pointerType === 'mouse' && info.button !== 0) return + if (!canSpeedHold) return + + // Wichtig im Auswahl-Modus: + // Falls der Teaser noch nicht läuft, beim Gedrückthalten aktivieren. + setMobileTopTeaserEnabled(true) setSpeedHoldKey(k) }} onPressEnd={() => { diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index ab1bf33..cd538e2 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -32,6 +32,7 @@ import CountryFlag, { resolveFinishedCountry } from './CountryFlag' import SpritePreloader from './SpritePreloader' import PreviewRatingOverlay from './PreviewRatingOverlay' import PreviewMetaOverlay from './PreviewMetaOverlay' +import DeletingPreviewOverlay from './DeletingPreviewOverlay' function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined @@ -548,6 +549,10 @@ function FinishedDownloadsGalleryCardInner({ />
) : null} + + {isDeleting ? ( + + ) : null} diff --git a/frontend/src/components/ui/RatingOverlay.tsx b/frontend/src/components/ui/RatingOverlay.tsx index 020f7e8..41e4be7 100644 --- a/frontend/src/components/ui/RatingOverlay.tsx +++ b/frontend/src/components/ui/RatingOverlay.tsx @@ -1314,7 +1314,7 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] { const spriteMeta = readPreviewSpriteMeta(metaRaw) const durationSeconds = readMetaDurationSeconds(metaRaw) - return rawSegments + const segments = rawSegments .map((raw, index): RatingSegment | null => { if (!isPlainObject(raw)) return null @@ -1358,6 +1358,32 @@ export function readRatingSegments(metaRaw: unknown): RatingSegment[] { } }) .filter(isRatingSegment) + + // Priorität 1: + // Sobald Positionen erkannt wurden, zeigen wir als Segmente nur Positionen. + const positionSegments = segments.filter((segment) => ( + segmentVisualKind(segment) === 'position' + )) + + if (positionSegments.length > 0) { + return positionSegments + } + + // Priorität 2: + // Wenn keine Positionen erkannt wurden, fallback auf Kleidung / Körperteile. + const bodyOrClothingSegments = segments.filter((segment) => { + const kind = segmentVisualKind(segment) + return kind === 'clothing' || kind === 'body' + }) + + if (bodyOrClothingSegments.length > 0) { + return bodyOrClothingSegments + } + + // Fallback: + // Falls wirklich weder Position noch Kleidung/Körperteil vorhanden ist, + // lassen wir die ursprünglichen Segmente stehen, damit die Liste nicht leer wird. + return segments } type SegmentVisualKind = 'position' | 'toy' | 'clothing' | 'body' | 'default' diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index fdd2c35..a0317fb 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -28,6 +28,8 @@ type RecorderSettings = { autoDeleteSmallDownloads?: boolean autoDeleteSmallDownloadsBelowMB?: number autoDeleteSmallDownloadsKeepFavorites?: boolean + autoDeleteLowRatedDownloads?: boolean + autoDeleteLowRatedDownloadsMaxStars?: number blurPreviews?: boolean teaserPlayback?: 'still' | 'hover' | 'all' teaserAudio?: boolean @@ -224,11 +226,13 @@ function SettingsSection(props: { type="button" onClick={toggleOpen} className={[ - 'flex w-full items-start justify-between gap-4 bg-white/95 p-4 text-left transition hover:bg-gray-50', - 'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500', - 'dark:bg-gray-950/70 dark:hover:bg-white/5 dark:focus-visible:ring-indigo-400', - open ? 'rounded-t-2xl rounded-b-none' : 'rounded-2xl', - ].join(' ')} + 'flex w-full items-start justify-between gap-4 p-4 text-left transition', + 'focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500', + 'dark:focus-visible:ring-indigo-400', + open + ? 'rounded-t-2xl rounded-b-none bg-gray-50 hover:bg-gray-100/80 dark:bg-white/5 dark:hover:bg-white/10' + : 'rounded-2xl bg-white hover:bg-gray-50 dark:bg-gray-950/70 dark:hover:bg-white/5', + ].join(' ')} aria-expanded={open} >
@@ -290,6 +294,8 @@ const DEFAULTS: RecorderSettings = { autoDeleteSmallDownloads: true, autoDeleteSmallDownloadsBelowMB: 200, autoDeleteSmallDownloadsKeepFavorites: true, + autoDeleteLowRatedDownloads: false, + autoDeleteLowRatedDownloadsMaxStars: 3, blurPreviews: false, teaserPlayback: 'hover', teaserAudio: false, @@ -501,6 +507,131 @@ function mapCleanupJobToTask(job: any): TaskItem { } } +const SETTINGS_RATING_STAR_PATH = + 'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z' + +function settingsRatingTone(stars: number) { + switch (stars) { + case 1: + return 'text-yellow-300 opacity-55' + case 2: + return 'text-yellow-300 opacity-65' + case 3: + return 'text-yellow-300 opacity-80' + case 4: + return 'text-yellow-300 opacity-95' + default: + return 'text-yellow-200 opacity-100' + } +} + +function RatingThresholdStar({ + stars, + filled, +}: { + stars: number + filled: boolean +}) { + return ( + + + + ) +} + +function RatingThresholdStars({ + value, + disabled, + onChange, +}: { + value: number | undefined + disabled?: boolean + onChange: (stars: number) => void +}) { + const selected = Math.max(1, Math.min(5, Math.round(Number(value ?? 3)))) + + return ( +
+
+ {[1, 2, 3, 4, 5].map((stars) => { + const filled = stars <= selected + const checked = stars === selected + + return ( + + ) + })} +
+ +
+ Löscht bis einschließlich {selected} Stern{selected === 1 ? '' : 'e'}. +
+
+ ) +} + export default function RecorderSettings({ onAssetsGenerated }: Props) { const [value, setValue] = useState(DEFAULTS) const [saving, setSaving] = useState(false) @@ -688,6 +819,10 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { autoDeleteSmallDownloadsBelowMB: data.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites: (data as any).autoDeleteSmallDownloadsKeepFavorites ?? DEFAULTS.autoDeleteSmallDownloadsKeepFavorites, + autoDeleteLowRatedDownloads: + (data as any).autoDeleteLowRatedDownloads ?? DEFAULTS.autoDeleteLowRatedDownloads, + autoDeleteLowRatedDownloadsMaxStars: + (data as any).autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars, blurPreviews: data.blurPreviews ?? DEFAULTS.blurPreviews, teaserPlayback: (data as any).teaserPlayback ?? DEFAULTS.teaserPlayback, teaserAudio: (data as any).teaserAudio ?? DEFAULTS.teaserAudio, @@ -1084,6 +1219,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { Math.min(100_000, Math.floor(Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB))) ) const autoDeleteSmallDownloadsKeepFavorites = !!value.autoDeleteSmallDownloadsKeepFavorites + const autoDeleteLowRatedDownloads = !!value.autoDeleteLowRatedDownloads + const autoDeleteLowRatedDownloadsMaxStars = Math.max( + 1, + Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3))) + ) const blurPreviews = !!value.blurPreviews const teaserPlayback = value.teaserPlayback === 'still' || value.teaserPlayback === 'all' || value.teaserPlayback === 'hover' @@ -1126,6 +1266,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { autoDeleteSmallDownloads, autoDeleteSmallDownloadsBelowMB, autoDeleteSmallDownloadsKeepFavorites, + autoDeleteLowRatedDownloads, + autoDeleteLowRatedDownloadsMaxStars, blurPreviews, teaserPlayback, teaserAudio, @@ -1201,22 +1343,35 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const mb = Number(value.autoDeleteSmallDownloadsBelowMB ?? DEFAULTS.autoDeleteSmallDownloadsBelowMB ?? 0) const doneDir = (value.doneDir || DEFAULTS.doneDir).trim() + const deleteLowRated = !!value.autoDeleteLowRatedDownloads + const maxStars = Math.max( + 1, + Math.min(5, Math.floor(Number(value.autoDeleteLowRatedDownloadsMaxStars ?? DEFAULTS.autoDeleteLowRatedDownloadsMaxStars ?? 3))) + ) if (!doneDir) { setErr('doneDir ist leer.') return null } - if (!mb || mb <= 0) { - setErr('Mindestgröße ist 0 – es würde nichts gelöscht.') + if ((!mb || mb <= 0) && !deleteLowRated) { + setErr('Mindestgröße ist 0 und Rating-Löschen ist deaktiviert – es würde nichts gelöscht.') return null } + const ratingLine = deleteLowRated + ? `• Rating ≤ ${maxStars} Stern${maxStars === 1 ? '' : 'e'}\n` + : '' + + const sizeLine = mb > 0 + ? `• Dateien < ${mb} MB in "${doneDir}"\n` + : '' + const ok = window.confirm( `Aufräumen:\n` + - `• Löscht Dateien in "${doneDir}" < ${mb} MB (Ordner "keep" wird übersprungen)\n` + - `• Löscht fehlerhafte/defekte MP4/TS-Dateien, wenn sie nicht lesbar sind\n` + - `• Räumt Record-Reste auf: .muxing.ts, verwaiste .ts und verwaiste/0-KB .audio.m4s\n` + - `• Entfernt verwaiste Previews/Thumbs/Generated-Assets ohne passende Datei\n\n` + + sizeLine + + ratingLine + + `• Entfernt defekte Video-Dateien\n` + + `• Löscht Record-Reste und verwaiste Assets\n\n` + `Fortfahren?` ) if (!ok) return null @@ -1790,7 +1945,7 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) {
+
+ + setValue((v) => ({ + ...v, + autoDeleteLowRatedDownloads: checked, + autoDeleteLowRatedDownloadsMaxStars: + v.autoDeleteLowRatedDownloadsMaxStars ?? 3, + })) + } + label="Downloads nach Analyse nach Rating löschen" + description="Löscht fertige Downloads automatisch nach der Analyse, wenn das Rating höchstens der gewählten Sternezahl entspricht." + /> + +
+
+
+ Sterne-Grenze +
+
+ Bis einschließlich dieser Bewertung wird gelöscht. +
+
+ +
+ + setValue((v) => ({ + ...v, + autoDeleteLowRatedDownloadsMaxStars: stars, + })) + } + /> +
+
+
+
void onImageExpandedChange?: (expanded: boolean) => void @@ -2009,6 +2011,15 @@ export default function TrainingTab(props: { const [trainingStatsError, setTrainingStatsError] = useState(null) const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) + const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => { + if (typeof window === 'undefined') return '' + + try { + return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || '' + } catch { + return '' + } + }) const [importedSampleQueue, setImportedSampleQueue] = useState([]) const importedSampleQueueRef = useRef([]) @@ -2530,6 +2541,68 @@ export default function TrainingTab(props: { const shownTrainingStep = trainingRunning ? trainingStatus?.training?.step || trainingStep || 'Training läuft…' : trainingStep + + const drawingCursorClass = + boxInteraction?.type === 'move' + ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing' + : drawingBox || (boxLabel && !uiLocked) + ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair' + : '' + + const trainingInfoJob = trainingStatus?.training ?? null + const trainingInfoKey = useMemo(() => { + const job = trainingInfoJob + + if (!job || job.running) return '' + + const finishedAt = String(job.finishedAt || '').trim() + const startedAt = String(job.startedAt || '').trim() + const message = String(job.message || job.error || '').trim() + + if (finishedAt) return finishedAt + if (startedAt && message) return `${startedAt}:${message}` + + return '' + }, [ + trainingInfoJob?.running, + trainingInfoJob?.finishedAt, + trainingInfoJob?.startedAt, + trainingInfoJob?.message, + trainingInfoJob?.error, + ]) + + const showTrainingInfo = + Boolean(trainingInfoKey) && + !trainingRunning && + dismissedTrainingInfoKey !== trainingInfoKey + + const trainingInfoMessage = String( + trainingInfoJob?.message || + trainingInfoJob?.error || + 'Training abgeschlossen.' + ).trim() + + const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob) + + const trainingInfoLooksPartial = + Boolean(trainingInfoJob?.error) || + /übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage) + + const dismissTrainingInfo = useCallback(() => { + if (!trainingInfoKey) return + + setDismissedTrainingInfoKey(trainingInfoKey) + + try { + window.localStorage.setItem( + TRAINING_INFO_DISMISSED_STORAGE_KEY, + trainingInfoKey + ) + } catch { + // ignore + } + }, [trainingInfoKey]) + const analysisConfidence = useMemo(() => { return currentAnalysisConfidence(sample?.prediction) }, [sample?.prediction]) @@ -3519,6 +3592,13 @@ export default function TrainingTab(props: { const startTraining = useCallback(async () => { shownTrainingCompletionRef.current = null + setDismissedTrainingInfoKey('') + + try { + window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) + } catch { + // ignore + } setTraining(true) setTrainingProgress(5) @@ -4302,6 +4382,98 @@ export default function TrainingTab(props: {
) : null} + {showTrainingInfo ? ( +
+
+
+ {trainingInfoLooksPartial ? ( +
+ +
+
+
+
+ Training-Info +
+ +
+ {trainingInfoMessage} +
+
+ + +
+ +
+
+
+ Dauer +
+
+ {trainingInfoDurationMs > 0 + ? formatDuration(trainingInfoDurationMs) + : '—'} +
+
+ +
+
+ Feedback +
+
+ {feedbackCount}/{requiredCount} +
+
+ +
+
+ YOLO Train +
+
+ {trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20} +
+
+ +
+
+ YOLO Val +
+
+ {trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3} +
+
+
+
+
+
+ ) : null} +