// frontend\src\components\ui\FinishedDownloads.tsx 'use client' import * as React from 'react' import { useMemo, useEffect, useCallback } from 'react' import Card from './Card' import type { ViewMode, SelectedItem, UndoAction, PersistedUndoState, RunFileMutationOptions, RecordJob, RecordJobMeta, StoredModelFlags, FinishedPostworkBadge, FinishedPostworkState, FinishedPostworkStepSummary, FinishedPostworkSummary, DoneSortMode, } from '../../types' import ButtonGroup from './ButtonGroup' import { ArrowUturnLeftIcon, TableCellsIcon, RectangleStackIcon, Squares2X2Icon, AdjustmentsHorizontalIcon, CheckCircleIcon, ExclamationTriangleIcon, ClockIcon, CheckIcon, XMarkIcon, ArchiveBoxIcon, TrashIcon, ClipboardDocumentIcon, } from '@heroicons/react/24/outline' import { type SwipeCardHandle } from './SwipeCard' import { flushSync } from 'react-dom' import FinishedDownloadsCardsView from './FinishedDownloadsCardsView' import FinishedDownloadsTableView from './FinishedDownloadsTableView' import FinishedDownloadsGalleryView from './FinishedDownloadsGalleryView' import Pagination from './Pagination' import { applyInlineVideoPolicy } from './videoPolicy' import TagBadge from './TagBadge' import Button from './Button' import { useNotify } from './notify' import { isHotName, stripHotPrefix } from './hotName' import LabeledSwitch from './LabeledSwitch' import Switch from './Switch' import LoadingSpinner from './LoadingSpinner' import HoverPopover from './HoverPopover' import type { FinishedDownloadsTeaserState, TeaserPlaybackMode } from './teaserPlayback' import Slider from './Slider' import TextInput from './TextInput' type Props = { jobs: RecordJob[] doneJobs: RecordJob[] modelsByKey: Record blurPreviews?: boolean teaserPlayback?: TeaserPlaybackMode teaserAudio?: boolean pauseTeasers?: boolean onOpenPlayer: (job: RecordJob, startAtSec?: number) => void onDeleteJob?: ( job: RecordJob ) => void | { undoToken?: string } | Promise onToggleHot?: ( job: RecordJob ) => void | { ok?: boolean; oldFile?: string; newFile?: string } | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onKeepJob?: (job: RecordJob) => void | Promise onSplitJob?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => void | Promise doneTotal: number page: number pageSize: number onPageSizeChange?: (size: number) => void onPageChange: (page: number) => void assetNonce?: number sortMode: DoneSortMode onSortModeChange: (m: DoneSortMode) => void loadMode?: 'paged' | 'all' includeKeep: boolean onIncludeKeepChange: (checked: boolean) => void } const norm = (p: string) => (p || '').replaceAll('\\', '/') const baseName = (p: string) => { const n = norm(p) const parts = n.split('/') 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) // Fallback (sollte selten sein) const f = baseName(j.output || '') return f || String((j as any)?.output || '') } const isTrashOutput = (output?: string) => { const p = norm(String(output ?? '')) // match: ".../.trash/file.ext" oder "...\ .trash\file.ext" return p.includes('/.trash/') || p.endsWith('/.trash') } 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) useEffect(() => { const mql = window.matchMedia(query) const onChange = () => setMatches(mql.matches) onChange() if (mql.addEventListener) mql.addEventListener('change', onChange) else mql.addListener(onChange) return () => { if (mql.removeEventListener) mql.removeEventListener('change', onChange) else mql.removeListener(onChange) } }, [query]) return matches } function modelHrefFromFlags(modelKey: string, flags?: StoredModelFlags): string | undefined { const key = String(modelKey || '').trim() if (!key) return undefined const anyFlags = flags as any const host = String(anyFlags?.host ?? anyFlags?.inputHost ?? '').trim().toLowerCase() const input = String(anyFlags?.input ?? '').trim() if (/^https?:\/\//i.test(input)) return input if (host.includes('chaturbate')) { return `https://chaturbate.com/${encodeURIComponent(key)}/` } if (host.includes('myfreecams')) { return `https://www.myfreecams.com/#${encodeURIComponent(key)}` } return undefined } function enrichJobForDetails(job: RecordJob, modelsByKey: Record): RecordJob { const model = modelNameFromOutput(job.output) const modelKey = lower(model) const flags = modelsByKey[modelKey] const href = modelHrefFromFlags(model, flags) return { ...job, sourceUrl: job.sourceUrl || href, } } const modelNameFromOutput = (output?: string) => { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) if (m?.[1]) return m[1] const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } const lower = (s: string) => (s || '').trim().toLowerCase() // Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt) const parseTags = (raw?: string): string[] => { const s = String(raw ?? '').trim() if (!s) return [] const parts = s .split(/[\n,;|]+/g) .map((p) => p.trim()) .filter(Boolean) // stable dedupe (case-insensitive), aber original casing behalten const seen = new Set() const out: string[] = [] for (const p of parts) { const k = p.toLowerCase() if (seen.has(k)) continue seen.add(k) out.push(p) } // wie in ModelsTab: alphabetisch (case-insensitive) out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) return out } // liest “irgendein” Size-Feld (falls du eins hast) aus dem Job const sizeBytesOf = (job: RecordJob): number | null => { const anyJob = job as any const v = anyJob.sizeBytes ?? anyJob.fileSizeBytes ?? anyJob.bytes ?? anyJob.size ?? null return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null } function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)) } async function copyTextToClipboard(text: string): Promise { const value = String(text ?? '').trim() if (!value) return false try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value) return true } } catch { // fallback below } try { const ta = document.createElement('textarea') ta.value = value ta.setAttribute('readonly', '') ta.style.position = 'fixed' ta.style.opacity = '0' ta.style.pointerEvents = 'none' document.body.appendChild(ta) ta.focus() ta.select() const ok = document.execCommand('copy') document.body.removeChild(ta) return ok } catch { return false } } function CopyErrorButton({ text }: { text: string }) { const [copied, setCopied] = React.useState(false) const timeoutRef = React.useRef(null) React.useEffect(() => { return () => { if (timeoutRef.current != null) { window.clearTimeout(timeoutRef.current) } } }, []) const handleClick = async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() const ok = await copyTextToClipboard(text || 'Unbekannter Fehler') if (!ok) return setCopied(true) if (timeoutRef.current != null) { window.clearTimeout(timeoutRef.current) } timeoutRef.current = window.setTimeout(() => { setCopied(false) timeoutRef.current = null }, 1200) } return ( ) } function errorTextOf(err: unknown): string { if (err instanceof Error) return err.message || String(err) return String(err ?? '') } function looksLikeFileInUseError(err: unknown): boolean { const s = errorTextOf(err).toLowerCase() return ( s.includes('wird gerade verwendet') || s.includes('wird gerade abgespielt') || s.includes('sharing violation') || s.includes('used by another process') || s.includes('file in use') || s.includes('409') ) } async function fetchWithTextError(input: RequestInfo | URL, init?: RequestInit) { const res = await fetch(input, init) if (!res.ok) { const text = await res.text().catch(() => '') const msg = (text || `HTTP ${res.status}`).trim() throw new Error(msg) } return res } function fileStemFromOutput(output?: string): string { const file = baseName(output || '') if (!file) return '' return stripHotPrefix(file.replace(/\.[^.]+$/, '')) } function getPostworkSummaryForOutput( output: string | undefined, summaries: Record ): FinishedPostworkSummary | undefined { const fileRaw = baseName(output || '') if (!fileRaw) return undefined const filePlain = stripHotPrefix(fileRaw) return summaries[fileRaw] ?? summaries[filePlain] ?? undefined } function parseMetaObject(metaRaw: unknown): RecordJobMeta | null { if (!metaRaw) return null if (typeof metaRaw === 'string') { try { return JSON.parse(metaRaw) as RecordJobMeta } catch { return null } } return typeof metaRaw === 'object' ? (metaRaw as RecordJobMeta) : null } function isPlainObject(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } function mergeMetaPreferDone( existingMetaRaw: string | RecordJobMeta | undefined, incomingMetaRaw: string | RecordJobMeta | undefined ): string | RecordJobMeta | undefined { const existingMeta = parseMetaObject(existingMetaRaw) const incomingMeta = parseMetaObject(incomingMetaRaw) if (!existingMeta && !incomingMeta) return existingMetaRaw ?? incomingMetaRaw if (!existingMeta) return incomingMetaRaw if (!incomingMeta) return existingMetaRaw const merged: RecordJobMeta = { ...(incomingMeta as Record), ...(existingMeta as Record), } as RecordJobMeta const incomingPostwork = isPlainObject(incomingMeta.postwork) ? incomingMeta.postwork : {} const existingPostwork = isPlainObject(existingMeta.postwork) ? existingMeta.postwork : {} const incomingCompleted = isPlainObject(incomingPostwork.completed) ? incomingPostwork.completed : {} const existingCompleted = isPlainObject(existingPostwork.completed) ? existingPostwork.completed : {} merged.postwork = { ...incomingPostwork, ...existingPostwork, completed: { ...incomingCompleted, ...existingCompleted, }, history: Array.isArray(existingPostwork.history) && existingPostwork.history.length > 0 ? existingPostwork.history : Array.isArray(incomingPostwork.history) ? incomingPostwork.history : undefined, } as any const incomingPreview = isPlainObject(incomingMeta.preview) ? incomingMeta.preview : {} const existingPreview = isPlainObject(existingMeta.preview) ? existingMeta.preview : {} merged.preview = { ...incomingPreview, ...existingPreview, clips: Array.isArray(existingPreview.clips) && existingPreview.clips.length > 0 ? existingPreview.clips : Array.isArray(incomingPreview.clips) ? incomingPreview.clips : undefined, thumb: { ...(isPlainObject(incomingPreview.thumb) ? incomingPreview.thumb : {}), ...(isPlainObject(existingPreview.thumb) ? existingPreview.thumb : {}), }, sprite: { ...(isPlainObject(incomingPreview.sprite) ? incomingPreview.sprite : {}), ...(isPlainObject(existingPreview.sprite) ? existingPreview.sprite : {}), }, } as any const incomingMedia = isPlainObject(incomingMeta.media) ? incomingMeta.media : {} const existingMedia = isPlainObject(existingMeta.media) ? existingMeta.media : {} merged.media = { ...incomingMedia, ...existingMedia, durationSeconds: typeof existingMedia.durationSeconds === 'number' && existingMedia.durationSeconds > 0 ? existingMedia.durationSeconds : typeof incomingMedia.durationSeconds === 'number' && incomingMedia.durationSeconds > 0 ? incomingMedia.durationSeconds : undefined, video: { ...(isPlainObject(incomingMedia.video) ? incomingMedia.video : {}), ...(isPlainObject(existingMedia.video) ? existingMedia.video : {}), }, } as any const incomingAnalysis = isPlainObject(incomingMeta.analysis) ? incomingMeta.analysis : {} const existingAnalysis = isPlainObject(existingMeta.analysis) ? existingMeta.analysis : {} merged.analysis = { ...incomingAnalysis, ...existingAnalysis, ai: isPlainObject(existingAnalysis.ai) && Object.keys(existingAnalysis.ai).length > 0 ? { ...(isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {}), ...(isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {}), } : isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : undefined, } as any return merged } function metaDurationSeconds(meta: any): number | undefined { const v = meta?.media?.durationSeconds ?? meta?.file?.durationSeconds return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined } function postworkBadgeTone(summary?: FinishedPostworkSummary): string { const state = String(summary?.state ?? '').toLowerCase() const label = String(summary?.label ?? '').toLowerCase() const isPartialDone = state === 'done' && label.startsWith('teilweise fertig') if (isPartialDone) { return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30' } switch (state) { case 'running': return 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-200 dark:ring-indigo-500/30' case 'queued': return 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/30' case 'done': return 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/30' case 'error': return 'bg-red-50 text-red-700 ring-red-200 dark:bg-red-500/10 dark:text-red-200 dark:ring-red-500/30' default: return 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' } } function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string { const p = String(phase ?? '').trim().toLowerCase() switch (`${queue}:${p}`) { case 'postwork:meta': return 'Meta' case 'postwork:thumb': return 'Vorschaubild' case 'postwork:teaser': return 'Teaser' case 'postwork:sprites': return 'Sprites' case 'enrich:analyze': return 'KI-Analyse' default: return p || (queue === 'enrich' ? 'KI-Analyse' : 'Meta') } } function shortRunningLabel(summary?: FinishedPostworkSummary): string { if (!summary) return 'Läuft' const runningStep = summary.steps.find((s) => s.state === 'running') if (runningStep) return runningStep.label const queuedStep = summary.steps.find((s) => s.state === 'queued') if (queuedStep) return queuedStep.label return 'Läuft' } function expectedStepsForBadges(): Array<{ key: string; queue: 'postwork' | 'enrich'; phase: string }> { return defaultPostworkStepDefs({ includeAnalyze: true, }) } function defaultPostworkStepDefs(opts?: { includeAnalyze?: boolean }): Array<{ key: string queue: 'postwork' | 'enrich' phase: string }> { const includeAnalyze = opts?.includeAnalyze ?? true const steps: Array<{ key: string queue: 'postwork' | 'enrich' phase: string }> = [ { key: 'postwork:meta', queue: 'postwork', phase: 'meta' }, { key: 'postwork:thumb', queue: 'postwork', phase: 'thumb' }, { key: 'postwork:teaser', queue: 'postwork', phase: 'teaser' }, { key: 'postwork:sprites', queue: 'postwork', phase: 'sprites' }, ] if (includeAnalyze) { steps.push({ key: 'enrich:analyze', queue: 'enrich', phase: 'analyze' }) } return steps } function buildMissingPostworkSummary( file: string, assetId?: string, label = 'Kein Status' ): FinishedPostworkSummary | null { if (!file) return null const steps: FinishedPostworkStepSummary[] = defaultPostworkStepDefs({ includeAnalyze: true, }).map((stepDef) => ({ key: stepDef.key, queue: stepDef.queue, phase: stepDef.phase, label: labelForStep(stepDef.queue, stepDef.phase), state: 'missing', ts: 0, })) return { file, assetId, state: 'missing', label, steps, ts: 0, } } function hasNonEmptyObject(value: unknown): boolean { return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record).length > 0 } function firstNonEmptyPath(...values: unknown[]): string { for (const v of values) { if (typeof v === 'string') { const s = v.trim() if (s) return s } } return '' } function readMetaVideoWidth(meta: any): number | undefined { const v = meta?.media?.video?.width ?? meta?.media?.video?.w ?? meta?.media?.width ?? meta?.file?.video?.width ?? meta?.file?.video?.w ?? meta?.file?.width ?? meta?.video?.width ?? meta?.video?.w ?? meta?.width return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined } function readMetaVideoHeight(meta: any): number | undefined { const v = meta?.media?.video?.height ?? meta?.media?.video?.h ?? meta?.media?.height ?? meta?.file?.video?.height ?? meta?.file?.video?.h ?? meta?.file?.height ?? meta?.video?.height ?? meta?.video?.h ?? meta?.height return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined } function readMetaDurationSeconds(meta: any): number | undefined { const v = meta?.media?.durationSeconds ?? meta?.media?.durationSec ?? meta?.media?.duration ?? meta?.file?.durationSeconds ?? meta?.file?.durationSec ?? meta?.file?.duration ?? meta?.durationSeconds ?? meta?.durationSec ?? meta?.duration return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined } function readCompletedFlag(meta: any, ...keys: string[]): boolean { const completed = meta?.postwork?.completed for (const key of keys) { const v = completed?.[key] ?? meta?.postwork?.[key] ?? meta?.preview?.[key] ?? meta?.[key] if (v === true) return true } return false } function hasDoneHistoryForPhase( meta: any, queue: 'postwork' | 'enrich', phase: string ): boolean { const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : [] return rawHistory.some((entry: any) => { const queueRaw = String(entry?.queue ?? '').trim().toLowerCase() const phaseRaw = normalizeFinishedPhase( queueRaw === 'enrich' ? 'enrich' : 'postwork', String(entry?.phase ?? '').trim().toLowerCase() ) const stateRaw = String(entry?.state ?? '').trim().toLowerCase() return queueRaw === queue && phaseRaw === phase && stateRaw === 'done' }) } function hasNonEmptyArray(value: unknown): boolean { return Array.isArray(value) && value.length > 0 } function buildCompletedPostworkSummaryFromMeta( file: string, assetId: string | undefined, meta: any ): FinishedPostworkSummary | null { if (!file || !meta || typeof meta !== 'object') return null const durationSeconds = readMetaDurationSeconds(meta) const videoWidth = readMetaVideoWidth(meta) const videoHeight = readMetaVideoHeight(meta) const thumbPath = firstNonEmptyPath( meta?.preview?.thumb?.path, meta?.preview?.thumb?.url, meta?.preview?.thumb?.src, meta?.preview?.thumbPath, meta?.previewThumb?.path, meta?.previewThumb?.url, meta?.previewThumbPath, meta?.thumbPath, meta?.thumbUrl, meta?.coverPath, meta?.coverUrl ) const spritePath = firstNonEmptyPath( meta?.preview?.sprite?.path, meta?.preview?.sprite?.url, meta?.preview?.sprite?.src, meta?.preview?.spritePath, meta?.previewSprite?.path, meta?.previewSprite?.url, meta?.previewSpritePath, meta?.spritePath, meta?.spriteUrl ) const clips = Array.isArray(meta?.preview?.clips) ? meta.preview.clips : Array.isArray(meta?.preview?.teaserClips) ? meta.preview.teaserClips : Array.isArray(meta?.preview?.segments) ? meta.preview.segments : Array.isArray(meta?.preview?.teaser?.clips) ? meta.preview.teaser.clips : Array.isArray(meta?.previewClips) ? meta.previewClips : Array.isArray(meta?.teaserClips) ? meta.teaserClips : [] const teaserPath = firstNonEmptyPath( meta?.preview?.teaser?.path, meta?.preview?.teaser?.url, meta?.preview?.teaser?.src, meta?.preview?.teaserPath, meta?.previewTeaser?.path, meta?.previewTeaser?.url, meta?.previewTeaserPath, meta?.teaserPath, meta?.teaserUrl ) const aiObject = meta?.analysis?.ai ?? meta?.ai const hasAiHits = Array.isArray(aiObject?.hits) && aiObject.hits.length > 0 const hasAiSegments = Array.isArray(aiObject?.segments) && aiObject.segments.length > 0 const hasMeta = readCompletedFlag(meta, 'meta') || hasDoneHistoryForPhase(meta, 'postwork', 'meta') || typeof durationSeconds === 'number' || (typeof videoWidth === 'number' && typeof videoHeight === 'number') || (typeof meta?.media?.video?.resolution === 'string' && meta.media.video.resolution.trim() !== '') || (typeof meta?.file?.video?.resolution === 'string' && meta.file.video.resolution.trim() !== '') const hasTeaser = readCompletedFlag(meta, 'teaser', 'assets') || hasDoneHistoryForPhase(meta, 'postwork', 'teaser') || teaserPath !== '' || hasNonEmptyArray(clips) const hasThumb = readCompletedFlag(meta, 'thumb', 'thumbnail', 'thumbnails') || hasDoneHistoryForPhase(meta, 'postwork', 'thumb') || meta?.preview?.thumb?.exists === true || thumbPath !== '' const hasSprites = readCompletedFlag(meta, 'sprites', 'sprite') || hasDoneHistoryForPhase(meta, 'postwork', 'sprites') || meta?.preview?.sprite?.exists === true || spritePath !== '' || ( typeof meta?.preview?.sprite?.count === 'number' && Number.isFinite(meta.preview.sprite.count) && meta.preview.sprite.count > 0 ) const hasAiAnalysis = readCompletedFlag(meta, 'analyze', 'analysis', 'ai') || hasDoneHistoryForPhase(meta, 'enrich', 'analyze') || hasNonEmptyObject(aiObject) || hasAiHits || hasAiSegments const stepDefs = defaultPostworkStepDefs({ includeAnalyze: true, }) const steps: FinishedPostworkStepSummary[] = stepDefs.map((stepDef) => { let state: FinishedPostworkState = 'missing' if (stepDef.key === 'postwork:meta') { state = hasMeta ? 'done' : 'missing' } else if (stepDef.key === 'postwork:thumb') { state = hasThumb ? 'done' : 'missing' } else if (stepDef.key === 'postwork:teaser') { state = hasTeaser ? 'done' : 'missing' } else if (stepDef.key === 'postwork:sprites') { state = hasSprites ? 'done' : 'missing' } else if (stepDef.key === 'enrich:analyze') { state = hasAiAnalysis ? 'done' : 'missing' } return { key: stepDef.key, queue: stepDef.queue, phase: stepDef.phase, label: labelForStep(stepDef.queue, stepDef.phase), state, ts: Number(meta?.updatedAtUnix ?? 0), } }) const doneCount = steps.filter((s) => s.state === 'done').length const allDone = steps.length > 0 && steps.every((s) => s.state === 'done') return { file, assetId, state: allDone ? 'done' : doneCount > 0 ? 'done' : 'missing', label: allDone ? 'Fertig' : doneCount > 0 ? `Teilweise fertig (${doneCount}/${steps.length})` : 'Kein Status', steps, ts: Number(meta?.updatedAtUnix ?? 0), } } function buildPostworkSummaryFromMetaHistory( file: string, assetId: string | undefined, meta: any ): FinishedPostworkSummary | null { if (!file || !meta || typeof meta !== 'object') return null const rawHistory = Array.isArray(meta?.postwork?.history) ? meta.postwork.history : [] if (rawHistory.length === 0) return null const badges: FinishedPostworkBadge[] = rawHistory .map((entry: any): FinishedPostworkBadge | null => { const queueRaw = String(entry?.queue ?? '').trim().toLowerCase() const phase = normalizeFinishedPhase( (queueRaw === 'enrich' ? 'enrich' : 'postwork'), String(entry?.phase ?? '').trim().toLowerCase() ) const state = String(entry?.state ?? '').trim().toLowerCase() if ((queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phase) return null if ( state !== 'queued' && state !== 'running' && state !== 'done' && state !== 'error' && state !== 'missing' ) { return null } return { file, assetId, queue: queueRaw as 'postwork' | 'enrich', phase, state: state as FinishedPostworkBadge['state'], label: typeof entry?.label === 'string' ? entry.label : undefined, position: typeof entry?.position === 'number' ? entry.position : undefined, waiting: typeof entry?.waiting === 'number' ? entry.waiting : undefined, running: typeof entry?.running === 'number' ? entry.running : undefined, maxParallel: typeof entry?.maxParallel === 'number' ? entry.maxParallel : undefined, ts: typeof entry?.ts === 'number' ? entry.ts : 0, } }) .filter(Boolean) as FinishedPostworkBadge[] if (badges.length === 0) return null return summarizePostworkSteps(file, badges) } function isAggregateAssetsPhase( queue: 'postwork' | 'enrich', phase?: string ): boolean { return queue === 'enrich' && String(phase ?? '').trim().toLowerCase() === 'assets' } function normalizeFinishedPhase( queue: 'postwork' | 'enrich', phase?: string ): string { const p = String(phase ?? '').trim().toLowerCase() if (queue === 'enrich') { if (p === 'analyze') return 'analyze' return p || 'analyze' } switch (p) { case 'postwork': case 'probe': case 'remuxing': case 'moving': return 'meta' case 'thumb': return 'thumb' case 'assets': return 'teaser' case 'sprites': return 'sprites' default: return p || 'meta' } } function summarizePostworkSteps( file: string, badges: FinishedPostworkBadge[] ): FinishedPostworkSummary | null { if (!file) return null const realBadges = badges.filter((b) => b && b.state !== 'missing') if (realBadges.length === 0) return null const expectedSteps = expectedStepsForBadges() const byKey = new Map() let aggregateAssetsBadge: FinishedPostworkBadge | null = null for (const badge of realBadges) { const queue = badge.queue const rawPhase = String(badge.phase ?? '').trim().toLowerCase() // ✅ Sammelstatus "enrich:assets" NICHT als eigene Extra-Zeile anzeigen // sondern später auf die bekannten Schritte verteilen. if (isAggregateAssetsPhase(queue, rawPhase)) { if (!aggregateAssetsBadge || (aggregateAssetsBadge.ts ?? 0) <= (badge.ts ?? 0)) { aggregateAssetsBadge = badge } continue } const phase = normalizeFinishedPhase(queue, rawPhase) const key = `${queue}:${phase}` const prev = byKey.get(key) if (prev && prev.ts > badge.ts) continue byKey.set(key, { key, queue, phase, label: labelForStep(queue, phase), state: badge.state, error: String(badge.error ?? '').trim() || undefined, ts: badge.ts, position: badge.position, waiting: badge.waiting, running: badge.running, maxParallel: badge.maxParallel, }) } // ✅ Erst alle erwarteten Schritte anlegen for (const stepDef of expectedSteps) { if (byKey.has(stepDef.key)) continue byKey.set(stepDef.key, { key: stepDef.key, queue: stepDef.queue, phase: stepDef.phase, label: labelForStep(stepDef.queue, stepDef.phase), state: 'missing', ts: 0, }) } // ✅ Dann ggf. den Sammelstatus "assets" auf alle noch nicht fertigen Schritte abbilden if (aggregateAssetsBadge) { for (const stepDef of expectedSteps) { const cur = byKey.get(stepDef.key) if (!cur) continue // Bereits fertige Schritte beibehalten if (cur.state === 'done') continue byKey.set(stepDef.key, { ...cur, state: aggregateAssetsBadge.state, ts: Math.max(cur.ts || 0, aggregateAssetsBadge.ts || 0), position: aggregateAssetsBadge.position, waiting: aggregateAssetsBadge.waiting, running: aggregateAssetsBadge.running, maxParallel: aggregateAssetsBadge.maxParallel, }) } } const order = expectedSteps.map((s) => s.key) const steps = Array.from(byKey.values()).sort((a, b) => { const ai = order.indexOf(a.key) const bi = order.indexOf(b.key) if (ai >= 0 && bi >= 0) return ai - bi if (ai >= 0) return -1 if (bi >= 0) return 1 return a.key.localeCompare(b.key) }) if (steps.length === 0) return null const visibleSteps = steps.filter((s) => s.state !== 'missing') const hasError = visibleSteps.some((s) => s.state === 'error') const hasRunning = visibleSteps.some((s) => s.state === 'running') const hasQueued = visibleSteps.some((s) => s.state === 'queued') const doneCount = steps.filter((s) => s.state === 'done').length const allDone = steps.length > 0 && steps.every((s) => s.state === 'done') const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError const state: FinishedPostworkSummary['state'] = hasError ? 'error' : hasRunning ? 'running' : hasQueued ? 'queued' : allDone ? 'done' : partiallyDone ? 'done' : 'missing' const label = hasError ? 'Fehler' : hasRunning ? shortRunningLabel({ file, steps, state: 'running', label: '', ts: 0 }) : hasQueued ? shortRunningLabel({ file, steps, state: 'queued', label: '', ts: 0 }) : allDone ? 'Fertig' : partiallyDone ? `Teilweise fertig (${doneCount}/${steps.length})` : 'Kein Status' const ts = Math.max(0, ...steps.map((s) => s.ts || 0)) return { file, assetId: realBadges.find((b) => b.assetId)?.assetId, state, label, steps, ts, } } function mergePostworkSummaries( historySummary: FinishedPostworkSummary | null | undefined, completedSummary: FinishedPostworkSummary | null | undefined, fallbackFile: string, fallbackAssetId?: string ): FinishedPostworkSummary | null { if (!historySummary && !completedSummary) return null if (!historySummary) return completedSummary ?? null if (!completedSummary) return historySummary const stepMap = new Map() for (const step of completedSummary.steps) { stepMap.set(step.key, step) } for (const step of historySummary.steps) { const prev = stepMap.get(step.key) if (!prev) { stepMap.set(step.key, step) continue } // Live-/History-Zustände mit Aktivität/Fehler sollen sichtbar bleiben if ( step.state === 'running' || step.state === 'queued' || step.state === 'error' ) { if (!prev || (step.ts || 0) >= (prev.ts || 0)) { stepMap.set(step.key, step) } continue } // "done" aus History ist ok, aber nur wenn Completed nicht schon auch done liefert. // Wenn Completed mehr Informationen hat, füllt es die restlichen Steps ohnehin. if (step.state === 'done' && prev.state === 'missing') { stepMap.set(step.key, step) continue } // missing aus History darf nie ein echtes Completed-Ergebnis überschreiben } const orderedSteps = defaultPostworkStepDefs({ includeAnalyze: true }).map((def) => { return ( stepMap.get(def.key) ?? { key: def.key, queue: def.queue, phase: def.phase, label: labelForStep(def.queue, def.phase), state: 'missing' as FinishedPostworkState, ts: 0, } ) }) const visibleSteps = orderedSteps.filter((s) => s.state !== 'missing') const hasError = visibleSteps.some((s) => s.state === 'error') const hasRunning = visibleSteps.some((s) => s.state === 'running') const hasQueued = visibleSteps.some((s) => s.state === 'queued') const doneCount = orderedSteps.filter((s) => s.state === 'done').length const allDone = orderedSteps.length > 0 && orderedSteps.every((s) => s.state === 'done') const partiallyDone = doneCount > 0 && !allDone && !hasRunning && !hasQueued && !hasError const state: FinishedPostworkSummary['state'] = hasError ? 'error' : hasRunning ? 'running' : hasQueued ? 'queued' : allDone ? 'done' : partiallyDone ? 'done' : 'missing' const label = hasError ? 'Fehler' : hasRunning ? shortRunningLabel({ file: historySummary.file || completedSummary.file || fallbackFile, steps: orderedSteps, state: 'running', label: '', ts: 0, }) : hasQueued ? shortRunningLabel({ file: historySummary.file || completedSummary.file || fallbackFile, steps: orderedSteps, state: 'queued', label: '', ts: 0, }) : allDone ? 'Fertig' : partiallyDone ? `Teilweise fertig (${doneCount}/${orderedSteps.length})` : 'Kein Status' const ts = Math.max( historySummary.ts || 0, completedSummary.ts || 0, ...orderedSteps.map((s) => s.ts || 0) ) return { file: historySummary.file || completedSummary.file || fallbackFile, assetId: historySummary.assetId || completedSummary.assetId || fallbackAssetId, state, label, steps: orderedSteps, ts, } } export function renderFinishedPostworkBadge( summary?: FinishedPostworkSummary, opts?: { onClick?: () => void clickable?: boolean disablePopover?: boolean } ): React.ReactNode { if (!summary) return null const clickable = Boolean(opts?.clickable && opts?.onClick) const className = [ 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', postworkBadgeTone(summary), clickable ? 'cursor-pointer transition-opacity hover:opacity-80' : '', ].join(' ') const badgeNode = clickable ? ( ) : ( {summary.state === 'running' ? ( ) : summary.state === 'queued' ? ( ) : summary.state === 'done' ? ( ) : summary.state === 'error' ? ( ) : summary.state === 'missing' ? ( ) : null} {summary.label} ) if (opts?.disablePopover) { return badgeNode } return ( open && (
{summary.state === 'missing' ? 'Postwork-Phasen' : 'Postwork-Status'}
{summary.file}
{summary.steps.map((step) => (
{step.label}
{step.state === 'running' ? ( <> Läuft ) : step.state === 'queued' ? ( <> {typeof step.position === 'number' && step.position > 0 ? `Wartet #${step.position}` : 'Wartet'} ) : step.state === 'done' ? ( <> Fertig ) : step.state === 'error' ? (
Fehler {step.error ? : null}
) : step.state === 'missing' ? ( <> Kein Status ) : ( )}
))}
) } > {badgeNode}
) } export default function FinishedDownloads({ jobs, doneJobs, blurPreviews, teaserPlayback, teaserAudio, pauseTeasers = false, onOpenPlayer, onDeleteJob, onToggleHot, onToggleFavorite, onToggleLike, onToggleWatch, onKeepJob, onSplitJob, onAddToDownloads, doneTotal, page, pageSize, onPageChange, onPageSizeChange, assetNonce, sortMode, onSortModeChange, modelsByKey, loadMode = 'paged', includeKeep, onIncludeKeepChange, }: Props) { // ----------------------------------------------------------------------------- // local constants // ----------------------------------------------------------------------------- const VIEW_KEY = 'finishedDownloads_view' const MOBILE_OPTS_KEY = 'finishedDownloads_mobileOptionsOpen_v1' const GALLERY_CARD_SCALE_KEY = 'finishedDownloads_galleryCardScale_v1' const LAST_UNDO_KEY = 'finishedDownloads_lastUndo_v1' const requestedTeaserPlaybackMode: TeaserPlaybackMode = teaserPlayback ?? 'hover' const teaserPlaybackMode: TeaserPlaybackMode = pauseTeasers ? 'still' : requestedTeaserPlaybackMode const notify = useNotify() // ----------------------------------------------------------------------------- // media queries + simple derived flags // ----------------------------------------------------------------------------- const canHover = useMediaQuery('(hover: hover) and (pointer: fine)') const isCoarsePointer = useMediaQuery('(hover: none) and (pointer: coarse)') const isTabletOrSmaller = useMediaQuery('(max-width: 1024px)') const isSmall = useMediaQuery('(max-width: 639px)') const effectiveTeaserPlaybackMode = !canHover && teaserPlaybackMode === 'hover' ? 'still' : teaserPlaybackMode const mobileNeedsTapForAudio = isCoarsePointer || isTabletOrSmaller const previewMuted = !Boolean(teaserAudio) const cardsMobileOffsetTopClass = 'mt-10' const cardsMobileOffsetBottomClass = 'mb-2' // ----------------------------------------------------------------------------- // state // ----------------------------------------------------------------------------- const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = React.useState(false) const [teaserKey, setTeaserKey] = React.useState(null) const [hoverTeaserKey, setHoverTeaserKey] = React.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 [undoing, setUndoing] = React.useState(false) const [bulkBusy, setBulkBusy] = React.useState(false) const [postworkByFile, setPostworkByFile] = React.useState>({}) const [selectedItems, setSelectedItems] = React.useState>({}) const [renamedFiles, setRenamedFiles] = React.useState>({}) const [view, setView] = React.useState('table') const [mobileOptionsOpen, setMobileOptionsOpen] = React.useState(false) const [selectionActionsOpen, setSelectionActionsOpen] = React.useState(false) const [galleryCardScale, setGalleryCardScale] = React.useState(1) const [galleryCardScaleLive, setGalleryCardScaleLive] = React.useState(1) const [galleryColumnCount, setGalleryColumnCount] = React.useState(1) const [tagFilter, setTagFilter] = React.useState([]) const [searchQuery, setSearchQuery] = React.useState('') const [durations, setDurations] = React.useState>({}) const [resolutions, setResolutions] = React.useState>({}) const [inlinePlay, setInlinePlay] = React.useState<{ key: string; nonce: number } | null>(null) const [hotBusyKeys, setHotBusyKeys] = React.useState>(() => new Set()) const [lastAction, setLastAction] = React.useState(() => { if (typeof window === 'undefined') return null try { const raw = localStorage.getItem(LAST_UNDO_KEY) if (!raw) return null const parsed = JSON.parse(raw) as PersistedUndoState if (!parsed || parsed.v !== 1 || !parsed.action) return null const ageMs = Date.now() - Number(parsed.ts || 0) if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > 30 * 60 * 1000) { localStorage.removeItem(LAST_UNDO_KEY) return null } return parsed.action } catch { return null } }) // ----------------------------------------------------------------------------- // refs // ----------------------------------------------------------------------------- const pointerPosRef = React.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 countHintRef = React.useRef({ pending: 0, t: 0, timer: 0 as any }) const scaleLiveRafRef = React.useRef(null) const scaleLivePendingRef = React.useRef(1) const swipeRefs = React.useRef>(new Map()) const fileToKeyRef = React.useRef>(new Map()) const durationsRef = React.useRef>({}) const durationsFlushTimerRef = React.useRef(null) const resolutionsRef = React.useRef>({}) const resolutionsFlushTimerRef = React.useRef(null) const removeTimersRef = React.useRef>(new Map()) // ----------------------------------------------------------------------------- // memoized derived data // ----------------------------------------------------------------------------- const forcePreviewMuted = mobileNeedsTapForAudio && teaserAudio ? !mobileTeaserAudioUnlocked : false const deferredSearchQuery = React.useDeferredValue(searchQuery) const activeTagSet = useMemo(() => new Set(tagFilter.map(lower)), [tagFilter]) const selectedKeys = useMemo(() => new Set(Object.keys(selectedItems)), [selectedItems]) const teaserState = useMemo( () => ({ mode: effectiveTeaserPlaybackMode, activeKey: teaserKey, hoverKey: hoverTeaserKey, audioEnabled: Boolean(teaserAudio), }), [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio] ) const modelTags = useMemo(() => { const tagsByModelKey: Record = {} const tagSetByModelKey: Record> = {} for (const [k, flags] of Object.entries(modelsByKey ?? {})) { const key = lower(k) const arr = parseTags(flags?.tags) tagsByModelKey[key] = arr tagSetByModelKey[key] = new Set(arr.map(lower)) } return { tagsByModelKey, tagSetByModelKey } }, [modelsByKey]) const searchTokens = useMemo( () => lower(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean), [deferredSearchQuery] ) const searchActiveForGlobalFetch = activeTagSet.size > 0 || searchTokens.some((t) => t.length >= 2) const globalFilterActive = searchActiveForGlobalFetch const effectiveAllMode = globalFilterActive || loadMode === 'all' const galleryPageSize = Math.max(2, galleryColumnCount * 2) const effectivePageSize = view === 'gallery' ? galleryPageSize : pageSize const doneJobsPage = doneJobs const doneTotalPage = doneTotal const applyRenamedOutputLocal = useCallback( (job: RecordJob): RecordJob => { const out = norm(job.output || '') const file = baseName(out) const override = renamedFiles[file] if (!override) return job const idx = out.lastIndexOf('/') const dir = idx >= 0 ? out.slice(0, idx + 1) : '' return { ...job, output: dir + override } }, [renamedFiles] ) const rows = useMemo(() => { const map = new Map() const doneBaseKeys = new Set() for (const j of doneJobsPage) { const jj = applyRenamedOutputLocal(j) const k = keyFor(jj) map.set(k, jj) doneBaseKeys.add(k) } for (const j of jobs) { const jj = applyRenamedOutputLocal(j) const k = keyFor(jj) if (map.has(k)) { const existing = map.get(k)! map.set(k, { ...existing, ...jj, meta: mergeMetaPreferDone((existing as any)?.meta, (jj as any)?.meta), }) continue } const file = baseName(jj.output || '') if (!file) continue for (const [existingKey, existingJob] of map.entries()) { if (baseName(existingJob.output || '') === file) { map.set(existingKey, { ...existingJob, ...jj, meta: mergeMetaPreferDone((existingJob as any)?.meta, (jj as any)?.meta), }) break } } } return Array.from(map.entries()) .map(([k, j]) => ({ k, j })) .filter(({ k, j }) => { const file = baseName(j.output || '') if (deletedKeys.has(k)) return false if (file && hiddenFiles.has(file)) return false if (isTrashOutput(j.output)) return false const anyJ = j as any const status = String(anyJ?.status ?? '').trim().toLowerCase() const endedAt = anyJ?.endedAt if (doneBaseKeys.has(k)) return true if ( status === 'finished' || status === 'failed' || status === 'stopped' ) { return true } if (endedAt) return true return false }) .map(({ j }) => j) }, [jobs, doneJobsPage, deletedKeys, hiddenFiles, applyRenamedOutputLocal]) const effectivePostworkSummaryByFile = useMemo>(() => { const next: Record = {} for (const [file, badges] of Object.entries(postworkByFile)) { const summary = summarizePostworkSteps(file, badges) if (summary) { next[file] = summary } } for (const job of rows) { const fileRaw = baseName(job.output || '') const filePlain = stripHotPrefix(fileRaw) if (!fileRaw) continue const rawMeta = (job as any)?.meta const meta = parseMetaObject(rawMeta) const assetId = fileStemFromOutput(job.output) const summaryFromHistory = buildPostworkSummaryFromMetaHistory(fileRaw, assetId, meta) const summaryFromCompleted = buildCompletedPostworkSummaryFromMeta(fileRaw, assetId, meta) const mergedMetaSummary = mergePostworkSummaries( summaryFromHistory, summaryFromCompleted, fileRaw, assetId ) const metaSummary = mergedMetaSummary ?? buildMissingPostworkSummary(fileRaw, assetId, 'Kein Status') const liveSummary = next[fileRaw] ?? next[filePlain] const summary = mergePostworkSummaries( liveSummary ?? null, metaSummary ?? null, fileRaw, assetId ) ?? metaSummary ?? liveSummary ?? null if (summary) { next[fileRaw] = summary if (filePlain && filePlain !== fileRaw) { next[filePlain] = summary } } } return next }, [postworkByFile, rows]) const viewRows = rows const visibleRows = useMemo(() => { const base = viewRows.filter((j) => !deletedKeys.has(keyFor(j))) const searched = searchTokens.length ? base.filter((j) => { const file = baseName(j.output || '') const model = modelNameFromOutput(j.output) const modelKey = lower(model) const tags = modelTags.tagsByModelKey[modelKey] ?? [] const hay = lower([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' ')) for (const t of searchTokens) { if (!hay.includes(t)) return false } return true }) : base if (activeTagSet.size === 0) return searched return searched.filter((j) => { const modelKey = lower(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 }) }, [viewRows, deletedKeys, activeTagSet, modelTags, searchTokens]) const selectedFiles = useMemo(() => { return Object.values(selectedItems) .map((x) => String(x.file || '').trim()) .filter(Boolean) }, [selectedItems]) const totalItemsForPagination = effectiveAllMode ? visibleRows.length : doneTotalPage const pageRows = useMemo(() => { if (effectiveAllMode) { const start = (page - 1) * effectivePageSize const end = start + effectivePageSize return visibleRows.slice(start, end) } if (view === 'gallery') { return visibleRows.slice(0, effectivePageSize) } return visibleRows }, [visibleRows, page, effectivePageSize, effectiveAllMode, view]) const emptyFolder = !effectiveAllMode && totalItemsForPagination === 0 const emptyByFilter = globalFilterActive && visibleRows.length === 0 const selectedCount = selectedFiles.length const allPageSelected = pageRows.length > 0 && pageRows.every((j) => selectedKeys.has(keyFor(j))) // ----------------------------------------------------------------------------- // callbacks // ----------------------------------------------------------------------------- const handleGalleryScaleLiveChange = useCallback((next: number) => { const clamped = Math.max(0.8, Math.min(1.35, next)) scaleLivePendingRef.current = clamped if (scaleLiveRafRef.current != null) return scaleLiveRafRef.current = requestAnimationFrame(() => { scaleLiveRafRef.current = null setGalleryCardScaleLive(scaleLivePendingRef.current) }) }, []) const handleGalleryScaleCommit = useCallback((next: number) => { const clamped = Math.max(0.8, Math.min(1.35, next)) if (scaleLiveRafRef.current != null) { cancelAnimationFrame(scaleLiveRafRef.current) scaleLiveRafRef.current = null } scaleLivePendingRef.current = clamped setGalleryCardScaleLive(clamped) setGalleryCardScale(clamped) }, []) const toggleSelected = useCallback((job: RecordJob) => { const k = keyFor(job) const file = baseName(job.output || '') if (!file) return setSelectedItems((prev) => { const next = { ...prev } if (next[k]) { delete next[k] } else { next[k] = { key: k, file } } return next }) }, []) const clearSelection = useCallback(() => { setSelectedItems({}) }, []) const selectPageRows = useCallback((rows: RecordJob[]) => { setSelectedItems((prev) => { const next = { ...prev } for (const j of rows) { const k = keyFor(j) const file = baseName(j.output || '') if (!file) continue next[k] = { key: k, file } } return next }) }, []) const deselectPageRows = useCallback((rows: RecordJob[]) => { setSelectedItems((prev) => { const next = { ...prev } for (const j of rows) { delete next[keyFor(j)] } return next }) }, []) const refreshHoverPreviewFromPointer = useCallback(() => { if (!canHover) return const pos = pointerPosRef.current if (!pos) { setHoverTeaserKey(null) return } const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null if (!el) { setHoverTeaserKey(null) return } const hoverHost = el.closest('[data-finished-hover-key]') const nextKey = hoverHost?.dataset.finishedHoverKey ?? null setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) }, [canHover]) const emitCountHint = useCallback((delta: number) => { if (!delta || !Number.isFinite(delta)) return countHintRef.current.pending += delta if (countHintRef.current.timer) return countHintRef.current.timer = window.setTimeout(() => { countHintRef.current.timer = 0 const d = countHintRef.current.pending countHintRef.current.pending = 0 if (!d) return window.dispatchEvent( new CustomEvent('finished-downloads:count-hint', { detail: { delta: d } }) ) }, 120) }, []) const jobForDetails = useCallback( (job: RecordJob): RecordJob => enrichJobForDetails(job, modelsByKey), [modelsByKey] ) const clearSearch = useCallback(() => setSearchQuery(''), []) const toggleTagFilter = useCallback((tag: string) => { const k = lower(tag) setTagFilter((prev) => { const has = prev.some((t) => lower(t) === k) return has ? prev.filter((t) => lower(t) !== k) : [...prev, tag] }) }, []) const clearTagFilter = useCallback(() => setTagFilter([]), []) const flushResolutionsSoon = useCallback(() => { if (resolutionsFlushTimerRef.current != null) return resolutionsFlushTimerRef.current = window.setTimeout(() => { resolutionsFlushTimerRef.current = null setResolutions({ ...resolutionsRef.current }) }, 200) }, []) const flushDurationsSoon = useCallback(() => { if (durationsFlushTimerRef.current != null) return durationsFlushTimerRef.current = window.setTimeout(() => { durationsFlushTimerRef.current = null setDurations({ ...durationsRef.current }) }, 200) }, []) const tryAutoplayInline = useCallback((domId: string) => { const host = document.getElementById(domId) const v = host?.querySelector('video') as HTMLVideoElement | null if (!v) return false applyInlineVideoPolicy(v, { muted: previewMuted }) const p = v.play?.() if (p && typeof (p as any).catch === 'function') { (p as Promise).catch(() => {}) } return true }, [previewMuted]) const startInline = useCallback((key: string) => { setInlinePlay((prev) => ( prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 } )) }, []) const startInlineAt = useCallback((key: string, seconds: number, domId: string) => { const safeSeconds = Number.isFinite(seconds) && seconds > 0 ? seconds : 0 setInlinePlay((prev) => ( prev?.key === key ? { key, nonce: prev.nonce + 1 } : { key, nonce: 1 } )) const trySeekAndPlay = (retriesLeft: number) => { const host = document.getElementById(domId) const v = host?.querySelector('video') as HTMLVideoElement | null if (!v) { if (retriesLeft > 0) { requestAnimationFrame(() => trySeekAndPlay(retriesLeft - 1)) } return } applyInlineVideoPolicy(v, { muted: previewMuted }) const applySeek = () => { try { const dur = Number(v.duration) const maxSeek = Number.isFinite(dur) && dur > 0 ? Math.max(0, dur - 0.05) : safeSeconds v.currentTime = Math.max(0, Math.min(safeSeconds, maxSeek)) } catch {} const p = v.play?.() if (p && typeof (p as any).catch === 'function') { ;(p as Promise).catch(() => {}) } } if (v.readyState >= 1) { applySeek() return } const onLoadedMetadata = () => { v.removeEventListener('loadedmetadata', onLoadedMetadata) applySeek() } v.addEventListener('loadedmetadata', onLoadedMetadata, { once: true }) const p = v.play?.() if (p && typeof (p as any).catch === 'function') { ;(p as Promise).catch(() => {}) } } requestAnimationFrame(() => trySeekAndPlay(8)) }, [previewMuted]) const openPlayer = useCallback((job: RecordJob) => { setInlinePlay(null) onOpenPlayer(job) }, [onOpenPlayer]) const openPlayerAt = useCallback((job: RecordJob, seconds: number) => { const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0 setInlinePlay(null) onOpenPlayer(job, s) }, [onOpenPlayer]) const handleScrubberClickIndex = useCallback( (job: RecordJob, segmentIndex: number, segmentCount: number) => { const idx = Number.isFinite(segmentIndex) ? Math.floor(segmentIndex) : 0 const count = Number.isFinite(segmentCount) ? Math.floor(segmentCount) : 0 if (count <= 0) { openPlayer(job) return } const k = keyFor(job) const durationSec = durations[k] ?? ((job as any)?.meta?.file?.durationSeconds as number | undefined) ?? 0 if (!Number.isFinite(durationSec) || durationSec <= 0) { openPlayer(job) return } const clampedIdx = Math.max(0, Math.min(idx, count - 1)) const secPerSegment = durationSec / count const startAtSec = clampedIdx * secPerSegment openPlayerAt(job, startAtSec) }, [durations, openPlayer, openPlayerAt] ) const markDeleting = useCallback((key: string, value: boolean) => { setDeletingKeys((prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) }, []) const markDeleted = useCallback((key: string) => { setDeletedKeys((prev) => { const next = new Set(prev) next.add(key) return next }) }, []) const markKeeping = useCallback((key: string, value: boolean) => { setKeepingKeys((prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) }, []) const markRemoving = useCallback((key: string, value: boolean) => { setRemovingKeys((prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) }, []) const cancelRemoveTimer = useCallback((key: string) => { const t = removeTimersRef.current.get(key) if (t != null) { window.clearTimeout(t) removeTimersRef.current.delete(key) } }, []) const restoreRow = useCallback( (key: string, file?: string) => { cancelRemoveTimer(key) setDeletedKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) setRemovingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) setDeletingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) setKeepingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }) if (file) { setHiddenFiles((prev) => { const next = new Set(prev) next.delete(file) return next }) } }, [cancelRemoveTimer] ) const animateRemove = useCallback( (key: string, file?: string) => { if (file) { setHiddenFiles((prev) => { const next = new Set(prev) next.add(file) return next }) } markRemoving(key, true) cancelRemoveTimer(key) const t = window.setTimeout(() => { removeTimersRef.current.delete(key) markDeleted(key) markRemoving(key, false) requestAnimationFrame(() => { requestAnimationFrame(() => { refreshHoverPreviewFromPointer() }) }) }, 320) removeTimersRef.current.set(key, t) }, [markDeleted, markRemoving, cancelRemoveTimer, refreshHoverPreviewFromPointer] ) const releasePlayingFile = useCallback( async (file: string, opts?: { close?: boolean }) => { window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) if (opts?.close) { window.dispatchEvent(new CustomEvent('player:close', { detail: { file } })) } await new Promise((r) => requestAnimationFrame(() => r())) window.dispatchEvent(new CustomEvent('player:release', { detail: { file } })) await new Promise((r) => window.setTimeout(r, 260)) }, [] ) const withFileReleaseRetry = useCallback( async ( file: string, run: () => Promise, opts?: { close?: boolean; attempts?: number; baseDelayMs?: number } ): Promise => { const attempts = Math.max(1, opts?.attempts ?? 4) const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220) let lastErr: unknown for (let attempt = 1; attempt <= attempts; attempt++) { try { await releasePlayingFile(file, { close: opts?.close ?? true }) if (attempt > 1) { await sleep(baseDelayMs * attempt) } else { await sleep(80) } return await run() } catch (e) { lastErr = e if (!looksLikeFileInUseError(e) || attempt >= attempts) { throw e } continue } } throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler')) }, [releasePlayingFile] ) const runFileMutation = useCallback( async (opts: RunFileMutationOptions): Promise<{ ok: boolean; result?: T }> => { const { file, rowKey, setBusy, isBusyNow, optimisticRemove, alreadyRemoved, run, onSuccess, onError, labels, } = opts if (!file) { notify.error(labels.invalidTitle, labels.invalidBody) return { ok: false } } if (isBusyNow?.()) return { ok: false } setBusy?.(true) try { if (optimisticRemove && !alreadyRemoved) { animateRemove(rowKey) } const result = await run() await onSuccess?.(result) return { ok: true, result } } catch (e: any) { if (optimisticRemove) { restoreRow(rowKey, file) } await onError?.(e) if (looksLikeFileInUseError(e)) { notify.error( labels.inUseTitle, `${file} wird noch verwendet (Player/Preview). Bitte kurz warten und erneut versuchen.` ) } else { const suffix = e?.message ? ` — ${String(e.message)}` : '' notify.error(labels.failTitle, `${labels.failPrefix ?? file}${suffix}`) } return { ok: false } } finally { setBusy?.(false) } }, [notify, animateRemove, restoreRow] ) const deleteVideo = useCallback( async (job: RecordJob, opts?: { alreadyRemoved?: boolean }): Promise => { const file = baseName(job.output || '') const key = keyFor(job) const res = await runFileMutation({ kind: 'delete', job, file, rowKey: key, setBusy: (v) => markDeleting(key, v), isBusyNow: () => deletingKeys.has(key), optimisticRemove: true, alreadyRemoved: opts?.alreadyRemoved, labels: { invalidTitle: 'Löschen nicht möglich', invalidBody: 'Kein Dateiname gefunden – kann nicht löschen.', inUseTitle: 'Löschen fehlgeschlagen', failTitle: 'Löschen fehlgeschlagen', failPrefix: file, }, run: async () => { if (onDeleteJob) { return await withFileReleaseRetry( file, async () => await onDeleteJob(job), { close: true, attempts: 4, baseDelayMs: 220 } ) } const r = await withFileReleaseRetry( file, async () => await fetchWithTextError(`/api/record/delete?file=${encodeURIComponent(file)}`, { method: 'POST', }), { close: true, attempts: 4, baseDelayMs: 220 } ) return (await r.json().catch(() => null)) as any }, onSuccess: async (result: any) => { const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' if (undoToken) { setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key }) } else { setLastAction(null) } emitCountHint(-1) } }) return res.ok }, [ deletingKeys, markDeleting, onDeleteJob, withFileReleaseRetry, runFileMutation, emitCountHint, ] ) const keepVideo = useCallback( async (job: RecordJob, opts?: { alreadyRemoved?: boolean }) => { const file = baseName(job.output || '') const key = keyFor(job) const res = await runFileMutation({ kind: 'keep', job, file, rowKey: key, setBusy: (v) => markKeeping(key, v), isBusyNow: () => keepingKeys.has(key) || deletingKeys.has(key), optimisticRemove: true, alreadyRemoved: opts?.alreadyRemoved, labels: { invalidTitle: 'Keep nicht möglich', invalidBody: 'Kein Dateiname gefunden – kann nicht behalten.', inUseTitle: 'Keep fehlgeschlagen', failTitle: 'Keep fehlgeschlagen', failPrefix: file, }, run: async () => { if (onKeepJob) { return await withFileReleaseRetry( file, async () => await onKeepJob(job), { close: true, attempts: 4, baseDelayMs: 220 } ) } const r = await withFileReleaseRetry( file, async () => await fetchWithTextError(`/api/record/keep?file=${encodeURIComponent(file)}`, { method: 'POST', }), { close: true, attempts: 4, baseDelayMs: 220 } ) return (await r.json().catch(() => null)) as any }, onSuccess: async (data: any) => { const keptFile = typeof data?.newFile === 'string' && data.newFile ? data.newFile : file setLastAction({ kind: 'keep', keptFile, originalFile: file, rowKey: key }) emitCountHint(includeKeep ? 0 : -1) } }) return res.ok }, [ markKeeping, keepingKeys, deletingKeys, withFileReleaseRetry, runFileMutation, emitCountHint, includeKeep, ] ) const applyRename = useCallback((oldFile: string, newFile: string) => { if (!oldFile || !newFile || oldFile === newFile) return setRenamedFiles((prev) => { const next: Record = { ...prev } for (const [k, v] of Object.entries(next)) { if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k] } next[oldFile] = newFile return next }) setSelectedItems((prev) => { let changed = false const next: Record = {} for (const [k, item] of Object.entries(prev)) { if (item.file === oldFile) { next[k] = { ...item, file: newFile } changed = true } else { next[k] = item } } return changed ? next : prev }) }, []) const clearRenamePair = useCallback((a: string, b: string) => { if (!a && !b) return setRenamedFiles((prev) => { const next: Record = { ...prev } for (const [k, v] of Object.entries(next)) { if (k === a || k === b || v === a || v === b) delete next[k] } return next }) }, []) const undoLastAction = useCallback(async () => { if (!lastAction || undoing) return setUndoing(true) const unhideRow = (keyOrToken: string, file?: string) => { setDeletedKeys((prev) => { const next = new Set(prev) next.delete(keyOrToken) return next }) setDeletingKeys((prev) => { const next = new Set(prev) next.delete(keyOrToken) return next }) setKeepingKeys((prev) => { const next = new Set(prev) next.delete(keyOrToken) return next }) setRemovingKeys((prev) => { const next = new Set(prev) next.delete(keyOrToken) return next }) if (file) { setHiddenFiles((prev) => { const next = new Set(prev) next.delete(file) return next }) } } try { if (lastAction.kind === 'delete') { const res = await fetch( `/api/record/restore?token=${encodeURIComponent(lastAction.undoToken)}`, { method: 'POST' } ) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) as any const restoredFile = String(data?.restoredFile || lastAction.originalFile) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) const visibleDelta = lastAction.from === 'keep' && !includeKeep ? 0 : +1 emitCountHint(visibleDelta) setLastAction(null) return } if (lastAction.kind === 'keep') { const res = await fetch( `/api/record/unkeep?file=${encodeURIComponent(lastAction.keptFile)}`, { method: 'POST' } ) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) as any const restoredFile = String(data?.newFile || lastAction.originalFile) if (lastAction.rowKey) unhideRow(lastAction.rowKey, lastAction.originalFile) unhideRow(lastAction.originalFile, lastAction.originalFile) unhideRow(restoredFile, restoredFile) emitCountHint(+1) setLastAction(null) return } if (lastAction.kind === 'hot') { const res = await fetch( `/api/record/toggle-hot?file=${encodeURIComponent(lastAction.currentFile)}`, { method: 'POST' } ) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = (await res.json().catch(() => null)) as any const oldFile = String(data?.oldFile || lastAction.currentFile) const newFile = String(data?.newFile || '') if (newFile) { applyRename(oldFile, newFile) } setLastAction(null) return } } catch (e: any) { notify.error('Undo fehlgeschlagen', String(e?.message || e)) } finally { setUndoing(false) } }, [ lastAction, undoing, notify, includeKeep, emitCountHint, applyRename, ]) const markHotBusy = useCallback((key: string, value: boolean) => { setHotBusyKeys((prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) }, []) const toggleHotVideo = useCallback( async (job: RecordJob): Promise => { const currentFile = baseName(job.output || '') const key = keyFor(job) const toggledName = (raw: string) => (isHotName(raw) ? stripHotPrefix(raw) : `HOT ${raw}`) const oldFile = currentFile const optimisticNew = toggledName(oldFile) await runFileMutation({ kind: 'rename', job, file: currentFile, rowKey: key, setBusy: (v) => markHotBusy(key, v), isBusyNow: () => hotBusyKeys.has(key), optimisticRemove: false, labels: { invalidTitle: 'HOT nicht möglich', invalidBody: 'Kein Dateiname gefunden – kann nicht HOT togglen.', inUseTitle: 'HOT umbenennen fehlgeschlagen', failTitle: 'HOT umbenennen fehlgeschlagen', failPrefix: oldFile, }, run: async () => { applyRename(oldFile, optimisticNew) if (onToggleHot) { const r = await onToggleHot(job) return r as any } const r = await withFileReleaseRetry( oldFile, async () => await fetchWithTextError( `/api/record/toggle-hot?file=${encodeURIComponent(oldFile)}`, { method: 'POST' } ), { close: true, attempts: 4, baseDelayMs: 220 } ) return (await r.json().catch(() => null)) as any }, onSuccess: async (data: any) => { const apiOld = typeof data?.oldFile === 'string' && data.oldFile ? data.oldFile : oldFile const apiNew = typeof data?.newFile === 'string' && data.newFile ? data.newFile : optimisticNew if (apiOld && apiNew && apiOld !== apiNew) { applyRename(apiOld, apiNew) } setLastAction({ kind: 'hot', currentFile: apiNew }) }, onError: async () => { clearRenamePair(oldFile, optimisticNew) setLastAction(null) }, }) }, [ hotBusyKeys, markHotBusy, runFileMutation, applyRename, clearRenamePair, onToggleHot, withFileReleaseRetry, ] ) const regenerateAssetsForFile = useCallback(async (file: string) => { const cleanFile = String(file || '').trim() if (!cleanFile) return false setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'postwork', state: 'queued', phase: 'meta', label: 'Meta', ts: Date.now(), }, { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'postwork', state: 'queued', phase: 'thumb', label: 'Vorschaubild', ts: Date.now(), }, { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'postwork', state: 'queued', phase: 'teaser', label: 'Teaser', ts: Date.now(), }, { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'postwork', state: 'queued', phase: 'sprites', label: 'Sprites', ts: Date.now(), }, { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'enrich', state: 'queued', phase: 'analyze', label: 'KI-Analyse', ts: Date.now(), }, ], })) try { const res = await fetch('/api/record/regenerate-assets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ file: cleanFile }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } return true } catch (e: any) { console.error('regenerate-assets failed', { file: cleanFile, error: e?.message ? String(e.message) : String(e), }) setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, assetId: fileStemFromOutput(cleanFile), queue: 'enrich', state: 'error', phase: 'assets', label: 'Fehler', ts: Date.now(), }, ], })) notify.error( 'Assets neu generieren fehlgeschlagen', e?.message ? String(e.message) : String(e) ) return false } }, [notify]) const runtimeOf = useCallback((job: RecordJob): string => { const k = keyFor(job) const meta = parseMetaObject((job as any)?.meta) const sec = durations[k] ?? metaDurationSeconds(meta) if (typeof sec === 'number' && Number.isFinite(sec) && sec > 0) { return formatDuration(sec * 1000) } const start = Date.parse(String(job.startedAt || '')) const end = Date.parse(String(job.endedAt || '')) if (Number.isFinite(start) && Number.isFinite(end) && end > start) { return formatDuration(end - start) } return '—' }, [durations]) const registerTeaserHost = useCallback( (key: string) => (el: HTMLDivElement | null) => { const prev = teaserHostsRef.current.get(key) if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev) if (el) { teaserHostsRef.current.set(key, el) elToKeyRef.current.set(el, key) teaserIORef.current?.observe(el) } else { if (prev && teaserIORef.current) teaserIORef.current.unobserve(prev) teaserHostsRef.current.delete(key) } }, [] ) const handleDuration = useCallback((job: RecordJob, seconds: number) => { if (!Number.isFinite(seconds) || seconds <= 0) return const k = keyFor(job) const old = durationsRef.current[k] if (typeof old === 'number' && Math.abs(old - seconds) < 0.5) return durationsRef.current = { ...durationsRef.current, [k]: seconds } flushDurationsSoon() }, [flushDurationsSoon]) const handleResolution = useCallback((job: RecordJob, w: number, h: number) => { if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) return const k = keyFor(job) const prev = resolutionsRef.current[k] if (prev && prev.w === w && prev.h === h) return resolutionsRef.current = { ...resolutionsRef.current, [k]: { w, h } } flushResolutionsSoon() }, [flushResolutionsSoon]) const bulkDeleteSelected = useCallback(async () => { if (bulkBusy) return if (selectedFiles.length === 0) return const ok = window.confirm(`${selectedFiles.length} Downloads löschen?`) if (!ok) return setBulkBusy(true) try { const res = await fetch('/api/record/delete-many', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: selectedFiles }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) const results = Array.isArray(data?.results) ? data.results : [] for (const item of results) { if (!item?.ok) continue const file = String(item.file || '').trim() if (!file) continue const key = Object.values(selectedItems).find((x) => x.file === file)?.key || fileToKeyRef.current.get(file) || file animateRemove(key, file) } const deletedCount = Number(data?.deleted ?? results.filter((x: any) => x?.ok).length ?? 0) clearSelection() emitCountHint(-deletedCount) if (deletedCount > 0) { notify.success?.('Löschen erfolgreich', `${deletedCount} Downloads gelöscht.`) } } catch (e: any) { notify.error('Bulk-Löschen fehlgeschlagen', String(e?.message || e)) } finally { setBulkBusy(false) } }, [ bulkBusy, selectedFiles, selectedItems, animateRemove, clearSelection, emitCountHint, notify, ]) const bulkKeepSelected = useCallback(async () => { if (bulkBusy) return if (selectedFiles.length === 0) return const ok = window.confirm(`${selectedFiles.length} Downloads behalten?`) if (!ok) return setBulkBusy(true) try { const res = await fetch('/api/record/keep-many', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: selectedFiles }), }) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) const results = Array.isArray(data?.results) ? data.results : [] for (const item of results) { if (!item?.ok) continue const file = String(item.file || '').trim() if (!file) continue const key = Object.values(selectedItems).find((x) => x.file === file)?.key || fileToKeyRef.current.get(file) || file animateRemove(key, file) } const keptCount = Number(data?.kept ?? results.filter((x: any) => x?.ok).length ?? 0) clearSelection() emitCountHint(includeKeep ? 0 : -keptCount) if (keptCount > 0) { notify.success?.('Keep erfolgreich', `${keptCount} Downloads behalten.`) } } catch (e: any) { notify.error('Bulk-Keep fehlgeschlagen', String(e?.message || e)) } finally { setBulkBusy(false) } }, [ bulkBusy, selectedFiles, selectedItems, animateRemove, clearSelection, emitCountHint, includeKeep, notify, ]) // ----------------------------------------------------------------------------- // effects // ----------------------------------------------------------------------------- useEffect(() => { if (view !== 'gallery') return const wanted = Math.max(2, galleryColumnCount * 2) if (pageSize !== wanted) { onPageSizeChange?.(wanted) } }, [view, galleryColumnCount, pageSize, onPageSizeChange]) useEffect(() => { try { const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY) const parsed = Number(raw) const initial = Number.isFinite(parsed) ? Math.max(0.8, Math.min(1.35, parsed)) : 1 setGalleryCardScale(initial) setGalleryCardScaleLive(initial) scaleLivePendingRef.current = initial } catch { setGalleryCardScale(1) setGalleryCardScaleLive(1) scaleLivePendingRef.current = 1 } }, []) useEffect(() => { try { localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScale)) } catch {} }, [galleryCardScale]) useEffect(() => { try { const saved = localStorage.getItem(VIEW_KEY) as ViewMode | null if (saved === 'table' || saved === 'cards' || saved === 'gallery') { setView(saved) } else { setView(window.matchMedia('(max-width: 639px)').matches ? 'cards' : 'table') } } catch { setView('table') } }, []) useEffect(() => { try { localStorage.setItem(VIEW_KEY, view) } catch {} }, [view]) useEffect(() => { try { const raw = localStorage.getItem(MOBILE_OPTS_KEY) setMobileOptionsOpen(raw === '1' || raw === 'true' || raw === 'yes') } catch { setMobileOptionsOpen(false) } }, []) useEffect(() => { try { localStorage.setItem(MOBILE_OPTS_KEY, mobileOptionsOpen ? '1' : '0') } catch {} }, [mobileOptionsOpen]) useEffect(() => { try { const raw = localStorage.getItem('finishedDownloads_pendingTags') if (!raw) return const arr = JSON.parse(raw) const tags = Array.isArray(arr) ? arr.map((t) => String(t || '').trim()).filter(Boolean) : [] if (tags.length === 0) return flushSync(() => setTagFilter(tags)) if (page !== 1) onPageChange(1) } catch { } finally { try { localStorage.removeItem('finishedDownloads_pendingTags') } catch {} } }, []) useEffect(() => { return () => { if (scaleLiveRafRef.current != null) { cancelAnimationFrame(scaleLiveRafRef.current) scaleLiveRafRef.current = null } } }, []) useEffect(() => { return () => { if (teaserKeyRafRef.current != null) { cancelAnimationFrame(teaserKeyRafRef.current) teaserKeyRafRef.current = null } } }, []) useEffect(() => { resolutionsRef.current = resolutions }, [resolutions]) useEffect(() => { return () => { if (resolutionsFlushTimerRef.current != null) { window.clearTimeout(resolutionsFlushTimerRef.current) resolutionsFlushTimerRef.current = null } } }, []) useEffect(() => { durationsRef.current = durations }, [durations]) useEffect(() => { return () => { if (durationsFlushTimerRef.current != null) { window.clearTimeout(durationsFlushTimerRef.current) durationsFlushTimerRef.current = null } } }, []) useEffect(() => { return () => { for (const t of removeTimersRef.current.values()) { window.clearTimeout(t) } removeTimersRef.current.clear() } }, []) useEffect(() => { if (!pauseTeasers) return setTeaserKey(null) setHoverTeaserKey(null) setInlinePlay(null) }, [pauseTeasers]) useEffect(() => { let alive = true const loadInitialPostworkState = async () => { try { const res = await fetch('/api/tasks/status', { cache: 'no-store' }) if (!res.ok) return const data = await res.json().catch(() => null) if (!alive || !data) return const jobs = Array.isArray(data?.regenerateAssetsJobs) ? data.regenerateAssetsJobs : [] if (jobs.length > 0) { setPostworkByFile((prev) => { const next = { ...prev } for (const item of jobs) { const file = String(item?.file ?? '').trim() if (!file) continue const assetId = String(item?.assetId ?? '').trim() || fileStemFromOutput(file) const phases = Array.isArray(item?.phases) ? item.phases : [] if (phases.length > 0) { const restored = phases .map((p: any): FinishedPostworkBadge | null => { const queueRaw = String(p?.queue ?? '').trim().toLowerCase() const phaseRaw = String(p?.phase ?? '').trim().toLowerCase() const rawState = String(p?.state ?? '').trim().toLowerCase() const state: FinishedPostworkState = rawState === 'queued' || rawState === 'running' || rawState === 'done' || rawState === 'error' || rawState === 'missing' ? rawState : rawState === 'cancelled' ? 'missing' : 'missing' if ( (queueRaw !== 'postwork' && queueRaw !== 'enrich') || !phaseRaw ) { return null } return { file, assetId, queue: queueRaw as 'postwork' | 'enrich', phase: phaseRaw, state, label: String(p?.label ?? '').trim() || labelForStep(queueRaw as 'postwork' | 'enrich', phaseRaw), position: typeof p?.position === 'number' ? p.position : undefined, waiting: typeof p?.waiting === 'number' ? p.waiting : undefined, running: typeof p?.running === 'number' ? p.running : undefined, maxParallel: typeof p?.maxParallel === 'number' ? p.maxParallel : undefined, ts: typeof p?.ts === 'number' && Number.isFinite(p.ts) ? p.ts : Date.now(), } }) .filter(Boolean) as FinishedPostworkBadge[] if (restored.length > 0) { next[file] = restored continue } } const rawState = String(item?.state ?? '').trim().toLowerCase() const state: FinishedPostworkState = rawState === 'queued' || rawState === 'running' || rawState === 'done' || rawState === 'error' || rawState === 'missing' ? rawState : rawState === 'cancelled' ? 'missing' : 'missing' if (state !== 'queued' && state !== 'running' && state !== 'error') { continue } next[file] = [ { file, assetId, queue: 'postwork', state, phase: 'meta', label: 'Meta', ts: Date.now(), }, { file, assetId, queue: 'postwork', state, phase: 'thumb', label: 'Vorschaubild', ts: Date.now(), }, { file, assetId, queue: 'postwork', state, phase: 'teaser', label: 'Teaser', ts: Date.now(), }, { file, assetId, queue: 'postwork', state, phase: 'sprites', label: 'Sprites', ts: Date.now(), }, { file, assetId, queue: 'enrich', state, phase: 'analyze', label: 'KI-Analyse', ts: Date.now(), }, ] } return next }) } } catch {} } void loadInitialPostworkState() return () => { alive = false } }, []) useEffect(() => { const onPointerMove = (e: PointerEvent) => { pointerPosRef.current = { x: e.clientX, y: e.clientY } } window.addEventListener('pointermove', onPointerMove, { passive: true }) return () => window.removeEventListener('pointermove', onPointerMove) }, []) useEffect(() => { try { if (!lastAction) { localStorage.removeItem(LAST_UNDO_KEY) return } const payload: PersistedUndoState = { v: 1, action: lastAction, ts: Date.now(), } localStorage.setItem(LAST_UNDO_KEY, JSON.stringify(payload)) } catch {} }, [lastAction]) useEffect(() => { if (effectiveAllMode) return if (totalItemsForPagination < 0) return const totalPages = Math.max(1, Math.ceil(totalItemsForPagination / effectivePageSize)) if (page > totalPages) { onPageChange(totalPages) } }, [ effectiveAllMode, totalItemsForPagination, effectivePageSize, page, onPageChange, ]) useEffect(() => { const m = new Map() for (const j of viewRows) { const f = baseName(j.output || '') if (!f) continue m.set(f, keyFor(j)) } fileToKeyRef.current = m }, [viewRows]) useEffect(() => { const onPostwork = (ev: Event) => { const detail = (ev as CustomEvent).detail const file = String(detail?.file ?? '').trim() if (!file) return setPostworkByFile((prev) => { const next = { ...prev } 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 } return next } const incoming: FinishedPostworkBadge = { ...detail, 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 const merged = cur.map((item) => { const itemKey = `${item.queue}:${String(item.phase ?? '').trim().toLowerCase() || 'postwork'}` if (itemKey !== key) return item replaced = true return (item.ts ?? 0) > (incoming.ts ?? 0) ? item : incoming }) next[file] = replaced ? merged : [...merged, incoming] return next }) } window.addEventListener('finished-downloads:postwork', onPostwork as EventListener) return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener) }, []) useEffect(() => { const onExternalDelete = (ev: Event) => { const detail = (ev as CustomEvent<{ file: string; phase: 'start' | 'success' | 'error' }>).detail if (!detail?.file) return const key = fileToKeyRef.current.get(detail.file) || detail.file if (detail.phase === 'start') { markDeleting(key, true) animateRemove(key, detail.file) return } if (detail.phase === 'error') { restoreRow(key, detail.file) swipeRefs.current.get(key)?.reset() return } if (detail.phase === 'success') { markDeleting(key, false) } } window.addEventListener('finished-downloads:delete', onExternalDelete as EventListener) return () => window.removeEventListener('finished-downloads:delete', onExternalDelete as EventListener) }, [animateRemove, markDeleting, restoreRow]) useEffect(() => { const onExternalRename = (ev: Event) => { const detail = (ev as CustomEvent<{ oldFile?: string; newFile?: string }>).detail const oldFile = String(detail?.oldFile ?? '').trim() const newFile = String(detail?.newFile ?? '').trim() if (!oldFile || !newFile || oldFile === newFile) return applyRename(oldFile, newFile) } window.addEventListener('finished-downloads:rename', onExternalRename as EventListener) return () => window.removeEventListener('finished-downloads:rename', onExternalRename as EventListener) }, [applyRename]) useEffect(() => { const shouldClampFromVisibleRows = effectiveAllMode if (!shouldClampFromVisibleRows) return const totalPages = Math.max(1, Math.ceil(visibleRows.length / effectivePageSize)) if (page > totalPages) { onPageChange(totalPages) } }, [ effectiveAllMode, visibleRows.length, page, effectivePageSize, onPageChange, ]) useEffect(() => { const active = !canHover && effectiveTeaserPlaybackMode !== 'still' && (view === 'cards' || view === 'gallery' || view === 'table') if (!active) { setTeaserKey(null) teaserIORef.current?.disconnect() teaserIORef.current = null return } if (view === 'cards' && inlinePlay?.key) { setTeaserKey(inlinePlay.key) return } teaserIORef.current?.disconnect() const io = new IntersectionObserver( (entries) => { let bestKey: string | null = null let bestRatio = 0 for (const ent of entries) { if (!ent.isIntersecting) continue const k = elToKeyRef.current.get(ent.target) if (!k) continue if (ent.intersectionRatio > bestRatio) { bestRatio = ent.intersectionRatio bestKey = k } } if (bestKey) { pendingTeaserKeyRef.current = bestKey if (teaserKeyRafRef.current == null) { teaserKeyRafRef.current = requestAnimationFrame(() => { teaserKeyRafRef.current = null const nextKey = pendingTeaserKeyRef.current setTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) }) } } }, { threshold: [0, 0.15, 0.3, 0.5, 0.7, 0.9], rootMargin: '0px', } ) teaserIORef.current = io for (const [k, el] of teaserHostsRef.current) { elToKeyRef.current.set(el, k) io.observe(el) } return () => { io.disconnect() if (teaserIORef.current === io) teaserIORef.current = null } }, [view, effectiveTeaserPlaybackMode, canHover, inlinePlay?.key]) useEffect(() => { if (!isSmall || view !== 'cards') return if (effectiveTeaserPlaybackMode === 'still') { setTeaserKey(null) return } const top = pageRows[0] if (!top) { setTeaserKey(null) return } const topKey = keyFor(top) setTeaserKey((prev) => (prev === topKey ? prev : topKey)) }, [isSmall, view, pageRows, effectiveTeaserPlaybackMode]) useEffect(() => { if (!isSmall) { swipeRefs.current = new Map() } }, [isSmall]) useEffect(() => { if (selectedCount === 0 && selectionActionsOpen) { setSelectionActionsOpen(false) } }, [selectedCount, selectionActionsOpen]) // ----------------------------------------------------------------------------- // render // ----------------------------------------------------------------------------- return ( <> {/* Toolbar (sticky) */}
{/* Desktop */}
Abgeschlossene Downloads
{totalItemsForPagination}
setSearchQuery(e.target.value)} placeholder="Suchen…" /> {(searchQuery || '').trim() !== '' ? ( ) : null}
{selectedCount > 0 ? ( ) : null}
{ if (page !== 1) onPageChange(1) onIncludeKeepChange(checked) }} />
{view !== 'table' ? (
{view === 'gallery' ? (
) : null}
) : null} setView(id as ViewMode)} size="md" ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: 'Tabelle', srLabel: 'Tabelle', }, { id: 'cards', icon: , label: 'Cards', srLabel: 'Cards', }, { id: 'gallery', icon: , label: 'Galerie', srLabel: 'Galerie', }, ]} />
{/* Mobile */}
Abgeschlossene Downloads
{totalItemsForPagination}
setView(id as ViewMode)} size="sm" ariaLabel="Ansicht" items={[ { id: 'table', icon: , label: undefined, srLabel: 'Tabelle', }, { id: 'cards', icon: , label: undefined, srLabel: 'Cards', }, { id: 'gallery', icon: , label: undefined, srLabel: 'Galerie', }, ]} />
{selectedCount > 0 ? ( ) : null}
{selectedCount > 0 ? (
{bulkBusy ? (
Wird verarbeitet…
) : null}
{bulkBusy ? (
Wird verarbeitet…
) : null}
) : null} {tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null}
setSearchQuery(e.target.value)} placeholder="Suchen…" /> {(searchQuery || '').trim() !== '' ? ( ) : null}
Keep anzeigen
Behaltene Downloads in der Liste
{ if (page !== 1) onPageChange(1) onIncludeKeepChange(checked) }} ariaLabel="Behaltene Downloads anzeigen" size="default" />
{view !== 'table' ? (
{view === 'gallery' ? (
) : null}
Sortierung
) : null}
{tagFilter.length > 0 ? (
Tag-Filter
{tagFilter.map((t) => ( ))}
) : null}
{emptyFolder ? (
📁
Keine abgeschlossenen Downloads
Im Zielordner ist aktuell nichts vorhanden.
) : emptyByFilter ? (
Keine Treffer für die aktuellen Filter.
{tagFilter.length > 0 || (searchQuery || '').trim() !== '' ? (
{tagFilter.length > 0 ? ( ) : null} {(searchQuery || '').trim() !== '' ? ( ) : null}
) : null}
) : (
{view === 'cards' && (
{ if (checked) selectPageRows(pageRows) else deselectPageRows(pageRows) }} isSmall={isSmall} jobForDetails={jobForDetails} isLoading={false} blurPreviews={blurPreviews} durations={durations} teaserState={teaserState} inlinePlay={inlinePlay} deletingKeys={deletingKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} swipeRefs={swipeRefs} keyFor={keyFor} baseName={baseName} modelNameFromOutput={modelNameFromOutput} runtimeOf={runtimeOf} sizeBytesOf={sizeBytesOf} formatBytes={formatBytes} lower={lower} onOpenPlayer={onOpenPlayer} openPlayer={openPlayer} onOpenPlayerAt={openPlayerAt} handleScrubberClickIndex={handleScrubberClickIndex} startInlineAt={startInlineAt} startInline={startInline} tryAutoplayInline={tryAutoplayInline} registerTeaserHost={registerTeaserHost} handleDuration={handleDuration} deleteVideo={deleteVideo} keepVideo={keepVideo} releasePlayingFile={releasePlayingFile} modelsByKey={modelsByKey} onToggleHot={toggleHotVideo} onToggleFavorite={onToggleFavorite} onToggleLike={onToggleLike} onToggleWatch={onToggleWatch} onSplit={onSplitJob} onAddToDownloads={onAddToDownloads} activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} assetNonce={assetNonce ?? 0} postworkByFile={effectivePostworkSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} mobileTeaserAudioUnlocked={mobileTeaserAudioUnlocked} onUnlockMobileTeaserAudio={() => setMobileTeaserAudioUnlocked(true)} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, disablePopover: isSmall, onClick: () => { if (!badge?.file) return if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) } />
)} {view === 'table' && ( { if (checked) selectPageRows(pageRows) else deselectPageRows(pageRows) }} jobForDetails={jobForDetails} keyFor={keyFor} baseName={baseName} lower={lower} modelNameFromOutput={modelNameFromOutput} runtimeOf={runtimeOf} sizeBytesOf={sizeBytesOf} formatBytes={formatBytes} resolutions={resolutions} durations={durations} canHover={canHover} teaserState={teaserState} setHoverTeaserKey={setHoverTeaserKey} registerTeaserHost={registerTeaserHost} handleDuration={handleDuration} handleResolution={handleResolution} blurPreviews={blurPreviews} assetNonce={assetNonce} deletingKeys={deletingKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} modelsByKey={modelsByKey} activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onOpenPlayer={onOpenPlayer} handleScrubberClickIndex={handleScrubberClickIndex} onSortModeChange={onSortModeChange} page={page} onPageChange={onPageChange} onToggleHot={toggleHotVideo} onToggleFavorite={onToggleFavorite} onToggleLike={onToggleLike} onToggleWatch={onToggleWatch} onSplit={onSplitJob} onAddToDownloads={onAddToDownloads} deleteVideo={deleteVideo} keepVideo={keepVideo} postworkByFile={effectivePostworkSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, disablePopover: isSmall, onClick: () => { if (!badge?.file) return if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) } /> )} {view === 'gallery' && ( { if (checked) selectPageRows(pageRows) else deselectPageRows(pageRows) }} isLoading={false} jobForDetails={jobForDetails} blurPreviews={blurPreviews} durations={durations} handleDuration={handleDuration} teaserState={teaserState} keyFor={keyFor} baseName={baseName} modelNameFromOutput={modelNameFromOutput} runtimeOf={runtimeOf} sizeBytesOf={sizeBytesOf} formatBytes={formatBytes} deletingKeys={deletingKeys} keepingKeys={keepingKeys} removingKeys={removingKeys} deletedKeys={deletedKeys} registerTeaserHost={registerTeaserHost} onOpenPlayer={onOpenPlayer} handleScrubberClickIndex={handleScrubberClickIndex} deleteVideo={deleteVideo} keepVideo={keepVideo} onToggleHot={toggleHotVideo} lower={lower} modelsByKey={modelsByKey} onToggleFavorite={onToggleFavorite} onToggleLike={onToggleLike} onToggleWatch={onToggleWatch} onSplit={onSplitJob} onAddToDownloads={onAddToDownloads} activeTagSet={activeTagSet} onToggleTagFilter={toggleTagFilter} onHoverPreviewKeyChange={setHoverTeaserKey} postworkByFile={effectivePostworkSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} cardScale={galleryCardScaleLive} renderPostworkBadge={(badge) => renderFinishedPostworkBadge(badge, { clickable: true, disablePopover: isSmall, onClick: () => { if (!badge?.file) return if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }) } /> )}
)}
{ flushSync(() => { setInlinePlay(null) setTeaserKey(null) setHoverTeaserKey(null) }) window.scrollTo({ top: 0, behavior: 'auto' }) onPageChange(p) }} showSummary={false} prevLabel="Zurück" nextLabel="Weiter" className="mt-0" />
) }