From 14412eab4cf5680a66625ba0497583de3b5ed637 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:40:16 +0200 Subject: [PATCH] updated everything --- backend/analyze.go | 5 + backend/assets/img/tray.ico | Bin 0 -> 3890 bytes backend/assets_generate.go | 82 +- backend/assets_sprite.go | 25 +- backend/chaturbate_online.go | 169 ++- .../data/pending-autostart/__global__.json | 154 +- backend/go.mod | 9 + backend/go.sum | 24 + backend/live.go | 10 + backend/main.go | 208 ++- backend/meta.go | 12 +- backend/postwork.go | 169 ++- backend/preview.go | 545 +------ backend/record.go | 140 +- backend/record_stream_cb.go | 28 +- backend/record_stream_hls.go | 61 + backend/recorder.go | 128 +- backend/recorder_settings.json | 5 + backend/routes.go | 1 + backend/server.go | 137 +- backend/settings.go | 30 + backend/split.go | 503 ++++++- backend/tasks_assets.go | 467 ++++-- backend/tasks_check_video.go | 665 +++++++++ backend/tasks_cleanup.go | 147 +- backend/tasks_exclusive.go | 108 +- backend/tasks_regenerate_assets.go | 231 +-- backend/tasks_release_file.go | 26 +- backend/tasks_status.go | 100 +- backend/teaser.go | 567 +++++++ backend/tray.go | 226 +++ backend/tray_stats.go | 232 +++ frontend/README.md | 2 +- frontend/src/App.tsx | 349 ++++- frontend/src/components/ui/Button.tsx | 10 +- frontend/src/components/ui/ButtonGroup.tsx | 6 +- frontend/src/components/ui/CategoriesTab.tsx | 36 +- frontend/src/components/ui/Checkbox.tsx | 6 +- frontend/src/components/ui/ContextMenu.tsx | 12 +- .../src/components/ui/FinishedDownloads.tsx | 882 ++++++++--- .../ui/FinishedDownloadsCardsView.tsx | 89 +- .../ui/FinishedDownloadsGalleryCard.tsx | 81 +- .../ui/FinishedDownloadsGalleryView.tsx | 34 +- .../ui/FinishedDownloadsTableView.tsx | 40 +- .../components/ui/FinishedVideoPreview.tsx | 10 +- frontend/src/components/ui/Icons.tsx | 409 ++++++ frontend/src/components/ui/LabeledSwitch.tsx | 73 +- frontend/src/components/ui/LazyMount.tsx | 14 +- frontend/src/components/ui/LiveVideo.tsx | 95 +- frontend/src/components/ui/LoadingSpinner.tsx | 4 +- frontend/src/components/ui/LoginPage.tsx | 6 +- frontend/src/components/ui/ModelDetails.tsx | 160 +- .../src/components/ui/ModelGenderIcon.tsx | 6 +- frontend/src/components/ui/ModelPreview.tsx | 211 ++- frontend/src/components/ui/ModelsTab.tsx | 115 +- frontend/src/components/ui/Pagination.tsx | 4 +- .../src/components/ui/PerformanceMonitor.tsx | 20 +- frontend/src/components/ui/Player.tsx | 218 +-- .../src/components/ui/PreviewScrubber.tsx | 34 +- frontend/src/components/ui/ProgressBar.tsx | 6 +- .../src/components/ui/RecordJobActions.tsx | 45 +- .../src/components/ui/RecorderSettings.tsx | 675 +++++++-- frontend/src/components/ui/Slider.tsx | 10 +- .../src/components/ui/SpritePreloader.tsx | 40 + frontend/src/components/ui/SwipeActionRow.tsx | 22 +- frontend/src/components/ui/SwipeCard.tsx | 64 +- frontend/src/components/ui/Switch.tsx | 45 +- frontend/src/components/ui/Table.tsx | 39 +- frontend/src/components/ui/Tabs.tsx | 4 +- frontend/src/components/ui/TagBadge.tsx | 6 +- frontend/src/components/ui/TagFilterBar.tsx | 38 +- frontend/src/components/ui/TagOverflowRow.tsx | 39 +- frontend/src/components/ui/Task.tsx | 6 +- frontend/src/components/ui/TaskList.tsx | 11 +- frontend/src/components/ui/TextInput.tsx | 24 +- frontend/src/components/ui/ToastProvider.tsx | 62 +- .../src/components/ui/VideoSplitModal.tsx | 1299 +++++++++++++---- frontend/src/components/ui/formatters.ts | 25 + 78 files changed, 8121 insertions(+), 2479 deletions(-) create mode 100644 backend/assets/img/tray.ico create mode 100644 backend/tasks_check_video.go create mode 100644 backend/teaser.go create mode 100644 backend/tray.go create mode 100644 backend/tray_stats.go create mode 100644 frontend/src/components/ui/Icons.tsx create mode 100644 frontend/src/components/ui/SpritePreloader.tsx diff --git a/backend/analyze.go b/backend/analyze.go index 5f2b6c7..f923f41 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -18,6 +18,7 @@ import ( "path/filepath" "sort" "strings" + "syscall" "time" ) @@ -268,6 +269,10 @@ func extractVideoFrameAt(ctx context.Context, outPath string, atSec float64) (im "-y", tmpPath, ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } if out, err := cmd.CombinedOutput(); err != nil { return nil, fmt.Errorf("ffmpeg fehlgeschlagen: %v: %s", err, strings.TrimSpace(string(out))) diff --git a/backend/assets/img/tray.ico b/backend/assets/img/tray.ico new file mode 100644 index 0000000000000000000000000000000000000000..e047b62b92ff303e88c900ff130e280098fe2bc7 GIT binary patch literal 3890 zcmcgvd2Ach9X6YQ+-{sWi5)w(6Fa^V+wl=6J`y*FuN=)qn;vWh4I#=jacoo|m6bmR zsy0A_O$c%H7>r{SXrL;gO-U~pY%K*g#DKvV1BQk=&=^pv>f85wwnJ8F|1Qzz-#dQq zeZT9yXKOU)@!x9J$fw4n)o2{|* zyGd8|mO)qbd2@ZuSyO`+=7tptCVf$|Zz_(-oQ#*|O1kGtV|~pT?)!OTZROi~ZN+^} zb=9%0TW`@=O^upJ(086*TRub1ckHGHblA=Cw_6bqI0ZH(W$YVR{;TbCEbDWPU#%;x zmGw5I-_d4<-P{1$@}7Q0?%4iB0eSG?QhvMt#ucF5Z94o3mrqAd1hPV^;D63>n{lR_u^%TXFXS9JGp!-5%BJor)P`;Kp+Y#*~I@*`)5k0G+4wfbz>r%_wD?-((v}=~Y8paap zs$tYs!Njqa##%Vcjc9GI|EkTTKU?BgF;jjDUjb2{lQsz0C!{86Gh#t6<6tSmN@-{ZN*JSEOvzf0-&&<;Tt<7P9~ zAr%cE(#c$PGFAc~rOnh{T@8L^FHcs z$72udpw2El@z4Xf`;Obt;b>9(?JZ4+6k}fSnX#syC0>!SfJZ^v5!xAL|H%G*c=g3& zc=`FmIB|3lzdw8sFD>rJk@>yYeD#%x=VRD%-BnoJH-{Gv&f}#+3pjrCIsEbXi+E($ z{jA>>br(3uFS!rL-}tpEXBN(YvJ!f?n|QVi2%wTC&*tp7f0QuAe^>l z`rNAOr&Lc)ixKVQFT|ZOXS{nEcW;}8cVigZic3*lz8N(YSE62F@)Bm}i0`^2HyP=&+!!+YZn0MDwEE!Xm&BQ&8#aw%pbhOw|Q@#o1 z7c8Lcf|pT#;R&`+uzd_wWjFKON)9@Z>JHEbAG*EmXc_jRZf**d`v>TM3Ka_dsMsq= zqVk!3)IB@~TOZFc>s)e}r0wojv$CO7-@bMmeZG=>C&_yf6$-BuCHrNsF&1|)K5oS= zIdDc@Xxue}>O*6wJdi=vfh?*I%wj zW>s&ofQ$EX6qRL1h&NDG{wK!%ZSuWM`4;ge$6lxW0}OSuT*G^bb>g^b3Z}!Cqv61M zG#uOj-H};n4^2Y5IDwkQRnQ$;4gLPL(9f@iap6)puFWYv$)E7MTU%64qP$BRYcD}{ z#h=Of4z!i;qqgb;=&C;?K4ANOXsg~uP32!$3%9Wz-5BT%Au*mrbfzDXi8$OFSHgJc zGU#4d3*E6<)IPrk#(m#K$C@O%CzI%z?njLGQYzY|;I&(5gGJ>d>T|$Qe{GTTBXax$ z`W2_pQ2QzIPqsfno%Zjjr9BO`J6WebWc#AXCZfpjJP&sV(Up$Cd}tH&zq=Ir7cW8m zOB-Q&Y9k_v0Mb1nWMYwmF7*f4@8owXuiZ+1sbjuL#>=3u{aEoe)qT$IC|{yk|CNHF z{@-ZSeaiV$XffQW>OY@~p@3f6(T&JR47Ov}z;NRGFdW~6rW04dy6tTNFM=+nf7N%FPfqnlE5F3mlPu>yg38bA#_Q3CIqYZ70 zMG%9M%U~xw_RYL2KI7YTmiN;c)~DcWz7=QKKMkL22l^sWj1ML;KGcU*MU1UXAU2VL z`}j?;y>TO~uWy0%jV*93ZbmX4gUpX{jbM~*i9?s$!MogkMq)86IV2LHAi}=u(H(db z;m*^g;Lz7Nsuzl+>`z99_Mo&d+X|fc=WR=>Q7|`=#%&*`rK=TTq9}4H_P{H?RZXMR^Gi; zY?n|RU&p>+b}R#>BYLGZ98)&Rp0sr+(fcp2qiubg#dJZ~=iQtiOr2UIx~oR8j_>KZ ziM*2Q`MwG^@a?>KYD{6nayiX0VrjnEFX_tM#na>1z&&K&+3`F^hf=3Pot_(P=H?3> zwib=8#dLny-!VDd7oS^|9r|!8pE*65Ph&Ed#?(j#Q*3jr&}M&{v7a8z5!q#9S=FB& yp)9VEJ!Jh1zZ-Du^mu0QqrpV)Q=tyeWTQ@ZzTINhENV30F8vn$f5@%;Is6}yk51zN literal 0 HcmV?d00001 diff --git a/backend/assets_generate.go b/backend/assets_generate.go index 428ca6d..6598964 100644 --- a/backend/assets_generate.go +++ b/backend/assets_generate.go @@ -60,13 +60,23 @@ func isFFmpegInputInvalidError(err error) bool { // Asset "ID" = Dateiname ohne Endung (immer OHNE "HOT " Prefix) func assetIDFromVideoPath(videoPath string) string { - base := filepath.Base(strings.TrimSpace(videoPath)) - if base == "" { - return "" + return canonicalAssetIDFromName(videoPath) +} + +func postWorkSortForVideoPath(videoPath string) (bucket int, sortName string) { + clean := strings.TrimSpace(videoPath) + if clean == "" { + return 0, "" } - id := strings.TrimSuffix(base, filepath.Ext(base)) - id = stripHotPrefix(id) - return strings.TrimSpace(id) + + p := strings.ToLower(filepath.ToSlash(clean)) + if strings.Contains(p, "/keep/") { + bucket = 1 + } else { + bucket = 0 + } + + return bucket, strings.ToLower(filepath.Base(clean)) } // Liefert die standardisierten Pfade (preview.jpg / preview.mp4 / preview-sprite.jpg / meta.json) @@ -93,6 +103,41 @@ func assetPathsForID(id string) (assetDir, thumbPath, previewPath, spritePath, m return assetDir, thumbPath, previewPath, spritePath, metaPath, nil } +func assetsTruthForVideo(videoPath string) finishedPhaseTruth { + videoPath = strings.TrimSpace(videoPath) + + var truth finishedPhaseTruth + if videoPath == "" { + return truth + } + + id := assetIDFromVideoPath(videoPath) + if id == "" { + return truth + } + + // Basiszustand aus bestehender Logik lesen + truth = finishedPhaseTruthForID(id) + + _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) + if err != nil { + return truth + } + + // Meta: nur ready, wenn Inhalt OK UND Datei wirklich vorhanden + truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath) + + // Dateibasierte Assets immer an echter Datei festmachen + truth.ThumbReady = fileExistsNonEmpty(thumbPath) + truth.TeaserReady = fileExistsNonEmpty(previewPath) + truth.SpritesReady = fileExistsNonEmpty(spritePath) + + // Analyse weiterhin inhaltlich prüfen + truth.AnalyzeReady = hasAIResultsForOutput(videoPath) + + return truth +} + type ensuredMeta struct { durSec float64 vw, vh int @@ -814,20 +859,20 @@ func ensureDeferredAssetsAndAI(ctx context.Context, videoPath, sourceURL string) return fmt.Errorf("deferred sprite failed for %s: %w", videoPath, err) } - spriteTruth := regeneratePhaseTruthForVideo(videoPath, id) + spriteTruth := assetsTruthForVideo(videoPath) if !spriteTruth.SpritesReady { return fmt.Errorf("preview-sprite.jpg fehlt nach deferred generation: %s", id) } - // 2) KI-Analyse + // 2) Analyse analyzeReady, err := ensureAnalyzeForVideoCtx(ctx, videoPath, sourceURL, "nsfw") if err != nil { return fmt.Errorf("deferred analyze failed for %s: %w", videoPath, err) } - analyzeTruth := regeneratePhaseTruthForVideo(videoPath, id) + analyzeTruth := assetsTruthForVideo(videoPath) if !analyzeReady && !analyzeTruth.AnalyzeReady { - return fmt.Errorf("KI-Analyse fehlt nach deferred analyze: %s", id) + return fmt.Errorf("Analyse fehlt nach deferred analyze: %s", id) } return nil @@ -845,7 +890,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string return false } - truth := finishedPhaseTruthForID(assetID) + truth := assetsTruthForVideo(outPath) needMeta := !truth.MetaReady needThumb := !truth.ThumbReady @@ -872,13 +917,20 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "queued", "Sprites warten", nil) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "KI-Analyse wartet", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "queued", "Analyse wartet", nil) } + sortBucket, sortName := postWorkSortForVideoPath(outPath) + ok := enrichQ.Enqueue(PostWorkTask{ - Key: "enrich:" + assetID, - Added: time.Now(), + Key: "enrich:" + assetID, + Added: time.Now(), + SortBucket: sortBucket, + SortName: sortName, Run: func(ctx context.Context) error { + if err := waitUntilEnrichMayRun(ctx); err != nil { + return err + } return runDeferredEnrichPipeline(ctx, outPath, sourceURL) }, }) @@ -903,7 +955,7 @@ func enqueueDeferredAssetsAndAI(job *RecordJob, outPath string, sourceURL string publishDeferredPhase(fileName, assetID, "postwork", "sprites", "error", "Sprites konnten nicht eingeplant werden", enqueueErr) } if needAnalyze { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse konnte nicht eingeplant werden", enqueueErr) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse konnte nicht eingeplant werden", enqueueErr) } return false diff --git a/backend/assets_sprite.go b/backend/assets_sprite.go index 61aa47f..9fd175f 100644 --- a/backend/assets_sprite.go +++ b/backend/assets_sprite.go @@ -10,6 +10,7 @@ import ( "os/exec" "path/filepath" "strings" + "syscall" ) const ( @@ -29,9 +30,21 @@ func previewSpriteStepSeconds(durationSec float64) float64 { return 5 } - step := durationSec / float64(previewSpriteFrameCount) - if step < 0.5 { - step = 0.5 + // 80 Zellen = 79 Abstände. + // Wir ziehen ein kleines Stück vom Ende ab, damit ffmpeg + // nicht "hinter" dem letzten gültigen Frame sampelt. + usableDur := durationSec - 0.10 + if usableDur <= 0 { + usableDur = durationSec + } + + step := usableDur / float64(previewSpriteFrameCount-1) + + // Nicht künstlich auf 0.5s anheben: + // genau das erzeugt bei kürzeren Videos zu wenige Frames + // und damit schwarze Rest-Zellen im Sprite. + if step < 0.05 { + step = 0.05 } return step @@ -97,7 +110,7 @@ func generatePreviewSpriteJPG( // robustere Filterkette vf := fmt.Sprintf( - "fps=1/%f,"+ + "fps=fps=1/%f:start_time=0:round=up,"+ "scale=%d:%d:force_original_aspect_ratio=decrease:flags=bilinear,"+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2:black,"+ "setsar=1,"+ @@ -127,6 +140,10 @@ func generatePreviewSpriteJPG( ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var stderr bytes.Buffer cmd.Stderr = &stderr diff --git a/backend/chaturbate_online.go b/backend/chaturbate_online.go index ae4a895..710d74a 100644 --- a/backend/chaturbate_online.go +++ b/backend/chaturbate_online.go @@ -78,7 +78,7 @@ type chaturbateCache struct { } var ( - cbHTTP = &http.Client{Timeout: 30 * time.Second} + cbHTTP = &http.Client{Timeout: 12 * time.Second} cbMu sync.RWMutex cb chaturbateCache @@ -89,6 +89,9 @@ var ( var ( cbRefreshMu sync.Mutex cbRefreshInFlight bool + + cbStoreSyncMu sync.Mutex + cbStoreSyncInFlight bool ) func normalizeList(in []string) []string { @@ -511,20 +514,39 @@ func refreshChaturbateSnapshotNow(ctx context.Context) (time.Time, error) { fetchedAtNow := cbApplySnapshot(rooms) if cbModelStore != nil { - copiedRooms := append([]ChaturbateRoom(nil), rooms...) - - syncChaturbateRoomStateIntoModelStore( - cbModelStore, - copiedRooms, - fetchedAtNow, - ) - - _ = cbModelStore.SyncChaturbateOnlineForKnownModels(copiedRooms, fetchedAtNow) + triggerChaturbateStoreSync(cbModelStore, rooms, fetchedAtNow) } return fetchedAtNow, nil } +func triggerChaturbateStoreSync(store *ModelStore, rooms []ChaturbateRoom, fetchedAt time.Time) { + if store == nil { + return + } + + roomsCopy := append([]ChaturbateRoom(nil), rooms...) + + cbStoreSyncMu.Lock() + if cbStoreSyncInFlight { + cbStoreSyncMu.Unlock() + return + } + cbStoreSyncInFlight = true + cbStoreSyncMu.Unlock() + + go func() { + defer func() { + cbStoreSyncMu.Lock() + cbStoreSyncInFlight = false + cbStoreSyncMu.Unlock() + }() + + syncChaturbateRoomStateIntoModelStore(store, roomsCopy, fetchedAt) + _ = store.SyncChaturbateOnlineForKnownModels(roomsCopy, fetchedAt) + }() +} + // startChaturbateOnlinePoller pollt die API alle paar Sekunden, // aber nur, wenn der Settings-Switch "useChaturbateApi" aktiviert ist. func startChaturbateOnlinePoller(store *ModelStore) { @@ -541,82 +563,101 @@ func startChaturbateOnlinePoller(store *ModelStore) { var tagsMu sync.Mutex var tagsLast time.Time - refreshOnce := func() { + refreshOnce := func(trigger string) { if !getSettings().UseChaturbateAPI { return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx) - cancel() - - if err != nil { - cbMu.RLock() - msg := cb.LastErr - prevFetchedAt := cb.FetchedAt - prevCount := len(cb.Rooms) - cbMu.RUnlock() - - if msg != lastLoggedErr { - if prevFetchedAt.IsZero() { - fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg) - } else { - fmt.Printf( - "⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n", - prevFetchedAt.Format(time.RFC3339), - prevCount, - msg, - ) - } - lastLoggedErr = msg - } + cbRefreshMu.Lock() + if cbRefreshInFlight { + cbRefreshMu.Unlock() return } + cbRefreshInFlight = true + cbRefreshMu.Unlock() - cbMu.RLock() - roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...) - roomsCount := len(cb.Rooms) - cbMu.RUnlock() + go func() { + defer func() { + cbRefreshMu.Lock() + cbRefreshInFlight = false + cbRefreshMu.Unlock() + }() - // Tags nur selten und asynchron - if cbModelStore != nil && len(roomsCopy) > 0 { - shouldFill := false + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + fetchedAtNow, err := refreshChaturbateSnapshotNow(ctx) + cancel() - tagsMu.Lock() - if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery { - tagsLast = time.Now() - shouldFill = true + if err != nil { + cbMu.RLock() + msg := cb.LastErr + prevFetchedAt := cb.FetchedAt + prevCount := len(cb.Rooms) + lastAttempt := cb.LastAttempt + cbMu.RUnlock() + + // wiederholt loggen, nicht nur bei geändertem Fehlertext + if msg != lastLoggedErr || time.Since(lastAttempt) > 30*time.Second { + if prevFetchedAt.IsZero() { + fmt.Println("❌ [chaturbate] online rooms fetch failed (no snapshot yet):", msg) + } else { + fmt.Printf( + "⚠️ [chaturbate] refresh failed, keeping snapshot from %s (%d rooms): %s\n", + prevFetchedAt.Format(time.RFC3339), + prevCount, + msg, + ) + } + lastLoggedErr = msg + } + return } - tagsMu.Unlock() - if shouldFill { - go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy) + cbMu.RLock() + roomsCopy := append([]ChaturbateRoom(nil), cb.Rooms...) + roomsCount := len(cb.Rooms) + cbMu.RUnlock() + + // Tags nur selten und asynchron + if cbModelStore != nil { + shouldFill := false + + tagsMu.Lock() + if tagsLast.IsZero() || time.Since(tagsLast) >= tagsFillEvery { + tagsLast = time.Now() + shouldFill = true + } + tagsMu.Unlock() + + if shouldFill { + go cbModelStore.FillMissingTagsFromChaturbateOnline(roomsCopy) + } } - } - if lastLoggedErr != "" { - fmt.Println("✅ [chaturbate] online rooms fetch recovered") - lastLoggedErr = "" - } + if lastLoggedErr != "" { + fmt.Println("✅ [chaturbate] online rooms fetch recovered") + lastLoggedErr = "" + } - if verboseLogs() && roomsCount != lastLoggedCount { - fmt.Printf( - "✅ [chaturbate] online rooms: %d (snapshot %s)\n", - roomsCount, - fetchedAtNow.Format(time.RFC3339), - ) - lastLoggedCount = roomsCount - } + if verboseLogs() && roomsCount != lastLoggedCount { + fmt.Printf( + "✅ [chaturbate] online rooms: %d (snapshot %s, trigger=%s)\n", + roomsCount, + fetchedAtNow.Format(time.RFC3339), + trigger, + ) + lastLoggedCount = roomsCount + } + }() } // ✅ sofort einmal versuchen - refreshOnce() + refreshOnce("startup") ticker := time.NewTicker(interval) defer ticker.Stop() for range ticker.C { - refreshOnce() + refreshOnce("ticker") } } diff --git a/backend/data/pending-autostart/__global__.json b/backend/data/pending-autostart/__global__.json index 91a5e39..34a60b1 100644 --- a/backend/data/pending-autostart/__global__.json +++ b/backend/data/pending-autostart/__global__.json @@ -1,114 +1,114 @@ { "items": [ { - "modelKey": "sw33t_g1rl", - "url": "https://www.myfreecams.com/#Sw33t_g1rl", + "modelKey": "xdaisyx", + "url": "https://www.myfreecams.com/#xDaisyx", "mode": "probe_retry", - "nextProbeAtMs": 1776172330458, + "nextProbeAtMs": 1777292591836, "currentShow": "offline", "source": "watched" }, { - "modelKey": "angel_from_sky", - "url": "https://chaturbate.com/angel_from_sky/", + "modelKey": "wekittycluby", + "url": "https://chaturbate.com/wekittycluby/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/angel_from_sky.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/wekittycluby.jpg", "source": "watched" }, { - "modelKey": "vanicha_007", - "url": "https://chaturbate.com/vanicha_007/", + "modelKey": "la__melanie", + "url": "https://chaturbate.com/la__melanie/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/la__melanie.jpg", + "source": "watched" + }, + { + "modelKey": "adrean_sofi", + "url": "https://chaturbate.com/adrean_sofi/", "mode": "wait_public", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/vanicha_007.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/adrean_sofi.jpg", "source": "watched" }, { - "modelKey": "tomokosturgeon", - "url": "https://chaturbate.com/tomokosturgeon/", + "modelKey": "extasy_maya", + "url": "https://chaturbate.com/extasy_maya/", "mode": "wait_public", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/tomokosturgeon.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/extasy_maya.jpg", "source": "watched" }, { - "modelKey": "tammieprotano", - "url": "https://chaturbate.com/tammieprotano/", + "modelKey": "rita__shy", + "url": "https://chaturbate.com/rita__shy/", "mode": "wait_public", "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/tammieprotano.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/rita__shy.jpg", "source": "watched" }, { - "modelKey": "troybailly", - "url": "https://chaturbate.com/troybailly/", + "modelKey": "shachatte", + "url": "https://chaturbate.com/shachatte/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/shachatte.jpg", + "source": "watched" + }, + { + "modelKey": "carolecronan", + "url": "https://chaturbate.com/carolecronan/", + "mode": "wait_public", + "currentShow": "away", + "imageUrl": "https://thumb.live.mmcdn.com/ri/carolecronan.jpg", + "source": "watched" + }, + { + "modelKey": "lora_lifelover", + "url": "https://chaturbate.com/lora_lifelover/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/lora_lifelover.jpg", + "source": "watched" + }, + { + "modelKey": "phantomlace", + "url": "https://chaturbate.com/phantomlace/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/phantomlace.jpg", + "source": "watched" + }, + { + "modelKey": "aurora_the_ballerina", + "url": "https://chaturbate.com/aurora_the_ballerina/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/aurora_the_ballerina.jpg", + "source": "watched" + }, + { + "modelKey": "anabel054", + "url": "https://chaturbate.com/anabel054/", + "mode": "wait_public", + "currentShow": "private", + "imageUrl": "https://thumb.live.mmcdn.com/ri/anabel054.jpg", + "source": "watched" + }, + { + "modelKey": "phqqipi", + "url": "https://chaturbate.com/phqqipi/", "mode": "wait_public", "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/troybailly.jpg", + "imageUrl": "https://thumb.live.mmcdn.com/ri/phqqipi.jpg", "source": "watched" }, { - "modelKey": "vonen_ray", - "url": "https://chaturbate.com/vonen_ray/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/vonen_ray.jpg", - "source": "watched" - }, - { - "modelKey": "sharitaklemme", - "url": "https://chaturbate.com/sharitaklemme/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/sharitaklemme.jpg", - "source": "watched" - }, - { - "modelKey": "sosaguillypleasure", - "url": "https://chaturbate.com/sosaguillypleasure/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/sosaguillypleasure.jpg", - "source": "watched" - }, - { - "modelKey": "kittymeow_xxx", - "url": "https://chaturbate.com/kittymeow_xxx/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/kittymeow_xxx.jpg", - "source": "watched" - }, - { - "modelKey": "superstar090", - "url": "https://chaturbate.com/superstar090/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/superstar090.jpg", - "source": "watched" - }, - { - "modelKey": "victoryglow", - "url": "https://chaturbate.com/victoryglow/", - "mode": "wait_public", - "currentShow": "private", - "imageUrl": "https://thumb.live.mmcdn.com/ri/victoryglow.jpg", - "source": "watched" - }, - { - "modelKey": "palmiratownzen", - "url": "https://chaturbate.com/palmiratownzen/", - "mode": "wait_public", - "currentShow": "hidden", - "imageUrl": "https://thumb.live.mmcdn.com/ri/palmiratownzen.jpg", - "source": "watched" - }, - { - "modelKey": "rafaelaloghry", - "url": "https://chaturbate.com/rafaelaloghry/", + "modelKey": "evewilder", + "url": "https://chaturbate.com/evewilder/", "mode": "probe_retry", - "nextProbeAtMs": 1776172331673, + "nextProbeAtMs": 1777292606854, "currentShow": "unknown", "source": "watched" } diff --git a/backend/go.mod b/backend/go.mod index c3eef54..d08aaeb 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,6 +4,7 @@ go 1.25.3 require ( github.com/PuerkitoBio/goquery v1.11.0 + github.com/getlantern/systray v1.2.2 github.com/google/uuid v1.6.0 github.com/grafov/m3u8 v0.12.1 github.com/jackc/pgx/v5 v5.8.0 @@ -15,11 +16,19 @@ require ( require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect + github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect + github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect + github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect + github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect + github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-stack/stack v1.8.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect diff --git a/backend/go.sum b/backend/go.sum index ca705d9..5acb24c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -9,8 +9,24 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBW github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4= +github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So= +github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk= +github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0= +github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc= +github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA= +github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA= +github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE= +github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -28,6 +44,10 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw= +github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= @@ -42,6 +62,7 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -96,11 +117,13 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -138,6 +161,7 @@ golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= gopkg.in/cenkalti/backoff.v1 v1.1.0 h1:Arh75ttbsvlpVA7WtVpH4u9h6Zl46xuptxqLxPiSo4Y= gopkg.in/cenkalti/backoff.v1 v1.1.0/go.mod h1:J6Vskwqd+OMVJl8C33mmtxTBs2gyzfv7UDAkHu8BrjI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/live.go b/backend/live.go index 13633b6..159ffc3 100644 --- a/backend/live.go +++ b/backend/live.go @@ -17,6 +17,7 @@ import ( "regexp" "strings" "sync" + "syscall" "time" ) @@ -536,6 +537,11 @@ func startPreviewHLS(ctx context.Context, job *RecordJob, m3u8URL, previewDir, h ) cmd := exec.CommandContext(ctx, ffmpegPath, hqArgs...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + var stderr bytes.Buffer cmd.Stderr = &stderr @@ -828,6 +834,10 @@ func recordPreviewLiveFMP4(w http.ResponseWriter, r *http.Request) { ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } stdout, err := cmd.StdoutPipe() if err != nil { diff --git a/backend/main.go b/backend/main.go index 66dbfaf..1b97caf 100644 --- a/backend/main.go +++ b/backend/main.go @@ -20,6 +20,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "github.com/grafov/m3u8" @@ -36,6 +37,10 @@ type Playlist struct { Framerate int } +var recordedAssetStemPrefixRe = regexp.MustCompile( + `^(.+?_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2})`, +) + const ( JobRunning JobStatus = "running" JobPostwork JobStatus = "postwork" // ✅ NEU: Aufnahme vorbei, Nacharbeiten laufen noch @@ -130,6 +135,29 @@ type ffprobeInfo struct { Streams []ffprobeStreamInfo `json:"streams"` } +func canonicalAssetIDFromName(name string) string { + base := strings.TrimSpace(filepath.Base(name)) + if base == "" { + return "" + } + + stem := strings.TrimSuffix(base, filepath.Ext(base)) + stem = stripHotPrefix(strings.TrimSpace(stem)) + stem = strings.TrimPrefix(stem, ".") + if stem == "" { + return "" + } + + // Wichtig: + // ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000" + // -> "chaesoffel_04_22_2026__12-22-16" + if m := recordedAssetStemPrefixRe.FindStringSubmatch(stem); m != nil { + return strings.TrimSpace(m[1]) + } + + return stem +} + func publishJobUpsertSnapshot(s jobEventSnapshot) { ev := map[string]any{ "type": "job_upsert", @@ -230,6 +258,10 @@ func probeVideoProps(ctx context.Context, filePath string) (w int, h int, fps fl "-of", "json", filePath, ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } out, err := cmd.Output() if err != nil { @@ -447,6 +479,11 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) { "-of", "default=noprint_wrappers=1:nokey=1", path, ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + out, err := cmd.Output() if err == nil { s := strings.TrimSpace(string(out)) @@ -461,6 +498,10 @@ func durationSecondsCached(ctx context.Context, path string) (float64, error) { // 2) Fallback: ffmpeg -i "Duration: HH:MM:SS.xx" parsen cmd2 := exec.CommandContext(ctx, ffmpegPath, "-i", path) + cmd2.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } b, _ := cmd2.CombinedOutput() // ffmpeg liefert hier oft ExitCode!=0, Output ist trotzdem da text := string(b) @@ -569,21 +610,41 @@ func removeGeneratedForID(id string) { return } + id = stripHotPrefix(id) + if isAssetIDInUse(id) { return } - id = strings.TrimSuffix(id, filepath.Ext(id)) - id = stripHotPrefix(id) - var err error id, err = sanitizeID(id) if err != nil || id == "" { return } + removed := map[string]struct{}{} + + removeDir := func(p string) { + p = strings.TrimSpace(p) + if p == "" { + return + } + cp := filepath.Clean(p) + if _, seen := removed[cp]; seen { + return + } + _ = os.RemoveAll(cp) + removed[cp] = struct{}{} + } + + // 1) Asset-Ordner mit preview.jpg / preview.mp4 / preview-sprite.jpg + if dir, err := generatedDirForID(id); err == nil && strings.TrimSpace(dir) != "" { + removeDir(dir) + } + + // 2) Separater Meta-Ordner generated/meta/ if root, _ := generatedMetaRoot(); strings.TrimSpace(root) != "" { - _ = os.RemoveAll(filepath.Join(root, id)) + removeDir(filepath.Join(root, id)) } } @@ -766,6 +827,11 @@ func remuxTSToMP4(tsPath, mp4Path string) error { "-movflags", "+faststart", mp4Path, ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { @@ -800,16 +866,35 @@ func remuxTSToMP4WithProgress( onRatio func(r float64), ) error { // ffmpeg progress kommt auf stdout als key=value - cmd := exec.CommandContext(ctx, ffmpegPath, + args := []string{ "-y", "-nostats", "-progress", "pipe:1", + "-hide_banner", + "-loglevel", "warning", + } + args = append(args, ffmpegInputTol...) + args = append(args, "-i", tsPath, + "-map", "0:v:0", + "-map", "0:a:0?", + "-dn", + "-ignore_unknown", + "-avoid_negative_ts", "make_zero", + "-max_interleave_delta", "0", + "-muxpreload", "0", + "-muxdelay", "0", "-c", "copy", "-movflags", "+faststart", mp4Path, ) + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + stdout, err := cmd.StdoutPipe() if err != nil { return err @@ -996,6 +1081,10 @@ func ensureFastStartMP4(path string) error { "-movflags", "+faststart", tmp, ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var stderr bytes.Buffer cmd.Stderr = &stderr @@ -1119,8 +1208,7 @@ func uniqueDestPath(dstDir, file string) (string, error) { } func idFromVideoPath(videoPath string) string { - name := filepath.Base(strings.TrimSpace(videoPath)) - return strings.TrimSuffix(name, filepath.Ext(name)) + return canonicalAssetIDFromName(videoPath) } func assetIDForJob(job *RecordJob) string { @@ -1521,3 +1609,109 @@ func fileExists(path string) bool { _, err := os.Stat(path) return err == nil } + +func validateVideoDecodesNearEnd(ctx context.Context, path string) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("empty path") + } + + dur, err := durationSecondsCached(ctx, path) + if err != nil || dur <= 0 { + return nil // best effort + } + + t := dur * 0.90 + if t < 0.1 { + t = 0.1 + } + + img, err := extractFrameAtTimeJPG(path, t) + if err != nil { + return fmt.Errorf("decode near end failed at %.3fs: %w", t, err) + } + if len(img) == 0 { + return fmt.Errorf("decode near end returned empty frame at %.3fs", t) + } + + return nil +} + +func uniqueSiblingTempPath(finalPath, suffix string) string { + finalPath = strings.TrimSpace(finalPath) + + dir := filepath.Dir(finalPath) + ext := filepath.Ext(finalPath) + base := strings.TrimSuffix(filepath.Base(finalPath), ext) + + ts := time.Now().Format("20060102_150405_000") + + if strings.TrimSpace(suffix) == "" { + return filepath.Join(dir, fmt.Sprintf(".%s.%s%s", base, ts, ext)) + } + + return filepath.Join(dir, fmt.Sprintf(".%s.%s.%s%s", base, suffix, ts, ext)) +} + +func reencodeTSToMP4(ctx context.Context, tsPath, mp4Path string) error { + tmp := uniqueSiblingTempPath(mp4Path, "tmp") + _ = os.Remove(tmp) + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "warning", + } + args = append(args, ffmpegInputTol...) + args = append(args, + "-i", tsPath, + "-map", "0:v:0", + "-map", "0:a:0?", + "-dn", + "-ignore_unknown", + + "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", + "-c:v", "libx264", + "-preset", "veryfast", + "-crf", "20", + "-pix_fmt", "yuv420p", + + "-c:a", "aac", + "-b:a", "128k", + "-ac", "2", + + "-af", "aresample=async=1:first_pts=0", + "-shortest", + "-movflags", "+faststart", + tmp, + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + var stderr bytes.Buffer + cmd.Stdout = io.Discard + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg reencode failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + } + + if err := requireNonEmptyRegularFile(tmp, "reencode tmp result"); err != nil { + _ = os.Remove(tmp) + return err + } + + _ = os.Remove(mp4Path) + + if err := os.Rename(tmp, mp4Path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename reencode tmp -> target failed: %w", err) + } + + return nil +} diff --git a/backend/meta.go b/backend/meta.go index ef6ef30..ba62e3e 100644 --- a/backend/meta.go +++ b/backend/meta.go @@ -93,11 +93,12 @@ type videoMeta struct { Version int `json:"version"` UpdatedAtUnix int64 `json:"updatedAtUnix"` - File videoFileMeta `json:"file"` - Media videoMediaMeta `json:"media"` - Preview previewMeta `json:"preview,omitempty"` - Analysis analysisMeta `json:"analysis,omitempty"` - Postwork postworkMeta `json:"postwork,omitempty"` + File videoFileMeta `json:"file"` + Media videoMediaMeta `json:"media"` + Preview previewMeta `json:"preview,omitempty"` + Analysis analysisMeta `json:"analysis,omitempty"` + Postwork postworkMeta `json:"postwork,omitempty"` + Validation map[string]any `json:"validation,omitempty"` } type aiSegmentMeta struct { @@ -436,6 +437,7 @@ func writeVideoMeta(metaPath string, fi os.FileInfo, dur float64, w int, h int, m.Preview = existing.Preview m.Analysis = existing.Analysis m.Postwork = existing.Postwork + m.Validation = existing.Validation if m.File.SourceURL == "" { m.File.SourceURL = existing.File.SourceURL diff --git a/backend/postwork.go b/backend/postwork.go index ce39485..44d3734 100644 --- a/backend/postwork.go +++ b/backend/postwork.go @@ -10,22 +10,27 @@ import ( // Eine Nacharbeit (kann ffmpeg, ffprobe, thumbnails, rename, etc. enthalten) type PostWorkTask struct { - Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren - Run func(ctx context.Context) error - Added time.Time + Key string // z.B. Dateiname oder Job-ID, zum Deduplizieren + Run func(ctx context.Context) error + Added time.Time + SortBucket int // 0 = /done, 1 = /done/keep + SortName string // Dateiname lower-case } type PostWorkQueue struct { - q chan PostWorkTask ffmpegSem chan struct{} // "heavy" gate: maxParallelFFmpeg gleichzeitig + maxQueued int - mu sync.Mutex + mu sync.Mutex + cond *sync.Cond + + queue []PostWorkTask inflight map[string]struct{} // dedupe: queued ODER running queued int // Anzahl inflight (queued + running) // ✅ für UI: Warteschlange + Running-Keys tracken - waitingKeys []string // FIFO der Keys, die noch NICHT gestartet sind + waitingKeys []string // sortierte wartende Keys runningKeys map[string]struct{} // Keys, die gerade wirklich laufen (Semaphor gehalten) cancelByKey map[string]context.CancelFunc } @@ -38,16 +43,71 @@ func NewPostWorkQueue(queueSize int, maxParallelFFmpeg int) *PostWorkQueue { maxParallelFFmpeg = 1 // Default: "nacheinander" } - return &PostWorkQueue{ - q: make(chan PostWorkTask, queueSize), + pq := &PostWorkQueue{ ffmpegSem: make(chan struct{}, maxParallelFFmpeg), + maxQueued: queueSize, + queue: make([]PostWorkTask, 0, queueSize), inflight: make(map[string]struct{}), waitingKeys: make([]string, 0, queueSize), runningKeys: make(map[string]struct{}), cancelByKey: make(map[string]context.CancelFunc), } + + pq.cond = sync.NewCond(&pq.mu) + return pq +} + +func lessPostWorkTask(a, b PostWorkTask) bool { + aHasSort := strings.TrimSpace(a.SortName) != "" + bHasSort := strings.TrimSpace(b.SortName) != "" + + if aHasSort || bHasSort { + ab := a.SortBucket + bb := b.SortBucket + + if !aHasSort { + ab = 999 + } + if !bHasSort { + bb = 999 + } + + if ab != bb { + return ab < bb + } + + an := strings.ToLower(strings.TrimSpace(a.SortName)) + bn := strings.ToLower(strings.TrimSpace(b.SortName)) + if an != bn { + return an < bn + } + } + + if !a.Added.Equal(b.Added) { + return a.Added.Before(b.Added) + } + + return a.Key < b.Key +} + +func containsWaitingKey(keys []string, key string) bool { + for _, k := range keys { + if k == key { + return true + } + } + return false +} + +func removeQueuedTaskLocked(tasks []PostWorkTask, key string) []PostWorkTask { + for i, t := range tasks { + if t.Key == key { + return append(tasks[:i], tasks[i+1:]...) + } + } + return tasks } // Enqueue dedupliziert nach Key (damit du nicht durch Events doppelt queue-st) @@ -55,31 +115,41 @@ func (pq *PostWorkQueue) Enqueue(task PostWorkTask) bool { if task.Key == "" || task.Run == nil { return false } + if task.Added.IsZero() { + task.Added = time.Now() + } pq.mu.Lock() + defer pq.mu.Unlock() + if _, ok := pq.inflight[task.Key]; ok { - pq.mu.Unlock() return false // schon queued oder läuft } - pq.inflight[task.Key] = struct{}{} - pq.waitingKeys = append(pq.waitingKeys, task.Key) // ✅ queued für UI - pq.queued++ - pq.mu.Unlock() - - select { - case pq.q <- task: - return true - default: - // Queue voll -> inflight zurückrollen - pq.mu.Lock() - delete(pq.inflight, task.Key) - if pq.queued > 0 { - pq.queued-- - } - pq.removeWaitingKeyLocked(task.Key) - pq.mu.Unlock() + if pq.maxQueued > 0 && len(pq.queue) >= pq.maxQueued { return false } + + pq.inflight[task.Key] = struct{}{} + pq.queued++ + + insertAt := len(pq.queue) + for i, existing := range pq.queue { + if lessPostWorkTask(task, existing) { + insertAt = i + break + } + } + + pq.queue = append(pq.queue, PostWorkTask{}) + copy(pq.queue[insertAt+1:], pq.queue[insertAt:]) + pq.queue[insertAt] = task + + pq.waitingKeys = append(pq.waitingKeys, "") + copy(pq.waitingKeys[insertAt+1:], pq.waitingKeys[insertAt:]) + pq.waitingKeys[insertAt] = task.Key + + pq.cond.Signal() + return true } func (pq *PostWorkQueue) Cancel(key string) bool { @@ -138,27 +208,30 @@ func (pq *PostWorkQueue) RemoveQueued(key string) bool { pq.queued-- } + pq.queue = removeQueuedTaskLocked(pq.queue, key) + // Das Task-Element bleibt evtl. noch im Channel liegen. // Beim Worker-Start muss es dann erkannt und übersprungen werden. return true } func (pq *PostWorkQueue) workerLoop(id int) { - for task := range pq.q { + for { + pq.mu.Lock() + for len(pq.queue) == 0 { + pq.cond.Wait() + } + task := pq.queue[0] + pq.queue = pq.queue[1:] + pq.mu.Unlock() + pq.mu.Lock() _, stillInflight := pq.inflight[task.Key] _, alreadyRunning := pq.runningKeys[task.Key] - - waitingFound := false - for _, k := range pq.waitingKeys { - if k == task.Key { - waitingFound = true - break - } - } + waitingFound := containsWaitingKey(pq.waitingKeys, task.Key) pq.mu.Unlock() - // queued Job wurde zwischenzeitlich entfernt -> Task aus dem Channel verwerfen + // queued Job wurde zwischenzeitlich entfernt -> verwerfen if !stillInflight || (!waitingFound && !alreadyRunning) { continue } @@ -188,7 +261,6 @@ func (pq *PostWorkQueue) workerLoop(id int) { defer cancel() - // ffmpeg/heavy-slot holen select { case pq.ffmpegSem <- struct{}{}: case <-ctx.Done(): @@ -198,7 +270,6 @@ func (pq *PostWorkQueue) workerLoop(id int) { <-pq.ffmpegSem }() - // jetzt wirklich von waiting -> running pq.mu.Lock() pq.removeWaitingKeyLocked(task.Key) pq.runningKeys[task.Key] = struct{}{} @@ -277,7 +348,7 @@ func (pq *PostWorkQueue) StatusForKey(key string) PostWorkKeyStatus { } // global (oder in deinem app struct halten) -var postWorkQ = NewPostWorkQueue(512, 2) // schneller kritischer Pfad +var postWorkQ = NewPostWorkQueue(512, 1) // schneller kritischer Pfad var enrichQ = NewPostWorkQueue(512, 1) // langsame Hintergrundaufgaben // --- Status Refresher (ehemals postwork_refresh.go) --- @@ -320,6 +391,26 @@ func clearEnrichStatusSnapshot(key string) { enrichStatusLastMu.Unlock() } +func waitUntilEnrichMayRun(ctx context.Context) error { + t := time.NewTicker(2 * time.Second) + defer t.Stop() + + for { + waiting, running, _ := postWorkQ.Stats() + + // enrich erst starten, wenn keine primäre Postwork mehr läuft + if waiting == 0 && running == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + } + } +} + func startEnrichStatusRefresher() { t := time.NewTicker(2 * time.Second) go func() { diff --git a/backend/preview.go b/backend/preview.go index 5432b19..df257df 100644 --- a/backend/preview.go +++ b/backend/preview.go @@ -2,7 +2,6 @@ package main import ( - "bufio" "bytes" "context" "crypto/sha1" @@ -18,7 +17,6 @@ import ( "image/jpeg" "image/png" "io" - "math" "math/rand" "net/http" "net/url" @@ -32,6 +30,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "golang.org/x/image/font" @@ -1194,6 +1193,10 @@ func extractLastFrameJPG(path string) ([]byte, error) { "-f", "image2pipe", "pipe:1", ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var out bytes.Buffer var stderr bytes.Buffer @@ -1233,6 +1236,10 @@ func extractFrameAtTimeJPG(path string, seconds float64) ([]byte, error) { "-f", "image2pipe", "pipe:1", ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var out bytes.Buffer var stderr bytes.Buffer @@ -1277,6 +1284,10 @@ func extractLastFrameJPGScaled(path string, width int, quality int) ([]byte, err "-f", "image2pipe", "pipe:1", ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var out bytes.Buffer var stderr bytes.Buffer @@ -1312,6 +1323,10 @@ func extractFirstFrameJPGScaled(path string, width int, quality int) ([]byte, er "-f", "image2pipe", "pipe:1", ) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var out bytes.Buffer var stderr bytes.Buffer @@ -1361,6 +1376,10 @@ func extractLiveFrameFromM3U8ThumbJPG(ctx context.Context, m3u8URL, cookie, user ) cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var out bytes.Buffer var stderr bytes.Buffer @@ -2163,525 +2182,3 @@ func servePreviewForFinishedFile(w http.ResponseWriter, r *http.Request, id stri _ = atomicWriteFile(thumbPath, img) servePreviewJPGBytes(w, img) } - -// ============================================================ -// Teaser generation (used by /api/generated/teaser) -// ============================================================ - -const minSegmentDuration = 0.75 -const defaultTeaserSegments = 12 - -type TeaserPreviewOptions struct { - Segments int - SegmentDuration float64 - Width int - Preset string - CRF int - Audio bool - AudioBitrate string - UseVsync2 bool -} - -func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) { - if dur <= 0 { - return 0, 0 - } - - n := o.Segments - if n < 1 { - n = 1 - } - - segDur := o.SegmentDuration - if segDur <= 0 { - segDur = 1 - } - if segDur < minSegmentDuration { - segDur = minSegmentDuration - } - - maxStart := dur - 0.05 - segDur - if maxStart < 0 { - maxStart = 0 - } - if n == 1 { - return 0, maxStart * 0.5 - } - - margin := 0.05 * maxStart - if margin < 0 { - margin = 0 - } - span := maxStart - 2*margin - if span < 0 { - span = maxStart - margin = 0 - } - - step := 0.0 - if n > 1 { - step = span / float64(n-1) - } - return step, margin -} - -func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error { - return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil) -} - -func generateTeaserClipsMP4WithProgress(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int, onRatio func(r float64)) error { - opts := TeaserPreviewOptions{ - Segments: maxClips, - SegmentDuration: clipLenSec, - Width: 640, - Preset: "veryfast", - CRF: 21, - Audio: true, - AudioBitrate: "128k", - UseVsync2: false, - } - return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio) -} - -func generateTeaserChunkMP4(ctx context.Context, src, out string, start, dur float64, opts TeaserPreviewOptions) error { - opts.Audio = true - - tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4" - segDur := dur - if segDur < minSegmentDuration { - segDur = minSegmentDuration - } - - args := []string{"-y", "-hide_banner", "-loglevel", "error"} - args = append(args, ffmpegInputTol...) - args = append(args, - "-ss", fmt.Sprintf("%.3f", start), - "-t", fmt.Sprintf("%.3f", segDur), - "-i", src, - "-map", "0:v:0", - "-c:v", "libx264", - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", opts.Preset, - "-crf", strconv.Itoa(opts.CRF), - "-threads", "2", - ) - - if opts.UseVsync2 { - args = append(args, "-vsync", "2") - } - - args = append(args, - "-map", "0:a:0", - "-c:a", "aac", - "-b:a", opts.AudioBitrate, - "-ac", "2", - "-shortest", - ) - - args = append(args, "-movflags", "+faststart", tmp) - - cmd := exec.CommandContext(ctx, ffmpegPath, args...) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) - } - _ = os.Remove(out) - return os.Rename(tmp, out) -} - -func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) { - if opts.SegmentDuration <= 0 { - opts.SegmentDuration = 1 - } - if opts.Segments <= 0 { - opts.Segments = defaultTeaserSegments - } - segDur = opts.SegmentDuration - if segDur < minSegmentDuration { - segDur = minSegmentDuration - } - - if dur > 0 && dur < segDur*float64(opts.Segments) { - opts.Segments = 1 - segDur = dur - } - - usedSegments = opts.Segments - if !(dur > 0) { - return []float64{0}, segDur, 1 - } - - stepSize, offset := opts.stepSizeAndOffset(dur) - starts = make([]float64, 0, opts.Segments) - for i := 0; i < opts.Segments; i++ { - t := offset + float64(i)*stepSize - maxStart := math.Max(0, dur-0.05-segDur) - if t < 0 { - t = 0 - } - if t > maxStart { - t = maxStart - } - if t < 0.05 { - t = 0.05 - } - starts = append(starts, t) - } - return starts, segDur, usedSegments -} - -func generateTeaserPreviewMP4WithProgress(ctx context.Context, srcPath, outPath string, opts TeaserPreviewOptions, onRatio func(r float64)) error { - opts.Audio = true - if opts.SegmentDuration <= 0 { - opts.SegmentDuration = 1 - } - if opts.Segments <= 0 { - opts.Segments = defaultTeaserSegments - } - if opts.Width <= 0 { - opts.Width = 640 - } - if opts.Preset == "" { - opts.Preset = "veryfast" - } - if opts.CRF <= 0 { - opts.CRF = 21 - } - if opts.AudioBitrate == "" { - opts.AudioBitrate = "128k" - } - segDur := opts.SegmentDuration - if segDur < minSegmentDuration { - segDur = minSegmentDuration - } - - dur, _ := durationSecondsCached(ctx, srcPath) - if dur > 0 && dur < segDur*float64(opts.Segments) { - opts.Segments = 1 - segDur = dur - } - - if !(dur > 0) { - if onRatio != nil { - onRatio(0) - } - err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts) - if onRatio != nil { - onRatio(1) - } - return err - } - - starts, segDurComputed, _ := computeTeaserStarts(dur, opts) - segDur = segDurComputed - - expectedOutSec := float64(len(starts)) * segDur - tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4" - - args := []string{"-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error"} - for _, t := range starts { - args = append(args, ffmpegInputTol...) - args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath) - } - - var fc strings.Builder - for i := range starts { - fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i) - fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i) - } - for i := range starts { - fmt.Fprintf(&fc, "[v%d][a%d]", i, i) - } - fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts)) - - args = append(args, "-filter_complex", fc.String()) - args = append(args, "-map", "[v]", "-map", "[a]") - args = append(args, - "-c:v", "libx264", - "-pix_fmt", "yuv420p", - "-profile:v", "high", - "-level", "4.2", - "-preset", opts.Preset, - "-crf", strconv.Itoa(opts.CRF), - "-threads", "2", - ) - if opts.UseVsync2 { - args = append(args, "-vsync", "2") - } - args = append(args, - "-c:a", "aac", - "-b:a", opts.AudioBitrate, - "-ac", "2", - "-shortest", - ) - args = append(args, "-movflags", "+faststart", tmp) - - cmd := exec.CommandContext(ctx, ffmpegPath, args...) - stdout, err := cmd.StdoutPipe() - if err != nil { - return err - } - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Start(); err != nil { - return err - } - - sc := bufio.NewScanner(stdout) - sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - var outSec float64 - var lastSent float64 - var lastAt time.Time - - send := func(outSec float64, force bool) { - if onRatio == nil { - return - } - if expectedOutSec > 0 && outSec > 0 { - r := outSec / expectedOutSec - if r < 0 { - r = 0 - } - if r > 1 { - r = 1 - } - if r-lastSent < 0.01 && !force { - return - } - if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force { - return - } - lastSent = r - lastAt = time.Now() - onRatio(r) - return - } - if force { - onRatio(1) - } - } - - for sc.Scan() { - line := strings.TrimSpace(sc.Text()) - if line == "" { - continue - } - parts := strings.SplitN(line, "=", 2) - if len(parts) != 2 { - continue - } - k, v := parts[0], parts[1] - switch k { - case "out_time_ms": - if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 { - outSec = float64(n) / 1_000_000.0 - send(outSec, false) - } - case "out_time": - if s := parseFFmpegOutTime(v); s > 0 { - outSec = s - send(outSec, false) - } - case "progress": - if strings.TrimSpace(v) == "end" { - send(outSec, true) - } - } - } - - if err := cmd.Wait(); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) - } - - _ = os.Remove(outPath) - return os.Rename(tmp, outPath) -} - -func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { - f, err := openForReadShareDelete(path) - if err != nil { - http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) - return - } - defer f.Close() - - fi, err := f.Stat() - if err != nil || fi.IsDir() || fi.Size() == 0 { - http.Error(w, "datei nicht gefunden", http.StatusNotFound) - return - } - - w.Header().Set("Cache-Control", "public, max-age=31536000") - w.Header().Set("Content-Type", "video/mp4") - 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 - } - if startSec < 0 { - startSec = 0 - } - - // temp schreiben -> rename - tmp := outPath + ".tmp.mp4" - - args := []string{ - "-y", - "-hide_banner", - "-loglevel", "error", - } - args = append(args, ffmpegInputTol...) - args = append(args, - "-ss", fmt.Sprintf("%.3f", startSec), - "-i", srcPath, - "-t", fmt.Sprintf("%.3f", durSec), - - // Video - "-vf", "scale=720:-2", - "-map", "0:v:0", - - // Audio (optional: falls kein Audio vorhanden ist, bricht ffmpeg NICHT ab) - "-map", "0:a:0", - "-c:a", "aac", - "-b:a", "128k", - "-ac", "2", - - "-c:v", "libx264", - "-preset", "veryfast", - "-threads", "2", - "-crf", "28", - "-pix_fmt", "yuv420p", - - // Wenn Audio minimal kürzer/länger ist, sauber beenden - "-shortest", - - "-movflags", "+faststart", - "-f", "mp4", - "-threads", "2", - tmp, - ) - - cmd := exec.CommandContext(ctx, ffmpegPath, args...) - if out, err := cmd.CombinedOutput(); err != nil { - _ = os.Remove(tmp) - return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) - } - - _ = os.Remove(outPath) - return os.Rename(tmp, outPath) -} - -func generatedTeaser(w http.ResponseWriter, r *http.Request) { - 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 - } - - assetID := stripHotPrefix(id) - if assetID == "" { - assetID = id - } - - assetDir, err := ensureGeneratedDir(assetID) - if err != nil { - http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) - return - } - - previewPath := filepath.Join(assetDir, "preview.mp4") - - // ✅ NEU: noGenerate=1 -> niemals on-the-fly erzeugen, nur liefern wenn vorhanden - qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate"))) - noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes" - - // Cache hit (neu) - if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { - serveTeaserFile(w, r, previewPath) - 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 - } - } - } - } - - // ✅ NEU: wenn noGenerate aktiv und bisher kein Teaser gefunden -> 404 - if noGen { - http.Error(w, "preview nicht verfügbar", http.StatusNotFound) - return - } - - // Neu erzeugen - if err := genSem.Acquire(r.Context()); err != nil { - http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout) - return - } - defer genSem.Release() - - genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) - defer cancel() - - 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) -} diff --git a/backend/record.go b/backend/record.go index be4ff24..be2e437 100644 --- a/backend/record.go +++ b/backend/record.go @@ -248,8 +248,8 @@ func handleDeleteMany(w http.ResponseWriter, r *http.Request) { continue } - // laufende/queued Nacharbeit abbrechen - cancelDeferredEnrichForFile(file) + // laufende/queued Nacharbeit abbrechen + Datei freigeben + releaseFileForMutation(file) target, from, fi, err := resolveDoneFileByName(doneAbs, file) if err != nil { @@ -391,7 +391,7 @@ func handleKeepMany(w http.ResponseWriter, r *http.Request) { continue } - cancelDeferredEnrichForFile(file) + releaseFileForMutation(file) src, from, fi, err := resolveDoneFileByName(doneAbs, file) if err != nil { @@ -932,9 +932,7 @@ func applyPreviewSpriteTruthToRecordJobMeta(j *RecordJob) { if outPath == "" { return } - base := filepath.Base(outPath) - id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) - id = strings.TrimSpace(id) + id := canonicalAssetIDFromName(outPath) if id == "" { return } @@ -1009,8 +1007,7 @@ func applyFinishedPhaseTruthToRecordJobMeta(j *RecordJob) { return } - base := filepath.Base(outPath) - id := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) + id := canonicalAssetIDFromName(outPath) id = strings.TrimSpace(id) if id == "" { return @@ -1348,8 +1345,7 @@ func recordRemoveQueuedPostwork(w http.ResponseWriter, r *http.Request) { _ = removeWithRetry(out) purgeDurationCacheForPath(out) - base := filepath.Base(out) - id1 := stripHotPrefix(strings.TrimSuffix(base, filepath.Ext(base))) + id1 := canonicalAssetIDFromName(out) if strings.TrimSpace(id1) != "" { removeGeneratedForID(id1) } @@ -1409,22 +1405,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { // ---- TS -> MP4 (on-demand remux) ---- if strings.ToLower(filepath.Ext(outPath)) == ".ts" { - newOut, err := maybeRemuxTS(outPath) - if err != nil { - writeErr(http.StatusInternalServerError, "TS Remux fehlgeschlagen: "+err.Error()) - return - } - if strings.TrimSpace(newOut) == "" { - writeErr(http.StatusInternalServerError, "TS kann im Browser nicht abgespielt werden; Remux hat keine MP4 erzeugt") - return - } - outPath = filepath.Clean(strings.TrimSpace(newOut)) - - fi, err := os.Stat(outPath) - if err != nil || fi == nil || fi.IsDir() || fi.Size() == 0 || strings.ToLower(filepath.Ext(outPath)) != ".mp4" { - writeErr(http.StatusInternalServerError, "Remux-Ergebnis ungültig") - return - } + writeErr(http.StatusConflict, "TS-Datei ist noch nicht nachbearbeitet und kann nicht direkt abgespielt werden") + return } // ✅ Falls Datei ".mp4" heißt, aber eigentlich TS/HTML ist -> nicht als MP4 ausliefern @@ -1432,12 +1414,8 @@ func recordVideo(w http.ResponseWriter, r *http.Request) { kind, _ := sniffVideoKind(outPath) switch kind { case "ts": - newOut, err := maybeRemuxTS(outPath) - if err != nil { - writeErr(http.StatusInternalServerError, "Datei ist TS (nur .mp4 benannt); Remux fehlgeschlagen: "+err.Error()) - return - } - outPath = filepath.Clean(strings.TrimSpace(newOut)) + writeErr(http.StatusConflict, "Datei hat .mp4-Endung, enthält aber TS-Daten und wird nicht on-demand remuxt") + return case "html": writeErr(http.StatusInternalServerError, "Server liefert HTML statt Video (Pfad/Lookup prüfen)") return @@ -1592,6 +1570,30 @@ func normalizeQueryModel(raw string) string { return strings.ToLower(strings.TrimSpace(s)) } +func isTemporaryDoneVideoName(name string) bool { + n := strings.ToLower(strings.TrimSpace(filepath.Base(name))) + if n == "" { + return false + } + + // versteckte / temporäre Dateien wie: + // ".chaesoffel_04_22_2026__12-22-16.tmp.20260422_134354_000.mp4" + if strings.HasPrefix(n, ".") { + return true + } + + // typische temporäre Marker + if strings.Contains(n, ".tmp.") || + strings.HasSuffix(n, ".tmp.mp4") || + strings.HasSuffix(n, ".tmp.ts") || + strings.Contains(n, ".partial.") || + strings.Contains(n, ".part.") { + return true + } + + return false +} + // buildDoneIndex: identical logic as your previous record_handlers.go (indexing done + keep) func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { items := make([]doneIndexItem, 0, 2048) @@ -1611,6 +1613,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { } name := filepath.Base(full) + if isTemporaryDoneVideoName(name) { + return + } + ext := strings.ToLower(filepath.Ext(name)) if ext != ".mp4" && ext != ".ts" { return @@ -1625,7 +1631,17 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { start := t base := strings.TrimSuffix(name, filepath.Ext(name)) - stem := strings.TrimPrefix(base, "HOT ") + + // WICHTIG: + // stabile ID ohne HOT-Prefix, damit Rename HOT <-> non-HOT + // im Frontend nicht zu einem neuen React-Key führt. + stableID := stripHotPrefix(base) + if strings.TrimSpace(stableID) == "" { + stableID = base + } + + // Timestamp immer auf Basis des HOT-bereinigten Namens parsen + stem := stableID if m := startedAtFromFilenameRe.FindStringSubmatch(stem); m != nil { mm, _ := strconv.Atoi(m[2]) dd, _ := strconv.Atoi(m[3]) @@ -1671,7 +1687,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { ended := t items = append(items, doneIndexItem{ job: &RecordJob{ - ID: base, + ID: stableID, Output: full, SourceURL: srcURL, Status: JobFinished, @@ -1692,9 +1708,10 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { if err != nil { return } + for _, e := range entries { if e.IsDir() { - if strings.EqualFold(e.Name(), ".trash") { + if strings.EqualFold(e.Name(), ".trash") || strings.EqualFold(e.Name(), ".postwork_tmp") { continue } if skipKeep && e.Name() == "keep" { @@ -1731,6 +1748,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { // done (ohne keep) scanDir(doneAbs, true) + // keep scanDir(filepath.Join(doneAbs, "keep"), false) @@ -1755,12 +1773,14 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { B := items[idx[b]] ta, tb := A.endedAt, B.endedAt + switch sortMode { case "completed_asc": if !ta.Equal(tb) { return ta.Before(tb) } return A.fileSort < B.fileSort + case "completed_desc": if !ta.Equal(tb) { return ta.After(tb) @@ -1775,6 +1795,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { return ta.After(tb) } return A.fileSort < B.fileSort + case "file_desc": if A.fileSort != B.fileSort { return A.fileSort > B.fileSort @@ -1797,6 +1818,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { return ta.After(tb) } return A.fileSort < B.fileSort + case "duration_desc": da, okA := durationForSort(A) db, okB := durationForSort(B) @@ -1819,6 +1841,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { return ta.After(tb) } return A.fileSort < B.fileSort + case "size_desc": if A.job.SizeBytes != B.job.SizeBytes { return A.job.SizeBytes > B.job.SizeBytes @@ -1827,6 +1850,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { return ta.After(tb) } return A.fileSort < B.fileSort + default: if !ta.Equal(tb) { return ta.After(tb) @@ -1844,6 +1868,7 @@ func buildDoneIndex(doneAbs string) ([]doneIndexItem, map[string][]int) { "duration_asc", "duration_desc", "size_asc", "size_desc", } + for _, m := range modes { sortedIdx["0|"+m] = mkSorted(false, m) sortedIdx["1|"+m] = mkSorted(true, m) @@ -1914,20 +1939,12 @@ func recordDoneMeta(w http.ResponseWriter, r *http.Request) { } outPath := filepath.Clean(strings.TrimSpace(full)) - if strings.ToLower(filepath.Ext(outPath)) == ".ts" { - if newOut, rerr := maybeRemuxTS(outPath); rerr == nil && strings.TrimSpace(newOut) != "" { - outPath = filepath.Clean(strings.TrimSpace(newOut)) - if nfi, serr := os.Stat(outPath); serr == nil && nfi != nil { - fi = nfi - } - } - } ensureMetaJSONForPlayback(r.Context(), outPath) resp := doneMetaFileResp{File: filepath.Base(outPath)} - id := stripHotPrefix(strings.TrimSuffix(filepath.Base(outPath), filepath.Ext(outPath))) + id := canonicalAssetIDFromName(outPath) if strings.TrimSpace(id) != "" { if mp, merr := generatedMetaFile(id); merr == nil && strings.TrimSpace(mp) != "" { @@ -2362,7 +2379,7 @@ func cancelDeferredEnrichForFile(file string) { return } - id := stripHotPrefix(strings.TrimSuffix(filepath.Base(file), filepath.Ext(file))) + id := canonicalAssetIDFromName(file) id = strings.TrimSpace(id) if id == "" { return @@ -2412,6 +2429,28 @@ func cancelDeferredEnrichForFile(file string) { } } +func releaseFileForMutation(file string) { + file = strings.TrimSpace(file) + if file == "" { + return + } + + assetsReleased := requestAssetsTaskSkip(file) + + // Enrich für genau diese Datei stoppen / aus Queue nehmen + cancelDeferredEnrichForFile(file) + + // WICHTIG: + // releaseFileTasks(file) darf NICHT mehr den globalen generate-assets Task abbrechen. + // Falls das dort aktuell passiert: rausnehmen. + resp := releaseFileTasks(file) + + // Kurz warten, damit Handles der betroffenen Datei schließen + if assetsReleased || resp.ReleasedAny { + time.Sleep(250 * time.Millisecond) + } +} + func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { http.Error(w, "Nur POST oder DELETE erlaubt", http.StatusMethodNotAllowed) @@ -2428,8 +2467,7 @@ func recordDeleteVideo(w http.ResponseWriter, r *http.Request) { return } - // ✅ laufende/queued späte Nacharbeiten für dieses Asset vorher stoppen - cancelDeferredEnrichForFile(file) + releaseFileForMutation(file) s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) @@ -2698,8 +2736,7 @@ func recordUnkeepVideo(w http.ResponseWriter, r *http.Request) { return } - // ✅ späte Nacharbeiten abbrechen, bevor die Datei aus keep zurück verschoben wird - cancelDeferredEnrichForFile(file) + releaseFileForMutation(file) s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) @@ -2771,8 +2808,7 @@ func recordKeepVideo(w http.ResponseWriter, r *http.Request) { return } - // ✅ späte Nacharbeiten abbrechen, bevor die Datei verschoben wird - cancelDeferredEnrichForFile(file) + releaseFileForMutation(file) s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) @@ -2880,7 +2916,7 @@ func recordToggleHot(w http.ResponseWriter, r *http.Request) { return } - cancelDeferredEnrichForFile(file) + releaseFileForMutation(file) s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) diff --git a/backend/record_stream_cb.go b/backend/record_stream_cb.go index 4826a10..d7b37d7 100644 --- a/backend/record_stream_cb.go +++ b/backend/record_stream_cb.go @@ -9,6 +9,8 @@ import ( "fmt" "net/http" "net/url" + "os" + "path/filepath" "strconv" "strings" "time" @@ -95,9 +97,32 @@ func RecordStream( const maxPlaylistRefreshes = 12 refreshes := 0 + attempt := 0 + useChunkMerge := strings.EqualFold(filepath.Ext(outputPath), ".ts") for { - err = handleM3U8Mode(ctx, stream, outputPath, job, httpCookie, hc.userAgent, pageURL) + attempt++ + + attemptOut := outputPath + mergeAfterRun := false + + // Nur ab dem ZWEITEN Versuch in Retry-Chunk schreiben. + // Der erste Versuch muss direkt in outputPath schreiben, + // damit der laufende Download eine echte Hauptdatei hat. + if useChunkMerge && attempt > 1 { + attemptOut = hlsRetryChunkPath(outputPath, attempt) + _ = os.Remove(attemptOut) + mergeAfterRun = true + } + + err = handleM3U8Mode(ctx, stream, attemptOut, job, httpCookie, hc.userAgent, pageURL) + + if mergeAfterRun && fileExistsNonEmpty(attemptOut) { + if aerr := appendFileAndRemove(outputPath, attemptOut); aerr != nil { + return fmt.Errorf("retry-chunk merge failed: %w", aerr) + } + } + if err == nil { return nil } @@ -133,7 +158,6 @@ func RecordStream( newStream, rerr := loadFreshHLS() if rerr != nil { - select { case <-ctx.Done(): return ctx.Err() diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index b93890a..4fdbf25 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strconv" "strings" + "syscall" "time" "github.com/grafov/m3u8" @@ -334,6 +335,62 @@ func buildFFmpegInputHeaders(httpCookie, userAgent, referer string) string { return strings.Join(lines, "\r\n") + "\r\n" } +func hlsRetryChunkPath(outFile string, attempt int) string { + dir := filepath.Dir(outFile) + ext := filepath.Ext(outFile) + base := strings.TrimSuffix(filepath.Base(outFile), ext) + + canonical := canonicalAssetIDFromName(base) + if canonical == "" { + canonical = base + } + + ts := time.Now().Format("20060102_150405_000") + return filepath.Join(dir, fmt.Sprintf(".%s.retry.%02d.%s%s", canonical, attempt, ts, ext)) +} + +func appendFileAndRemove(dstPath, srcPath string) error { + srcPath = strings.TrimSpace(srcPath) + dstPath = strings.TrimSpace(dstPath) + + if srcPath == "" || dstPath == "" { + return fmt.Errorf("append path leer") + } + if filepath.Clean(srcPath) == filepath.Clean(dstPath) { + return nil + } + + sfi, err := os.Stat(srcPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if sfi == nil || sfi.IsDir() || sfi.Size() <= 0 { + _ = os.Remove(srcPath) + return nil + } + + src, err := os.Open(srcPath) + if err != nil { + return err + } + defer src.Close() + + dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + return err + } + + return os.Remove(srcPath) +} + func handleM3U8Mode( ctx context.Context, stream *selectedHLSStream, @@ -439,6 +496,10 @@ func handleM3U8Mode( } cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } var stderr bytes.Buffer cmd.Stdout = io.Discard diff --git a/backend/recorder.go b/backend/recorder.go index f0e9a0a..8184ddd 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -532,6 +532,27 @@ func requireNonEmptyRegularFile(path string, label string) error { return nil } +func buildPostworkTempMP4Path(tsPath string) (string, error) { + s := getSettings() + + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) + if err != nil { + return "", fmt.Errorf("doneDir auflösung fehlgeschlagen: %w", err) + } + doneAbs = strings.TrimSpace(doneAbs) + if doneAbs == "" { + return "", fmt.Errorf("doneDir ist leer") + } + + tmpDir := filepath.Join(doneAbs, ".postwork_tmp") + if err := os.MkdirAll(tmpDir, 0o755); err != nil { + return "", fmt.Errorf("postwork tmp dir erstellen fehlgeschlagen: %w", err) + } + + base := strings.TrimSuffix(filepath.Base(strings.TrimSpace(tsPath)), filepath.Ext(tsPath)) + ".mp4" + return uniqueDestPath(tmpDir, base) +} + func runPrimaryPostworkPipeline( ctx context.Context, job *RecordJob, @@ -543,6 +564,9 @@ func runPrimaryPostworkPipeline( return out, err } + cleanupTSAfterMove := "" + removeTempOutOnMoveFailure := false + // 1) TS -> MP4 zuerst if strings.EqualFold(filepath.Ext(out), ".ts") { tsPath := out @@ -557,8 +581,12 @@ func runPrimaryPostworkPipeline( } inSize := fi.Size() - mp4Path := strings.TrimSuffix(tsPath, filepath.Ext(tsPath)) + ".mp4" - _ = os.Remove(mp4Path) + + mp4Path, err := buildPostworkTempMP4Path(tsPath) + if err != nil { + return tsPath, fmt.Errorf("postwork temp mp4 path failed: %w", err) + } + _ = removeWithRetry(mp4Path) dur := 0.0 dctx, cancel := context.WithTimeout(ctx, 6*time.Second) @@ -587,6 +615,7 @@ func runPrimaryPostworkPipeline( }, ) if err != nil { + _ = removeWithRetry(mp4Path) return tsPath, fmt.Errorf("ts remux failed: %w", err) } @@ -595,20 +624,31 @@ func runPrimaryPostworkPipeline( return tsPath, err } - // best effort cleanup der alten TS - if err := removeWithRetry(tsPath); err != nil && !os.IsNotExist(err) { - fmt.Println("⚠️ ts cleanup after remux:", err) - } - purgeDurationCacheForPath(tsPath) + if err := validateVideoDecodesNearEnd(ctx, mp4Path); err != nil { + fmt.Println("⚠️ remux mp4 decode validation failed, fallback to reencode:", err) + _ = removeWithRetry(mp4Path) + + if rerr := reencodeTSToMP4(ctx, tsPath, mp4Path); rerr != nil { + _ = removeWithRetry(mp4Path) + return tsPath, fmt.Errorf("remux invalid (%v), reencode fallback failed: %w", err, rerr) + } + + if err2 := requireNonEmptyRegularFile(mp4Path, "reencode result"); err2 != nil { + _ = removeWithRetry(mp4Path) + return tsPath, err2 + } + + if err2 := validateVideoDecodesNearEnd(ctx, mp4Path); err2 != nil { + _ = removeWithRetry(mp4Path) + return tsPath, fmt.Errorf("reencoded mp4 still invalid: %w", err2) + } + } + + // TS erst NACH erfolgreichem Move löschen + cleanupTSAfterMove = tsPath + removeTempOutOnMoveFailure = true out = mp4Path - - if fi2, err := os.Stat(out); err == nil && fi2 != nil && !fi2.IsDir() { - jobsMu.Lock() - job.Output = out - job.SizeBytes = fi2.Size() - jobsMu.Unlock() - } } // 2) Danach nach done verschieben @@ -616,20 +656,42 @@ func runPrimaryPostworkPipeline( setPhase("moving", 0) } + fallbackOut := out + if cleanupTSAfterMove != "" { + fallbackOut = cleanupTSAfterMove + } + moved, err := moveToDoneDir(out) if err != nil || strings.TrimSpace(moved) == "" { - if err != nil { - return out, fmt.Errorf("moveToDoneDir failed: %w", err) + if removeTempOutOnMoveFailure { + _ = removeWithRetry(out) + purgeDurationCacheForPath(out) } - return out, fmt.Errorf("moveToDoneDir returned empty path") + + if err != nil { + return fallbackOut, fmt.Errorf("moveToDoneDir failed: %w", err) + } + return fallbackOut, fmt.Errorf("moveToDoneDir returned empty path") } out = strings.TrimSpace(moved) if err := requireNonEmptyRegularFile(out, "moved output"); err != nil { + if cleanupTSAfterMove != "" { + _ = removeWithRetry(out) + purgeDurationCacheForPath(out) + return cleanupTSAfterMove, err + } return out, err } + if cleanupTSAfterMove != "" { + if err := removeWithRetry(cleanupTSAfterMove); err != nil && !os.IsNotExist(err) { + fmt.Println("⚠️ ts cleanup after successful move:", err) + } + purgeDurationCacheForPath(cleanupTSAfterMove) + } + jobsMu.Lock() job.Output = out if fi, err := os.Stat(out); err == nil && fi != nil && !fi.IsDir() { @@ -1209,7 +1271,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st return nil } - truth := finishedPhaseTruthForID(assetID) + truth := assetsTruthForVideo(outPath) // ------------------------------------------------- // Fehlende Vorstufen nur nachziehen, NICHT alles neu @@ -1222,7 +1284,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st if cerr := abortIfCanceled(err, "postwork", "meta"); cerr != nil { return cerr } - if err != nil || !okMeta || !finishedPhaseTruthForID(assetID).MetaReady { + if err != nil || !okMeta || !assetsTruthForVideo(outPath).MetaReady { if err == nil { err = fmt.Errorf("meta konnte nicht erzeugt werden") } @@ -1231,7 +1293,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } publishDeferredPhase(fileName, assetID, "postwork", "meta", "done", "Meta", nil) - truth = finishedPhaseTruthForID(assetID) + truth = assetsTruthForVideo(outPath) } if !truth.ThumbReady { @@ -1241,7 +1303,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st if cerr := abortIfCanceled(err, "postwork", "thumb"); cerr != nil { return cerr } - if err != nil || !finishedPhaseTruthForID(assetID).ThumbReady { + if err != nil || !assetsTruthForVideo(outPath).ThumbReady { if err == nil { err = fmt.Errorf("vorschaubild konnte nicht erzeugt werden") } @@ -1250,7 +1312,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } publishDeferredPhase(fileName, assetID, "postwork", "thumb", "done", "Vorschaubild", nil) - truth = finishedPhaseTruthForID(assetID) + truth = assetsTruthForVideo(outPath) } if !truth.TeaserReady { @@ -1260,7 +1322,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st if cerr := abortIfCanceled(err, "postwork", "teaser"); cerr != nil { return cerr } - if err != nil || !finishedPhaseTruthForID(assetID).TeaserReady { + if err != nil || !assetsTruthForVideo(outPath).TeaserReady { if err == nil { err = fmt.Errorf("teaser konnte nicht erzeugt werden") } @@ -1269,7 +1331,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st } publishDeferredPhase(fileName, assetID, "postwork", "teaser", "done", "Teaser", nil) - truth = finishedPhaseTruthForID(assetID) + truth = assetsTruthForVideo(outPath) } // Ab hier erst die langsamen Hintergrundphasen @@ -1282,7 +1344,7 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st if cerr := abortIfCanceled(err, "postwork", "sprites"); cerr != nil { return cerr } - if err != nil || !finishedPhaseTruthForID(assetID).SpritesReady { + if err != nil || !assetsTruthForVideo(outPath).SpritesReady { if err == nil { err = fmt.Errorf("sprites konnten nicht erzeugt werden") } @@ -1291,29 +1353,33 @@ func runDeferredEnrichPipeline(ctx context.Context, outPath string, sourceURL st firstErr = err } } else { + if _, checkErr := runTailBlackoutCheck(ctx, outPath); checkErr != nil { + fmt.Println("⚠️ sprite tail blackout check failed:", outPath, checkErr) + } + publishDeferredPhase(fileName, assetID, "postwork", "sprites", "done", "Sprites", nil) } } - truth = finishedPhaseTruthForID(assetID) + truth = assetsTruthForVideo(outPath) if !truth.AnalyzeReady { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "KI-Analyse", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "running", "Analyse", nil) _, err := ensureAnalyzeForVideoCtx(ctx, outPath, sourceURL, "nsfw") if cerr := abortIfCanceled(err, "enrich", "analyze"); cerr != nil { return cerr } - if err != nil || !finishedPhaseTruthForID(assetID).AnalyzeReady { + if err != nil || !assetsTruthForVideo(outPath).AnalyzeReady { if err == nil { - err = fmt.Errorf("ki-analyse konnte nicht erzeugt werden") + err = fmt.Errorf("Analyse konnte nicht erzeugt werden") } - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen", err) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "error", "Analyse fehlgeschlagen", err) if firstErr == nil { firstErr = err } } else { - publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "KI-Analyse", nil) + publishDeferredPhase(fileName, assetID, "enrich", "analyze", "done", "Analyse", nil) } } diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 9639e26..06c03bb 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -16,5 +16,10 @@ "teaserPlayback": "all", "teaserAudio": true, "enableNotifications": true, + "generateAssetsMeta": true, + "generateAssetsThumb": true, + "generateAssetsTeaser": true, + "generateAssetsSprites": false, + "generateAssetsAnalyze": false, "encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A==" } diff --git a/backend/routes.go b/backend/routes.go index 3f2d4f3..db35d58 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -81,6 +81,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { // Tasks api.HandleFunc("/api/tasks/status", tasksStatusHandler) api.HandleFunc("/api/tasks/generate-assets", tasksGenerateAssets) + api.HandleFunc("/api/tasks/check-videos", checkVideosTaskHandler) // -------------------------- // 3) ModelStore (Postgres) diff --git a/backend/server.go b/backend/server.go index 0c755a4..40a9a42 100644 --- a/backend/server.go +++ b/backend/server.go @@ -4,21 +4,103 @@ package main import ( "context" "fmt" + "net" "net/http" "os" + "os/exec" "os/signal" + "runtime" + "strings" "sync" "syscall" "time" ) +func escapePowerShell(s string) string { + return strings.ReplaceAll(s, "'", "''") +} + +func escapeAppleScript(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + +func showAppNotification(title, message string) error { + switch runtime.GOOS { + case "windows": + ps := fmt.Sprintf(` + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + + $n = New-Object System.Windows.Forms.NotifyIcon + $n.Icon = [System.Drawing.SystemIcons]::Information + $n.BalloonTipTitle = '%s' + $n.BalloonTipText = '%s' + $n.Visible = $true + $n.ShowBalloonTip(3000) + Start-Sleep -Milliseconds 3500 + $n.Dispose() + `, escapePowerShell(title), escapePowerShell(message)) + + cmd := exec.Command( + "powershell", + "-NoProfile", + "-NonInteractive", + "-WindowStyle", "Hidden", + "-ExecutionPolicy", "Bypass", + "-Command", ps, + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + + return cmd.Run() + + case "darwin": + return exec.Command( + "osascript", + "-e", + fmt.Sprintf( + `display notification "%s" with title "%s"`, + escapeAppleScript(message), + escapeAppleScript(title), + ), + ).Run() + + case "linux": + return exec.Command("notify-send", title, message).Run() + + default: + return nil + } +} + +func buildStartupNotificationMessage() string { + return "Die App läuft im Hintergrund." +} + // --- main --- func main() { loadSettings() + // Nur eine Instanz erlauben: + appLn, err := net.Listen("tcp", ":9999") + if err != nil { + _ = showAppNotification( + "Recorder", + "Die Anwendung wird bereits ausgeführt.", + ) + fmt.Println("⚠️ Die App läuft bereits oder Port 9999 ist bereits belegt.") + os.Exit(0) + } + defer appLn.Close() + fixKeepRootFilesIntoModelSubdirs() - postWorkQ.StartWorkers(2) + postWorkQ.StartWorkers(1) enrichQ.StartWorkers(1) startPostWorkStatusRefresher() startEnrichStatusRefresher() @@ -86,25 +168,52 @@ func main() { Handler: handler, } + var shutdownOnce sync.Once + shutdown := func() { + shutdownOnce.Do(func() { + fmt.Println("🛑 Beende Server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _ = srv.Shutdown(ctx) + closeNSFW() + }) + } + stopSig := make(chan os.Signal, 1) signal.Notify(stopSig, os.Interrupt, syscall.SIGTERM) go func() { <-stopSig - fmt.Println("🛑 Beende Server...") - - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _ = srv.Shutdown(ctx) - closeNSFW() + shutdown() }() - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - fmt.Println("❌ HTTP-Server Fehler:", err) - closeNSFW() - os.Exit(1) - } + serverErrCh := make(chan error, 1) - closeNSFW() + go func() { + if err := srv.Serve(appLn); err != nil && err != http.ErrServerClosed { + serverErrCh <- err + return + } + serverErrCh <- nil + }() + + go func() { + _ = showAppNotification( + "Recorder gestartet", + buildStartupNotificationMessage(), + ) + }() + + go func() { + err := <-serverErrCh + if err != nil { + fmt.Println("❌ HTTP-Server Fehler:", err) + shutdown() + } + }() + + runTray(shutdown, buildTrayStats) + shutdown() } diff --git a/backend/settings.go b/backend/settings.go index 4c909dc..2f9d089 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -39,6 +39,12 @@ type RecorderSettings struct { EnableNotifications bool `json:"enableNotifications"` + GenerateAssetsMeta bool `json:"generateAssetsMeta"` + GenerateAssetsThumb bool `json:"generateAssetsThumb"` + GenerateAssetsTeaser bool `json:"generateAssetsTeaser"` + GenerateAssetsSprites bool `json:"generateAssetsSprites"` + GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` + // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` } @@ -69,6 +75,12 @@ var ( EnableNotifications: true, + GenerateAssetsMeta: true, + GenerateAssetsThumb: true, + GenerateAssetsTeaser: true, + GenerateAssetsSprites: false, + GenerateAssetsAnalyze: false, + EncryptedCookies: "", } settingsFile = "recorder_settings.json" @@ -223,6 +235,12 @@ type RecorderSettingsPublic struct { TeaserAudio bool `json:"teaserAudio"` EnableNotifications bool `json:"enableNotifications"` + + GenerateAssetsMeta bool `json:"generateAssetsMeta"` + GenerateAssetsThumb bool `json:"generateAssetsThumb"` + GenerateAssetsTeaser bool `json:"generateAssetsTeaser"` + GenerateAssetsSprites bool `json:"generateAssetsSprites"` + GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` } func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { @@ -250,6 +268,12 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { TeaserAudio: s.TeaserAudio, EnableNotifications: s.EnableNotifications, + + GenerateAssetsMeta: s.GenerateAssetsMeta, + GenerateAssetsThumb: s.GenerateAssetsThumb, + GenerateAssetsTeaser: s.GenerateAssetsTeaser, + GenerateAssetsSprites: s.GenerateAssetsSprites, + GenerateAssetsAnalyze: s.GenerateAssetsAnalyze, } } @@ -401,6 +425,12 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.EnableNotifications = in.EnableNotifications + next.GenerateAssetsMeta = in.GenerateAssetsMeta + next.GenerateAssetsThumb = in.GenerateAssetsThumb + next.GenerateAssetsTeaser = in.GenerateAssetsTeaser + next.GenerateAssetsSprites = in.GenerateAssetsSprites + next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze + dbChanged := strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) diff --git a/backend/split.go b/backend/split.go index f991eda..a6ecfd0 100644 --- a/backend/split.go +++ b/backend/split.go @@ -3,6 +3,7 @@ package main import ( + "bufio" "bytes" "context" "encoding/json" @@ -14,16 +15,26 @@ import ( "sort" "strconv" "strings" + "syscall" "time" ) +type splitVideoSegmentIn struct { + Start float64 `json:"start"` + End float64 `json:"end"` + Label string `json:"label,omitempty"` +} + type splitVideoRequest struct { - File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4" - Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0] + File string `json:"file"` // z. B. "model_01_01_2026__12-00-00.mp4" + Splits []float64 `json:"splits"` // Sekunden, z. B. [120.5, 300.0] + Segments []splitVideoSegmentIn `json:"segments"` // echte Segmente + DeleteOriginal bool `json:"deleteOriginal,omitempty"` } type splitVideoSegmentResponse struct { Index int `json:"index"` + Label string `json:"label,omitempty"` Start float64 `json:"start"` End float64 `json:"end"` Duration float64 `json:"duration"` @@ -38,6 +49,21 @@ type splitVideoResponse struct { Segments []splitVideoSegmentResponse `json:"segments"` } +type splitProgressEvent struct { + Type string `json:"type"` + OK bool `json:"ok,omitempty"` + File string `json:"file,omitempty"` + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + Progress float64 `json:"progress,omitempty"` // 0..1 aktuelles Segment + Overall float64 `json:"overall,omitempty"` // 0..1 gesamt + Start float64 `json:"start,omitempty"` + End float64 `json:"end,omitempty"` + Message string `json:"message,omitempty"` + Segment *splitVideoSegmentResponse `json:"segment,omitempty"` + Segments []splitVideoSegmentResponse `json:"segments,omitempty"` +} + func recordSplitVideo(w http.ResponseWriter, r *http.Request) { if !mustMethod(w, r, http.MethodPost) { return @@ -59,6 +85,8 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) { return } + stream := r.URL.Query().Get("stream") == "1" + s := getSettings() doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil { @@ -82,31 +110,43 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) { srcPath = filepath.Clean(srcPath) - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - - durationSec, err := durationSecondsCached(ctx, srcPath) + probeCtx, probeCancel := context.WithTimeout(r.Context(), 20*time.Second) + durationSec, err := durationSecondsCached(probeCtx, srcPath) + probeCancel() if err != nil || durationSec <= 0 { http.Error(w, "videodauer konnte nicht ermittelt werden", http.StatusInternalServerError) return } - points, err := normalizeSplitPoints(req.Splits, durationSec) - if err != nil { - http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest) - return + var segments []normalizedSegment + + if len(req.Segments) > 0 { + segments, err = normalizeSegments(req.Segments, durationSec) + if err != nil { + http.Error(w, "segments ungültig: "+err.Error(), http.StatusBadRequest) + return + } + } else { + points, err := normalizeSplitPoints(req.Splits, durationSec) + if err != nil { + http.Error(w, "splits ungültig: "+err.Error(), http.StatusBadRequest) + return + } + + if len(points) == 0 { + http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest) + return + } + + segments = buildSplitSegments(points, durationSec) + if len(segments) < 2 { + http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest) + return + } } - if len(points) == 0 { - http.Error(w, "keine gültigen splits übergeben", http.StatusBadRequest) - return - } - - segments := buildSplitSegments(points, durationSec) - if len(segments) < 2 { - http.Error(w, "zu wenige segmente nach split-berechnung", http.StatusBadRequest) - return - } + splitCtx, splitCancel := context.WithTimeout(r.Context(), splitTimeoutForSegments(len(segments))) + defer splitCancel() outDir := filepath.Join(filepath.Dir(srcPath), "_split") if err := os.MkdirAll(outDir, 0o755); err != nil { @@ -126,11 +166,78 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) { Source: srcPath, } - for i, seg := range segments { - outName := fmt.Sprintf("%s__part_%02d%s", base, i+1, ext) - outPath := filepath.Join(outDir, outName) + var flusher http.Flusher + if stream { + var ok bool + flusher, ok = w.(http.Flusher) + if !ok { + http.Error(w, "streaming wird von diesem server nicht unterstützt", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/x-ndjson") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "start", + OK: true, + File: req.File, + Total: len(segments), + Message: "Split gestartet", + }) + } + + for i, seg := range segments { + rawName := fmt.Sprintf("%s_%d%s", base, i+1, ext) + outPath := uniqueSplitOutPath(outDir, rawName) + outName := filepath.Base(outPath) + + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "segment_start", + File: req.File, + Current: i + 1, + Total: len(segments), + Start: seg.Start, + End: seg.End, + Overall: float64(i) / float64(len(segments)), + Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)), + }) + } + + err := splitSingleSegment(splitCtx, srcPath, outPath, seg.Start, seg.Duration, func(ratio float64) { + if !stream { + return + } + + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "segment_progress", + File: req.File, + Current: i + 1, + Total: len(segments), + Start: seg.Start, + End: seg.End, + Progress: clamp01(ratio), + Overall: clamp01((float64(i) + clamp01(ratio)) / float64(len(segments))), + Message: fmt.Sprintf("Segment %d/%d wird gesplittet", i+1, len(segments)), + }) + }) + if err != nil { + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "error", + File: req.File, + Current: i + 1, + Total: len(segments), + Start: seg.Start, + End: seg.End, + Message: fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), + }) + return + } - if err := splitSingleSegment(r.Context(), srcPath, outPath, seg.Start, seg.Duration); err != nil { http.Error( w, fmt.Sprintf("segment %d konnte nicht erzeugt werden: %v", i+1, err), @@ -139,26 +246,242 @@ func recordSplitVideo(w http.ResponseWriter, r *http.Request) { return } - resp.Segments = append(resp.Segments, splitVideoSegmentResponse{ + segResp := splitVideoSegmentResponse{ Index: i + 1, + Label: seg.Label, Start: seg.Start, End: seg.End, Duration: seg.Duration, File: outName, Path: outPath, - }) + } + resp.Segments = append(resp.Segments, segResp) + + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "segment_done", + File: req.File, + Current: i + 1, + Total: len(segments), + Start: seg.Start, + End: seg.End, + Overall: float64(i+1) / float64(len(segments)), + Segment: &segResp, + Message: fmt.Sprintf("Segment %d/%d fertig", i+1, len(segments)), + }) + } + } + + if req.DeleteOriginal { + originalFile := filepath.Base(srcPath) + originalID := stripHotPrefix(strings.TrimSuffix(originalFile, filepath.Ext(originalFile))) + + releaseFileForMutation(originalFile) + + if err := removeWithRetry(srcPath); err != nil { + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "error", + File: req.File, + Total: len(resp.Segments), + Overall: 1, + Message: "Originaldatei konnte nicht gelöscht werden: " + err.Error(), + }) + return + } + + http.Error(w, "Originaldatei konnte nicht gelöscht werden: "+err.Error(), http.StatusInternalServerError) + return + } + + purgeDurationCacheForPath(srcPath) + removeJobsByOutputBasename(originalFile) + + if strings.TrimSpace(originalID) != "" { + removeGeneratedForID(originalID) + } + + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "source_deleted", + File: req.File, + Total: len(resp.Segments), + Overall: 1, + Message: "Originaldatei gelöscht", + }) + } } notifyDoneChanged() + + if stream { + _ = writeSplitProgressEvent(w, flusher, splitProgressEvent{ + Type: "done", + OK: true, + File: req.File, + Total: len(resp.Segments), + Overall: 1, + Segments: resp.Segments, + Message: "Split abgeschlossen", + }) + return + } + respondJSON(w, resp) } +func sanitizeTimeForFilename(v float64) string { + if v < 0 { + v = 0 + } + return strings.ReplaceAll(formatFFSec(v), ".", "_") +} + +func splitTimeoutForSegments(count int) time.Duration { + if count < 1 { + count = 1 + } + + // grob 2 Minuten pro Segment + Puffer + d := time.Duration(count)*2*time.Minute + 30*time.Second + + if d < 5*time.Minute { + d = 5 * time.Minute + } + if d > 2*time.Hour { + d = 2 * time.Hour + } + + return d +} + +func clamp01(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} + +func writeSplitProgressEvent(w http.ResponseWriter, flusher http.Flusher, ev splitProgressEvent) error { + b, err := json.Marshal(ev) + if err != nil { + return err + } + + if _, err := w.Write(append(b, '\n')); err != nil { + return err + } + + flusher.Flush() + return nil +} + type normalizedSegment struct { + Label string Start float64 End float64 Duration float64 } +func normalizeSegments(raw []splitVideoSegmentIn, duration float64) ([]normalizedSegment, error) { + if duration <= 0 { + return nil, fmt.Errorf("duration <= 0") + } + if len(raw) == 0 { + return nil, fmt.Errorf("keine segmente übergeben") + } + + out := make([]normalizedSegment, 0, len(raw)) + + for _, seg := range raw { + start := seg.Start + end := seg.End + + if start > end { + start, end = end, start + } + + if start < 0 { + start = 0 + } + if end > duration { + end = duration + } + + dur := end - start + if dur <= 0.10 { + continue + } + + out = append(out, normalizedSegment{ + Label: strings.TrimSpace(seg.Label), + Start: start, + End: end, + Duration: dur, + }) + } + + if len(out) == 0 { + return nil, fmt.Errorf("keine gültigen segmente übrig") + } + + sort.Slice(out, func(i, j int) bool { + if out[i].Start == out[j].Start { + return out[i].End < out[j].End + } + return out[i].Start < out[j].Start + }) + + // near-duplicate entfernen + dedup := make([]normalizedSegment, 0, len(out)) + for _, seg := range out { + if len(dedup) == 0 { + dedup = append(dedup, seg) + continue + } + + last := dedup[len(dedup)-1] + sameStart := absFloat(last.Start-seg.Start) < 0.20 + sameEnd := absFloat(last.End-seg.End) < 0.20 + + if sameStart && sameEnd { + continue + } + + dedup = append(dedup, seg) + } + + if len(dedup) == 0 { + return nil, fmt.Errorf("keine eindeutigen segmente übrig") + } + + return dedup, nil +} + +func uniqueSplitOutPath(dir, fileName string) string { + ext := filepath.Ext(fileName) + base := strings.TrimSuffix(fileName, ext) + + outPath := filepath.Join(dir, fileName) + if _, err := os.Stat(outPath); err == nil { + // existiert schon -> unten nächsten Namen suchen + } else if os.IsNotExist(err) { + return outPath + } + + for i := 2; i < 10000; i++ { + candidate := filepath.Join(dir, fmt.Sprintf("%s__%02d%s", base, i, ext)) + if _, err := os.Stat(candidate); os.IsNotExist(err) { + return candidate + } + } + + return filepath.Join(dir, fmt.Sprintf("%s__%d%s", base, time.Now().UnixNano(), ext)) +} + func normalizeSplitPoints(raw []float64, duration float64) ([]float64, error) { if duration <= 0 { return nil, fmt.Errorf("duration <= 0") @@ -218,7 +541,29 @@ func buildSplitSegments(points []float64, duration float64) []normalizedSegment return out } -func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, startSec, durSec float64) error { +func splitSingleSegmentTimeout(durSec float64) time.Duration { + if durSec <= 0 { + return 10 * time.Minute + } + + d := time.Duration(durSec*4)*time.Second + 2*time.Minute + + if d < 5*time.Minute { + d = 5 * time.Minute + } + if d > 90*time.Minute { + d = 90 * time.Minute + } + + return d +} + +func splitSingleSegment( + parentCtx context.Context, + srcPath, outPath string, + startSec, durSec float64, + onProgress func(ratio float64), +) error { if strings.TrimSpace(srcPath) == "" { return fmt.Errorf("srcPath leer") } @@ -229,19 +574,27 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star return fmt.Errorf("dauer <= 0") } - tmpPath := outPath + ".part" - + ext := strings.ToLower(filepath.Ext(outPath)) + base := strings.TrimSuffix(outPath, filepath.Ext(outPath)) + tmpPath := base + ".part" + ext _ = os.Remove(tmpPath) - _ = os.Remove(outPath) - ctx, cancel := context.WithTimeout(parentCtx, 2*time.Minute) + timeout := splitSingleSegmentTimeout(durSec) + + ctx, cancel := context.WithTimeout(parentCtx, timeout) defer cancel() - // Re-Encode ist robuster/framesauberer als -c copy + formatName := "mp4" + if ext == ".ts" { + formatName = "mpegts" + } + args := []string{ "-y", + "-nostats", + "-progress", "pipe:1", "-hide_banner", - "-loglevel", "error", + "-loglevel", "warning", "-ss", formatFFSec(startSec), "-i", srcPath, "-t", formatFFSec(durSec), @@ -255,15 +608,93 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", + "-f", formatName, tmpPath, } cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + var stderr bytes.Buffer cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { + if err := cmd.Start(); err != nil { + return err + } + + if onProgress != nil { + onProgress(0) + } + + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + lastRatio := -1.0 + send := func(outSec float64, force bool) { + if onProgress == nil || durSec <= 0 { + return + } + + ratio := clamp01(outSec / durSec) + if !force && lastRatio >= 0 && absFloat(lastRatio-ratio) < 0.01 { + return + } + + lastRatio = ratio + onProgress(ratio) + } + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + + switch k { + case "out_time_us": + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { + send(float64(n)/1_000_000.0, false) + } + case "out_time_ms": + if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil && n > 0 { + send(float64(n)/1_000.0, false) + } + case "out_time": + if s := parseFFmpegOutTime(v); s > 0 { + send(s, false) + } + case "progress": + if strings.TrimSpace(v) == "end" { + send(durSec, true) + } + } + } + + if err := sc.Err(); err != nil { _ = os.Remove(tmpPath) + return err + } + + if err := cmd.Wait(); err != nil { + _ = os.Remove(tmpPath) + + if ctx.Err() != nil { + return fmt.Errorf("ffmpeg timeout nach %s: %w", timeout, ctx.Err()) + } + msg := strings.TrimSpace(stderr.String()) if msg != "" { return fmt.Errorf("%w (%s)", err, msg) @@ -282,6 +713,10 @@ func splitSingleSegment(parentCtx context.Context, srcPath, outPath string, star return fmt.Errorf("rename fehlgeschlagen: %w", err) } + if onProgress != nil { + onProgress(1) + } + return nil } diff --git a/backend/tasks_assets.go b/backend/tasks_assets.go index dbdb6d5..3f6da5c 100644 --- a/backend/tasks_assets.go +++ b/backend/tasks_assets.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -20,13 +21,14 @@ import ( type AssetsTaskState struct { Queued bool `json:"queued"` Running bool `json:"running"` + QueuedAt time.Time `json:"queuedAt"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` 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"` CurrentFile string `json:"currentFile,omitempty"` CurrentQueue string `json:"currentQueue,omitempty"` @@ -35,10 +37,101 @@ type AssetsTaskState struct { Text string `json:"text,omitempty"` } +type assetsPhaseSelection struct { + Meta bool + Thumb bool + Teaser bool + Sprites bool + Analyze bool +} + var assetsTaskMu sync.Mutex var assetsTaskState AssetsTaskState var assetsTaskCancel context.CancelFunc +var assetsTaskFileCancels = map[string]context.CancelFunc{} +var assetsTaskSkipFiles = map[string]struct{}{} + +func selectedAssetsPhasesFromSettings() assetsPhaseSelection { + s := getSettings() + return assetsPhaseSelection{ + Meta: s.GenerateAssetsMeta, + Thumb: s.GenerateAssetsThumb, + Teaser: s.GenerateAssetsTeaser, + Sprites: s.GenerateAssetsSprites, + Analyze: s.GenerateAssetsAnalyze, + } +} + +func assetsTaskFileKey(file string) string { + return strings.ToLower(strings.TrimSpace(filepath.Base(file))) +} + +func resetAssetsTaskFileControlLocked() { + assetsTaskFileCancels = map[string]context.CancelFunc{} + assetsTaskSkipFiles = map[string]struct{}{} +} + +func registerAssetsTaskFileCancel(file string, cancel context.CancelFunc) { + key := assetsTaskFileKey(file) + if key == "" { + return + } + + assetsTaskMu.Lock() + assetsTaskFileCancels[key] = cancel + assetsTaskMu.Unlock() +} + +func clearAssetsTaskFileControl(file string) { + key := assetsTaskFileKey(file) + if key == "" { + return + } + + assetsTaskMu.Lock() + delete(assetsTaskFileCancels, key) + delete(assetsTaskSkipFiles, key) + assetsTaskMu.Unlock() +} + +func requestAssetsTaskSkip(file string) bool { + key := assetsTaskFileKey(file) + if key == "" { + return false + } + + var cancel context.CancelFunc + + assetsTaskMu.Lock() + assetsTaskSkipFiles[key] = struct{}{} + cancel = assetsTaskFileCancels[key] + assetsTaskMu.Unlock() + + if cancel != nil { + cancel() + return true + } + + return false +} + +func isAssetsTaskSkipRequested(file string) bool { + key := assetsTaskFileKey(file) + if key == "" { + return false + } + + assetsTaskMu.Lock() + _, ok := assetsTaskSkipFiles[key] + assetsTaskMu.Unlock() + return ok +} + +func (p assetsPhaseSelection) Any() bool { + return p.Meta || p.Thumb || p.Teaser || p.Sprites || p.Analyze +} + // updateAssetsState mutiert den State atomar und triggert danach SSE notify. // notifyAssetsChanged() muss außerhalb des Locks passieren. func updateAssetsState(fn func(st *AssetsTaskState)) AssetsTaskState { @@ -85,13 +178,14 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { assetsTaskState = AssetsTaskState{ Queued: true, Running: false, + QueuedAt: time.Now(), + StartedAt: time.Time{}, + FinishedAt: nil, Total: 0, Done: 0, GeneratedThumbs: 0, GeneratedPreviews: 0, Skipped: 0, - StartedAt: time.Time{}, - FinishedAt: nil, Error: "", CurrentFile: "", CurrentQueue: "", @@ -99,6 +193,7 @@ func tasksGenerateAssets(w http.ResponseWriter, r *http.Request) { CurrentLabel: "", Text: "Wartet…", } + resetAssetsTaskFileControlLocked() st := assetsTaskState assetsTaskMu.Unlock() @@ -189,21 +284,117 @@ func publishAssetsTaskPhase(fileName string, queue string, phase string, state s ) } -func clearAssetsTaskFinishedPostworkStates(fileName string) { - defs := []struct { - queue string - phase string - }{ - {queue: "postwork", phase: "meta"}, - {queue: "postwork", phase: "thumb"}, - {queue: "postwork", phase: "teaser"}, - {queue: "postwork", phase: "sprites"}, - {queue: "enrich", phase: "analyze"}, +func clearAssetsTaskFinishedPostworkStates(fileName string, sel assetsPhaseSelection) { + if sel.Meta { + publishAssetsTaskPhase(fileName, "postwork", "meta", "missing", "") + } + if sel.Thumb { + publishAssetsTaskPhase(fileName, "postwork", "thumb", "missing", "") + } + if sel.Teaser { + publishAssetsTaskPhase(fileName, "postwork", "teaser", "missing", "") + } + if sel.Sprites { + publishAssetsTaskPhase(fileName, "postwork", "sprites", "missing", "") + } + if sel.Analyze { + publishAssetsTaskPhase(fileName, "enrich", "analyze", "missing", "") + } +} + +func fileExistsNonEmpty(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false } - for _, def := range defs { - publishAssetsTaskPhase(fileName, def.queue, def.phase, "missing", "") + fi, err := os.Stat(path) + return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 +} + +type orderedAssetItem struct { + name string + path string +} + +func listAssetsDoneThenKeepByFilenameAsc(doneAbs string) []orderedAssetItem { + indexedItems, _ := buildDoneIndex(doneAbs) + + doneItems := make([]orderedAssetItem, 0, len(indexedItems)) + keepItems := make([]orderedAssetItem, 0, len(indexedItems)) + seen := make(map[string]struct{}, len(indexedItems)) + + for _, entry := range indexedItems { + if entry.job == nil { + continue + } + + full := strings.TrimSpace(entry.job.Output) + if full == "" { + continue + } + + cleanKey := strings.ToLower(filepath.Clean(full)) + if _, ok := seen[cleanKey]; ok { + continue + } + seen[cleanKey] = struct{}{} + + it := orderedAssetItem{ + name: filepath.Base(full), + path: full, + } + + if entry.fromKeep { + keepItems = append(keepItems, it) + } else { + doneItems = append(doneItems, it) + } } + + sort.Slice(doneItems, func(i, j int) bool { + ai := strings.ToLower(doneItems[i].name) + aj := strings.ToLower(doneItems[j].name) + if ai != aj { + return ai < aj + } + return strings.ToLower(filepath.Clean(doneItems[i].path)) < strings.ToLower(filepath.Clean(doneItems[j].path)) + }) + + sort.Slice(keepItems, func(i, j int) bool { + ai := strings.ToLower(keepItems[i].name) + aj := strings.ToLower(keepItems[j].name) + if ai != aj { + return ai < aj + } + return strings.ToLower(filepath.Clean(keepItems[i].path)) < strings.ToLower(filepath.Clean(keepItems[j].path)) + }) + + return append(doneItems, keepItems...) +} + +func assetsTaskTruthForVideo(id string, videoPath string) finishedPhaseTruth { + truth := finishedPhaseTruthForID(id) + + _, thumbPath, previewPath, spritePath, metaPath, err := assetPathsForID(id) + if err != nil { + return truth + } + + // meta muss nicht nur logisch "ready" sein, sondern die Datei muss auch existieren + truth.MetaReady = truth.MetaReady && fileExistsNonEmpty(metaPath) + + // dateibasierte Assets IMMER an echter Datei festmachen + truth.ThumbReady = fileExistsNonEmpty(thumbPath) + truth.TeaserReady = fileExistsNonEmpty(previewPath) + truth.SpritesReady = fileExistsNonEmpty(spritePath) + + // Analyse bleibt inhaltlich/meta-basiert + if strings.TrimSpace(videoPath) != "" { + truth.AnalyzeReady = hasAIResultsForOutput(videoPath) + } + + return truth } func runGenerateMissingAssets(ctx context.Context) { @@ -237,67 +428,27 @@ func runGenerateMissingAssets(ctx context.Context) { st.Error = err.Error() } }) - } - abortIfCanceled := func(stepErr error, fileName string) bool { - if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) || ctx.Err() != nil { - if strings.TrimSpace(fileName) != "" { - clearAssetsTaskFinishedPostworkStates(fileName) - } - finishWithErr(context.Canceled) - return true - } - return false + assetsTaskMu.Lock() + resetAssetsTaskFileControlLocked() + assetsTaskMu.Unlock() } s := getSettings() + sel := selectedAssetsPhasesFromSettings() + if !sel.Any() { + finishWithErr(nil) + return + } + doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { finishWithErr(fmt.Errorf("doneDir auflösung fehlgeschlagen: %v", err)) return } - type item struct { - name string - path string - } + items := listAssetsDoneThenKeepByFilenameAsc(doneAbs) - indexedItems, sortedIdx := buildDoneIndex(doneAbs) - - // gleiche Grundreihenfolge wie FinishedDownloads-Default: - // includeKeep = true, sort = completed_desc - order := sortedIdx["1|completed_desc"] - - seen := map[string]struct{}{} - items := make([]item, 0, len(order)) - - for _, idx := range order { - if idx < 0 || idx >= len(indexedItems) { - continue - } - - j := indexedItems[idx].job - if j == nil { - continue - } - - full := strings.TrimSpace(j.Output) - if full == "" { - continue - } - - if _, ok := seen[full]; ok { - continue - } - seen[full] = struct{}{} - - items = append(items, item{ - name: filepath.Base(full), - path: full, - }) - } - - // Initialisierung: Total etc. + SSE Push type assetCandidate struct { name string path string @@ -307,6 +458,7 @@ func runGenerateMissingAssets(ctx context.Context) { candidates := make([]assetCandidate, 0, len(items)) skippedCount := 0 + initialDone := 0 for _, it := range items { if err := ctx.Err(); err != nil { @@ -317,30 +469,33 @@ func runGenerateMissingAssets(ctx context.Context) { base := strings.TrimSuffix(it.name, filepath.Ext(it.name)) id := stripHotPrefix(base) if strings.TrimSpace(id) == "" { - clearAssetsTaskFinishedPostworkStates(it.name) + clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ + initialDone++ continue } vfi, verr := os.Stat(it.path) if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { - clearAssetsTaskFinishedPostworkStates(it.name) + clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ + initialDone++ continue } - beforeTruth := finishedPhaseTruthForID(id) + beforeTruth := assetsTaskTruthForVideo(id, it.path) - needMeta := !beforeTruth.MetaReady - needThumb := !beforeTruth.ThumbReady - needTeaser := !beforeTruth.TeaserReady - needSprites := !beforeTruth.SpritesReady - needAnalyze := !beforeTruth.AnalyzeReady + needMeta := sel.Meta && !beforeTruth.MetaReady + needThumb := sel.Thumb && !beforeTruth.ThumbReady + needTeaser := sel.Teaser && !beforeTruth.TeaserReady + needSprites := sel.Sprites && !beforeTruth.SpritesReady + needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady - // Bereits vollständig -> gar nicht erst in die Arbeitsliste aufnehmen + // Bereits vollständig bzgl. ausgewählter Phasen -> als erledigt/skipped mitzählen if !needMeta && !needThumb && !needTeaser && !needSprites && !needAnalyze { - clearAssetsTaskFinishedPostworkStates(it.name) + clearAssetsTaskFinishedPostworkStates(it.name, sel) skippedCount++ + initialDone++ continue } @@ -353,8 +508,8 @@ func runGenerateMissingAssets(ctx context.Context) { } updateAssetsState(func(st *AssetsTaskState) { - st.Total = len(candidates) - st.Done = 0 + st.Total = len(items) // alle Dateien, inkl. keep + skips + st.Done = initialDone // vorhandene/ungültige direkt als erledigt zählen st.GeneratedThumbs = 0 st.GeneratedPreviews = 0 st.Skipped = skippedCount @@ -363,42 +518,109 @@ func runGenerateMissingAssets(ctx context.Context) { st.CurrentQueue = "" st.CurrentPhase = "" st.CurrentLabel = "" + st.Text = "" }) + if len(items) == 0 { + finishWithErr(nil) + return + } + if len(candidates) == 0 { finishWithErr(nil) return } - for i, it := range candidates { + for _, it := range candidates { if err := ctx.Err(); err != nil { finishWithErr(err) return } + fileCtx, fileCancel := context.WithCancel(ctx) + registerAssetsTaskFileCancel(it.name, fileCancel) + + finishFileControl := func() { + fileCancel() + clearAssetsTaskFileControl(it.name) + } + + skipCurrentFile := func() { + clearAssetsTaskFinishedPostworkStates(it.name, sel) + updateAssetsState(func(st *AssetsTaskState) { + st.Done++ + st.Skipped++ + st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" + }) + finishFileControl() + } + + handleFileAbort := func(stepErr error) (skipFile bool, stopAll bool) { + if stepErr == nil { + if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { + return true, false + } + return false, false + } + + if errors.Is(stepErr, context.Canceled) || errors.Is(stepErr, context.DeadlineExceeded) { + // globaler Task wurde beendet + if ctx.Err() != nil && !isAssetsTaskSkipRequested(it.name) { + finishFileControl() + finishWithErr(context.Canceled) + return false, true + } + + // nur diese eine Datei wurde freigegeben/übersprungen + return true, false + } + + // Datei wurde evtl. während des Verarbeitungsschritts gelöscht/verschoben + if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { + return true, false + } + + return false, false + } + updateAssetsState(func(st *AssetsTaskState) { st.CurrentFile = it.name }) + if isAssetsTaskSkipRequested(it.name) || !fileExistsNonEmpty(it.path) { + skipCurrentFile() + continue + } + id := it.id vfi, verr := os.Stat(it.path) if verr != nil || vfi == nil || vfi.IsDir() || vfi.Size() <= 0 { - clearAssetsTaskFinishedPostworkStates(it.name) + clearAssetsTaskFinishedPostworkStates(it.name, sel) updateAssetsState(func(st *AssetsTaskState) { - st.Done = i + 1 + st.Done++ + st.Skipped++ + st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" }) + + finishFileControl() continue } beforeTruth := it.beforeTruth - needMeta := !beforeTruth.MetaReady - needThumb := !beforeTruth.ThumbReady - needTeaser := !beforeTruth.TeaserReady - needSprites := !beforeTruth.SpritesReady - needAnalyze := !beforeTruth.AnalyzeReady + needMeta := sel.Meta && !beforeTruth.MetaReady + needThumb := sel.Thumb && !beforeTruth.ThumbReady + needTeaser := sel.Teaser && !beforeTruth.TeaserReady + needSprites := sel.Sprites && !beforeTruth.SpritesReady + needAnalyze := sel.Analyze && !beforeTruth.AnalyzeReady // Pfade einmalig über zentralen Helper _, _, _, _, metaPath, perr := assetPathsForID(id) @@ -416,13 +638,19 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") } if needAnalyze { - publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } updateAssetsState(func(st *AssetsTaskState) { st.Error = "mindestens ein Eintrag konnte nicht verarbeitet werden (siehe Logs)" - st.Done = i + 1 + st.Done++ + st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" }) + + finishFileControl() fmt.Println("⚠️ assetPathsForID:", perr) continue } @@ -449,9 +677,12 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "meta", "running", "Meta") - okMeta, merr := ensureMetaForVideoCtx(ctx, it.path, sourceURL) - if abortIfCanceled(merr, it.name) { + okMeta, merr := ensureMetaForVideoCtx(fileCtx, it.path, sourceURL) + if skipFile, stopAll := handleFileAbort(merr); stopAll { return + } else if skipFile { + skipCurrentFile() + continue } if merr != nil || !okMeta { publishAssetsTaskPhase(it.name, "postwork", "meta", "error", "Meta fehlgeschlagen") @@ -473,14 +704,17 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "thumb", "running", "Vorschaubild") - resThumb, terr := ensurePrimaryAssetsForVideoWithProgressCtx(ctx, it.path, sourceURL, nil) - if abortIfCanceled(terr, it.name) { + thumbGeneratedNow, terr := ensureThumbForVideoCtx(fileCtx, it.path, sourceURL) + if skipFile, stopAll := handleFileAbort(terr); stopAll { return + } else if skipFile { + skipCurrentFile() + continue } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "thumb", "error", "Vorschaubild fehlgeschlagen") - } else if finishedPhaseTruthForID(id).ThumbReady { - if resThumb.ThumbGenerated { + } else if assetsTaskTruthForVideo(id, it.path).ThumbReady { + if thumbGeneratedNow { thumbGenerated = true } publishAssetsTaskPhase(it.name, "postwork", "thumb", "done", "Vorschaubild") @@ -502,13 +736,16 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "teaser", "running", "Teaser") - genTeaser, terr := ensureTeaserForVideoCtx(ctx, it.path, sourceURL) - if abortIfCanceled(terr, it.name) { + genTeaser, terr := ensureTeaserForVideoCtx(fileCtx, it.path, sourceURL) + if skipFile, stopAll := handleFileAbort(terr); stopAll { return + } else if skipFile { + skipCurrentFile() + continue } if terr != nil { publishAssetsTaskPhase(it.name, "postwork", "teaser", "error", "Teaser fehlgeschlagen") - } else if finishedPhaseTruthForID(id).TeaserReady { + } else if assetsTaskTruthForVideo(id, it.path).TeaserReady { if genTeaser { previewGenerated = true } @@ -531,13 +768,16 @@ func runGenerateMissingAssets(ctx context.Context) { publishAssetsTaskPhase(it.name, "postwork", "sprites", "running", "Sprites") - _, serr := ensureSpritesForVideoCtx(ctx, it.path, sourceURL) - if abortIfCanceled(serr, it.name) { + _, serr := ensureSpritesForVideoCtx(fileCtx, it.path, sourceURL) + if skipFile, stopAll := handleFileAbort(serr); stopAll { return + } else if skipFile { + skipCurrentFile() + continue } if serr != nil { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") - } else if finishedPhaseTruthForID(id).SpritesReady { + } else if assetsTaskTruthForVideo(id, it.path).SpritesReady { publishAssetsTaskPhase(it.name, "postwork", "sprites", "done", "Sprites") } else { publishAssetsTaskPhase(it.name, "postwork", "sprites", "error", "Sprites fehlgeschlagen") @@ -552,21 +792,24 @@ func runGenerateMissingAssets(ctx context.Context) { st.CurrentFile = it.name st.CurrentQueue = "enrich" st.CurrentPhase = "analyze" - st.CurrentLabel = "KI-Analyse" + st.CurrentLabel = "Analyse" }) - publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "KI-Analyse") + publishAssetsTaskPhase(it.name, "enrich", "analyze", "running", "Analyse") - _, aerr := ensureAnalyzeForVideoCtx(ctx, it.path, sourceURL, "nsfw") - if abortIfCanceled(aerr, it.name) { + _, aerr := ensureAnalyzeForVideoCtx(fileCtx, it.path, sourceURL, "nsfw") + if skipFile, stopAll := handleFileAbort(aerr); stopAll { return + } else if skipFile { + skipCurrentFile() + continue } if aerr != nil { - publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") - } else if finishedPhaseTruthForID(id).AnalyzeReady { - publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "KI-Analyse") + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") + } else if assetsTaskTruthForVideo(id, it.path).AnalyzeReady { + publishAssetsTaskPhase(it.name, "enrich", "analyze", "done", "Analyse") } else { - publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "KI-Analyse fehlgeschlagen") + publishAssetsTaskPhase(it.name, "enrich", "analyze", "error", "Analyse fehlgeschlagen") } } @@ -577,8 +820,14 @@ func runGenerateMissingAssets(ctx context.Context) { if previewGenerated { st.GeneratedPreviews++ } - st.Done = i + 1 + st.Done++ + st.CurrentFile = "" + st.CurrentQueue = "" + st.CurrentPhase = "" + st.CurrentLabel = "" }) + + finishFileControl() } finishWithErr(nil) diff --git a/backend/tasks_check_video.go b/backend/tasks_check_video.go new file mode 100644 index 0000000..b8a8088 --- /dev/null +++ b/backend/tasks_check_video.go @@ -0,0 +1,665 @@ +// backend\tasks_check_video.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "image" + "image/jpeg" + "math" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type tailBlackoutSample struct { + Role string `json:"role,omitempty"` + TimeSec float64 `json:"timeSec"` + AvgLuma float64 `json:"avgLuma"` + BlackRatio float64 `json:"blackRatio"` + MostlyBlack bool `json:"mostlyBlack"` +} + +type tailBlackoutResult struct { + CheckedAt string `json:"checkedAt"` + IsCorrupt bool `json:"isCorrupt"` + Reason string `json:"reason,omitempty"` + ReferenceBlack bool `json:"referenceBlack,omitempty"` + BlackTailFrames int `json:"blackTailFrames"` + SampleCount int `json:"sampleCount"` + Samples []tailBlackoutSample `json:"samples,omitempty"` +} + +type checkVideosTaskState struct { + mu sync.Mutex + queued bool + running bool + done int + total int + text string + currentFile string + err string + queuedAt time.Time + startedAt time.Time + finishedAt time.Time + cancel context.CancelFunc +} + +func shortTaskFilename(name string, max int) string { + s := strings.TrimSpace(name) + if s == "" { + return "" + } + if max <= 1 || len(s) <= max { + return s + } + return "…" + s[len(s)-(max-1):] +} + +func snapshotCheckVideosTaskState() CheckVideosTaskState { + checkVideosTask.mu.Lock() + defer checkVideosTask.mu.Unlock() + + // Fertige/abgebrochene/fehlerhafte Tasks nach kurzer Zeit nicht mehr zurückgeben + if !checkVideosTask.queued && + !checkVideosTask.running && + !checkVideosTask.finishedAt.IsZero() && + time.Since(checkVideosTask.finishedAt) > 4*time.Second { + return CheckVideosTaskState{} + } + + out := CheckVideosTaskState{ + Queued: checkVideosTask.queued, + Running: checkVideosTask.running, + QueuedAt: checkVideosTask.queuedAt, + StartedAt: checkVideosTask.startedAt, + Text: checkVideosTask.text, + Error: checkVideosTask.err, + CurrentFile: checkVideosTask.currentFile, + Done: checkVideosTask.done, + Total: checkVideosTask.total, + } + + if !checkVideosTask.finishedAt.IsZero() { + finishedAt := checkVideosTask.finishedAt + out.FinishedAt = &finishedAt + } + + return out +} + +var checkVideosTask checkVideosTaskState + +func clampFloat(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func shouldCheckFinishedVideo(path string) bool { + n := strings.ToLower(strings.ReplaceAll(path, "\\", "/")) + base := filepath.Base(n) + ext := strings.ToLower(filepath.Ext(base)) + + if strings.Contains(n, "/.trash/") { + return false + } + + switch base { + case "preview.jpg", "preview.mp4", "preview-sprite.jpg": + return false + } + + switch ext { + case ".mp4", ".mkv", ".webm", ".ts", ".mov": + return true + default: + return false + } +} + +func hasUsableSpriteForVideo(videoPath string) bool { + assetID := assetIDFromVideoPath(videoPath) + if assetID == "" { + return false + } + + _, _, _, spritePath, _, err := assetPathsForID(assetID) + if err != nil { + return false + } + + fi, err := os.Stat(spritePath) + return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 +} + +func collectFinishedVideosForCheck() ([]string, error) { + s := getSettings() + + doneAbs, err := resolvePathRelativeToApp(strings.TrimSpace(s.DoneDir)) + if err != nil { + return nil, err + } + + files := make([]string, 0, 256) + + err = filepath.Walk(doneAbs, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return nil + } + if info == nil || info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return nil + } + if !shouldCheckFinishedVideo(path) { + return nil + } + if !hasUsableSpriteForVideo(path) { + return nil + } + files = append(files, path) + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(files) + return files, nil +} + +func finishCheckVideosTask(err error) { + checkVideosTask.mu.Lock() + checkVideosTask.queued = false + checkVideosTask.running = false + checkVideosTask.cancel = nil + checkVideosTask.finishedAt = time.Now() + + errText := strings.TrimSpace(func() string { + if err == nil { + return "" + } + return err.Error() + }()) + + cancelled := + strings.EqualFold(errText, "abgebrochen") || + strings.EqualFold(errText, "abbruch") || + strings.EqualFold(errText, "cancelled") || + strings.EqualFold(errText, "canceled") + + if cancelled { + checkVideosTask.err = "" + checkVideosTask.text = "Abgebrochen." + } else if err != nil { + checkVideosTask.err = errText + checkVideosTask.text = "Fehler bei der Video-Prüfung." + } else { + checkVideosTask.err = "" + checkVideosTask.text = "Prüfung abgeschlossen." + } + + checkVideosTask.mu.Unlock() + + publishTaskState() +} + +func runCheckVideosTask(ctx context.Context) { + files, err := collectFinishedVideosForCheck() + if err != nil { + finishCheckVideosTask(err) + return + } + + checkVideosTask.mu.Lock() + checkVideosTask.queued = false + checkVideosTask.running = true + checkVideosTask.total = len(files) + checkVideosTask.done = 0 + checkVideosTask.startedAt = time.Now() + checkVideosTask.text = "Prüfe Videos…" + checkVideosTask.mu.Unlock() + + publishTaskState() + + var firstErr error + + for i, file := range files { + select { + case <-ctx.Done(): + finishCheckVideosTask(fmt.Errorf("abgebrochen")) + return + default: + } + + checkVideosTask.mu.Lock() + checkVideosTask.currentFile = file + checkVideosTask.text = shortTaskFilename(file, 52) + checkVideosTask.done = i + checkVideosTask.mu.Unlock() + + if _, err := runTailBlackoutCheck(ctx, file); err != nil { + if firstErr == nil { + firstErr = err + } + } + + checkVideosTask.mu.Lock() + checkVideosTask.done = i + 1 + checkVideosTask.mu.Unlock() + } + + finishCheckVideosTask(firstErr) +} + +func startCheckVideosTask() error { + checkVideosTask.mu.Lock() + defer checkVideosTask.mu.Unlock() + + if checkVideosTask.running || checkVideosTask.queued { + return fmt.Errorf("task läuft bereits") + } + + ctx, cancel := context.WithCancel(context.Background()) + + checkVideosTask.queued = true + checkVideosTask.running = false + checkVideosTask.done = 0 + checkVideosTask.total = 0 + checkVideosTask.text = "Wartet…" + checkVideosTask.currentFile = "" + checkVideosTask.err = "" + checkVideosTask.queuedAt = time.Now() + checkVideosTask.startedAt = time.Time{} + checkVideosTask.finishedAt = time.Time{} + checkVideosTask.cancel = cancel + + publishTaskState() + go runCheckVideosTask(ctx) + return nil +} + +func cancelCheckVideosTask() { + checkVideosTask.mu.Lock() + cancel := checkVideosTask.cancel + checkVideosTask.mu.Unlock() + + if cancel != nil { + cancel() + } +} + +func checkVideosTaskHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + if err := startCheckVideosTask(); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true}) + return + + case http.MethodDelete: + cancelCheckVideosTask() + w.WriteHeader(http.StatusNoContent) + return + + default: + http.Error(w, "Nur POST/DELETE erlaubt", http.StatusMethodNotAllowed) + return + } +} + +func analyzeImageBoundsBlackness(img image.Image, bounds image.Rectangle) (avgLuma float64, blackRatio float64, err error) { + b := bounds.Intersect(img.Bounds()) + w := b.Dx() + h := b.Dy() + if w <= 0 || h <= 0 { + return 0, 0, fmt.Errorf("invalid image bounds") + } + + totalPixels := w * h + step := int(math.Sqrt(float64(totalPixels) / 12000.0)) + if step < 1 { + step = 1 + } + + var ( + sumLuma float64 + count int + black int + ) + + for y := b.Min.Y; y < b.Max.Y; y += step { + for x := b.Min.X; x < b.Max.X; x += step { + r, g, bb, _ := img.At(x, y).RGBA() + + r8 := float64(r >> 8) + g8 := float64(g >> 8) + b8 := float64(bb >> 8) + + luma := 0.2126*r8 + 0.7152*g8 + 0.0722*b8 + sumLuma += luma + count++ + + if luma <= 16 { + black++ + } + } + } + + if count == 0 { + return 0, 0, fmt.Errorf("no sampled pixels") + } + + avgLuma = sumLuma / float64(count) + blackRatio = float64(black) / float64(count) + return avgLuma, blackRatio, nil +} + +func analyzeJPGBlackness(jpg []byte) (avgLuma float64, blackRatio float64, err error) { + img, err := jpeg.Decode(bytes.NewReader(jpg)) + if err != nil { + return 0, 0, err + } + return analyzeImageBoundsBlackness(img, img.Bounds()) +} + +func spriteCellBounds(img image.Image, cols, rows, index int) (image.Rectangle, error) { + if cols <= 0 || rows <= 0 { + return image.Rectangle{}, fmt.Errorf("invalid sprite layout") + } + if index < 0 || index >= cols*rows { + return image.Rectangle{}, fmt.Errorf("sprite index out of range") + } + + b := img.Bounds() + col := index % cols + row := index / cols + + x0 := b.Min.X + (col*b.Dx())/cols + x1 := b.Min.X + ((col+1)*b.Dx())/cols + y0 := b.Min.Y + (row*b.Dy())/rows + y1 := b.Min.Y + ((row+1)*b.Dy())/rows + + rect := image.Rect(x0, y0, x1, y1) + if rect.Dx() <= 0 || rect.Dy() <= 0 { + return image.Rectangle{}, fmt.Errorf("invalid sprite cell bounds") + } + + return rect, nil +} + +func analyzeSpriteCellBlackness(img image.Image, cols, rows, index int) (avgLuma float64, blackRatio float64, err error) { + rect, err := spriteCellBounds(img, cols, rows, index) + if err != nil { + return 0, 0, err + } + return analyzeImageBoundsBlackness(img, rect) +} + +func isMostlyBlackFrame(avgLuma, blackRatio float64) bool { + return avgLuma <= 18.0 && blackRatio >= 0.96 +} + +func makeTailSample(role string, t, avgLuma, blackRatio float64) tailBlackoutSample { + return tailBlackoutSample{ + Role: role, + TimeSec: t, + AvgLuma: avgLuma, + BlackRatio: blackRatio, + MostlyBlack: isMostlyBlackFrame(avgLuma, blackRatio), + } +} + +func uniqueTailIndexes(indexes []int, count int) []int { + if count <= 0 { + return nil + } + + seen := map[int]struct{}{} + out := make([]int, 0, len(indexes)) + + for _, idx := range indexes { + if idx < 0 { + idx = 0 + } + if idx >= count { + idx = count - 1 + } + if _, ok := seen[idx]; ok { + continue + } + seen[idx] = struct{}{} + out = append(out, idx) + } + + return out +} + +func spriteSampleTimeSec(index int, stepSec float64) float64 { + if stepSec <= 0 || index < 0 { + return 0 + } + return float64(index) * stepSec +} + +func uniqueTailTimes(times []float64, dur float64) []float64 { + maxT := math.Max(0.15, dur-0.15) + seen := map[int]struct{}{} + out := make([]float64, 0, len(times)) + + for _, t := range times { + t = clampFloat(t, 0.10, maxT) + key := int(math.Round(t * 1000)) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, t) + } + + return out +} + +func inspectVideoTailBlackout(videoPath string) (*tailBlackoutResult, error) { + assetID := assetIDFromVideoPath(videoPath) + if assetID == "" { + return nil, fmt.Errorf("asset id fehlt") + } + + _, _, _, spritePath, metaPath, err := assetPathsForID(assetID) + if err != nil { + return nil, err + } + + f, err := os.Open(spritePath) + if err != nil { + return nil, fmt.Errorf("sprite open failed: %w", err) + } + defer f.Close() + + img, err := jpeg.Decode(f) + if err != nil { + return nil, fmt.Errorf("sprite decode failed: %w", err) + } + + cols, rows, count, _, _ := fixedPreviewSpriteLayout() + stepSec := 0.0 + + if ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath); ok { + if ps.Cols > 0 { + cols = ps.Cols + } + if ps.Rows > 0 { + rows = ps.Rows + } + if ps.Count > 0 { + count = ps.Count + } + if ps.StepSeconds > 0 { + stepSec = ps.StepSeconds + } + } + + maxCells := cols * rows + if maxCells <= 0 { + return nil, fmt.Errorf("invalid sprite layout") + } + if count <= 0 { + count = maxCells + } + if count > maxCells { + count = maxCells + } + if count < 2 { + return nil, fmt.Errorf("sprite enthält zu wenige frames") + } + + res := &tailBlackoutResult{ + CheckedAt: time.Now().Format(time.RFC3339Nano), + } + + refIndex := int(math.Round(float64(count-1) * 0.55)) + if avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, refIndex); err == nil { + refSample := makeTailSample("reference", spriteSampleTimeSec(refIndex, stepSec), avg, ratio) + res.ReferenceBlack = refSample.MostlyBlack + res.Samples = append(res.Samples, refSample) + } + + tailIndexes := uniqueTailIndexes([]int{ + int(math.Round(float64(count-1) * 0.82)), + int(math.Round(float64(count-1) * 0.88)), + int(math.Round(float64(count-1) * 0.94)), + int(math.Round(float64(count-1) * 0.985)), + }, count) + + validTail := 0 + blackTail := 0 + + for _, idx := range tailIndexes { + avg, ratio, err := analyzeSpriteCellBlackness(img, cols, rows, idx) + if err != nil { + continue + } + + sample := makeTailSample("tail", spriteSampleTimeSec(idx, stepSec), avg, ratio) + res.Samples = append(res.Samples, sample) + + validTail++ + if sample.MostlyBlack { + blackTail++ + } + } + + res.SampleCount = validTail + res.BlackTailFrames = blackTail + + switch { + case validTail >= 4 && blackTail >= 3 && !res.ReferenceBlack: + res.IsCorrupt = true + res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen sind fast komplett schwarz", blackTail, validTail) + + case validTail >= 3 && blackTail == validTail && !res.ReferenceBlack: + res.IsCorrupt = true + res.Reason = fmt.Sprintf("alle %d geprüften Sprite-Endzellen sind fast komplett schwarz", validTail) + + case validTail >= 4 && blackTail == validTail: + res.IsCorrupt = true + res.Reason = "das komplette geprüfte Sprite-Ende ist fast komplett schwarz" + + default: + res.IsCorrupt = false + if validTail == 0 { + res.Reason = "keine validen Sprite-Endzellen prüfbar" + } else { + res.Reason = fmt.Sprintf("%d/%d Sprite-Endzellen schwarz", blackTail, validTail) + } + } + + return res, nil +} + +func persistTailBlackoutResult(videoPath string, res *tailBlackoutResult) error { + if res == nil { + return nil + } + + assetID := assetIDFromVideoPath(videoPath) + if assetID == "" { + return fmt.Errorf("asset id fehlt") + } + + _, _, _, _, metaPath, err := assetPathsForID(assetID) + if err != nil { + return err + } + + raw, err := os.ReadFile(metaPath) + if err != nil { + return err + } + + root := map[string]any{} + if len(raw) > 0 { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + if err := dec.Decode(&root); err != nil { + return err + } + } + + validation, _ := root["validation"].(map[string]any) + if validation == nil { + validation = map[string]any{} + } + + b, err := json.Marshal(res) + if err != nil { + return err + } + + node := map[string]any{} + if err := json.Unmarshal(b, &node); err != nil { + return err + } + + validation["tailBlackout"] = node + root["validation"] = validation + + out, err := json.Marshal(root) + if err != nil { + return err + } + + return atomicWriteFile(metaPath, out) +} + +func runTailBlackoutCheck(ctx context.Context, videoPath string) (*tailBlackoutResult, error) { + _ = ctx + + res, err := inspectVideoTailBlackout(videoPath) + if err != nil { + return nil, err + } + + if err := persistTailBlackoutResult(videoPath, res); err != nil { + return nil, err + } + + return res, nil +} diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 1fb88db..e6685d1 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -3,6 +3,7 @@ package main import ( + "context" "encoding/json" "fmt" "io/fs" @@ -61,7 +62,6 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { s := getSettings() - // doneDir auflösen doneAbs, err := resolvePathRelativeToApp(s.DoneDir) if err != nil || strings.TrimSpace(doneAbs) == "" { setCleanupTaskError("doneDir auflösung fehlgeschlagen") @@ -74,11 +74,8 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { return } - // Threshold: standardmäßig aus Settings mb := int(s.AutoDeleteSmallDownloadsBelowMB) - // optional Body-Override (wenn du es später brauchst) - // (Frontend sendet aktuell nichts; ist trotzdem safe) var req cleanupReq if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&req) @@ -90,44 +87,62 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { mb = 0 } - // ✅ exklusiv warten: Cleanup startet erst, wenn andere exklusive Tasks fertig sind - if err := acquireExclusiveTask(r.Context()); err != nil { - http.Error(w, err.Error(), http.StatusRequestTimeout) + cleanupTaskMu.Lock() + if cleanupTaskState.Queued || cleanupTaskState.Running { + st := cleanupTaskState + cleanupTaskMu.Unlock() + writeJSON(w, http.StatusOK, st) return } - defer releaseExclusiveTask() + cleanupTaskMu.Unlock() - setCleanupTaskRunning("Räume auf…") + setCleanupTaskQueued("Wartet…") + st := snapshotCleanupTaskState() - resp := cleanupResp{} + go func(doneAbs string, mb int) { + ctx := context.Background() - // 1) Kleine Downloads löschen (wenn mb > 0) - if mb > 0 { - threshold := int64(mb) * 1024 * 1024 - cleanupSmallFiles(doneAbs, threshold, &resp) - } + if err := acquireExclusiveTask(ctx); err != nil { + setCleanupTaskError(err.Error()) + time.AfterFunc(7*time.Second, func() { + clearCleanupTaskState() + }) + return + } + defer releaseExclusiveTask() - // ✅ Beim manuellen Aufräumen: Generated-GC synchron laufen lassen, - // damit die Zahlen in der JSON-Response landen. - gcStats := triggerGeneratedGarbageCollectorSync() - resp.GeneratedOrphansChecked = gcStats.Checked - resp.GeneratedOrphansRemoved = gcStats.Removed - resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes - resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) + setCleanupTaskRunning("Räume auf…") - // ✅ Generated-GC zur insgesamt freigegebenen Größe addieren - resp.DeletedBytes += gcStats.RemovedBytes - resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) + resp := cleanupResp{} - setCleanupTaskDone( - cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), - ) + if mb > 0 { + threshold := int64(mb) * 1024 * 1024 + cleanupSmallFiles(doneAbs, threshold, &resp) + } - time.AfterFunc(7*time.Second, func() { - clearCleanupTaskState() - }) + gcStats := triggerGeneratedGarbageCollectorSync() + resp.GeneratedOrphansChecked = gcStats.Checked + resp.GeneratedOrphansRemoved = gcStats.Removed + resp.GeneratedOrphansRemovedBytes = gcStats.RemovedBytes + resp.GeneratedOrphansRemovedBytesHuman = formatBytesSI(gcStats.RemovedBytes) - writeJSON(w, http.StatusOK, resp) + resp.DeletedBytes += gcStats.RemovedBytes + resp.DeletedBytesHuman = formatBytesSI(resp.DeletedBytes) + + if resp.DeletedFiles > 0 || resp.GeneratedOrphansRemoved > 0 { + notifyDoneChanged() + } + + setCleanupTaskDone( + cleanupProgressText(resp.DeletedFiles, resp.ScannedFiles, resp.DeletedBytes), + ) + + time.AfterFunc(7*time.Second, func() { + clearCleanupTaskState() + }) + }(doneAbs, mb) + + writeJSON(w, http.StatusOK, st) } func cleanupProgressText(deleted int, total int, freedBytes int64) string { @@ -317,6 +332,67 @@ func startGeneratedGarbageCollector() { }() } +func collectLiveIDsFromTrash(trashDir string, live map[string]struct{}) { + trashDir = strings.TrimSpace(trashDir) + if trashDir == "" || live == nil { + return + } + + entries, err := os.ReadDir(trashDir) + if err != nil { + return + } + + for _, e := range entries { + if e == nil || e.IsDir() { + continue + } + + name := strings.TrimSpace(e.Name()) + if name == "" { + continue + } + + // last.json ignorieren + if strings.EqualFold(name, "last.json") { + continue + } + + // Format in .trash: + // __ + parts := strings.SplitN(name, "__", 2) + if len(parts) != 2 { + continue + } + + originalFile := strings.TrimSpace(parts[1]) + if originalFile == "" { + continue + } + + ext := strings.ToLower(filepath.Ext(originalFile)) + if ext != ".mp4" && ext != ".ts" { + continue + } + + full := filepath.Join(trashDir, name) + fi, err := os.Stat(full) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + continue + } + + id := stripHotPrefix(strings.TrimSuffix(filepath.Base(originalFile), ext)) + id = strings.TrimSpace(id) + if id == "" { + continue + } + + if sid, err := sanitizeID(id); err == nil && sid != "" { + live[sid] = struct{}{} + } + } +} + // Core-Logik ohne Delay (für manuelle Trigger, z.B. nach Cleanup) // Liefert Stats zurück, damit /api/settings/cleanup die Zahlen anzeigen kann. func runGeneratedGarbageCollector() generatedGCStats { @@ -334,7 +410,7 @@ func runGeneratedGarbageCollector() generatedGCStats { return stats } - // 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash ignorieren + // 1) Live-IDs sammeln: alle mp4/ts unter /done (rekursiv), .trash zunächst ignorieren live := make(map[string]struct{}, 4096) _ = filepath.WalkDir(doneAbs, func(p string, d fs.DirEntry, err error) error { @@ -371,6 +447,11 @@ func runGeneratedGarbageCollector() generatedGCStats { return nil }) + // 1a) Dateien in .trash ebenfalls als "live" behandeln, + // damit ihre Generated-Dateien erst gelöscht werden, + // wenn sie wirklich endgültig aus .trash verschwinden. + collectLiveIDsFromTrash(filepath.Join(doneAbs, ".trash"), live) + // 1b) Auch aktuell laufende / nachbearbeitete Jobs als "live" behandeln. // Sonst räumt der GC generated/meta/ weg, obwohl RecordStream dort noch schreibt. jobsMu.RLock() diff --git a/backend/tasks_exclusive.go b/backend/tasks_exclusive.go index c532563..56a8192 100644 --- a/backend/tasks_exclusive.go +++ b/backend/tasks_exclusive.go @@ -4,12 +4,88 @@ package main import ( "context" + "sync/atomic" ) -var exclusiveTaskSem = make(chan struct{}, 1) +type exclusiveTaskRequest struct { + id uint64 + granted chan struct{} +} + +type exclusiveTaskCancelRequest struct { + id uint64 + ack chan bool +} + +var ( + exclusiveTaskReqCh = make(chan *exclusiveTaskRequest) + exclusiveTaskCancelCh = make(chan exclusiveTaskCancelRequest) + exclusiveTaskReleaseCh = make(chan struct{}, 1) + exclusiveTaskNextID atomic.Uint64 +) func init() { - exclusiveTaskSem <- struct{}{} + go runExclusiveTaskQueue() +} + +func runExclusiveTaskQueue() { + queue := make([]*exclusiveTaskRequest, 0, 8) + + locked := false + var activeID uint64 + + grantNext := func() { + if locked { + return + } + + for len(queue) > 0 { + req := queue[0] + queue = queue[1:] + + if req == nil { + continue + } + + locked = true + activeID = req.id + close(req.granted) + return + } + + activeID = 0 + } + + for { + grantNext() + + select { + case req := <-exclusiveTaskReqCh: + if req != nil { + queue = append(queue, req) + } + + case cancelReq := <-exclusiveTaskCancelCh: + grantedAlready := locked && activeID == cancelReq.id + + if !grantedAlready { + for i, req := range queue { + if req != nil && req.id == cancelReq.id { + queue = append(queue[:i], queue[i+1:]...) + break + } + } + } + + cancelReq.ack <- grantedAlready + + case <-exclusiveTaskReleaseCh: + if locked { + locked = false + activeID = 0 + } + } + } } func acquireExclusiveTask(ctx context.Context) error { @@ -17,17 +93,41 @@ func acquireExclusiveTask(ctx context.Context) error { ctx = context.Background() } + req := &exclusiveTaskRequest{ + id: exclusiveTaskNextID.Add(1), + granted: make(chan struct{}), + } + select { + case exclusiveTaskReqCh <- req: case <-ctx.Done(): return ctx.Err() - case <-exclusiveTaskSem: + } + + select { + case <-req.granted: return nil + + case <-ctx.Done(): + ack := make(chan bool, 1) + + exclusiveTaskCancelCh <- exclusiveTaskCancelRequest{ + id: req.id, + ack: ack, + } + + grantedAlready := <-ack + if grantedAlready { + return nil + } + + return ctx.Err() } } func releaseExclusiveTask() { select { - case exclusiveTaskSem <- struct{}{}: + case exclusiveTaskReleaseCh <- struct{}{}: default: } } diff --git a/backend/tasks_regenerate_assets.go b/backend/tasks_regenerate_assets.go index a6e9772..0f2d7c2 100644 --- a/backend/tasks_regenerate_assets.go +++ b/backend/tasks_regenerate_assets.go @@ -9,13 +9,12 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "sync" "time" ) -const regenerateAssetsMaxParallel = 1 - type regenerateAssetsRequest struct { File string `json:"file"` } @@ -47,10 +46,8 @@ type regenerateAssetsJob struct { } var ( - regenerateAssetsMu sync.Mutex - regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file - regenerateAssetsQueue []string // file names - regenerateAssetsWorkersStarted bool + regenerateAssetsMu sync.Mutex + regenerateAssetsJobs = map[string]*regenerateAssetsJob{} // key = file ) func publishFinishedPostworkPhase( @@ -152,56 +149,11 @@ func firstMissingDeferredRegeneratePhaseError(videoPath string, assetID string) return errors.New("preview-sprite.jpg fehlt oder ist leer") } if !truth.AnalyzeReady { - return errors.New("KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") + return errors.New("Analyse fehlt oder wurde nicht in meta.json gespeichert") } return nil } -func ensureRegenerateAssetsWorkersStarted() { - regenerateAssetsMu.Lock() - defer regenerateAssetsMu.Unlock() - - if regenerateAssetsWorkersStarted { - return - } - - regenerateAssetsWorkersStarted = true - - for i := 0; i < regenerateAssetsMaxParallel; i++ { - go regenerateAssetsWorker() - } -} - -func enqueueRegenerateAssetsJob(job *regenerateAssetsJob) { - regenerateAssetsMu.Lock() - defer regenerateAssetsMu.Unlock() - - regenerateAssetsJobs[job.File] = job - regenerateAssetsQueue = append(regenerateAssetsQueue, job.File) -} - -func nextQueuedRegenerateAssetsJob() *regenerateAssetsJob { - regenerateAssetsMu.Lock() - defer regenerateAssetsMu.Unlock() - - for len(regenerateAssetsQueue) > 0 { - file := regenerateAssetsQueue[0] - regenerateAssetsQueue = regenerateAssetsQueue[1:] - - job := regenerateAssetsJobs[file] - if job == nil { - continue - } - if job.State != "queued" { - continue - } - - return job - } - - return nil -} - func markRegenerateAssetsJobRunning(file string) *regenerateAssetsJob { regenerateAssetsMu.Lock() defer regenerateAssetsMu.Unlock() @@ -270,14 +222,69 @@ func removeRegenerateAssetsJobLater(file string, after time.Duration) { }) } +func regeneratePhaseOrder(queue string, phase string) int { + q := strings.ToLower(strings.TrimSpace(queue)) + p := strings.ToLower(strings.TrimSpace(phase)) + + switch q + ":" + p { + case "postwork:meta": + return 0 + case "postwork:thumb": + return 1 + case "postwork:teaser": + return 2 + case "postwork:sprites": + return 3 + case "enrich:analyze": + return 4 + default: + return 999 + } +} + func getRegenerateAssetsJobsSnapshot() []map[string]any { regenerateAssetsMu.Lock() defer regenerateAssetsMu.Unlock() - out := make([]map[string]any, 0, len(regenerateAssetsJobs)) + jobs := make([]*regenerateAssetsJob, 0, len(regenerateAssetsJobs)) for _, job := range regenerateAssetsJobs { - phases := make([]map[string]any, 0, len(job.Phases)) + if job != nil { + jobs = append(jobs, job) + } + } + + sort.SliceStable(jobs, func(i, j int) bool { + ai := jobs[i].EnqueuedAt.UnixMilli() + aj := jobs[j].EnqueuedAt.UnixMilli() + + if ai != aj { + return ai < aj + } + + return jobs[i].File < jobs[j].File + }) + + out := make([]map[string]any, 0, len(jobs)) + + for _, job := range jobs { + phaseList := make([]regenerateAssetsPhase, 0, len(job.Phases)) for _, p := range job.Phases { + phaseList = append(phaseList, p) + } + + sort.SliceStable(phaseList, func(i, j int) bool { + oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase) + oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase) + + if oi != oj { + return oi < oj + } + + return phaseList[i].TS < phaseList[j].TS + }) + + phases := make([]map[string]any, 0, len(phaseList)) + for _, p := range phaseList { phases = append(phases, map[string]any{ "queue": p.Queue, "phase": p.Phase, @@ -288,12 +295,17 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any { } out = append(out, map[string]any{ - "file": job.File, - "assetId": job.AssetID, - "state": job.State, - "error": job.Error, - "phases": phases, - "enqueuedAt": job.EnqueuedAt.UnixMilli(), + "file": job.File, + "assetId": job.AssetID, + "state": job.State, + "error": job.Error, + "phases": phases, + "enqueuedAt": func() int64 { + if job.EnqueuedAt.IsZero() { + return 0 + } + return job.EnqueuedAt.UnixMilli() + }(), "startedAt": func() int64 { if job.StartedAt.IsZero() { return 0 @@ -312,35 +324,6 @@ func getRegenerateAssetsJobsSnapshot() []map[string]any { return out } -func regenerateAssetsWorker() { - for { - job := nextQueuedRegenerateAssetsJob() - if job == nil { - time.Sleep(250 * time.Millisecond) - continue - } - - // ✅ wartet global, bis kein anderer exklusiver Task läuft - if err := acquireExclusiveTask(job.Ctx); err != nil { - finishRegenerateAssetsJob(job.File, "cancelled", "Abgebrochen") - setRegenerateAssetsTaskError(job.File, "Abgebrochen") - removeRegenerateAssetsJobLater(job.File, 2*time.Second) - continue - } - - job = markRegenerateAssetsJobRunning(job.File) - if job == nil { - releaseExclusiveTask() - continue - } - - func() { - defer releaseExclusiveTask() - runRegenerateAssetsJob(job) - }() - } -} - func runRegenerateAssetsJob(job *regenerateAssetsJob) { file := job.File videoPath := job.VideoPath @@ -385,7 +368,7 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "") // Alte Assets löschen if id != "" { @@ -471,8 +454,8 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { publishFinishedPostworkPhase(file, id, "postwork", "sprites", "done", "Sprites", "") - // 5) KI-Analyse - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "KI-Analyse", "") + // 5) Analyse + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "running", "Analyse", "") if _, err := ensureAnalyzeForVideoCtx(ctx, videoPath, "", "nsfw"); err != nil { if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled { @@ -484,11 +467,11 @@ func runRegenerateAssetsJob(job *regenerateAssetsJob) { } if !regeneratePhaseTruthForVideo(videoPath, id).AnalyzeReady { - failJob("enrich", "analyze", "KI-Analyse fehlt oder wurde nicht in meta.json gespeichert") + failJob("enrich", "analyze", "Analyse fehlt oder wurde nicht in meta.json gespeichert") return } - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "KI-Analyse", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "done", "Analyse", "") finishRegenerateAssetsJob(file, "done", "") setRegenerateAssetsTaskDone(file, "Fertig") @@ -577,15 +560,30 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { return } - ensureRegenerateAssetsWorkersStarted() - regenerateAssetsMu.Lock() if existing := regenerateAssetsJobs[file]; existing != nil { + state := existing.State + errText := existing.Error + + phaseList := make([]regenerateAssetsPhase, 0, len(existing.Phases)) + for _, p := range existing.Phases { + phaseList = append(phaseList, p) + } regenerateAssetsMu.Unlock() - w.Header().Set("Content-Type", "application/json") - phases := make([]map[string]any, 0, len(existing.Phases)) - for _, p := range existing.Phases { + sort.SliceStable(phaseList, func(i, j int) bool { + oi := regeneratePhaseOrder(phaseList[i].Queue, phaseList[i].Phase) + oj := regeneratePhaseOrder(phaseList[j].Queue, phaseList[j].Phase) + + if oi != oj { + return oi < oj + } + + return phaseList[i].TS < phaseList[j].TS + }) + + phases := make([]map[string]any, 0, len(phaseList)) + for _, p := range phaseList { phases = append(phases, map[string]any{ "queue": p.Queue, "phase": p.Phase, @@ -595,11 +593,12 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { }) } + w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "ok": true, "file": file, - "state": existing.State, - "error": existing.Error, + "state": state, + "error": errText, "phases": phases, }) return @@ -624,13 +623,39 @@ func handleRegenerateAssets(w http.ResponseWriter, r *http.Request) { Phases: make(map[string]regenerateAssetsPhase), } - enqueueRegenerateAssetsJob(job) + regenerateAssetsMu.Lock() + regenerateAssetsJobs[file] = job + regenerateAssetsMu.Unlock() publishFinishedPostworkPhase(file, id, "postwork", "meta", "queued", "Meta", "") publishFinishedPostworkPhase(file, id, "postwork", "thumb", "queued", "Vorschaubild", "") publishFinishedPostworkPhase(file, id, "postwork", "teaser", "queued", "Teaser", "") publishFinishedPostworkPhase(file, id, "postwork", "sprites", "queued", "Sprites", "") - publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "KI-Analyse", "") + publishFinishedPostworkPhase(file, id, "enrich", "analyze", "queued", "Analyse", "") + + go func(file string, runCtx context.Context) { + if err := acquireExclusiveTask(runCtx); err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + finishRegenerateAssetsJob(file, "cancelled", "Abgebrochen") + setRegenerateAssetsTaskError(file, "Abgebrochen") + removeRegenerateAssetsJobLater(file, 2*time.Second) + return + } + + finishRegenerateAssetsJob(file, "error", err.Error()) + setRegenerateAssetsTaskError(file, err.Error()) + removeRegenerateAssetsJobLater(file, 5*time.Second) + return + } + defer releaseExclusiveTask() + + runningJob := markRegenerateAssetsJobRunning(file) + if runningJob == nil { + return + } + + runRegenerateAssetsJob(runningJob) + }(file, runCtx) w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/backend/tasks_release_file.go b/backend/tasks_release_file.go index 0b22047..0e130e6 100644 --- a/backend/tasks_release_file.go +++ b/backend/tasks_release_file.go @@ -7,7 +7,6 @@ import ( "net/http" "path/filepath" "strings" - "time" ) type releaseFileTasksRequest struct { @@ -44,20 +43,24 @@ func sameBaseFile(a, b string) bool { } func cancelAssetsTaskForFile(file string) bool { + file = strings.TrimSpace(file) + if file == "" { + return false + } + assetsTaskMu.Lock() - cancel := assetsTaskCancel - running := assetsTaskState.Running - currentFile := assetsTaskState.CurrentFile + active := assetsTaskState.Running || assetsTaskState.Queued assetsTaskMu.Unlock() - if !running || cancel == nil { - return false - } - if !sameBaseFile(currentFile, file) { + if !active { return false } - cancel() + // Wichtig: + // NICHT den globalen Assets-Task canceln. + // Stattdessen nur diese Datei im Assets-Task zum Überspringen markieren. + _ = requestAssetsTaskSkip(file) + return true } @@ -183,11 +186,6 @@ func releaseFileTasks(file string) releaseFileTasksResponse { resp.PostworkCancelled || resp.EnrichCancelled - // ffmpeg / Analyse / Sprite-Generierung einen Moment zum Beenden geben - if resp.ReleasedAny { - time.Sleep(900 * time.Millisecond) - } - return resp } diff --git a/backend/tasks_status.go b/backend/tasks_status.go index 80d0336..2b03cb1 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -11,13 +11,29 @@ import ( ) type CleanupTaskState struct { - Running bool `json:"running"` - Done int `json:"done"` - Total int `json:"total"` - Text string `json:"text,omitempty"` - CurrentFile string `json:"currentFile,omitempty"` - Error string `json:"error,omitempty"` - FinishedAt *string `json:"finishedAt,omitempty"` + Queued bool `json:"queued"` + Running bool `json:"running"` + QueuedAt time.Time `json:"queuedAt"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` + CurrentFile string `json:"currentFile,omitempty"` + Done int `json:"done"` + Total int `json:"total"` +} + +type CheckVideosTaskState struct { + Queued bool `json:"queued"` + Running bool `json:"running"` + QueuedAt time.Time `json:"queuedAt"` + StartedAt time.Time `json:"startedAt"` + FinishedAt *time.Time `json:"finishedAt,omitempty"` + Text string `json:"text,omitempty"` + Error string `json:"error,omitempty"` + CurrentFile string `json:"currentFile,omitempty"` + Done int `json:"done"` + Total int `json:"total"` } type RegenerateAssetsTaskState struct { @@ -31,6 +47,7 @@ type TasksStatusResponse struct { GenerateAssets AssetsTaskState `json:"generateAssets"` RegenerateAssets RegenerateAssetsTaskState `json:"regenerateAssets"` RegenerateAssetsJobs []map[string]any `json:"regenerateAssetsJobs"` + CheckVideos CheckVideosTaskState `json:"checkVideos"` Cleanup CleanupTaskState `json:"cleanup"` AnyRunning bool `json:"anyRunning"` } @@ -43,9 +60,9 @@ var ( regenerateAssetsTaskState RegenerateAssetsTaskState ) -func cleanupNowISO() *string { - s := time.Now().Format(time.RFC3339) - return &s +func cleanupNowTime() *time.Time { + t := time.Now() + return &t } func anyToString(v any) string { @@ -120,16 +137,45 @@ func snapshotCleanupTaskState() CleanupTaskState { return st } -func setCleanupTaskRunning(text string) { +func setCleanupTaskQueued(text string) { cleanupTaskMu.Lock() cleanupTaskState = CleanupTaskState{ - Running: true, + Queued: true, + Running: false, + QueuedAt: time.Now(), + StartedAt: time.Time{}, + FinishedAt: nil, + Text: strings.TrimSpace(text), + Error: "", + CurrentFile: "", Done: 0, Total: 0, - Text: strings.TrimSpace(text), - CurrentFile: "", - Error: "", + } + cleanupTaskMu.Unlock() + + publishTaskState() +} + +func setCleanupTaskRunning(text string) { + cleanupTaskMu.Lock() + + now := time.Now() + queuedAt := cleanupTaskState.QueuedAt + if queuedAt.IsZero() { + queuedAt = now + } + + cleanupTaskState = CleanupTaskState{ + Queued: false, + Running: true, + QueuedAt: queuedAt, + StartedAt: now, FinishedAt: nil, + Text: strings.TrimSpace(text), + Error: "", + CurrentFile: "", + Done: 0, + Total: 0, } cleanupTaskMu.Unlock() @@ -138,6 +184,16 @@ func setCleanupTaskRunning(text string) { func setCleanupTaskProgress(done, total int, currentFile, text string) { cleanupTaskMu.Lock() + + now := time.Now() + if cleanupTaskState.QueuedAt.IsZero() { + cleanupTaskState.QueuedAt = now + } + if cleanupTaskState.StartedAt.IsZero() { + cleanupTaskState.StartedAt = now + } + + cleanupTaskState.Queued = false cleanupTaskState.Running = true cleanupTaskState.Done = done cleanupTaskState.Total = total @@ -145,6 +201,7 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) { cleanupTaskState.Text = strings.TrimSpace(text) cleanupTaskState.Error = "" cleanupTaskState.FinishedAt = nil + cleanupTaskMu.Unlock() publishTaskState() @@ -152,11 +209,13 @@ func setCleanupTaskProgress(done, total int, currentFile, text string) { func setCleanupTaskDone(text string) { cleanupTaskMu.Lock() + + cleanupTaskState.Queued = false cleanupTaskState.Running = false cleanupTaskState.Text = strings.TrimSpace(text) cleanupTaskState.CurrentFile = "" cleanupTaskState.Error = "" - cleanupTaskState.FinishedAt = cleanupNowISO() + cleanupTaskState.FinishedAt = cleanupNowTime() if cleanupTaskState.Total > 0 { cleanupTaskState.Done = cleanupTaskState.Total @@ -169,11 +228,14 @@ func setCleanupTaskDone(text string) { func setCleanupTaskError(errText string) { cleanupTaskMu.Lock() + + cleanupTaskState.Queued = false cleanupTaskState.Running = false cleanupTaskState.Text = "" cleanupTaskState.Error = strings.TrimSpace(errText) cleanupTaskState.CurrentFile = "" - cleanupTaskState.FinishedAt = cleanupNowISO() + cleanupTaskState.FinishedAt = cleanupNowTime() + cleanupTaskMu.Unlock() publishTaskState() @@ -188,6 +250,7 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { assets := getGenerateAssetsTaskStatus() regenerate := snapshotRegenerateAssetsTaskState() regenerateJobs := getRegenerateAssetsJobsSnapshot() + checkVideos := snapshotCheckVideosTaskState() cleanup := snapshotCleanupTaskState() anyRegenerateRunning := regenerate.Running @@ -205,8 +268,9 @@ func tasksStatusHandler(w http.ResponseWriter, r *http.Request) { GenerateAssets: assets, RegenerateAssets: regenerate, RegenerateAssetsJobs: regenerateJobs, + CheckVideos: checkVideos, Cleanup: cleanup, - AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || cleanup.Running, + AnyRunning: assets.Queued || assets.Running || anyRegenerateRunning || checkVideos.Queued || checkVideos.Running || cleanup.Queued || cleanup.Running, } w.Header().Set("Content-Type", "application/json") diff --git a/backend/teaser.go b/backend/teaser.go new file mode 100644 index 0000000..95026cd --- /dev/null +++ b/backend/teaser.go @@ -0,0 +1,567 @@ +// backend\teaser.go + +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "math" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// ============================================================ +// Teaser generation (preview.mp4) +// Route: +// - /api/generated/teaser?id= +// ============================================================ + +const minSegmentDuration = 0.75 +const defaultTeaserSegments = 18 + +type TeaserPreviewOptions struct { + Segments int + SegmentDuration float64 + Width int + Preset string + CRF int + Audio bool + AudioBitrate string + UseVsync2 bool +} + +func (o TeaserPreviewOptions) stepSizeAndOffset(dur float64) (float64, float64) { + if dur <= 0 { + return 0, 0 + } + + n := o.Segments + if n < 1 { + n = 1 + } + + segDur := o.SegmentDuration + if segDur <= 0 { + segDur = 1 + } + if segDur < minSegmentDuration { + segDur = minSegmentDuration + } + + maxStart := dur - 0.05 - segDur + if maxStart < 0 { + maxStart = 0 + } + if n == 1 { + return 0, maxStart * 0.5 + } + + margin := 0.05 * maxStart + if margin < 0 { + margin = 0 + } + span := maxStart - 2*margin + if span < 0 { + span = maxStart + margin = 0 + } + + step := 0.0 + if n > 1 { + step = span / float64(n-1) + } + return step, margin +} + +func generateTeaserClipsMP4(ctx context.Context, srcPath, outPath string, clipLenSec float64, maxClips int) error { + return generateTeaserClipsMP4WithProgress(ctx, srcPath, outPath, clipLenSec, maxClips, nil) +} + +func generateTeaserClipsMP4WithProgress( + ctx context.Context, + srcPath, outPath string, + clipLenSec float64, + maxClips int, + onRatio func(r float64), +) error { + opts := TeaserPreviewOptions{ + Segments: maxClips, + SegmentDuration: clipLenSec, + Width: 640, + Preset: "veryfast", + CRF: 21, + Audio: true, + AudioBitrate: "128k", + UseVsync2: false, + } + return generateTeaserPreviewMP4WithProgress(ctx, srcPath, outPath, opts, onRatio) +} + +func generateTeaserChunkMP4( + ctx context.Context, + src, out string, + start, dur float64, + opts TeaserPreviewOptions, +) error { + opts.Audio = true + + tmp := strings.TrimSuffix(out, ".mp4") + ".part.mp4" + segDur := dur + if segDur < minSegmentDuration { + segDur = minSegmentDuration + } + + args := []string{"-y", "-hide_banner", "-loglevel", "error"} + args = append(args, ffmpegInputTol...) + args = append(args, + "-ss", fmt.Sprintf("%.3f", start), + "-t", fmt.Sprintf("%.3f", segDur), + "-i", src, + "-map", "0:v:0", + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", opts.Preset, + "-crf", strconv.Itoa(opts.CRF), + "-threads", "2", + ) + + if opts.UseVsync2 { + args = append(args, "-vsync", "2") + } + + args = append(args, + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", opts.AudioBitrate, + "-ac", "2", + "-shortest", + ) + + args = append(args, "-movflags", "+faststart", tmp) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg teaser chunk failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + } + _ = os.Remove(out) + return os.Rename(tmp, out) +} + +func computeTeaserStarts(dur float64, opts TeaserPreviewOptions) (starts []float64, segDur float64, usedSegments int) { + if opts.SegmentDuration <= 0 { + opts.SegmentDuration = 1 + } + if opts.Segments <= 0 { + opts.Segments = defaultTeaserSegments + } + segDur = opts.SegmentDuration + if segDur < minSegmentDuration { + segDur = minSegmentDuration + } + + if dur > 0 && segDur > dur { + segDur = dur + } + + usedSegments = opts.Segments + if !(dur > 0) { + return []float64{0}, segDur, 1 + } + + stepSize, offset := opts.stepSizeAndOffset(dur) + starts = make([]float64, 0, opts.Segments) + for i := 0; i < opts.Segments; i++ { + t := offset + float64(i)*stepSize + maxStart := math.Max(0, dur-0.05-segDur) + if t < 0 { + t = 0 + } + if t > maxStart { + t = maxStart + } + if t < 0.05 { + t = 0.05 + } + starts = append(starts, t) + } + return starts, segDur, usedSegments +} + +func generateTeaserPreviewMP4WithProgress( + ctx context.Context, + srcPath, outPath string, + opts TeaserPreviewOptions, + onRatio func(r float64), +) error { + opts.Audio = true + if opts.SegmentDuration <= 0 { + opts.SegmentDuration = 1 + } + if opts.Segments <= 0 { + opts.Segments = defaultTeaserSegments + } + if opts.Width <= 0 { + opts.Width = 640 + } + if opts.Preset == "" { + opts.Preset = "veryfast" + } + if opts.CRF <= 0 { + opts.CRF = 21 + } + if opts.AudioBitrate == "" { + opts.AudioBitrate = "128k" + } + segDur := opts.SegmentDuration + if segDur < minSegmentDuration { + segDur = minSegmentDuration + } + + dur, _ := durationSecondsCached(ctx, srcPath) + if dur > 0 && dur < segDur*float64(opts.Segments) { + opts.Segments = 1 + segDur = dur + } + + if !(dur > 0) { + if onRatio != nil { + onRatio(0) + } + err := generateTeaserChunkMP4(ctx, srcPath, outPath, 0, math.Min(8, segDur), opts) + if onRatio != nil { + onRatio(1) + } + return err + } + + starts, segDurComputed, _ := computeTeaserStarts(dur, opts) + segDur = segDurComputed + + expectedOutSec := float64(len(starts)) * segDur + tmp := strings.TrimSuffix(outPath, ".mp4") + ".part.mp4" + + args := []string{"-y", "-nostats", "-progress", "pipe:1", "-hide_banner", "-loglevel", "error"} + for _, t := range starts { + args = append(args, ffmpegInputTol...) + args = append(args, "-ss", fmt.Sprintf("%.3f", t), "-t", fmt.Sprintf("%.3f", segDur), "-i", srcPath) + } + + var fc strings.Builder + for i := range starts { + fmt.Fprintf(&fc, "[%d:v]scale=%d:-2,setsar=1,setpts=PTS-STARTPTS[v%d];", i, opts.Width, i) + fmt.Fprintf(&fc, "[%d:a]aresample=48000,aformat=channel_layouts=stereo,asetpts=PTS-STARTPTS[a%d];", i, i) + } + for i := range starts { + fmt.Fprintf(&fc, "[v%d][a%d]", i, i) + } + fmt.Fprintf(&fc, "concat=n=%d:v=1:a=1[v][a]", len(starts)) + + args = append(args, "-filter_complex", fc.String()) + args = append(args, "-map", "[v]", "-map", "[a]") + args = append(args, + "-c:v", "libx264", + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", opts.Preset, + "-crf", strconv.Itoa(opts.CRF), + "-threads", "2", + ) + if opts.UseVsync2 { + args = append(args, "-vsync", "2") + } + args = append(args, + "-c:a", "aac", + "-b:a", opts.AudioBitrate, + "-ac", "2", + "-shortest", + ) + args = append(args, "-movflags", "+faststart", tmp) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Start(); err != nil { + return err + } + + sc := bufio.NewScanner(stdout) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + + var outSec float64 + var lastSent float64 + var lastAt time.Time + + send := func(outSec float64, force bool) { + if onRatio == nil { + return + } + if expectedOutSec > 0 && outSec > 0 { + r := outSec / expectedOutSec + if r < 0 { + r = 0 + } + if r > 1 { + r = 1 + } + if r-lastSent < 0.01 && !force { + return + } + if !lastAt.IsZero() && time.Since(lastAt) < 150*time.Millisecond && !force { + return + } + lastSent = r + lastAt = time.Now() + onRatio(r) + return + } + if force { + onRatio(1) + } + } + + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + k, v := parts[0], parts[1] + switch k { + case "out_time_ms": + if n, perr := strconv.ParseInt(strings.TrimSpace(v), 10, 64); perr == nil && n > 0 { + outSec = float64(n) / 1_000_000.0 + send(outSec, false) + } + case "out_time": + if s := parseFFmpegOutTime(v); s > 0 { + outSec = s + send(outSec, false) + } + case "progress": + if strings.TrimSpace(v) == "end" { + send(outSec, true) + } + } + } + + if err := cmd.Wait(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg teaser preview failed: %v (%s)", err, strings.TrimSpace(stderr.String())) + } + + _ = os.Remove(outPath) + return os.Rename(tmp, outPath) +} + +func serveTeaserFile(w http.ResponseWriter, r *http.Request, path string) { + f, err := openForReadShareDelete(path) + if err != nil { + http.Error(w, "datei öffnen fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + defer f.Close() + + fi, err := f.Stat() + if err != nil || fi.IsDir() || fi.Size() == 0 { + http.Error(w, "datei nicht gefunden", http.StatusNotFound) + return + } + + w.Header().Set("Cache-Control", "public, max-age=31536000") + w.Header().Set("Content-Type", "video/mp4") + 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 + } + if startSec < 0 { + startSec = 0 + } + + tmp := outPath + ".tmp.mp4" + + args := []string{ + "-y", + "-hide_banner", + "-loglevel", "error", + } + args = append(args, ffmpegInputTol...) + args = append(args, + "-ss", fmt.Sprintf("%.3f", startSec), + "-i", srcPath, + "-t", fmt.Sprintf("%.3f", durSec), + + "-vf", "scale=720:-2", + "-map", "0:v:0", + + "-map", "0:a:0", + "-c:a", "aac", + "-b:a", "128k", + "-ac", "2", + + "-c:v", "libx264", + "-preset", "veryfast", + "-threads", "2", + "-crf", "28", + "-pix_fmt", "yuv420p", + + "-shortest", + + "-movflags", "+faststart", + "-f", "mp4", + "-threads", "2", + tmp, + ) + + cmd := exec.CommandContext(ctx, ffmpegPath, args...) + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, // CREATE_NO_WINDOW + } + + if out, err := cmd.CombinedOutput(); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("ffmpeg teaser failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + + _ = os.Remove(outPath) + return os.Rename(tmp, outPath) +} + +func generatedTeaser(w http.ResponseWriter, r *http.Request) { + 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 + } + + assetID := stripHotPrefix(id) + if assetID == "" { + assetID = id + } + + assetDir, err := ensureGeneratedDir(assetID) + if err != nil { + http.Error(w, "generated-dir nicht verfügbar: "+err.Error(), http.StatusInternalServerError) + return + } + + previewPath := filepath.Join(assetDir, "preview.mp4") + + qNoGen := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("noGenerate"))) + noGen := qNoGen == "1" || qNoGen == "true" || qNoGen == "yes" + + if fi, err := os.Stat(previewPath); err == nil && !fi.IsDir() && fi.Size() > 0 { + serveTeaserFile(w, r, previewPath) + return + } + + 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 + } + } + } + } + + if noGen { + http.Error(w, "preview nicht verfügbar", http.StatusNotFound) + return + } + + if err := genSem.Acquire(r.Context()); err != nil { + http.Error(w, "abgebrochen: "+err.Error(), http.StatusRequestTimeout) + return + } + defer genSem.Release() + + genCtx, cancel := context.WithTimeout(r.Context(), 3*time.Minute) + defer cancel() + + if err := generateTeaserClipsMP4(genCtx, outPath, previewPath, minSegmentDuration, defaultTeaserSegments); err != nil { + 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) +} diff --git a/backend/tray.go b/backend/tray.go new file mode 100644 index 0000000..c6fb60b --- /dev/null +++ b/backend/tray.go @@ -0,0 +1,226 @@ +// backend\tray.go + +package main + +import ( + _ "embed" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/getlantern/systray" +) + +//go:embed assets/img/tray.ico +var trayIcon []byte + +func runTray(onQuit func(), statsFn func() TrayStats) { + systray.Run(func() { + if len(trayIcon) > 0 { + systray.SetIcon(trayIcon) + } + + systray.SetTitle("Recorder") + systray.SetTooltip("Recorder Backend läuft") + + mStatus := systray.AddMenuItem("Status wird geladen…", "Aktueller Recorder-Status") + mStatus.Disable() + + mAutostart := systray.AddMenuItem("Autostart: …", "Autostart-Status") + mAutostart.Disable() + + mDownloads := systray.AddMenuItem("Downloads: …", "Download-Status") + mDownloads.Disable() + + mPostwork := systray.AddMenuItem("Postwork: …", "Postwork-Status") + mPostwork.Disable() + + mEnrich := systray.AddMenuItem("Enrich: …", "Enrich-Status") + mEnrich.Disable() + + mDisk := systray.AddMenuItem("Freier Speicher: …", "Speicherplatz") + mDisk.Disable() + + mUptime := systray.AddMenuItem("Uptime: …", "Laufzeit") + mUptime.Disable() + + systray.AddSeparator() + + mOpenFrontend := systray.AddMenuItem("Frontend öffnen", "Öffnet das Web-Frontend") + mOpenRecordDir := systray.AddMenuItem("Record-Ordner öffnen", "Öffnet den Aufnahmeordner") + mOpenDoneDir := systray.AddMenuItem("Done-Ordner öffnen", "Öffnet den Done-Ordner") + + systray.AddSeparator() + + mToggleAutostart := systray.AddMenuItem("Autostart pausieren", "Autostart pausieren oder fortsetzen") + mStopAll := systray.AddMenuItem( + "Alle Downloads stoppen + Autostart pausieren", + "Stoppt alle aktiven Downloads und pausiert den Autostart", + ) + + systray.AddSeparator() + + mQuit := systray.AddMenuItem("Beenden", "Backend sauber beenden") + + updateStatus := func() { + if statsFn == nil { + return + } + + stats := statsFn() + + mStatus.SetTitle(stats.MenuTitle()) + mAutostart.SetTitle(stats.AutostartLine()) + mDownloads.SetTitle(stats.DownloadsLine()) + mPostwork.SetTitle(stats.PostworkLine()) + mEnrich.SetTitle(stats.EnrichLine()) + mDisk.SetTitle(stats.DiskLine()) + mUptime.SetTitle(stats.UptimeLine()) + + mToggleAutostart.SetTitle(stats.ToggleAutostartTitle()) + + if stats.AutostartPausedByDisk { + mToggleAutostart.Disable() + } else { + mToggleAutostart.Enable() + } + + if stats.ActiveDownloads > 0 || !stats.AutostartPaused { + mStopAll.Enable() + } else { + mStopAll.Disable() + } + + systray.SetTooltip(stats.Tooltip()) + } + + updateStatus() + + go func() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + updateStatus() + + case <-mOpenFrontend.ClickedCh: + _ = openBrowser("http://localhost:9999") + + case <-mOpenRecordDir.ClickedCh: + _ = openFolder(resolveConfiguredDir(getSettings().RecordDir)) + + case <-mOpenDoneDir.ClickedCh: + _ = openFolder(resolveConfiguredDir(getSettings().DoneDir)) + + case <-mToggleAutostart.ClickedCh: + state := getAutostartStatePayload() + + if state.PausedByDisk { + updateStatus() + continue + } + + if state.Paused { + setAutostartPaused(false) + } else { + setAutostartPaused(true) + } + + updateStatus() + + case <-mStopAll.ClickedCh: + _ = stopAllDownloadsAndPauseAutostart() + updateStatus() + + case <-mQuit.ClickedCh: + onQuit() + systray.Quit() + return + } + } + }() + }, func() { + // optional cleanup + }) +} + +func stopAllDownloadsAndPauseAutostart() int { + // Immer zuerst Autostart pausieren, damit nichts direkt wieder neu startet. + setAutostartPaused(true) + + // Danach aktive Downloads stoppen. + return stopAllActiveDownloads() +} + +func stopAllActiveDownloads() int { + jobsMu.RLock() + toStop := make([]*RecordJob, 0, len(jobs)) + + for _, job := range jobs { + if job == nil { + continue + } + if isPostworkJob(job) { + continue + } + if isTerminalJobStatus(job.Status) { + continue + } + if job.EndedAt != nil { + continue + } + + toStop = append(toStop, job) + } + jobsMu.RUnlock() + + if len(toStop) > 0 { + stopJobsInternal(toStop) + } + + return len(toStop) +} + +func resolveConfiguredDir(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + + if abs, err := resolvePathRelativeToApp(raw); err == nil && strings.TrimSpace(abs) != "" { + return abs + } + + return raw +} + +func openFolder(dir string) error { + dir = filepath.Clean(strings.TrimSpace(dir)) + if dir == "" { + return nil + } + + switch runtime.GOOS { + case "windows": + return exec.Command("explorer", dir).Start() + case "darwin": + return exec.Command("open", dir).Start() + default: + return exec.Command("xdg-open", dir).Start() + } +} + +func openBrowser(url string) error { + switch runtime.GOOS { + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + default: + return exec.Command("xdg-open", url).Start() + } +} diff --git a/backend/tray_stats.go b/backend/tray_stats.go new file mode 100644 index 0000000..680b33b --- /dev/null +++ b/backend/tray_stats.go @@ -0,0 +1,232 @@ +// backend\tray_stats.go + +package main + +import ( + "fmt" + "strconv" + "strings" + "time" + + godisk "github.com/shirou/gopsutil/v3/disk" +) + +type TrayStats struct { + ActiveDownloads int + DownloadLimitEnabled bool + DownloadLimitMax int + DownloadLimitReached bool + + PostworkWaiting int + PostworkRunning int + + EnrichWaiting int + EnrichRunning int + + AutostartPaused bool + AutostartPausedByUser bool + AutostartPausedByDisk bool + + DiskFreeBytes uint64 + HasDiskInfo bool + DiskEmergency bool + + Uptime time.Duration +} + +func buildTrayStats() TrayStats { + active := activeRecordingJobsCount() + + limitEnabled, limitMax, _, limitReached := concurrentDownloadsLimitState() + if limitMax < 1 { + limitMax = 1 + } + + pwWaiting, pwRunning, _ := postWorkQ.Stats() + enWaiting, enRunning, _ := enrichQ.Stats() + + auto := getAutostartStatePayload() + + diskFree, hasDisk := getTrayDiskFreeBytes() + + return TrayStats{ + ActiveDownloads: active, + DownloadLimitEnabled: limitEnabled, + DownloadLimitMax: limitMax, + DownloadLimitReached: limitReached, + + PostworkWaiting: pwWaiting, + PostworkRunning: pwRunning, + + EnrichWaiting: enWaiting, + EnrichRunning: enRunning, + + AutostartPaused: auto.Paused, + AutostartPausedByUser: auto.PausedByUser, + AutostartPausedByDisk: auto.PausedByDisk, + + DiskFreeBytes: diskFree, + HasDiskInfo: hasDisk, + DiskEmergency: diskEmergencyActive(), + + Uptime: time.Since(serverStartedAt).Round(time.Second), + } +} + +func getTrayDiskFreeBytes() (uint64, bool) { + s := getSettings() + + recordDir, err := resolvePathRelativeToApp(s.RecordDir) + if err != nil || strings.TrimSpace(recordDir) == "" { + recordDir = strings.TrimSpace(s.RecordDir) + } + if strings.TrimSpace(recordDir) == "" { + return 0, false + } + + u, err := godisk.Usage(recordDir) + if err != nil || u == nil { + return 0, false + } + + return u.Free, true +} + +func diskEmergencyActive() bool { + return isAutostartPaused() && getAutostartStatePayload().PausedByDisk +} + +func (s TrayStats) MenuTitle() string { + return fmt.Sprintf( + "Aktiv: %d • PW: %d/%d • Enrich: %d/%d", + s.ActiveDownloads, + s.PostworkRunning, + s.PostworkWaiting, + s.EnrichRunning, + s.EnrichWaiting, + ) +} + +func (s TrayStats) Tooltip() string { + return fmt.Sprintf( + "Recorder | Downloads %s | PW %d/%d | Enrich %d/%d", + s.downloadLimitText(), + s.PostworkRunning, + s.PostworkWaiting, + s.EnrichRunning, + s.EnrichWaiting, + ) +} + +func (s TrayStats) AutostartLine() string { + switch { + case s.AutostartPausedByDisk: + return "Autostart: Pausiert (Disk-Notbremse)" + case s.AutostartPausedByUser: + return "Autostart: Pausiert" + default: + return "Autostart: Aktiv" + } +} + +func (s TrayStats) DownloadsLine() string { + if s.DownloadLimitEnabled { + if s.DownloadLimitReached { + return fmt.Sprintf( + "Downloads: %d/%d aktiv (Limit erreicht)", + s.ActiveDownloads, + s.DownloadLimitMax, + ) + } + return fmt.Sprintf( + "Downloads: %d/%d aktiv", + s.ActiveDownloads, + s.DownloadLimitMax, + ) + } + + return fmt.Sprintf("Downloads: %d aktiv (kein Limit)", s.ActiveDownloads) +} + +func (s TrayStats) PostworkLine() string { + return fmt.Sprintf( + "Postwork: %d läuft • %d wartend", + s.PostworkRunning, + s.PostworkWaiting, + ) +} + +func (s TrayStats) EnrichLine() string { + return fmt.Sprintf( + "Enrich: %d läuft • %d wartend", + s.EnrichRunning, + s.EnrichWaiting, + ) +} + +func (s TrayStats) DiskLine() string { + if !s.HasDiskInfo { + return "Freier Speicher: unbekannt" + } + + if s.DiskEmergency { + return fmt.Sprintf( + "Freier Speicher: %s (kritisch)", + formatTrayBytes(s.DiskFreeBytes), + ) + } + + return fmt.Sprintf( + "Freier Speicher: %s", + formatTrayBytes(s.DiskFreeBytes), + ) +} + +func (s TrayStats) UptimeLine() string { + return "Uptime: " + formatTrayDuration(s.Uptime) +} + +func (s TrayStats) ToggleAutostartTitle() string { + if s.AutostartPausedByDisk { + return "Autostart fortsetzen (gesperrt: Disk)" + } + if s.AutostartPaused { + return "Autostart fortsetzen" + } + return "Autostart pausieren" +} + +func (s TrayStats) downloadLimitText() string { + if s.DownloadLimitEnabled { + return fmt.Sprintf("%d/%d", s.ActiveDownloads, s.DownloadLimitMax) + } + return strconv.Itoa(s.ActiveDownloads) +} + +func formatTrayDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + + total := int(d.Seconds()) + h := total / 3600 + m := (total % 3600) / 60 + s := total % 60 + + return fmt.Sprintf("%02d:%02d:%02d", h, m, s) +} + +func formatTrayBytes(b uint64) string { + const unit = 1000 + if b < unit { + return fmt.Sprintf("%d B", b) + } + + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp]) +} diff --git a/frontend/README.md b/frontend/README.md index d2e7761..a3e8f50 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -9,7 +9,7 @@ Currently, two official plugins are available: ## React Compiler -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://dev/learn/react-compiler/installation). ## Expanding the ESLint configuration diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8d98725..dd5a114 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ // frontend\src\App.tsx -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type SetStateAction } from 'react' import './App.css' import Button from './components/ui/Button' import CookieModal from './components/ui/CookieModal' @@ -26,7 +26,6 @@ import { } from '@heroicons/react/24/solid' import PerformanceMonitor from './components/ui/PerformanceMonitor' import { useNotify } from './components/ui/notify' -//import { startChaturbateOnlinePolling } from './lib/chaturbateOnlinePoller' import CategoriesTab from './components/ui/CategoriesTab' import LoginPage from './components/ui/LoginPage' import VideoSplitModal from './components/ui/VideoSplitModal' @@ -125,6 +124,25 @@ type TaskStateEvent = { ts?: number } +type BackgroundSplitProgress = { + phase: 'preparing' | 'splitting' | 'done' | 'error' + total: number + current: number + completed: number + segmentProgress?: number + overallProgress?: number + message?: string + segment?: { + index: number + label: string + start: number + end: number + duration: number + } + startedAtMs: number + sourceFile: string +} + const DEFAULT_RECORDER_SETTINGS: RecorderSettingsState = { recordDir: 'records', doneDir: 'records/done', @@ -602,6 +620,61 @@ function upsertJobById(list: RecordJob[], incoming: RecordJob): RecordJob[] { } export default function App() { + + const [splitProgressByFile, setSplitProgressByFile] = useState>({}) + const splitProgressClearTimersRef = useRef>({}) + + useEffect(() => { + return () => { + for (const t of Object.values(splitProgressClearTimersRef.current)) { + window.clearTimeout(t) + } + } + }, []) + + const clearSplitProgressTimer = useCallback((file: string) => { + const t = splitProgressClearTimersRef.current[file] + if (t != null) { + window.clearTimeout(t) + delete splitProgressClearTimersRef.current[file] + } + }, []) + + const scheduleSplitProgressCleanup = useCallback((file: string, delayMs: number) => { + clearSplitProgressTimer(file) + + splitProgressClearTimersRef.current[file] = window.setTimeout(() => { + setSplitProgressByFile((prev) => { + if (!(file in prev)) return prev + const next = { ...prev } + delete next[file] + return next + }) + delete splitProgressClearTimersRef.current[file] + }, delayMs) + }, [clearSplitProgressTimer]) + + const pushSplitProgress = useCallback(( + file: string, + progress: Omit + ) => { + clearSplitProgressTimer(file) + + setSplitProgressByFile((prev) => ({ + ...prev, + [file]: { + sourceFile: file, + startedAtMs: prev[file]?.startedAtMs ?? Date.now(), + ...progress, + }, + })) + + if (progress.phase === 'done') { + scheduleSplitProgressCleanup(file, 8000) + } else if (progress.phase === 'error') { + scheduleSplitProgressCleanup(file, 15000) + } + }, [clearSplitProgressTimer, scheduleSplitProgressCleanup]) const [authChecked, setAuthChecked] = useState(false) const [authed, setAuthed] = useState(false) @@ -3766,7 +3839,7 @@ export default function App() { ref={sourceUrlInputRef} value={sourceUrl} size='sm' - onChange={(e) => setSourceUrl(e.target.value)} + onChange={(e: { target: { value: SetStateAction } }) => setSourceUrl(e.target.value)} selectAllOnFocus selectAllOnMouseDown placeholder="https://…" @@ -3890,6 +3963,7 @@ export default function App() { setDonePage(1) donePrefetchRef.current = null }} + splitProgressByFile={splitProgressByFile} /> ) : null} @@ -3952,13 +4026,268 @@ export default function App() { setSplitModalOpen(false) setSplitJob(null) }} - onApply={async ({ job, splits, segments }) => { - console.log('VIDEO_SPLIT_APPLY', { - jobId: job.id, - output: job.output, - splits, - segments, - }) + onApply={({ job, splits, segments, deleteOriginal }) => { + const file = baseName(job.output || '') + if (!file) { + throw new Error('Kein Dateiname vorhanden.') + } + + const payload = + Array.isArray(segments) && segments.length > 0 + ? { + file, + segments: segments.map((seg) => ({ + start: Number(seg.start), + end: Number(seg.end), + })), + deleteOriginal, + } + : { + file, + splits, + deleteOriginal, + } + + const clamp01 = (n: number) => Math.max(0, Math.min(1, n)) + + const totalFallback = + Array.isArray(segments) && segments.length > 0 + ? segments.length + : Math.max(0, splits.length + 1) + + const selectedSegmentForEvent = (ev: any) => { + const pos = Number(ev?.current ?? 0) + if (!Number.isFinite(pos) || pos < 1 || pos > segments.length) { + return null + } + return segments[pos - 1] ?? null + } + + const publish = ( + progress: Omit + ) => { + pushSplitProgress(file, progress) + } + + void (async () => { + let buffer = '' + let finalData: any = null + let total = totalFallback + + try { + publish({ + phase: 'preparing', + total: totalFallback, + current: 0, + completed: 0, + segmentProgress: 0, + overallProgress: 0, + message: 'Split wird vorbereitet…', + }) + + const res = await fetch('/api/record/split?stream=1', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + if (!res.body) { + throw new Error('Split-Stream konnte nicht gelesen werden.') + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + + const applyEvent = (ev: any) => { + const type = String(ev?.type ?? '').trim() + + switch (type) { + case 'start': { + total = Math.max(1, Number(ev?.total ?? totalFallback)) + publish({ + phase: 'preparing', + total, + current: 0, + completed: 0, + segmentProgress: 0, + overallProgress: 0, + message: String(ev?.message || 'Split gestartet'), + }) + break + } + + case 'segment_start': { + const current = Math.max(1, Number(ev?.current ?? 1)) + const evTotal = Math.max(1, Number(ev?.total ?? total)) + const currentSegment = selectedSegmentForEvent(ev) + + publish({ + phase: 'splitting', + total: evTotal, + current, + completed: current - 1, + segmentProgress: 0, + overallProgress: clamp01((current - 1) / evTotal), + message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`), + segment: currentSegment + ? { + index: currentSegment.index, + label: currentSegment.label, + start: currentSegment.start, + end: currentSegment.end, + duration: currentSegment.duration, + } + : { + index: current, + label: `Segment ${current}`, + start: Number(ev?.start ?? 0), + end: Number(ev?.end ?? 0), + duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)), + }, + }) + break + } + + case 'segment_progress': { + const current = Math.max(1, Number(ev?.current ?? 1)) + const evTotal = Math.max(1, Number(ev?.total ?? total)) + const segmentProgress = clamp01(Number(ev?.progress ?? 0)) + const overallProgress = clamp01( + Number.isFinite(Number(ev?.overall)) + ? Number(ev?.overall) + : (current - 1 + segmentProgress) / evTotal + ) + const currentSegment = selectedSegmentForEvent(ev) + + publish({ + phase: 'splitting', + total: evTotal, + current, + completed: current - 1, + segmentProgress, + overallProgress, + message: String(ev?.message || `Segment ${current}/${evTotal} wird gesplittet`), + segment: currentSegment + ? { + index: currentSegment.index, + label: currentSegment.label, + start: currentSegment.start, + end: currentSegment.end, + duration: currentSegment.duration, + } + : { + index: current, + label: `Segment ${current}`, + start: Number(ev?.start ?? 0), + end: Number(ev?.end ?? 0), + duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)), + }, + }) + break + } + + case 'segment_done': { + const current = Math.max(1, Number(ev?.current ?? 1)) + const evTotal = Math.max(1, Number(ev?.total ?? total)) + const currentSegment = selectedSegmentForEvent(ev) + + publish({ + phase: current >= evTotal ? 'done' : 'splitting', + total: evTotal, + current, + completed: current, + segmentProgress: 1, + overallProgress: clamp01(current / evTotal), + message: String(ev?.message || `Segment ${current}/${evTotal} fertig`), + segment: currentSegment + ? { + index: currentSegment.index, + label: currentSegment.label, + start: currentSegment.start, + end: currentSegment.end, + duration: currentSegment.duration, + } + : { + index: current, + label: `Segment ${current}`, + start: Number(ev?.start ?? 0), + end: Number(ev?.end ?? 0), + duration: Math.max(0, Number(ev?.end ?? 0) - Number(ev?.start ?? 0)), + }, + }) + break + } + + case 'error': { + throw new Error(String(ev?.message || 'Split fehlgeschlagen')) + } + + case 'done': { + finalData = ev + publish({ + phase: 'done', + total: Math.max(1, Number(ev?.total ?? total)), + current: Math.max(1, Number(ev?.total ?? total)), + completed: Math.max(1, Number(ev?.total ?? total)), + segmentProgress: 1, + overallProgress: 1, + message: String(ev?.message || 'Split abgeschlossen'), + }) + break + } + } + } + + while (true) { + const { value, done } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const rawLine of lines) { + const line = rawLine.trim() + if (!line) continue + applyEvent(JSON.parse(line)) + } + } + + buffer += decoder.decode() + if (buffer.trim()) { + applyEvent(JSON.parse(buffer.trim())) + } + + if (!finalData?.ok) { + throw new Error('Split wurde nicht erfolgreich abgeschlossen.') + } + + bumpAssets() + void loadDonePage(donePageRef.current, doneSortRef.current) + void loadDoneCount() + } catch (err) { + const message = + err instanceof Error ? err.message : 'Split fehlgeschlagen.' + + publish({ + phase: 'error', + total: Math.max(1, totalFallback), + current: 0, + completed: 0, + segmentProgress: 0, + overallProgress: 0, + message, + }) + + notify.error('Split fehlgeschlagen', `${file} — ${message}`) + } + })() }} /> diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index f149d4f..55fe266 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,22 +1,22 @@ // frontend\src\components\ui\Button.tsx -import * as React from 'react' +import { type ButtonHTMLAttributes, type ReactNode } from 'react' type Variant = 'primary' | 'secondary' | 'soft' type Size = 'xs' | 'sm' | 'md' | 'lg' type Color = 'indigo' | 'blue' | 'emerald' | 'red' | 'amber' export type ButtonProps = Omit< - React.ButtonHTMLAttributes, + ButtonHTMLAttributes, 'children' > & { - children: React.ReactNode + children: ReactNode variant?: Variant size?: Size color?: Color rounded?: 'sm' | 'md' | 'full' - leadingIcon?: React.ReactNode - trailingIcon?: React.ReactNode + leadingIcon?: ReactNode + trailingIcon?: ReactNode isLoading?: boolean className?: string } diff --git a/frontend/src/components/ui/ButtonGroup.tsx b/frontend/src/components/ui/ButtonGroup.tsx index 6cce81c..cd1167a 100644 --- a/frontend/src/components/ui/ButtonGroup.tsx +++ b/frontend/src/components/ui/ButtonGroup.tsx @@ -1,14 +1,14 @@ // components/ui/ButtonGroup.tsx 'use client' -import * as React from 'react' +import { type ReactNode } from 'react' type Size = 'sm' | 'md' | 'lg' export type ButtonGroupItem = { id: string - label?: React.ReactNode - icon?: React.ReactNode + label?: ReactNode + icon?: ReactNode srLabel?: string disabled?: boolean } diff --git a/frontend/src/components/ui/CategoriesTab.tsx b/frontend/src/components/ui/CategoriesTab.tsx index c0ad957..728132f 100644 --- a/frontend/src/components/ui/CategoriesTab.tsx +++ b/frontend/src/components/ui/CategoriesTab.tsx @@ -2,7 +2,7 @@ 'use client' -import * as React from 'react' +import { useEffect, useRef, useState, useCallback } from 'react' import clsx from 'clsx' import Card from './Card' import Button from './Button' @@ -93,25 +93,25 @@ type CoverInfoListItem = { export default function CategoriesTab() { - const [rows, setRows] = React.useState([]) - const [loading, setLoading] = React.useState(false) - const [err, setErr] = React.useState(null) - const [coverBust, setCoverBust] = React.useState(() => Date.now()) - const [coverState, setCoverState] = React.useState>({}) - const [hasCoverByTag, setHasCoverByTag] = React.useState>({}) + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(false) + const [err, setErr] = useState(null) + const [coverBust, setCoverBust] = useState(() => Date.now()) + const [coverState, setCoverState] = useState>({}) + const [hasCoverByTag, setHasCoverByTag] = useState>({}) - const [renewing, setRenewing] = React.useState(false) - const [renewProgress, setRenewProgress] = React.useState<{ done: number; total: number } | null>(null) + const [renewing, setRenewing] = useState(false) + const [renewProgress, setRenewProgress] = useState<{ done: number; total: number } | null>(null) // TagLower -> Modelname fürs Cover (UI-Overlay) - const [coverModelByTag, setCoverModelByTag] = React.useState>({}) + const [coverModelByTag, setCoverModelByTag] = useState>({}) - const refreshAbortRef = React.useRef(null) + const refreshAbortRef = useRef(null) // TagLower -> outputs[] - const candidatesRef = React.useRef>({}) + const candidatesRef = useRef>({}) - const buildCandidates = React.useCallback((models: StoredModel[], doneJobs: RecordJob[]) => { + const buildCandidates = useCallback((models: StoredModel[], doneJobs: RecordJob[]) => { // 1) modelKeyLower -> tagsLower[] const tagsByModelKey: Record = {} for (const m of Array.isArray(models) ? models : []) { @@ -148,7 +148,7 @@ export default function CategoriesTab() { candidatesRef.current = out }, []) - const goToFinishedDownloadsWithTag = React.useCallback((tag: string) => { + const goToFinishedDownloadsWithTag = useCallback((tag: string) => { const clean = String(tag || '').trim() if (!clean) return @@ -172,7 +172,7 @@ export default function CategoriesTab() { ) }, []) - const goToModelsWithTag = React.useCallback((tag: string) => { + const goToModelsWithTag = useCallback((tag: string) => { const clean = String(tag || '').trim() if (!clean) return @@ -196,7 +196,7 @@ export default function CategoriesTab() { ) }, []) - const refresh = React.useCallback(async () => { + const refresh = useCallback(async () => { // laufenden refresh abbrechen refreshAbortRef.current?.abort() @@ -326,14 +326,14 @@ export default function CategoriesTab() { } }, [buildCandidates]) - React.useEffect(() => { + useEffect(() => { void refresh() return () => { refreshAbortRef.current?.abort() } }, [refresh]) - const renewCovers = React.useCallback(async () => { + const renewCovers = useCallback(async () => { if (renewing) return setRenewing(true) setErr(null) diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/Checkbox.tsx index 1dfe96d..6f884ee 100644 --- a/frontend/src/components/ui/Checkbox.tsx +++ b/frontend/src/components/ui/Checkbox.tsx @@ -2,7 +2,7 @@ 'use client' -import * as React from 'react' +import { useEffect, useRef } from 'react' type Props = { checked: boolean @@ -25,9 +25,9 @@ export default function Checkbox({ name, className = '', }: Props) { - const ref = React.useRef(null) + const ref = useRef(null) - React.useEffect(() => { + useEffect(() => { if (ref.current) { ref.current.indeterminate = indeterminate && !checked } diff --git a/frontend/src/components/ui/ContextMenu.tsx b/frontend/src/components/ui/ContextMenu.tsx index 7ece491..3c03cac 100644 --- a/frontend/src/components/ui/ContextMenu.tsx +++ b/frontend/src/components/ui/ContextMenu.tsx @@ -2,12 +2,12 @@ 'use client' -import * as React from 'react' +import { useEffect, useRef, useState, useLayoutEffect, type ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' export type ContextMenuItem = { - label: React.ReactNode + label: ReactNode onClick: () => void disabled?: boolean danger?: boolean @@ -23,11 +23,11 @@ type Props = { } export default function ContextMenu({ open, x, y, items, onClose, className }: Props) { - const menuRef = React.useRef(null) - const [pos, setPos] = React.useState<{ left: number; top: number }>({ left: x, top: y }) + const menuRef = useRef(null) + const [pos, setPos] = useState<{ left: number; top: number }>({ left: x, top: y }) // Position clamped in viewport - React.useLayoutEffect(() => { + useLayoutEffect(() => { if (!open) return const el = menuRef.current if (!el) return @@ -43,7 +43,7 @@ export default function ContextMenu({ open, x, y, items, onClose, className }: P }, [open, x, y, items.length]) // close on ESC / scroll / resize - React.useEffect(() => { + useEffect(() => { if (!open) return const onKeyDown = (e: KeyboardEvent) => e.key === 'Escape' && onClose() diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index 2739863..94101d2 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -2,8 +2,7 @@ 'use client' -import * as React from 'react' -import { useMemo, useEffect, useCallback } from 'react' +import { useEffect, useRef, useState, useMemo, useCallback, type MouseEvent, useDeferredValue, type ReactNode } from 'react' import Card from './Card' import type { ViewMode, @@ -59,6 +58,7 @@ import { useSelectionVersion, type FinishedSelectionStore, } from './finishedSelectionStore' +import { formatResolution, formatDuration, formatBytes } from './formatters' type Props = { jobs: RecordJob[] @@ -94,6 +94,7 @@ type Props = { loadMode?: 'paged' | 'all' includeKeep: boolean onIncludeKeepChange: (checked: boolean) => void + splitProgressByFile?: Record } const norm = (p: string) => (p || '').replaceAll('\\', '/') @@ -104,14 +105,26 @@ const baseName = (p: string) => { return parts[parts.length - 1] || '' } -const keyFor = (j: RecordJob) => { - // ✅ Primär: stabile Job-ID (bleibt gleich bei Rename/HOT) - const id = (j as any)?.id - if (id != null && String(id).trim() !== '') return String(id) +const stemWithoutExt = (p: string) => { + const f = baseName(p || '') + return stripHotPrefix(f.replace(/\.[^.]+$/, '')) +} - // Fallback (sollte selten sein) - const f = baseName(j.output || '') - return f || String((j as any)?.output || '') +const keyFor = (j: RecordJob) => { + const id = String((j as any)?.id ?? '').trim() + const stableFromOutput = stemWithoutExt(j.output || '') + + // doneJobs aus dem Backend verwenden oft dateinamige IDs. + // Wenn die ID effektiv nur der Dateiname ist, HOT-normalisieren. + if (id) { + const normalizedId = stripHotPrefix(id) + if (stableFromOutput && normalizedId === stableFromOutput) { + return stableFromOutput + } + return id + } + + return stableFromOutput || String((j as any)?.output || '') } const isTrashOutput = (output?: string) => { @@ -150,32 +163,8 @@ function nearestPresetIndex(value: number) { return bestIndex } -function formatDuration(ms: number): string { - if (!Number.isFinite(ms) || ms <= 0) return '—' - const totalSec = Math.floor(ms / 1000) - const h = Math.floor(totalSec / 3600) - const m = Math.floor((totalSec % 3600) / 60) - const s = totalSec % 60 - if (h > 0) return `${h}h ${m}m` - if (m > 0) return `${m}m ${s}s` - return `${s}s` -} - -function formatBytes(bytes?: number | null): string { - if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—' - const units = ['B', 'KB', 'MB', 'GB', 'TB'] - let v = bytes - let i = 0 - while (v >= 1024 && i < units.length - 1) { - v /= 1024 - i++ - } - const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2 - return `${v.toFixed(digits)} ${units[i]}` -} - function useMediaQuery(query: string) { - const [matches, setMatches] = React.useState(false) + const [matches, setMatches] = useState(false) useEffect(() => { const mql = window.matchMedia(query) @@ -301,10 +290,10 @@ async function copyTextToClipboard(text: string): Promise { } function CopyErrorButton({ text }: { text: string }) { - const [copied, setCopied] = React.useState(false) - const timeoutRef = React.useRef(null) + const [copied, setCopied] = useState(false) + const timeoutRef = useRef(null) - React.useEffect(() => { + useEffect(() => { return () => { if (timeoutRef.current != null) { window.clearTimeout(timeoutRef.current) @@ -312,7 +301,7 @@ function CopyErrorButton({ text }: { text: string }) { } }, []) - const handleClick = async (e: React.MouseEvent) => { + const handleClick = async (e: MouseEvent) => { e.preventDefault() e.stopPropagation() @@ -548,6 +537,145 @@ function postworkBadgeTone(summary?: FinishedPostworkSummary): string { } } +type SplitProgressOverlay = { + phase: 'preparing' | 'splitting' | 'done' | 'error' + total: number + current: number + completed: number + segmentProgress?: number + overallProgress?: number + message?: string + segment?: { + index: number + label: string + start: number + end: number + duration: number + } + startedAtMs?: number + sourceFile?: string +} + +type DownloadIntegrityBadgeInfo = { + label: string + detail: string + blackTailFrames?: number + sampleCount?: number +} + +function matchesCorruptOnlyFilter(metaRaw: unknown, enabled: boolean): boolean { + if (!enabled) return true + return readDownloadIntegrityInfo(metaRaw).state === 'corrupt' +} + +function readDownloadIntegrityState(metaRaw: unknown): 'corrupt' | 'ok' | 'unchecked' { + const meta = parseMetaObject(metaRaw) as any + const node = meta?.validation?.tailBlackout + + if (!node || typeof node !== 'object') return 'unchecked' + if (node?.isCorrupt === true) return 'corrupt' + return 'ok' +} + +type DownloadIntegrityState = 'corrupt' | 'ok' | 'unchecked' + +function readDownloadIntegrityInfo(metaRaw: unknown): { + state: DownloadIntegrityState + checked: boolean + node?: Record +} { + const meta = parseMetaObject(metaRaw) as any + const node = meta?.validation?.tailBlackout + + if (!node || typeof node !== 'object') { + return { + state: 'unchecked', + checked: false, + } + } + + return { + state: node?.isCorrupt === true ? 'corrupt' : 'ok', + checked: true, + node, + } +} + +function readDownloadIntegrityBadge(metaRaw: unknown): DownloadIntegrityBadgeInfo | null { + const info = readDownloadIntegrityInfo(metaRaw) + const node = info.node + + if (!info.checked || info.state !== 'corrupt' || !node) return null + + const blackTailFrames = + typeof node?.blackTailFrames === 'number' && Number.isFinite(node.blackTailFrames) + ? node.blackTailFrames + : undefined + + const sampleCount = + typeof node?.sampleCount === 'number' && Number.isFinite(node.sampleCount) + ? node.sampleCount + : Array.isArray(node?.samples) + ? node.samples.length + : undefined + + const reason = String(node?.reason ?? '').trim() + + const fallbackDetail = + typeof blackTailFrames === 'number' && + typeof sampleCount === 'number' && + sampleCount > 0 + ? `${blackTailFrames}/${sampleCount} Frames am Ende sind fast komplett schwarz` + : 'Mehrere Frames am Ende sind fast komplett schwarz' + + return { + label: 'Fehlerhaft', + detail: reason || fallbackDetail, + blackTailFrames, + sampleCount, + } +} + +function renderDownloadIntegrityBadge(metaRaw: unknown): ReactNode { + const info = readDownloadIntegrityBadge(metaRaw) + if (!info) return null + + const badgeNode = ( + + + {info.label} + + ) + + return ( + + open && ( +
+
+
+ Download-Prüfung +
+
+ Verdacht auf beschädigtes Videoende +
+
+ +
+ {info.detail} +
+
+ ) + } + > + {badgeNode} +
+ ) +} + function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string { const p = String(phase ?? '').trim().toLowerCase() @@ -561,9 +689,9 @@ function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string { case 'postwork:sprites': return 'Sprites' case 'enrich:analyze': - return 'KI-Analyse' + return 'Analyse' default: - return p || (queue === 'enrich' ? 'KI-Analyse' : 'Meta') + return p || (queue === 'enrich' ? 'Analyse' : 'Meta') } } @@ -1208,8 +1336,7 @@ function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): a.position === b.position && a.waiting === b.waiting && a.running === b.running && - a.maxParallel === b.maxParallel && - (a.ts ?? 0) === (b.ts ?? 0) + a.maxParallel === b.maxParallel ) } @@ -1229,6 +1356,26 @@ function samePostworkBadgeList( return true } +function samePostworkBadgeMap( + a?: Record, + b?: Record +): boolean { + const aa = a ?? {} + const bb = b ?? {} + + const aKeys = Object.keys(aa) + const bKeys = Object.keys(bb) + + if (aKeys.length !== bKeys.length) return false + + for (const key of aKeys) { + if (!(key in bb)) return false + if (!samePostworkBadgeList(aa[key], bb[key])) return false + } + + return true +} + export function renderFinishedPostworkBadge( summary?: FinishedPostworkSummary, opts?: { @@ -1236,7 +1383,7 @@ export function renderFinishedPostworkBadge( clickable?: boolean disablePopover?: boolean } -): React.ReactNode { +): ReactNode { if (!summary) return null const clickable = Boolean(opts?.clickable && opts?.onClick) @@ -1369,6 +1516,64 @@ export function renderFinishedPostworkBadge( ) } +function buildSplitProgressSummary( + file: string, + progress?: SplitProgressOverlay +): FinishedPostworkSummary | null { + if (!file || !progress) return null + + const rawOverall = + typeof progress.overallProgress === 'number' + ? progress.overallProgress + : progress.phase === 'done' + ? 1 + : 0 + + const overall = Math.max(0, Math.min(1, rawOverall)) + const percent = Math.round(overall * 100) + + const state: FinishedPostworkState = + progress.phase === 'error' + ? 'error' + : progress.phase === 'done' + ? 'done' + : 'running' + + const label = + progress.phase === 'error' + ? 'Split Fehler' + : progress.phase === 'done' + ? 'Split fertig' + : `Split ${percent}%` + + const stepLabel = + progress.segment?.label?.trim() || + progress.message?.trim() || + 'Video wird gesplittet' + + return { + file, + assetId: fileStemFromOutput(file), + state, + label, + ts: Number(progress.startedAtMs ?? Date.now()), + steps: [ + { + key: 'postwork:split', + queue: 'postwork', + phase: 'split', + label: stepLabel, + state, + error: + progress.phase === 'error' + ? String(progress.message ?? 'Split fehlgeschlagen') + : undefined, + ts: Number(progress.startedAtMs ?? Date.now()), + }, + ], + } +} + export default function FinishedDownloads({ jobs, doneJobs, @@ -1397,6 +1602,7 @@ export default function FinishedDownloads({ loadMode = 'paged', includeKeep, onIncludeKeepChange, + splitProgressByFile = {}, }: Props) { // ----------------------------------------------------------------------------- // local constants @@ -1432,34 +1638,39 @@ export default function FinishedDownloads({ // ----------------------------------------------------------------------------- // state // ----------------------------------------------------------------------------- - const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false) + const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = useState(false) - const [teaserKey, setTeaserKey] = React.useState(null) - const [hoverTeaserKey, setHoverTeaserKey] = React.useState(null) + const [teaserKey, setTeaserKey] = useState(null) + const [hoverTeaserKey, setHoverTeaserKey] = useState(null) - const [deletedKeys, setDeletedKeys] = React.useState>(() => new Set()) - const [deletingKeys, setDeletingKeys] = React.useState>(() => new Set()) - const [keepingKeys, setKeepingKeys] = React.useState>(() => new Set()) - const [removingKeys, setRemovingKeys] = React.useState>(() => new Set()) - const [hiddenFiles, setHiddenFiles] = React.useState>(() => new Set()) + const [deletedKeys, setDeletedKeys] = useState>(() => new Set()) + const [deletingKeys, setDeletingKeys] = useState>(() => new Set()) + const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) + const [removingKeys, setRemovingKeys] = useState>(() => new Set()) + const [hiddenFiles, setHiddenFiles] = useState>(() => new Set()) - const [undoing, setUndoing] = React.useState(false) - const [bulkBusy, setBulkBusy] = React.useState(false) + const [undoing, setUndoing] = useState(false) + const [bulkBusy, setBulkBusy] = useState(false) + + const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false) const [postworkByFile, setPostworkByFile] = - React.useState>({}) + useState>({}) - const [renamedFiles, setRenamedFiles] = React.useState>({}) + const [generateAssetsLiveByFile, setGenerateAssetsLiveByFile] = + useState>({}) - const [view, setView] = React.useState('table') - const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) - const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) + const [renamedFiles, setRenamedFiles] = useState>({}) - const [galleryCardScaleIndex, setGalleryCardScaleIndex] = React.useState( + const [view, setView] = useState('table') + const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false) + const [selectionActionsOpen, setSelectionActionsOpen] = useState(false) + + const [galleryCardScaleIndex, setGalleryCardScaleIndex] = useState( DEFAULT_GALLERY_CARD_SCALE_INDEX ) - const selectionStoreRef = React.useRef(null) + const selectionStoreRef = useRef(null) const selectionStore = selectionStoreRef.current ?? @@ -1474,24 +1685,24 @@ export default function FinishedDownloads({ setGalleryCardScaleIndex(clampPresetIndex(nextIndex)) }, []) - const [allDoneJobs, setAllDoneJobs] = React.useState(null) - const [allDoneJobsLoading, setAllDoneJobsLoading] = React.useState(false) + const [allDoneJobs, setAllDoneJobs] = useState(null) + const [allDoneJobsLoading, setAllDoneJobsLoading] = useState(false) - const allDoneJobsRequestRef = React.useRef(0) + const allDoneJobsRequestRef = useRef(0) - const [tagFilter, setTagFilter] = React.useState([]) - const [searchQuery, setSearchQuery] = React.useState('') + const [tagFilter, setTagFilter] = useState([]) + const [searchQuery, setSearchQuery] = useState('') - const [durations, setDurations] = React.useState>({}) + const [durations, setDurations] = useState>({}) const [resolutions, setResolutions] = - React.useState>({}) + useState>({}) const [inlinePlay, setInlinePlay] = - React.useState<{ key: string; nonce: number } | null>(null) + useState<{ key: string; nonce: number } | null>(null) - const [hotBusyKeys, setHotBusyKeys] = React.useState>(() => new Set()) + const [hotBusyKeys, setHotBusyKeys] = useState>(() => new Set()) - const [lastAction, setLastAction] = React.useState(() => { + const [lastAction, setLastAction] = useState(() => { if (typeof window === 'undefined') return null try { const raw = localStorage.getItem(LAST_UNDO_KEY) @@ -1512,31 +1723,86 @@ export default function FinishedDownloads({ } }) + const resolutionOf = useCallback((job: RecordJob): { w: number; h: number } | null => { + const k = keyFor(job) + + const cached = resolutions[k] + if (cached?.w > 0 && cached?.h > 0) { + return cached + } + + const meta = parseMetaObject((job as any)?.meta) + const w = readMetaVideoWidth(meta) + const h = readMetaVideoHeight(meta) + + if (w && h) { + return { w, h } + } + + return null + }, [resolutions]) + + const resolutionLabelOf = useCallback((job: RecordJob): string => { + return formatResolution(resolutionOf(job)) || '—' + }, [resolutionOf]) + // ----------------------------------------------------------------------------- // refs // ----------------------------------------------------------------------------- - const pointerPosRef = React.useRef<{ x: number; y: number } | null>(null) + const pointerPosRef = useRef<{ x: number; y: number } | null>(null) - const teaserHostsRef = React.useRef>(new Map()) - const teaserIORef = React.useRef(null) - const elToKeyRef = React.useRef>(new WeakMap()) - const teaserKeyRafRef = React.useRef(null) - const pendingTeaserKeyRef = React.useRef(null) + const teaserHostsRef = useRef>(new Map()) + const teaserIORef = useRef(null) + const elToKeyRef = useRef>(new WeakMap()) + const teaserKeyRafRef = useRef(null) + const pendingTeaserKeyRef = useRef(null) - const countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) + const countHintRef = useRef({ pending: 0, t: 0, timer: 0 as any }) - const swipeRefs = React.useRef>(new Map()) - const fileToKeyRef = React.useRef>(new Map()) + const swipeRefs = useRef>(new Map()) + const fileToKeyRef = useRef>(new Map()) - const durationsRef = React.useRef>({}) - const durationsFlushTimerRef = React.useRef(null) + const durationsRef = useRef>({}) + const durationsFlushTimerRef = useRef(null) - const resolutionsRef = React.useRef>({}) - const resolutionsFlushTimerRef = React.useRef(null) + const resolutionsRef = useRef>({}) + const resolutionsFlushTimerRef = useRef(null) - const removeTimersRef = React.useRef>(new Map()) + const removeTimersRef = useRef>(new Map()) - const regenerateTaskFilesRef = React.useRef>(new Set()) + const regenerateTaskFilesRef = useRef>(new Set()) + + const [doneMetaByFile, setDoneMetaByFile] = useState>({}) + + const refreshDoneMetaForFile = useCallback(async (file: string) => { + const cleanFile = String(file || '').trim() + if (!cleanFile) return + + try { + const res = await fetch( + `/api/record/done/meta?file=${encodeURIComponent(cleanFile)}`, + { cache: 'no-store' } + ) + + if (!res.ok) return + + const data = await res.json().catch(() => null) + const meta = data?.meta + + if (!meta || typeof meta !== 'object') return + + setDoneMetaByFile((prev) => ({ + ...prev, + [cleanFile]: meta as RecordJobMeta, + })) + } catch { + // ignore + } + }, []) + + const renderIntegrityBadge = useCallback((job: RecordJob) => { + return renderDownloadIntegrityBadge((job as any)?.meta) + }, []) // ----------------------------------------------------------------------------- // memoized derived data @@ -1546,7 +1812,7 @@ export default function FinishedDownloads({ ? !mobileTeaserAudioUnlocked : false - const deferredSearchQuery = React.useDeferredValue(searchQuery) + const deferredSearchQuery = useDeferredValue(searchQuery) const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter]) @@ -1597,13 +1863,15 @@ export default function FinishedDownloads({ ) const searchActiveForGlobalFetch = - activeTagSet.size > 0 || searchTokens.length > 0 + activeTagSet.size > 0 || + searchTokens.length > 0 || + showOnlyCorrupt const globalFilterActive = searchActiveForGlobalFetch const effectiveAllMode = globalFilterActive || loadMode === 'all' const DEFAULT_GALLERY_PAGE_SIZE = 8 - const [galleryPageSize, setGalleryPageSize] = React.useState( + const [galleryPageSize, setGalleryPageSize] = useState( DEFAULT_GALLERY_PAGE_SIZE ) @@ -1641,7 +1909,17 @@ export default function FinishedDownloads({ const doneBaseKeys = new Set() for (const j of doneJobsPage) { - const jj = applyRenamedOutputLocal(j) + let jj = applyRenamedOutputLocal(j) + + const file = baseName(jj.output || '') + const metaOverride = file ? doneMetaByFile[file] : undefined + + if (metaOverride) { + jj = { + ...jj, + meta: mergeMetaPreferDone(metaOverride, (jj as any)?.meta), + } + } const k = keyFor(jj) map.set(k, jj) @@ -1705,7 +1983,7 @@ export default function FinishedDownloads({ return false }) .map(({ j }) => j) - }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal]) + }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal, doneMetaByFile]) const effectivePostworkSummaryByFile = useMemo>(() => { const next: Record = {} @@ -1714,6 +1992,27 @@ export default function FinishedDownloads({ const summary = summarizePostworkSteps(file, badges) if (summary) { next[file] = summary + + const filePlain = stripHotPrefix(file) + if (filePlain && filePlain !== file) { + next[filePlain] = summary + } + } + } + + for (const [file, badges] of Object.entries(generateAssetsLiveByFile)) { + const summary = summarizePostworkSteps(file, badges) + if (!summary) continue + + const filePlain = stripHotPrefix(file) + const existing = next[file] ?? (filePlain ? next[filePlain] : undefined) + + const merged = + mergePostworkSummaries(summary, existing ?? null, file, summary.assetId) ?? summary + + next[file] = merged + if (filePlain && filePlain !== file) { + next[filePlain] = merged } } @@ -1761,7 +2060,25 @@ export default function FinishedDownloads({ } return next - }, [postworkByFile, rows]) + }, [postworkByFile, generateAssetsLiveByFile, rows]) + + const effectiveBadgeSummaryByFile = useMemo>(() => { + const next = { ...effectivePostworkSummaryByFile } + + for (const [file, progress] of Object.entries(splitProgressByFile)) { + const summary = buildSplitProgressSummary(file, progress) + if (!summary) continue + + next[file] = summary + + const plain = stripHotPrefix(file) + if (plain && plain !== file) { + next[plain] = summary + } + } + + return next + }, [effectivePostworkSummaryByFile, splitProgressByFile]) const viewRows = rows @@ -1784,19 +2101,33 @@ export default function FinishedDownloads({ }) : base - if (activeTagSet.size === 0) return searched + const tagged = + activeTagSet.size === 0 + ? searched + : searched.filter((j) => { + const modelKey = lowerTag(modelNameFromOutput(j.output)) + const have = modelTags.tagSetByModelKey[modelKey] + if (!have || have.size === 0) return false - return searched.filter((j) => { - const modelKey = lowerTag(modelNameFromOutput(j.output)) - const have = modelTags.tagSetByModelKey[modelKey] - if (!have || have.size === 0) return false + for (const t of activeTagSet) { + if (!have.has(t)) return false + } + return true + }) - for (const t of activeTagSet) { - if (!have.has(t)) return false - } - return true + const corruptFiltered = tagged.filter((j) => { + return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt) }) - }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) + + return corruptFiltered + }, [ + viewRows, + deletedKeys, + activeTagSet, + modelTags, + searchTokens, + showOnlyCorrupt, + ]) const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage @@ -1817,15 +2148,15 @@ export default function FinishedDownloads({ const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 const emptyByFilter = globalFilterActive && visibleRows.length === 0 - const selectedCount = React.useMemo(() => { + const selectedCount = useMemo(() => { return selectionStore.getCount() }, [selectionStore, selectionVersion]) - const selectedFiles = React.useMemo(() => { + const selectedFiles = useMemo(() => { return selectionStore.getSelectedFiles() }, [selectionStore, selectionVersion]) - const allPageSelected = React.useMemo(() => { + const allPageSelected = useMemo(() => { return ( pageRows.length > 0 && pageRows.every((j) => selectionStore.isSelected(keyFor(j))) @@ -1836,7 +2167,7 @@ export default function FinishedDownloads({ const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen const selectionModeActive = selectedCount > 0 - const selectedKeys = React.useMemo(() => { + const selectedKeys = useMemo(() => { const next = new Set() for (const j of pageRows) { @@ -1952,7 +2283,7 @@ export default function FinishedDownloads({ setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag))) }, []) - const renderTagFilterControls = (opts?: { mobile?: boolean }): React.ReactNode => ( + const renderTagFilterControls = (opts?: { mobile?: boolean }): ReactNode => ( void>() ) + const integrityMetaPendingRef = useRef>(new Set()) + const registerTeaserHost = useCallback((key: string) => { const existing = teaserHostCallbackMapRef.current.get(key) if (existing) return existing @@ -3008,9 +3341,27 @@ export default function FinishedDownloads({ // ----------------------------------------------------------------------------- useEffect(() => { - if (!globalFilterActive) return - if (page !== 1) onPageChange(1) - }, [globalFilterActive, deferredSearchQuery, tagFilter, page, onPageChange]) + if (!showOnlyCorrupt) return + + for (const job of viewRows) { + const file = baseName(job.output || '') + if (!file) continue + + const state = readDownloadIntegrityState((job as any)?.meta) + if (state !== 'unchecked') continue + + if (integrityMetaPendingRef.current.has(file)) continue + integrityMetaPendingRef.current.add(file) + + void refreshDoneMetaForFile(file).finally(() => { + integrityMetaPendingRef.current.delete(file) + }) + } + }, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile]) + + useEffect(() => { + onPageChange(1) + }, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange]) useEffect(() => { if (!globalFilterActive) { @@ -3202,6 +3553,52 @@ export default function FinishedDownloads({ const data = await res.json().catch(() => null) if (!data) return + const generateAssets = data?.generateAssets + const gaFile = String(generateAssets?.currentFile ?? '').trim() + const gaQueueRaw = String(generateAssets?.currentQueue ?? '').trim().toLowerCase() + const gaPhaseRaw = String(generateAssets?.currentPhase ?? '').trim().toLowerCase() + const gaLabel = String(generateAssets?.currentLabel ?? '').trim() + + const gaQueue: 'postwork' | 'enrich' | null = + gaQueueRaw === 'postwork' + ? 'postwork' + : gaQueueRaw === 'enrich' + ? 'enrich' + : null + + const gaPhase = + gaQueue && gaPhaseRaw + ? normalizeFinishedPhase(gaQueue, gaPhaseRaw) + : '' + + const liveTs = + typeof generateAssets?.ts === 'number' && Number.isFinite(generateAssets.ts) + ? generateAssets.ts + : 0 + + const nextGenerateAssetsLiveByFile: Record = + (generateAssets?.running || generateAssets?.queued) && gaFile && gaQueue && gaPhase + ? { + [gaFile]: [ + { + file: gaFile, + assetId: fileStemFromOutput(gaFile), + queue: gaQueue, + phase: gaPhase, + state: generateAssets?.running ? 'running' : 'queued', + label: gaLabel || labelForStep(gaQueue, gaPhase), + ts: liveTs, + }, + ], + } + : {} + + setGenerateAssetsLiveByFile((prev) => { + return samePostworkBadgeMap(prev, nextGenerateAssetsLiveByFile) + ? prev + : nextGenerateAssetsLiveByFile + }) + const jobs = Array.isArray(data?.regenerateAssetsJobs) ? data.regenerateAssetsJobs : [] @@ -3329,6 +3726,16 @@ export default function FinishedDownloads({ void refreshPostworkState() }, [refreshPostworkState]) + useEffect(() => { + const timer = window.setInterval(() => { + void refreshPostworkState() + }, 1500) + + return () => { + window.clearInterval(timer) + } + }, [refreshPostworkState]) + useEffect(() => { const onFocus = () => { void refreshPostworkState() @@ -3414,36 +3821,49 @@ export default function FinishedDownloads({ const file = String(detail?.file ?? '').trim() if (!file) return + const phase = String(detail.phase ?? '').trim().toLowerCase() + + if ( + detail.state === 'done' && + ( + phase === 'sprites' || + phase === 'thumb' || + phase === 'teaser' || + phase === 'meta' || + phase === 'analyze' + ) + ) { + void refreshDoneMetaForFile(file) + } + setPostworkByFile((prev) => { - const next = { ...prev } + const cur = Array.isArray(prev[file]) ? prev[file] : [] if (detail.state === 'missing') { - const cur = Array.isArray(prev[file]) ? prev[file] : [] const missingKey = `${detail.queue}:${String(detail.phase ?? '').trim().toLowerCase() || 'postwork'}` const filtered = cur.filter((item) => { const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` return itemKey !== missingKey }) - if (filtered.length === 0) { - delete next[file] - } else { - next[file] = filtered + if (samePostworkBadgeList(cur, filtered)) { + return prev } + const next = { ...prev } + + if (filtered.length === 0) delete next[file] + else next[file] = filtered + return next } const incoming: FinishedPostworkBadge = { ...detail, - label: - String(detail?.label ?? '').trim() === file - ? undefined - : detail?.label, + label: String(detail?.label ?? '').trim() === file ? undefined : detail?.label, error: String(detail?.error ?? '').trim() || undefined, } - const cur = Array.isArray(prev[file]) ? prev[file] : [] const key = `${incoming.queue}:${String(incoming.phase ?? '').trim().toLowerCase() || 'postwork'}` let replaced = false @@ -3454,14 +3874,52 @@ export default function FinishedDownloads({ return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming }) - next[file] = replaced ? merged : [...merged, incoming] - return next + const nextBadges = replaced ? merged : [...merged, incoming] + + if (samePostworkBadgeList(cur, nextBadges)) { + return prev + } + + return { + ...prev, + [file]: nextBadges, + } }) } window.addEventListener('finished-downloads:postwork', onPostwork as EventListener) return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener) - }, []) + }, [refreshDoneMetaForFile]) + + useEffect(() => { + const onIntegrityRefresh = (ev: Event) => { + const detail = (ev as CustomEvent<{ file?: string; all?: boolean }>).detail + const file = String(detail?.file ?? '').trim() + + if (file) { + const clean = baseName(file) + if (clean) { + void refreshDoneMetaForFile(clean) + } + return + } + + const files = new Set( + rows + .map((job) => baseName(job.output || '')) + .filter(Boolean) + ) + + for (const f of files) { + void refreshDoneMetaForFile(f) + } + } + + window.addEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener) + return () => { + window.removeEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener) + } + }, [rows, refreshDoneMetaForFile]) useEffect(() => { const onExternalDelete = (ev: Event) => { @@ -3620,18 +4078,15 @@ export default function FinishedDownloads({ }, [selectedCount, selectionActionsOpen]) const desktopToolbarControlsClass = [ - 'flex flex-wrap items-start gap-3', - 'min-[1800px]:flex-nowrap min-[1800px]:items-center', + 'flex items-center justify-between gap-3', ].join(' ') const desktopToolbarLeftClass = [ - 'flex min-w-0 flex-wrap items-center gap-2', - 'min-[1800px]:min-w-max min-[1800px]:shrink-0 min-[1800px]:flex-nowrap', + 'flex min-w-0 items-center gap-2', ].join(' ') const desktopToolbarRightClass = [ - 'flex w-full min-w-0 flex-wrap items-center gap-2', - 'min-[1800px]:ml-auto min-[1800px]:w-auto min-[1800px]:min-w-max min-[1800px]:flex-nowrap min-[1800px]:justify-end', + 'flex min-w-0 items-center gap-2', ].join(' ') const handleToggleSelectAllPage = useCallback((checked: boolean) => { @@ -3657,6 +4112,14 @@ export default function FinishedDownloads({ [isSmall, regenerateAssetsForFile] ) + const isCompactDesktop = useMediaQuery('(min-width: 640px) and (max-width: 1399px)') + + const keepToggleLabel = isCompactDesktop + ? 'Keep anzeigen' + : 'Behaltene Downloads anzeigen' + + const corruptToggleLabel = 'Nur fehlerhafte' + // ----------------------------------------------------------------------------- // render // ----------------------------------------------------------------------------- @@ -3679,12 +4142,33 @@ export default function FinishedDownloads({
Abgeschlossene Downloads
+ {totalItemsForPagination} + + {selectedCount > 0 ? ( + + ) : null} -
+
{/* links */}
-
- { - if (page !== 1) onPageChange(1) - onIncludeKeepChange(checked) - }} - /> -
- - {selectedCount > 0 ? ( -
- +
+
+ { + if (page !== 1) onPageChange(1) + onIncludeKeepChange(checked) + }} + />
- ) : null} + +
+ { + if (page !== 1) onPageChange(1) + setShowOnlyCorrupt(checked) + }} + /> +
+
{/* rechts */}
{view === 'gallery' ? ( -
+
+
@@ -3814,7 +4301,7 @@ export default function FinishedDownloads({ ) : null}
setView(id as ViewMode)} - size="md" + size={isCompactDesktop ? 'sm' : 'md'} ariaLabel="Ansicht" items={[ { id: 'table', icon: , - label: 'Tabelle', + label: isCompactDesktop ? undefined : 'Tabelle', srLabel: 'Tabelle', }, { id: 'cards', icon: , - label: 'Cards', + label: isCompactDesktop ? undefined : 'Cards', srLabel: 'Cards', }, { id: 'gallery', icon: , - label: 'Galerie', + label: isCompactDesktop ? undefined : 'Galerie', srLabel: 'Galerie', }, ]} @@ -3890,7 +4377,7 @@ export default function FinishedDownloads({
+
+
+
+
+ Nur fehlerhafte Downloads +
+
+ Zeigt nur auffällige Sprite-/Ende-Prüfungen +
+
+ + { + if (page !== 1) onPageChange(1) + setShowOnlyCorrupt(checked) + }} + ariaLabel="Nur fehlerhafte Downloads anzeigen" + size="default" + /> +
+
+ {view !== 'table' ? (
{view === 'gallery' ? ( @@ -4264,7 +4774,9 @@ export default function FinishedDownloads({ Keine Treffer für die aktuellen Filter.
- {tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? ( + {tagFilter.length > 0 || + (searchQuery || '').trim() !== '' || + showOnlyCorrupt ? (
{tagFilter.length > 0 ? ( + ) : null} ) : (
@@ -4325,6 +4847,8 @@ export default function FinishedDownloads({ tryAutoplayInline={tryAutoplayInline} registerTeaserHost={registerTeaserHost} handleDuration={handleDuration} + handleResolution={handleResolution} + resolutionLabelOf={resolutionLabelOf} deleteVideo={deleteVideo} keepVideo={keepVideo} releasePlayingFile={releasePlayingFile} @@ -4339,12 +4863,13 @@ export default function FinishedDownloads({ onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} assetNonce={assetNonce ?? 0} - postworkByFile={effectivePostworkSummaryByFile} + postworkByFile={effectiveBadgeSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} renderPostworkBadge={renderClickablePostworkBadge} + renderIntegrityBadge={renderIntegrityBadge} />
)} @@ -4397,10 +4922,11 @@ export default function FinishedDownloads({ onAddToDownloads={onAddToDownloads} deleteVideo={deleteVideo} keepVideo={keepVideo} - postworkByFile={effectivePostworkSummaryByFile} + postworkByFile={effectiveBadgeSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} renderPostworkBadge={renderClickablePostworkBadge} + renderIntegrityBadge={renderIntegrityBadge} /> )} @@ -4416,6 +4942,8 @@ export default function FinishedDownloads({ blurPreviews={blurPreviews} durations={durations} handleDuration={handleDuration} + handleResolution={handleResolution} + resolutionLabelOf={resolutionLabelOf} teaserState={teaserState} keyFor={keyFor} baseName={baseName} @@ -4442,12 +4970,14 @@ export default function FinishedDownloads({ activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} - postworkByFile={effectivePostworkSummaryByFile} + postworkByFile={effectiveBadgeSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} cardScale={galleryCardScale} renderPostworkBadge={renderClickablePostworkBadge} onRecommendedPageSizeChange={setGalleryPageSize} + assetNonce={assetNonce ?? 0} + renderIntegrityBadge={renderIntegrityBadge} /> )}
diff --git a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index 3c09c37..795dab7 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -2,12 +2,12 @@ 'use client' import { + memo, useCallback, useEffect, useMemo, useRef, useState, - type MutableRefObject, type ReactNode, } from 'react' import Card from './Card' @@ -41,11 +41,16 @@ import { import ModelGenderIcon from './ModelGenderIcon' import { parseFinishedTags, resolveFinishedModelFlags } from './finishedModelFlags' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' -import React from 'react' import { type FinishedSelectionStore, useSelectionChecked, } from './finishedSelectionStore' +import SpritePreloader from './SpritePreloader' + + +type SwipeRefs = { + current: Map +} type Props = { rows: RecordJob[] @@ -65,7 +70,7 @@ type Props = { keepingKeys: Set removingKeys: Set - swipeRefs: MutableRefObject> + swipeRefs: SwipeRefs assetNonce?: number @@ -92,6 +97,8 @@ type Props = { registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void handleDuration: (job: RecordJob, seconds: number) => void + handleResolution: (job: RecordJob, w: number, h: number) => void + resolutionLabelOf: (job: RecordJob) => string deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise @@ -119,6 +126,7 @@ type Props = { forcePreviewMuted?: boolean mobileTeaserAudioUnlocked?: boolean onUnlockMobileTeaserAudio?: () => void + renderIntegrityBadge?: (job: RecordJob) => ReactNode } function CardBlurWrapper({ @@ -326,6 +334,7 @@ type FinishedDownloadsDesktopCardProps = { jobForDetails: (job: RecordJob) => RecordJob handleDuration: (job: RecordJob, seconds: number) => void + handleResolution: (job: RecordJob, w: number, h: number) => void handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void onToggleSelected: (job: RecordJob) => void @@ -349,6 +358,7 @@ type FinishedDownloadsDesktopCardProps = { keepVideo: (job: RecordJob) => Promise renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode + renderIntegrityBadge?: (job: RecordJob) => ReactNode } function samePostworkSummaryLite( @@ -415,7 +425,7 @@ function SelectionCheckboxOverlay({ ) } -const FinishedDownloadsDesktopCard = React.memo( +const FinishedDownloadsDesktopCard = memo( function FinishedDownloadsDesktopCard({ job: j, selectionStore, @@ -438,6 +448,7 @@ const FinishedDownloadsDesktopCard = React.memo( lower, jobForDetails, handleDuration, + handleResolution, onToggleSelected, onHoverPreviewKeyChange, onOpenPlayer, @@ -456,6 +467,7 @@ const FinishedDownloadsDesktopCard = React.memo( deleteVideo, keepVideo, renderPostworkBadge, + renderIntegrityBadge, }: FinishedDownloadsDesktopCardProps) { const k = keyFor(j) const busy = isDeleting || isKeeping || isRemoving @@ -469,7 +481,7 @@ const FinishedDownloadsDesktopCard = React.memo( disabled: false, }) - const handleSelectOrOpen = React.useCallback(() => { + const handleSelectOrOpen = useCallback(() => { if (selectionStore.hasAnySelection()) { onToggleSelected(j) return true @@ -585,6 +597,7 @@ const FinishedDownloadsDesktopCard = React.memo( variant="fill" durationSeconds={durationSeconds ?? (j as any)?.meta?.file?.durationSeconds} onDuration={handleDuration} + onResolution={handleResolution} showPopover={false} blur={inlineActive ? false : Boolean(blurPreviews)} animated={shouldAnimateTeaser({ @@ -627,9 +640,10 @@ const FinishedDownloadsDesktopCard = React.memo(
- {renderPostworkBadge && postworkBadge ? ( -
- {renderPostworkBadge(postworkBadge)} + {(renderIntegrityBadge || (renderPostworkBadge && postworkBadge)) ? ( +
+ {renderIntegrityBadge ? renderIntegrityBadge(j) : null} + {renderPostworkBadge && postworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
@@ -709,6 +723,7 @@ const FinishedDownloadsDesktopCard = React.memo( prev.isRemoving === next.isRemoving && samePostworkSummaryLite(prev.postworkBadge, next.postworkBadge) && prev.durationSeconds === next.durationSeconds && + prev.assetNonce === next.assetNonce && prev.forcePreviewMuted === next.forcePreviewMuted && prev.blurPreviews === next.blurPreviews && prev.teaserState.mode === next.teaserState.mode && @@ -759,6 +774,7 @@ function FinishedDownloadsCardsView({ tryAutoplayInline, registerTeaserHost, handleDuration, + handleResolution, deleteVideo, keepVideo, @@ -779,6 +795,7 @@ function FinishedDownloadsCardsView({ forcePreviewMuted, mobileTeaserAudioUnlocked, onUnlockMobileTeaserAudio, + renderIntegrityBadge, }: Props) { const footerTapRef = useRef>({}) @@ -943,6 +960,17 @@ function FinishedDownloadsCardsView({ const scrubberCount = hasScrubberUi ? sprite.count : 0 const scrubberStepSeconds = hasScrubberUi ? sprite.stepSeconds : 0 + const teaserPreloadEnabled = + opts?.mobileStackTopOnlyVideo + ? false + : (opts?.preloadTeaserWhenStill ? true : !isSmall) + + const shouldPreloadSprite = + Boolean(teaserPreloadEnabled && hasSpriteScrubber && spriteUrl) + + const spriteScrubbingActive = + hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number' + const scrubProgressRatio = scrubProgressRatioFromIndex(scrubActiveIndex, scrubberCount) const spriteFrameStyle = buildSpriteFrameStyle({ @@ -1116,9 +1144,10 @@ function FinishedDownloadsCardsView({ variant="fill" durationSeconds={durations[k] ?? (j as any)?.meta?.file?.durationSeconds} onDuration={handleDuration} + onResolution={handleResolution} showPopover={false} blur={inlineActive ? false : Boolean(blurPreviews)} - animated={allowTeaserAnimation} + animated={allowTeaserAnimation && !spriteScrubbingActive} animatedMode="teaser" animatedTrigger="always" inlineVideo={!opts?.disableInline && inlineActive ? 'always' : false} @@ -1129,18 +1158,16 @@ function FinishedDownloadsCardsView({ popoverMuted={previewMuted} assetNonce={assetNonce ?? 0} alwaysLoadStill={forceLoadStill} - teaserPreloadEnabled={ - opts?.mobileStackTopOnlyVideo - ? false - : (opts?.preloadTeaserWhenStill ? true : !isSmall) - } + teaserPreloadEnabled={teaserPreloadEnabled} teaserPreloadRootMargin={ opts?.preloadTeaserWhenStill ? '1200px 0px' : (isSmall ? '900px 0px' : '700px 0px') } - scrubProgressRatio={scrubProgressRatio} - preferScrubProgress={scrubHovering && typeof scrubActiveIndex === 'number'} + scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio} + preferScrubProgress={ + !hasSpriteScrubber && scrubHovering && typeof scrubActiveIndex === 'number' + } forceActive={Boolean(isSmall && opts?.mobileStackTopOnlyVideo && !opts?.forceStill)} activationNonce={ previewRenderRole === 'top' @@ -1180,18 +1207,11 @@ function FinishedDownloadsCardsView({ Ton aktivieren ) : null} - - {/* ✅ Sprite einmal vorladen, damit der erste Scrub-Move sofort sichtbar ist */} - {hasSpriteScrubber && spriteUrl ? ( - - ) : null} + + {/* ✅ Scrub-Frame Overlay via Sprite (kein Request pro Move) */} {hasSpriteScrubber && spriteFrameStyle && !inlineActive ? ( @@ -1263,6 +1283,8 @@ function FinishedDownloadsCardsView({ e.preventDefault() e.stopPropagation() + if (isSmall) return + if (selectionStore.hasAnySelection()) { onToggleSelected(j) return @@ -1291,9 +1313,10 @@ function FinishedDownloadsCardsView({ />
- {renderPostworkBadge && livePostwork ? ( -
- {renderPostworkBadge(livePostwork)} + {(renderIntegrityBadge || (renderPostworkBadge && livePostwork)) ? ( +
+ {renderIntegrityBadge ? renderIntegrityBadge(j) : null} + {renderPostworkBadge && livePostwork ? renderPostworkBadge(livePostwork) : null}
) : null}
@@ -1549,6 +1572,7 @@ function FinishedDownloadsCardsView({ lower={lower} jobForDetails={jobForDetails} handleDuration={handleDuration} + handleResolution={handleResolution} handleScrubberClickIndex={handleScrubberClickIndex} onToggleSelected={onToggleSelected} onSelectOnly={onSelectOnly} @@ -1569,6 +1593,7 @@ function FinishedDownloadsCardsView({ deleteVideo={deleteVideo} keepVideo={keepVideo} renderPostworkBadge={renderPostworkBadge} + renderIntegrityBadge={renderIntegrityBadge} /> ) })} @@ -1783,5 +1808,5 @@ function FinishedDownloadsCardsView({ ) } -const MemoFinishedDownloadsCardsView = React.memo(FinishedDownloadsCardsView) +const MemoFinishedDownloadsCardsView = memo(FinishedDownloadsCardsView) export default MemoFinishedDownloadsCardsView \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index 63304ec..ba65cfd 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -2,7 +2,7 @@ 'use client' -import * as React from 'react' +import { useState, useMemo, useCallback, memo, type ReactNode, type SyntheticEvent } from 'react' import type { RecordJob, GalleryModelFlags, @@ -12,7 +12,6 @@ import FinishedVideoPreview from './FinishedVideoPreview' import RecordJobActions from './RecordJobActions' import TagOverflowRow from './TagOverflowRow' import { isHotName, stripHotPrefix } from './hotName' -import { formatResolution } from './formatters' import PreviewScrubber from './PreviewScrubber' import type { FinishedDownloadsTeaserState } from './teaserPlayback' import { @@ -29,6 +28,7 @@ import { import ModelGenderIcon from './ModelGenderIcon' import { resolveFinishedModelFlags } from './finishedModelFlags' import CountryFlag, { resolveFinishedCountry } from './CountryFlag' +import SpritePreloader from './SpritePreloader' function normalizeDurationSeconds(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined @@ -49,7 +49,7 @@ type Props = { output: string | undefined, summaries: Record ) => FinishedPostworkSummary | undefined - renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode durationSeconds?: number teaserState: FinishedDownloadsTeaserState @@ -76,6 +76,8 @@ type Props = { onToggleTagFilter: (tag: string) => void onOpenPlayer: (job: RecordJob) => void handleDuration: (job: RecordJob, seconds: number) => void + handleResolution: (job: RecordJob, w: number, h: number) => void + resolutionLabelOf: (job: RecordJob) => string handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise @@ -86,11 +88,13 @@ type Props = { onSplit?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise jobForDetails: (job: RecordJob) => RecordJob + renderIntegrityBadge?: (job: RecordJob) => ReactNode contentPaddingClass: string footerMinHeightClass: string titleClass: string sublineClass: string + assetNonce?: number } function FinishedDownloadsGalleryCardInner({ @@ -119,11 +123,12 @@ function FinishedDownloadsGalleryCardInner({ removingKeys, registerTeaserHost, onToggleSelected, - onSelectOnly, onHoverPreviewKeyChange, onToggleTagFilter, onOpenPlayer, handleDuration, + handleResolution, + resolutionLabelOf, handleScrubberClickIndex, deleteVideo, keepVideo, @@ -134,19 +139,21 @@ function FinishedDownloadsGalleryCardInner({ onSplit, onAddToDownloads, jobForDetails, + renderIntegrityBadge, contentPaddingClass, footerMinHeightClass, titleClass, sublineClass, + assetNonce }: Props) { const k = rowKey const checked = selectedKeys.has(rowKey) - const [scrubIndexByKey, setScrubIndexByKey] = React.useState>({}) - const [hoveredThumbKey, setHoveredThumbKey] = React.useState(null) + const [scrubIndexByKey, setScrubIndexByKey] = useState>({}) + const [hoveredThumbKey, setHoveredThumbKey] = useState(null) - const handlePrimaryClick = React.useCallback( - (e?: React.SyntheticEvent) => { + const handlePrimaryClick = useCallback( + (e?: SyntheticEvent) => { e?.preventDefault() e?.stopPropagation() @@ -157,10 +164,10 @@ function FinishedDownloadsGalleryCardInner({ onOpenPlayer(j) }, - [selectionModeActive, onSelectOnly, onOpenPlayer, j] + [selectionModeActive, onToggleSelected, onOpenPlayer, j] ) - const setScrubIndexForKey = React.useCallback((key: string, index: number | undefined) => { + const setScrubIndexForKey = useCallback((key: string, index: number | undefined) => { setScrubIndexByKey((prev) => { if (index === undefined) { if (!(key in prev)) return prev @@ -173,7 +180,7 @@ function FinishedDownloadsGalleryCardInner({ }) }, []) - const clearScrubIndex = React.useCallback((key: string) => { + const clearScrubIndex = useCallback((key: string) => { setScrubIndexForKey(key, undefined) }, [setScrubIndexForKey]) @@ -192,7 +199,7 @@ function FinishedDownloadsGalleryCardInner({ lower ) - const tags = React.useMemo(() => { + const tags = useMemo(() => { const s = String(flags?.tags ?? '').trim() if (!s) return [] as string[] const parts = s.split(/[\n,;|]+/g).map((p) => p.trim()).filter(Boolean) @@ -223,9 +230,9 @@ function FinishedDownloadsGalleryCardInner({ const dur = runtimeOf(j) const size = formatBytes(sizeBytesOf(j)) - const meta = React.useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta]) + const meta = useMemo(() => parseJobMeta((j as any)?.meta), [(j as any)?.meta]) - const resObj = React.useMemo(() => { + const resObj = useMemo(() => { const w = meta?.media?.video?.width ?? meta?.file?.video?.width const h = meta?.media?.video?.height ?? meta?.file?.video?.height if ( @@ -237,20 +244,22 @@ function FinishedDownloadsGalleryCardInner({ return null }, [meta]) - const resLabel = formatResolution(resObj) + const resLabelRaw = resolutionLabelOf(j) + const resLabel = resLabelRaw && resLabelRaw !== '—' ? resLabelRaw : '' const isRemoving = removingKeys.has(k) const isDeleting = deletingKeys.has(k) const isKeeping = keepingKeys.has(k) const busy = isDeleting || isKeeping || isRemoving - const sprite = React.useMemo( + const sprite = useMemo( () => readPreviewSpriteInfo((j as any)?.meta, { fallbackPath: flags?.previewScrubberPath, fallbackCount: flags?.previewScrubberCount, + versionFallback: assetNonce ?? 0, }), - [(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount] + [(j as any)?.meta, flags?.previewScrubberPath, flags?.previewScrubberCount, assetNonce] ) const spriteUrl = sprite.url @@ -259,11 +268,12 @@ function FinishedDownloadsGalleryCardInner({ const scrubberCount = sprite.count const scrubberStepSeconds = sprite.stepSeconds const hasScrubber = hasScrubberUi + const shouldPreloadSprite = Boolean(hasSpriteScrubber && spriteUrl) const activeScrubIndex = scrubIndexByKey[k] const scrubProgressRatio = scrubProgressRatioFromIndex(activeScrubIndex, scrubberCount) - const spriteFrameStyle = React.useMemo( + const spriteFrameStyle = useMemo( () => buildSpriteFrameStyle({ spriteUrl, @@ -276,13 +286,15 @@ function FinishedDownloadsGalleryCardInner({ ) const showScrubberSpriteInThumb = Boolean(spriteFrameStyle) - const hideTeaserUnderOverlay = showScrubberSpriteInThumb const previewDurationSeconds = durationSeconds ?? normalizeDurationSeconds(meta?.media?.durationSeconds) ?? normalizeDurationSeconds(meta?.file?.durationSeconds) + const spriteScrubbingActive = + hasSpriteScrubber && typeof activeScrubIndex === 'number' + return (
stripHotPrefix(baseName(p))} durationSeconds={previewDurationSeconds} onDuration={handleDuration} + onResolution={handleResolution} variant="fill" showPopover={false} blur={blurPreviews} animated={shouldAnimateTeaser({ state: teaserState, itemKey: k, - disabled: hideTeaserUnderOverlay, + disabled: spriteScrubbingActive, })} animatedMode="teaser" animatedTrigger="always" muted={previewMuted} popoverMuted={previewMuted} - scrubProgressRatio={scrubProgressRatio} - preferScrubProgress={typeof activeScrubIndex === 'number'} + assetNonce={assetNonce ?? 0} + scrubProgressRatio={hasSpriteScrubber ? undefined : scrubProgressRatio} + preferScrubProgress={!hasSpriteScrubber && typeof activeScrubIndex === 'number'} forceActive={isPreviewActive} teaserPreloadEnabled />
- {hasSpriteScrubber && spriteUrl ? ( - - ) : null} + {showScrubberSpriteInThumb && spriteFrameStyle ? ( - {showPostworkBadge && renderPostworkBadge ? ( -
- {renderPostworkBadge(postworkBadge)} + {(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? ( +
+ {renderIntegrityBadge ? renderIntegrityBadge(j) : null} + {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
@@ -519,5 +528,5 @@ function FinishedDownloadsGalleryCardInner({ ) } -const FinishedDownloadsGalleryCard = React.memo(FinishedDownloadsGalleryCardInner) +const FinishedDownloadsGalleryCard = memo(FinishedDownloadsGalleryCardInner) export default FinishedDownloadsGalleryCard \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx index 1526cda..eeb259d 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryView.tsx @@ -1,7 +1,7 @@ // frontend\src\components\ui\FinishedDownloadsGalleryView.tsx 'use client' -import * as React from 'react' +import { useEffect, useRef, useMemo, useCallback, memo, type ReactNode } from 'react' import type { RecordJob, GalleryModelFlags, @@ -28,10 +28,12 @@ type Props = { summaries: Record ) => FinishedPostworkSummary | undefined teaserState: FinishedDownloadsTeaserState - renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode cardScale?: number handleDuration: (job: RecordJob, seconds: number) => void + handleResolution: (job: RecordJob, w: number, h: number) => void + resolutionLabelOf: (job: RecordJob) => string handleScrubberClickIndex: (job: RecordJob, segmentIndex: number, segmentCount: number) => void jobForDetails: (job: RecordJob) => RecordJob @@ -68,6 +70,8 @@ type Props = { onRecommendedPageSizeChange?: (size: number) => void forcePreviewMuted?: boolean + assetNonce?: number + renderIntegrityBadge?: (job: RecordJob) => ReactNode } function snapTo(value: number, step: number, base = 0) { @@ -96,6 +100,8 @@ function FinishedDownloadsGalleryView({ onToggleSelected, onSelectOnly, handleDuration, + handleResolution, + resolutionLabelOf, handleScrubberClickIndex, jobForDetails, keyFor, @@ -124,10 +130,12 @@ function FinishedDownloadsGalleryView({ onAddToDownloads, onRecommendedPageSizeChange, forcePreviewMuted, + assetNonce, + renderIntegrityBadge, }: Props) { const observeTeasers = shouldObserveTeasers(teaserState.mode) - const registerTeaserHostIfNeeded = React.useCallback( + const registerTeaserHostIfNeeded = useCallback( (key: string) => (el: HTMLDivElement | null) => { if (!observeTeasers) { registerTeaserHost(key)(null) @@ -145,26 +153,26 @@ function FinishedDownloadsGalleryView({ const effectiveScale = clamp(effectiveScaleRaw, 0.8, 1.35) - const layoutScale = React.useMemo( + const layoutScale = useMemo( () => snapTo(effectiveScale, 0.05, 0.8), [effectiveScale] ) - const sizePreset = React.useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => { + const sizePreset = useMemo<'sm' | 'md' | 'lg' | 'xl'>(() => { if (layoutScale >= 1.14) return 'xl' if (layoutScale >= 0.99) return 'lg' if (layoutScale >= 0.88) return 'md' return 'sm' }, [layoutScale]) - const minCardWidth = React.useMemo(() => { + const minCardWidth = useMemo(() => { return Math.round(270 * layoutScale) }, [layoutScale]) - const rootRef = React.useRef(null) - const lastRecommendedPageSizeRef = React.useRef(null) + const rootRef = useRef(null) + const lastRecommendedPageSizeRef = useRef(null) - const recommendPageSize = React.useCallback(() => { + const recommendPageSize = useCallback(() => { if (!onRecommendedPageSizeChange) return const el = rootRef.current @@ -190,7 +198,7 @@ function FinishedDownloadsGalleryView({ onRecommendedPageSizeChange(nextPageSize) }, [minCardWidth, onRecommendedPageSizeChange]) - React.useEffect(() => { + useEffect(() => { recommendPageSize() const el = rootRef.current @@ -291,6 +299,8 @@ function FinishedDownloadsGalleryView({ onToggleTagFilter={onToggleTagFilter} onOpenPlayer={onOpenPlayer} handleDuration={handleDuration} + handleResolution={handleResolution} + resolutionLabelOf={resolutionLabelOf} handleScrubberClickIndex={handleScrubberClickIndex} deleteVideo={deleteVideo} keepVideo={keepVideo} @@ -305,6 +315,8 @@ function FinishedDownloadsGalleryView({ footerMinHeightClass={footerMinHeightClass} titleClass={titleClass} sublineClass={sublineClass} + assetNonce={assetNonce ?? 0} + renderIntegrityBadge={renderIntegrityBadge} />
) @@ -323,5 +335,5 @@ function FinishedDownloadsGalleryView({ ) } -const MemoFinishedDownloadsGalleryView = React.memo(FinishedDownloadsGalleryView) +const MemoFinishedDownloadsGalleryView = memo(FinishedDownloadsGalleryView) export default MemoFinishedDownloadsGalleryView \ No newline at end of file diff --git a/frontend/src/components/ui/FinishedDownloadsTableView.tsx b/frontend/src/components/ui/FinishedDownloadsTableView.tsx index b062ddf..3d0660d 100644 --- a/frontend/src/components/ui/FinishedDownloadsTableView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsTableView.tsx @@ -2,7 +2,7 @@ 'use client' -import * as React from 'react' +import { useState, useMemo, useCallback, type Dispatch, type ReactNode, type SetStateAction } from 'react' import Table, { type Column, type SortState } from './Table' import type { RecordJob, @@ -50,12 +50,12 @@ type Props = { summaries: Record ) => FinishedPostworkSummary | undefined jobForDetails: (job: RecordJob) => RecordJob - renderPostworkBadge?: (badge?: FinishedPostworkSummary) => React.ReactNode + renderPostworkBadge?: (badge?: FinishedPostworkSummary) => ReactNode // teaser/preview canHover: boolean teaserState: FinishedDownloadsTeaserState - setHoverTeaserKey: React.Dispatch> + setHoverTeaserKey: Dispatch> registerTeaserHost: (key: string) => (el: HTMLDivElement | null) => void handleDuration: (job: RecordJob, seconds: number) => void handleResolution: (job: RecordJob, w: number, h: number) => void @@ -88,6 +88,7 @@ type Props = { deleteVideo: (job: RecordJob) => Promise keepVideo: (job: RecordJob) => Promise forcePreviewMuted?: boolean + renderIntegrityBadge?: (job: RecordJob) => ReactNode } export default function FinishedDownloadsTableView({ @@ -143,10 +144,11 @@ export default function FinishedDownloadsTableView({ deleteVideo, keepVideo, forcePreviewMuted, + renderIntegrityBadge, }: Props) { - const [sort, setSort] = React.useState(null) + const [sort, setSort] = useState(null) - const runtimeSecondsForSort = React.useCallback((job: RecordJob) => { + const runtimeSecondsForSort = useCallback((job: RecordJob) => { // 1) Prefer real video duration (ffprobe / backend) const sec = (job as any)?.meta?.file?.durationSeconds if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) return sec @@ -162,7 +164,7 @@ export default function FinishedDownloadsTableView({ return Number.POSITIVE_INFINITY }, []) - const parseTags = React.useCallback((raw?: string): string[] => { + const parseTags = useCallback((raw?: string): string[] => { const s = String(raw ?? '').trim() if (!s) return [] const parts = s @@ -182,11 +184,11 @@ export default function FinishedDownloadsTableView({ return out }, []) - const parseMeta = React.useCallback((j: RecordJob): any | null => { + const parseMeta = useCallback((j: RecordJob): any | null => { return parseJobMeta((j as any)?.meta) }, []) - const previewSpriteInfoOf = React.useCallback( + const previewSpriteInfoOf = useCallback( (j: RecordJob): { count: number; stepSeconds: number } | null => { const meta = parseMeta(j) let ps: any = meta?.previewSprite ?? meta?.preview?.sprite ?? null @@ -212,9 +214,9 @@ export default function FinishedDownloadsTableView({ [parseMeta] ) - const [scrubActiveByKey, setScrubActiveByKey] = React.useState>({}) + const [scrubActiveByKey, setScrubActiveByKey] = useState>({}) - const setScrubActiveIndex = React.useCallback((key: string, index: number | undefined) => { + const setScrubActiveIndex = useCallback((key: string, index: number | undefined) => { setScrubActiveByKey((prev) => { if (prev[key] === index) return prev return { ...prev, [key]: index } @@ -227,7 +229,7 @@ export default function FinishedDownloadsTableView({ const someSelectedOnPage = rows.some((j) => selectedKeys.has(keyFor(j))) && !allSelectedOnPage - const handlePrimaryAction = React.useCallback( + const handlePrimaryAction = useCallback( (job: RecordJob) => { if (selectionModeActive) { onToggleSelected(job) @@ -239,7 +241,7 @@ export default function FinishedDownloadsTableView({ [selectionModeActive, onToggleSelected, onOpenPlayer] ) - const columns = React.useMemo[]>(() => { + const columns = useMemo[]>(() => { return [ { key: 'select', @@ -411,9 +413,10 @@ export default function FinishedDownloadsTableView({ />
- {showPostworkBadge && renderPostworkBadge ? ( -
- {renderPostworkBadge(postworkBadge)} + {(renderIntegrityBadge || (showPostworkBadge && renderPostworkBadge)) ? ( +
+ {renderIntegrityBadge ? renderIntegrityBadge(j) : null} + {showPostworkBadge && renderPostworkBadge ? renderPostworkBadge(postworkBadge) : null}
) : null}
@@ -604,9 +607,10 @@ export default function FinishedDownloadsTableView({ onToggleSelectAllPage, allSelectedOnPage, someSelectedOnPage, + renderIntegrityBadge ]) - const sortStateToMode = React.useCallback((s: SortState): DoneSortMode => { + const sortStateToMode = useCallback((s: SortState): DoneSortMode => { if (!s) return 'completed_desc' const anyS = s as any const key = String(anyS.key ?? anyS.columnKey ?? anyS.id ?? '') @@ -621,7 +625,7 @@ export default function FinishedDownloadsTableView({ return asc ? 'completed_asc' : 'completed_desc' }, []) - const handleSortChange = React.useCallback( + const handleSortChange = useCallback( (s: SortState) => { setSort(s) // Table Pfeil onSortModeChange(sortStateToMode(s)) @@ -630,7 +634,7 @@ export default function FinishedDownloadsTableView({ [onSortModeChange, sortStateToMode, page, onPageChange] ) - const rowClassName = React.useCallback( + const rowClassName = useCallback( (j: RecordJob) => { const k = keyFor(j) return [ diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 81b4e81..0b7791e 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -150,8 +150,7 @@ export default function FinishedVideoPreview({ return m }, [job]) - // ✅ merge statt "meta ?? fetchedMeta" - // job.meta bleibt Basis, fetchedMeta ergänzt fehlende Felder (z.B. previewClips) + // meta aus job normalisiert; dient als Basis für Preview-/Teaser-Daten const metaForPreview = meta const [progressMountTick, setProgressMountTick] = useState(0) @@ -685,8 +684,8 @@ export default function FinishedVideoPreview({ const teaserSrc = useMemo(() => { if (!previewId) return '' - return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1` - }, [previewId]) + return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1&v=${v}` + }, [previewId, v]) // --- Inline Video sichtbar? const showingInlineVideo = @@ -1403,7 +1402,7 @@ export default function FinishedVideoPreview({ {/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */} {!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? (