This commit is contained in:
Linrador 2026-06-19 08:12:43 +02:00
parent cf86cdd868
commit 1775cb5acd
9 changed files with 497 additions and 90 deletions

View File

@ -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"))
}

View File

@ -747,10 +747,19 @@ func findPostworkAudioSidecarForTS(tsPath string) (string, bool) {
dir := filepath.Dir(tsPath)
base := strings.TrimSuffix(filepath.Base(tsPath), filepath.Ext(tsPath))
if base != "" {
candidates = append(candidates,
filepath.Join(dir, base+".audio.m4s"),
filepath.Join(dir, "."+base+".audio.m4s"),
filepath.Join(dir, "."+canonicalAssetIDFromName(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{}

View File

@ -6,7 +6,7 @@
"ffmpegPath": "",
"autoAddToDownloadList": true,
"autoStartAddedDownloads": true,
"enableConcurrentDownloadsLimit": true,
"enableConcurrentDownloadsLimit": false,
"maxConcurrentDownloads": 80,
"useProviderApis": true,
"useChaturbateApi": true,

View File

@ -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(ctx context.Context) (queued int, skipped int, err error) {
if err := ctx.Err(); err != nil {
return 0, 0, err
}
func enqueuePostworkLeftoversFromRecordDir() (queued int, skipped int) {
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 {

View File

@ -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)

View File

@ -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"`

View File

@ -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<string, string> | null | undefined): Record<string, string> {
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<number | null>(null)
const overviewAbortRef = useRef<AbortController | null>(null)
const overviewPendingKeysRef = useRef<string[] | null>(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<AppModelsSnapshot>('/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({
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])

View File

@ -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<string, TeaserMissingEntry>()
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<string>('')
@ -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,11 +1029,6 @@ export default function FinishedVideoPreview({
lastMountedRef.current.clips = null
}, [previewId])
if (!videoSrc) {
return <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// ✅ aktive Asset-Nutzung (z.B. poster etc.)
const shouldLoadAssets =
shouldPreloadAnimatedAssets || effectiveInView || showingInlineVideo || teaserActive
@ -877,10 +1037,25 @@ export default function FinishedVideoPreview({
!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 <div className={[sizeClass, 'rounded bg-gray-100 dark:bg-white/5'].join(' ')} />
}
// ✅ 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 ? (
<video
key={`teaser-prewarm-${previewId}`}
key={`teaser-prewarm-${previewId}-${normalizeAssetNonce(assetNonce)}`}
src={teaserSrc}
className="hidden"
muted
playsInline
preload="metadata"
onLoadedData={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onCanPlay={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
}}
onLoadedData={markTeaserAvailable}
onCanPlay={markTeaserAvailable}
onError={markTeaserUnavailable}
/>
) : null}
@ -1510,18 +1676,9 @@ export default function FinishedVideoPreview({
loop
preload={mobilePreviewConstrained ? 'metadata' : 'auto'}
poster={shouldLoadAssets ? (thumbSrc || undefined) : undefined}
onLoadedData={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onCanPlay={() => {
setTeaserOk(true)
setTeaserReady(true)
}}
onError={() => {
setTeaserOk(false)
setTeaserReady(false)
}}
onLoadedData={markTeaserAvailable}
onCanPlay={markTeaserAvailable}
onError={markTeaserUnavailable}
/>
) : null}

View File

@ -484,6 +484,7 @@ function mapCheckVideosJobToTask(job: any): TaskItem {
}
function mapCleanupJobToTask(job: any): TaskItem {
const rawId = String(job?.id || '')
const err = String(job?.error || '').trim()
let status: TaskItem['status'] = 'idle'
@ -493,20 +494,29 @@ function mapCleanupJobToTask(job: any): TaskItem {
else if (job?.finishedAt) status = 'done'
return {
id: cleanupTaskDomId(String(job?.id || '')),
id: cleanupTaskDomId(rawId),
status,
title: 'Aufräumen',
title: cleanupTaskTitle(rawId),
text: String(job?.text || job?.currentFile || ''),
done: Number(job?.done ?? 0),
total: Number(job?.total ?? 0),
err: status === 'error' ? err : undefined,
cancellable: status === 'queued' || status === 'running',
cancellable: job?.cancellable !== false && (status === 'queued' || status === 'running'),
fading: false,
queuedAtMs: parseTaskTimeMs(job?.queuedAt),
startedAtMs: parseTaskTimeMs(job?.startedAt),
}
}
function cleanupTaskTitle(rawId: string) {
const id = String(rawId || '').trim().toLowerCase()
if (id.startsWith('cleanup-leftovers-startup')) return 'Startup-Cleanup'
if (id.startsWith('cleanup-leftovers-hourly')) return 'Stündlicher Cleanup'
if (id.startsWith('cleanup-leftovers')) return 'Record-Reste prüfen'
return 'Aufräumen'
}
const SETTINGS_RATING_STAR_PATH =
'M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.176 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 9.72c-.783-.57-.38-1.81.588-1.81H7.03a1 1 0 00.951-.69l1.07-3.292z'