From 1775cb5acd5ec416c65f5ef63d65f5da28d471e8 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 19 Jun 2026 08:12:43 +0200 Subject: [PATCH] updated --- backend/record.go | 2 + backend/recorder.go | 17 +- backend/recorder_settings.json | 2 +- backend/startup_leftovers.go | 183 ++++++++++++-- backend/tasks_cleanup.go | 46 +++- backend/tasks_status.go | 1 + frontend/src/App.tsx | 91 ++++++- .../components/ui/FinishedVideoPreview.tsx | 229 +++++++++++++++--- .../src/components/ui/RecorderSettings.tsx | 16 +- 9 files changed, 497 insertions(+), 90 deletions(-) diff --git a/backend/record.go b/backend/record.go index fcd47bc..1e36c1f 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1310,10 +1310,12 @@ func postworkAudioSidecarCandidatesForOutput(out string) []string { // Explizite Fallbacks, falls canonicalAssetIDFromName anders normalisiert. if base != "" { + candidates = append(candidates, filepath.Join(dir, base+".audio.m4s")) candidates = append(candidates, filepath.Join(dir, "."+base+".audio.m4s")) } if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" { + candidates = append(candidates, filepath.Join(dir, canonical+".audio.m4s")) candidates = append(candidates, filepath.Join(dir, "."+canonical+".audio.m4s")) } diff --git a/backend/recorder.go b/backend/recorder.go index efc4a52..0c9a5fe 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -747,10 +747,19 @@ func findPostworkAudioSidecarForTS(tsPath string) (string, bool) { dir := filepath.Dir(tsPath) base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath)) - candidates = append(candidates, - filepath.Join(dir, "."+base+".audio.m4s"), - filepath.Join(dir, "."+canonicalAssetIDFromName(base)+".audio.m4s"), - ) + if base != "" { + candidates = append(candidates, + filepath.Join(dir, base+".audio.m4s"), + filepath.Join(dir, "."+base+".audio.m4s"), + ) + } + + if canonical := strings.TrimSpace(canonicalAssetIDFromName(base)); canonical != "" { + candidates = append(candidates, + filepath.Join(dir, canonical+".audio.m4s"), + filepath.Join(dir, "."+canonical+".audio.m4s"), + ) + } seen := map[string]bool{} diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index 19c714a..8900b7c 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -6,7 +6,7 @@ "ffmpegPath": "", "autoAddToDownloadList": true, "autoStartAddedDownloads": true, - "enableConcurrentDownloadsLimit": true, + "enableConcurrentDownloadsLimit": false, "maxConcurrentDownloads": 80, "useProviderApis": true, "useChaturbateApi": true, diff --git a/backend/startup_leftovers.go b/backend/startup_leftovers.go index d3c602e..b7abbf5 100644 --- a/backend/startup_leftovers.go +++ b/backend/startup_leftovers.go @@ -3,6 +3,8 @@ package main import ( + "context" + "fmt" "os" "path/filepath" "strings" @@ -29,9 +31,45 @@ func startPostworkLeftoverScanOnStartup() { time.Sleep(postworkLeftoverInitialDelay) runPostworkLeftoverScan := func(reason string) { - removedMuxing, skippedMuxing := cleanupStaleMuxingTempFilesFromRecordDir() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + jobID := newTaskID(postworkLeftoverTaskPrefix(reason)) + createCleanupJob(jobID, "Wartet…", cancel) + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + st.StartedAt = time.Now() + st.Done = 0 + st.Total = 2 + st.CurrentFile = "" + st.Text = postworkLeftoverRunningText(reason) + st.Error = "" + st.FinishedAt = nil + }) + + removedMuxing, skippedMuxing, err := cleanupStaleMuxingTempFilesFromRecordDir(ctx) + if err != nil { + finishCleanupJob(jobID, "", err) + return + } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Done = 1 + st.Total = 2 + st.Text = "Suche offene Postwork-Dateien…" + st.CurrentFile = "" + }) + + queued, skipped, err := enqueuePostworkLeftoversFromRecordDir(ctx) + if err != nil { + finishCleanupJob(jobID, "", err) + return + } + + summary := postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing) - queued, skipped := enqueuePostworkLeftoversFromRecordDir() if queued > 0 || skipped > 0 || removedMuxing > 0 || skippedMuxing > 0 { appLogln( "🧩 [startup-postwork] leftover scan fertig:", @@ -42,6 +80,14 @@ func startPostworkLeftoverScanOnStartup() { "skippedMuxing=", skippedMuxing, ) } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Done = 2 + st.Total = 2 + st.Text = summary + st.CurrentFile = "" + }) + finishCleanupJob(jobID, summary, nil) } runPostworkLeftoverScan("startup") @@ -55,7 +101,72 @@ func startPostworkLeftoverScanOnStartup() { }() } -func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) { +func postworkLeftoverTaskPrefix(reason string) string { + switch strings.TrimSpace(strings.ToLower(reason)) { + case "startup": + return "cleanup-leftovers-startup" + case "interval": + return "cleanup-leftovers-hourly" + default: + return "cleanup-leftovers" + } +} + +func postworkLeftoverRunningText(reason string) string { + switch strings.TrimSpace(strings.ToLower(reason)) { + case "startup": + return "Startup-Cleanup: prüfe Record-Reste…" + case "interval": + return "Stündlicher Cleanup: prüfe Record-Reste…" + default: + return "Prüfe Record-Reste…" + } +} + +func sleepContext(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + + timer := time.NewTimer(d) + defer timer.Stop() + + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func postworkLeftoverScanSummary(queued, skipped, removedMuxing, skippedMuxing int) string { + parts := make([]string, 0, 4) + + if queued > 0 { + parts = append(parts, fmt.Sprintf("Postwork eingereiht: %d", queued)) + } + if removedMuxing > 0 { + parts = append(parts, fmt.Sprintf("Muxing-Temps gelöscht: %d", removedMuxing)) + } + if skipped > 0 { + parts = append(parts, fmt.Sprintf("Postwork übersprungen: %d", skipped)) + } + if skippedMuxing > 0 { + parts = append(parts, fmt.Sprintf("Muxing-Temps übersprungen: %d", skippedMuxing)) + } + + if len(parts) == 0 { + return "Keine Record-Reste gefunden." + } + + return strings.Join(parts, " | ") +} + +func cleanupStaleMuxingTempFilesFromRecordDir(ctx context.Context) (removed int, skipped int, err error) { + if err := ctx.Err(); err != nil { + return 0, 0, err + } + s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) @@ -66,18 +177,22 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) { recordAbs = strings.TrimSpace(recordAbs) if recordAbs == "" { appLogln("⚠️ [startup-postwork] recordDir ist leer") - return 0, 0 + return 0, 0, nil } entries, err := os.ReadDir(recordAbs) if err != nil { appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) - return 0, 0 + return 0, 0, nil } now := time.Now() for _, e := range entries { + if err := ctx.Err(); err != nil { + return removed, skipped, err + } + if e == nil || e.IsDir() { continue } @@ -123,7 +238,11 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) { continue } - if !singleFileStable(path) { + stable, err := singleFileStable(ctx, path) + if err != nil { + return removed, skipped, err + } + if !stable { skipped++ appLogln("⏭️ [startup-postwork] .muxing.ts nicht stabil:", name) continue @@ -146,7 +265,7 @@ func cleanupStaleMuxingTempFilesFromRecordDir() (removed int, skipped int) { ) } - return removed, skipped + return removed, skipped, nil } func isMuxingTempTSName(name string) bool { @@ -160,10 +279,10 @@ func isMuxingTempTSName(name string) bool { return strings.HasSuffix(lowerName, ".muxing.ts") } -func singleFileStable(path string) bool { +func singleFileStable(ctx context.Context, path string) (bool, error) { path = strings.TrimSpace(path) if path == "" { - return false + return false, nil } sizeOf := func(p string) (int64, bool) { @@ -177,20 +296,26 @@ func singleFileStable(path string) bool { size1, ok1 := sizeOf(path) if !ok1 { - return false + return false, nil } - time.Sleep(1500 * time.Millisecond) + if err := sleepContext(ctx, 1500*time.Millisecond); err != nil { + return false, err + } size2, ok2 := sizeOf(path) if !ok2 { - return false + return false, nil } - return size1 == size2 + return size1 == size2, nil } -func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { +func enqueuePostworkLeftoversFromRecordDir(ctx context.Context) (queued int, skipped int, err error) { + if err := ctx.Err(); err != nil { + return 0, 0, err + } + s := getSettings() recordAbs, err := resolvePathRelativeToApp(s.RecordDir) @@ -200,13 +325,13 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { recordAbs = strings.TrimSpace(recordAbs) if recordAbs == "" { appLogln("⚠️ [startup-postwork] recordDir ist leer") - return 0, 0 + return 0, 0, nil } entries, err := os.ReadDir(recordAbs) if err != nil { appLogln("⚠️ [startup-postwork] recordDir lesen fehlgeschlagen:", err) - return 0, 0 + return 0, 0, nil } now := time.Now() @@ -225,6 +350,10 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { } for _, e := range entries { + if err := ctx.Err(); err != nil { + return queued, skipped, err + } + if e == nil || e.IsDir() { continue } @@ -311,7 +440,11 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { continue } - if !postworkLeftoverFilesStable(tsPath, audioPath) { + stable, err := postworkLeftoverFilesStable(ctx, tsPath, audioPath) + if err != nil { + return queued, skipped, err + } + if !stable { skip(name, "files not stable", filepath.Base(audioPath)) continue } @@ -323,7 +456,7 @@ func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) { } } - return queued, skipped + return queued, skipped, nil } func postworkLeftoverAlreadyTracked(tsPath string) bool { @@ -373,11 +506,11 @@ func postworkLeftoverDoneMP4Exists(tsPath string) bool { return err == nil && fi != nil && !fi.IsDir() && fi.Size() > 0 } -func postworkLeftoverFilesStable(tsPath string, audioPath string) bool { +func postworkLeftoverFilesStable(ctx context.Context, tsPath string, audioPath string) (bool, error) { tsPath = strings.TrimSpace(tsPath) audioPath = strings.TrimSpace(audioPath) if tsPath == "" || audioPath == "" { - return false + return false, nil } sizeOf := func(p string) (int64, bool) { @@ -391,18 +524,20 @@ func postworkLeftoverFilesStable(tsPath string, audioPath string) bool { ts1, ok1 := sizeOf(tsPath) audio1, ok2 := sizeOf(audioPath) if !ok1 || !ok2 { - return false + return false, nil } - time.Sleep(1500 * time.Millisecond) + if err := sleepContext(ctx, 1500*time.Millisecond); err != nil { + return false, err + } ts2, ok1 := sizeOf(tsPath) audio2, ok2 := sizeOf(audioPath) if !ok1 || !ok2 { - return false + return false, nil } - return ts1 == ts2 && audio1 == audio2 + return ts1 == ts2 && audio1 == audio2, nil } func enqueuePostworkLeftover(tsPath string, tsInfo os.FileInfo) bool { diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 6966d92..20f85cd 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -62,6 +62,33 @@ type cleanupTaskJob struct { var cleanupJobsMu sync.Mutex var cleanupJobs = map[string]*cleanupTaskJob{} +func createCleanupJob(jobID string, text string, cancel context.CancelFunc) CleanupTaskState { + st := CleanupTaskState{ + ID: jobID, + Queued: true, + Running: false, + Cancellable: cancel != nil, + QueuedAt: time.Now(), + StartedAt: time.Time{}, + FinishedAt: nil, + Text: strings.TrimSpace(text), + Error: "", + CurrentFile: "", + Done: 0, + Total: 0, + } + + cleanupJobsMu.Lock() + cleanupJobs[jobID] = &cleanupTaskJob{ + State: st, + Cancel: cancel, + } + cleanupJobsMu.Unlock() + + publishTaskState() + return st +} + func snapshotCleanupJobs() []CleanupTaskState { cleanupJobsMu.Lock() defer cleanupJobsMu.Unlock() @@ -184,6 +211,7 @@ func settingsCleanupHandler(w http.ResponseWriter, r *http.Request) { ID: jobID, Queued: true, Running: false, + Cancellable: true, QueuedAt: time.Now(), StartedAt: time.Time{}, FinishedAt: nil, @@ -467,16 +495,14 @@ func cleanupTSPathForAudioSidecar(audioPath string) string { dir := filepath.Dir(audioPath) name := filepath.Base(audioPath) + lowName := strings.ToLower(name) - if !strings.HasPrefix(name, ".") { - return "" - } - if !strings.HasSuffix(strings.ToLower(name), ".audio.m4s") { + if !strings.HasSuffix(lowName, ".audio.m4s") { return "" } - base := strings.TrimPrefix(name, ".") - base = strings.TrimSuffix(base, ".audio.m4s") + base := name[:len(name)-len(".audio.m4s")] + base = strings.TrimPrefix(base, ".") if strings.TrimSpace(base) == "" { return "" @@ -543,7 +569,7 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin if strings.HasSuffix(lowName, ".muxing.ts") { resp.DeletedMuxingTemps++ - } else if strings.HasPrefix(name, ".") && strings.HasSuffix(lowName, ".audio.m4s") { + } else if strings.HasSuffix(lowName, ".audio.m4s") { resp.DeletedAudioOrphans++ } else if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") { resp.DeletedTSOrphans++ @@ -637,7 +663,7 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs } // Fall 1: - // foo.ts existiert, aber .foo.audio.m4s fehlt. + // foo.ts existiert, aber kein passendes foo.audio.m4s/.foo.audio.m4s. if !strings.HasPrefix(name, ".") && strings.EqualFold(filepath.Ext(name), ".ts") { resp.ScannedFiles++ @@ -650,8 +676,8 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs } // Fall 2: - // .foo.audio.m4s existiert, aber foo.ts fehlt. - if strings.HasPrefix(name, ".") && strings.HasSuffix(low, ".audio.m4s") { + // foo.audio.m4s/.foo.audio.m4s existiert, aber foo.ts fehlt. + if strings.HasSuffix(low, ".audio.m4s") { resp.ScannedFiles++ tsPath := cleanupTSPathForAudioSidecar(full) diff --git a/backend/tasks_status.go b/backend/tasks_status.go index e4fc307..2c32538 100644 --- a/backend/tasks_status.go +++ b/backend/tasks_status.go @@ -16,6 +16,7 @@ type CleanupTaskState struct { ID string `json:"id"` Queued bool `json:"queued"` Running bool `json:"running"` + Cancellable bool `json:"cancellable"` QueuedAt time.Time `json:"queuedAt"` StartedAt time.Time `json:"startedAt"` FinishedAt *time.Time `json:"finishedAt,omitempty"` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5650143..06aac2d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,8 @@ import LastUpdatedText from './components/ui/LastUpdatedText' import TrainingTab from './components/ui/TrainingTab' const COOKIE_STORAGE_KEY = 'record_cookies' +const MODELS_OVERVIEW_DEBOUNCE_MS = 750 +const MODELS_OVERVIEW_COOLDOWN_MS = 5000 function normalizeCookies(obj: Record | null | undefined): Record { const input = obj ?? {} @@ -215,6 +217,20 @@ function mergeStoredModel( } } +function storedModelShallowEqual(a?: StoredModel | null, b?: StoredModel | null) { + if (a === b) return true + if (!a || !b) return false + + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (!Object.is((a as any)[key], (b as any)[key])) { + return false + } + } + + return true +} + function normalizePendingShow(v: unknown): 'public' | 'private' | 'hidden' | 'away' | 'offline' | 'unknown' { const s = String(v ?? '').trim().toLowerCase() @@ -1747,6 +1763,8 @@ export default function App() { const overviewLastSigRef = useRef('') const overviewLastAtRef = useRef(0) const overviewTimerRef = useRef(null) + const overviewAbortRef = useRef(null) + const overviewPendingKeysRef = useRef(null) const loadAppModelsSnapshot = useCallback(async (keys: string[]) => { const uniqKeys = Array.from( @@ -1760,30 +1778,51 @@ export default function App() { const sig = uniqKeys.join('|') const now = Date.now() - if (overviewInFlightRef.current) return - if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < 1500) return + if (overviewInFlightRef.current) { + overviewPendingKeysRef.current = uniqKeys + return + } + + if (overviewLastSigRef.current === sig && now - overviewLastAtRef.current < MODELS_OVERVIEW_COOLDOWN_MS) return overviewInFlightRef.current = true overviewLastSigRef.current = sig overviewLastAtRef.current = now + overviewAbortRef.current?.abort() + + const controller = new AbortController() + overviewAbortRef.current = controller try { const data = await apiJSON('/api/models/overview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store' as any, + signal: controller.signal, body: JSON.stringify({ keys: uniqKeys, includeWatched: true, }), }) - setHeaderStats({ - totalCount: Number(data?.totalCount ?? 0), - onlineModelsCount: Number(data?.onlineModelsCount ?? 0), - onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), - onlineFavCount: Number(data?.onlineFavCount ?? 0), - onlineLikedCount: Number(data?.onlineLikedCount ?? 0), + setHeaderStats((prev) => { + const next = { + totalCount: Number(data?.totalCount ?? 0), + onlineModelsCount: Number(data?.onlineModelsCount ?? 0), + onlineWatchedModelsCount: Number(data?.onlineWatchedModelsCount ?? 0), + onlineFavCount: Number(data?.onlineFavCount ?? 0), + onlineLikedCount: Number(data?.onlineLikedCount ?? 0), + } + + return ( + prev.totalCount === next.totalCount && + prev.onlineModelsCount === next.onlineModelsCount && + prev.onlineWatchedModelsCount === next.onlineWatchedModelsCount && + prev.onlineFavCount === next.onlineFavCount && + prev.onlineLikedCount === next.onlineLikedCount + ) + ? prev + : next }) const safeList = Array.isArray(data?.models) ? data.models : [] @@ -1791,17 +1830,40 @@ export default function App() { setModelsByKey((prev) => { const next = { ...prev } + let changed = false for (const [k, incoming] of Object.entries(built)) { - next[k] = mergeStoredModel(prev[k], incoming) + const merged = mergeStoredModel(prev[k], incoming) + if (!storedModelShallowEqual(prev[k], merged)) { + next[k] = merged + changed = true + } } - return next + return changed ? next : prev }) - } catch { + } catch (error: any) { + if (error?.name === 'AbortError') return // ignore } finally { overviewInFlightRef.current = false + if (overviewAbortRef.current === controller) { + overviewAbortRef.current = null + } + + const pendingKeys = overviewPendingKeysRef.current + overviewPendingKeysRef.current = null + + if (pendingKeys && pendingKeys.join('|') !== sig) { + if (overviewTimerRef.current != null) { + window.clearTimeout(overviewTimerRef.current) + } + + overviewTimerRef.current = window.setTimeout(() => { + overviewTimerRef.current = null + void loadAppModelsSnapshot(pendingKeys) + }, MODELS_OVERVIEW_DEBOUNCE_MS) + } } }, [buildModelsByKey]) @@ -2172,7 +2234,7 @@ export default function App() { overviewTimerRef.current = window.setTimeout(() => { void loadAppModelsSnapshot(requiredModelKeys) - }, 250) + }, MODELS_OVERVIEW_DEBOUNCE_MS) } if (lastOverviewSigRef.current !== requiredModelKeysSig) { @@ -2242,6 +2304,11 @@ export default function App() { window.clearTimeout(overviewTimerRef.current) overviewTimerRef.current = null } + + overviewPendingKeysRef.current = null + overviewAbortRef.current?.abort() + overviewAbortRef.current = null + overviewInFlightRef.current = false } }, [authed, loadAppModelsSnapshot, requiredModelKeysSig, requiredModelKeys, upsertModelCache]) diff --git a/frontend/src/components/ui/FinishedVideoPreview.tsx b/frontend/src/components/ui/FinishedVideoPreview.tsx index 78eec5f..d091013 100644 --- a/frontend/src/components/ui/FinishedVideoPreview.tsx +++ b/frontend/src/components/ui/FinishedVideoPreview.tsx @@ -144,6 +144,143 @@ function useMobilePreviewConstrained() { return constrained } +const TEASER_MISSING_TTL_MS = 5 * 60 * 1000 +const TEASER_MISSING_CACHE_MAX = 600 +const MAX_TEASER_PREWARMS = 4 + +type TeaserMissingEntry = { + failedAt: number + assetNonce: number +} + +const teaserMissingById = new Map() +const teaserPrewarmSubscribers = new Set<() => void>() +let activeTeaserPrewarms = 0 + +function teaserCacheKey(id: string) { + return String(id ?? '').trim().toLowerCase() +} + +function normalizeAssetNonce(value: unknown) { + const n = Number(value ?? 0) + return Number.isFinite(n) ? n : 0 +} + +function pruneTeaserMissingCache(now = Date.now()) { + for (const [key, entry] of teaserMissingById.entries()) { + if (now - entry.failedAt > TEASER_MISSING_TTL_MS) { + teaserMissingById.delete(key) + } + } + + if (teaserMissingById.size <= TEASER_MISSING_CACHE_MAX) return + + const oldKeys = Array.from(teaserMissingById.entries()) + .sort((a, b) => a[1].failedAt - b[1].failedAt) + .slice(0, teaserMissingById.size - TEASER_MISSING_CACHE_MAX) + .map(([key]) => key) + + for (const key of oldKeys) teaserMissingById.delete(key) +} + +function isTeaserMissingCached(id: string, assetNonce: unknown) { + const key = teaserCacheKey(id) + if (!key) return false + + const entry = teaserMissingById.get(key) + if (!entry) return false + + const now = Date.now() + if (now - entry.failedAt > TEASER_MISSING_TTL_MS) { + teaserMissingById.delete(key) + return false + } + + if (entry.assetNonce !== normalizeAssetNonce(assetNonce)) { + teaserMissingById.delete(key) + return false + } + + return true +} + +function markTeaserMissing(id: string, assetNonce: unknown) { + const key = teaserCacheKey(id) + if (!key) return + + const now = Date.now() + pruneTeaserMissingCache(now) + teaserMissingById.set(key, { + failedAt: now, + assetNonce: normalizeAssetNonce(assetNonce), + }) +} + +function clearTeaserMissing(id: string) { + const key = teaserCacheKey(id) + if (!key) return + teaserMissingById.delete(key) +} + +function notifyTeaserPrewarmSlotChanged() { + for (const fn of teaserPrewarmSubscribers) fn() +} + +function tryAcquireTeaserPrewarmSlot() { + if (activeTeaserPrewarms >= MAX_TEASER_PREWARMS) return false + activeTeaserPrewarms += 1 + return true +} + +function releaseTeaserPrewarmSlot() { + if (activeTeaserPrewarms > 0) { + activeTeaserPrewarms -= 1 + } + notifyTeaserPrewarmSlotChanged() +} + +function useTeaserPrewarmSlot(enabled: boolean, key: string) { + const [hasSlot, setHasSlot] = useState(false) + const hasSlotRef = useRef(false) + + useEffect(() => { + if (!enabled || !key) { + if (hasSlotRef.current) { + hasSlotRef.current = false + releaseTeaserPrewarmSlot() + } + setHasSlot(false) + return + } + + let alive = true + + const acquire = () => { + if (!alive || hasSlotRef.current) return + if (!tryAcquireTeaserPrewarmSlot()) return + + hasSlotRef.current = true + setHasSlot(true) + } + + teaserPrewarmSubscribers.add(acquire) + acquire() + + return () => { + alive = false + teaserPrewarmSubscribers.delete(acquire) + + if (hasSlotRef.current) { + hasSlotRef.current = false + releaseTeaserPrewarmSlot() + } + setHasSlot(false) + } + }, [enabled, key]) + + return hasSlot +} + export default function FinishedVideoPreview({ job, getFileName, @@ -172,6 +309,7 @@ export default function FinishedVideoPreview({ inlineNonce = 0, inlineControls = false, inlineLoop = true, + assetNonce = 0, muted = DEFAULT_INLINE_MUTED, popoverMuted = DEFAULT_INLINE_MUTED, alwaysLoadStill = false, @@ -352,6 +490,8 @@ export default function FinishedVideoPreview({ const [teaserReady, setTeaserReady] = useState(false) const [teaserOk, setTeaserOk] = useState(true) + const [teaserCacheVersion, setTeaserCacheVersion] = useState(0) + const [teaserPrewarmDone, setTeaserPrewarmDone] = useState(false) // ✅ verhindert doppelte Parent-Callbacks (z.B. Count/Meta-Refresh im Parent) const emittedDurationRef = useRef('') @@ -619,13 +759,14 @@ export default function FinishedVideoPreview({ useEffect(() => { setTeaserReady(false) - setTeaserOk(true) + setTeaserPrewarmDone(false) + setTeaserOk(!isTeaserMissingCached(previewId, assetNonce)) emittedDurationRef.current = '' emittedResolutionRef.current = '' teaserSoundForcedRef.current = false setMetaLoaded(false) - }, [previewId]) + }, [previewId, assetNonce]) useEffect(() => { const onRelease = (ev: any) => { @@ -736,10 +877,34 @@ export default function FinishedVideoPreview({ return `/api/preview?id=${encodeURIComponent(previewId)}&t=${thumbTimeSec}` }, [previewId, thumbTimeSec]) + const teaserKnownMissing = useMemo( + () => isTeaserMissingCached(previewId, assetNonce), + [previewId, assetNonce, teaserCacheVersion] + ) + const teaserSrc = useMemo(() => { if (!previewId) return '' + if (teaserKnownMissing) return '' return `/api/generated/teaser?id=${encodeURIComponent(previewId)}&noGenerate=1` - }, [previewId]) + }, [previewId, teaserKnownMissing]) + + const markTeaserAvailable = useCallback(() => { + if (isTeaserMissingCached(previewId, assetNonce)) { + clearTeaserMissing(previewId) + setTeaserCacheVersion((v) => v + 1) + } + setTeaserOk(true) + setTeaserReady(true) + setTeaserPrewarmDone(true) + }, [previewId, assetNonce]) + + const markTeaserUnavailable = useCallback(() => { + markTeaserMissing(previewId, assetNonce) + setTeaserCacheVersion((v) => v + 1) + setTeaserOk(false) + setTeaserReady(false) + setTeaserPrewarmDone(true) + }, [previewId, assetNonce]) // --- Inline Video sichtbar? const showingInlineVideo = @@ -864,23 +1029,33 @@ export default function FinishedVideoPreview({ lastMountedRef.current.clips = null }, [previewId]) - if (!videoSrc) { - return
- } - - // ✅ aktive Asset-Nutzung (z.B. poster etc.) const shouldLoadAssets = shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive - + const teaserCanPrewarm = animated && !mobilePreviewConstrained && animatedMode === 'teaser' && Boolean(teaserSrc) && + !teaserKnownMissing && + !teaserPrewarmDone && !showingInlineVideo && shouldPreloadAnimatedAssets && animatedTrigger !== 'hover' - + + const teaserPrewarmWanted = + teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') + + const teaserPrewarmHasSlot = useTeaserPrewarmSlot( + teaserPrewarmWanted, + `${previewId}:${normalizeAssetNonce(assetNonce)}` + ) + + if (!videoSrc) { + return
+ } + + // ✅ aktive Asset-Nutzung (z.B. poster etc.) const showStillImage = shouldLoadStill && !showingInlineVideo && @@ -1464,26 +1639,17 @@ export default function FinishedVideoPreview({ ) : null} {/* ✅ Teaser prewarm (lädt Datei/Metadata vor, bleibt unsichtbar) */} - {!showingInlineVideo && teaserCanPrewarm && !(teaserActive && animatedMode === 'teaser') ? ( + {teaserPrewarmHasSlot ? (