// frontend\src\components\ui\FinishedDownloads.tsx 'use client' import { useEffect, useRef, useState, useMemo, useCallback, type MouseEvent, useDeferredValue, type ReactNode } from 'react' import Card from './Card' import type { ViewMode, 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 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' import TagFilterBar from './TagFilterBar' import { parseTagList, lowerTag } from './tagUtils' import { createFinishedSelectionStore, useSelectionVersion, type FinishedSelectionStore, } from './finishedSelectionStore' import { formatResolution, formatDuration, formatBytes } from './formatters' import RatingOverlay from './RatingOverlay' 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; from?: 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 | { newFile?: string } | 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 splitProgressByFile?: Record } const norm = (p: string) => (p || '').replaceAll('\\', '/') const baseName = (p: string) => { const n = norm(p) const parts = n.split('/') return parts[parts.length - 1] || '' } const stemWithoutExt = (p: string) => { const f = baseName(p || '') return stripHotPrefix(f.replace(/\.[^.]+$/, '')) } const keyFor = (j: RecordJob) => { const id = String((j as any)?.id ?? '').trim() const stableFromOutput = stemWithoutExt(j.output || '') // doneJobs aus dem Backend verwenden oft dateinamige IDs. // Wenn die ID effektiv nur der Dateiname ist, HOT-normalisieren. if (id) { const normalizedId = stripHotPrefix(id) if (stableFromOutput && normalizedId === stableFromOutput) { return stableFromOutput } return id } return stableFromOutput || String((j as any)?.output || '') } const isTrashOutput = (output?: string) => { const p = norm(String(output ?? '')) // match: ".../.trash/file.ext" oder "...\ .trash\file.ext" return p.includes('/.trash/') || p.endsWith('/.trash') } const GALLERY_CARD_SCALE_PRESETS = [ { label: 'Klein', scale: 0.8 }, { label: 'Normal', scale: 1.0 }, { label: 'XL', scale: 1.2 }, ] as const const DEFAULT_GALLERY_CARD_SCALE_INDEX = 2 function clampPresetIndex(value: number) { return Math.max( 0, Math.min(GALLERY_CARD_SCALE_PRESETS.length - 1, Math.round(value)) ) } function nearestPresetIndex(value: number) { let bestIndex = 0 let bestDist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[0].scale) for (let i = 1; i < GALLERY_CARD_SCALE_PRESETS.length; i++) { const dist = Math.abs(value - GALLERY_CARD_SCALE_PRESETS[i].scale) if (dist < bestDist) { bestDist = dist bestIndex = i } } return bestIndex } function useMediaQuery(query: string) { const [matches, setMatches] = 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) if (job.sourceUrl || !href) return job return { ...job, sourceUrl: href, } } const reModelFromFilename = /^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}/ const modelNameFromOutput = (output?: string) => { const fileRaw = baseName(output || '') const file = stripHotPrefix(fileRaw) if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') const m = stem.match(reModelFromFilename) if (m?.[1]?.trim()) { return m[1].trim() } const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } // Tags kommen aus dem ModelStore als String (meist komma-/semicolon-getrennt) const parseTags = parseTagList const lower = lowerTag // 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] = useState(false) const timeoutRef = useRef(null) useEffect(() => { return () => { if (timeoutRef.current != null) { window.clearTimeout(timeoutRef.current) } } }, []) const handleClick = async (e: 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('cannot access the file') || s.includes('access is denied') || s.includes('zugriff verweigert') || s.includes('wird von einem anderen prozess verwendet') || 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 : {} const incomingHighlights = isPlainObject(incomingAnalysis.highlights) ? incomingAnalysis.highlights : {} const existingHighlights = isPlainObject(existingAnalysis.highlights) ? existingAnalysis.highlights : {} const incomingAi = isPlainObject(incomingAnalysis.ai) ? incomingAnalysis.ai : {} const existingAi = isPlainObject(existingAnalysis.ai) ? existingAnalysis.ai : {} merged.analysis = { ...incomingAnalysis, ...existingAnalysis, highlights: Object.keys(existingHighlights).length > 0 ? { ...incomingHighlights, ...existingHighlights, } : Object.keys(incomingHighlights).length > 0 ? incomingHighlights : undefined, // Altbestand weiter unterstützen. ai: Object.keys(existingAi).length > 0 ? { ...incomingAi, ...existingAi, } : Object.keys(incomingAi).length > 0 ? incomingAi : 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('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' } } type SplitProgressOverlay = { phase: 'preparing' | 'splitting' | 'done' | 'error' total: number current: number completed: number segmentProgress?: number overallProgress?: number message?: string segment?: { index: number label: string start: number end: number duration: number } startedAtMs?: number sourceFile?: string } type DownloadIntegrityBadgeInfo = { label: string detail: string blackTailFrames?: number sampleCount?: number } function matchesCorruptOnlyFilter(metaRaw: unknown, enabled: boolean): boolean { if (!enabled) return true return readDownloadIntegrityInfo(metaRaw).state === 'corrupt' } function readDownloadIntegrityState(metaRaw: unknown): 'corrupt' | 'ok' | 'unchecked' { const meta = parseMetaObject(metaRaw) as any const node = meta?.validation?.tailBlackout if (!node || typeof node !== 'object') return 'unchecked' if (node?.isCorrupt === true) return 'corrupt' return 'ok' } type DownloadIntegrityState = 'corrupt' | 'ok' | 'unchecked' function readDownloadIntegrityInfo(metaRaw: unknown): { state: DownloadIntegrityState checked: boolean node?: Record } { const meta = parseMetaObject(metaRaw) as any const node = meta?.validation?.tailBlackout if (!node || typeof node !== 'object') { return { state: 'unchecked', checked: false, } } return { state: node?.isCorrupt === true ? 'corrupt' : 'ok', checked: true, node, } } function readDownloadIntegrityBadge(metaRaw: unknown): DownloadIntegrityBadgeInfo | null { const info = readDownloadIntegrityInfo(metaRaw) const node = info.node if (!info.checked || info.state !== 'corrupt' || !node) return null const blackTailFrames = typeof node?.blackTailFrames === 'number' && Number.isFinite(node.blackTailFrames) ? node.blackTailFrames : undefined const sampleCount = typeof node?.sampleCount === 'number' && Number.isFinite(node.sampleCount) ? node.sampleCount : Array.isArray(node?.samples) ? node.samples.length : undefined const reason = String(node?.reason ?? '').trim() const fallbackDetail = typeof blackTailFrames === 'number' && typeof sampleCount === 'number' && sampleCount > 0 ? `${blackTailFrames}/${sampleCount} Frames am Ende sind fast komplett schwarz` : 'Mehrere Frames am Ende sind fast komplett schwarz' return { label: 'Fehlerhaft', detail: reason || fallbackDetail, blackTailFrames, sampleCount, } } function renderDownloadIntegrityBadge(metaRaw: unknown): ReactNode { const info = readDownloadIntegrityBadge(metaRaw) if (!info) return null const badgeNode = ( {info.label} ) return ( open && (
Download-Prüfung
Verdacht auf beschädigtes Videoende
{info.detail}
) } > {badgeNode}
) } function labelForStep(queue: 'postwork' | 'enrich', phase?: string): string { const p = String(phase ?? '').trim().toLowerCase() 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 'Analyse' default: return p || (queue === 'enrich' ? '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?.highlights ?? 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', 'highlights') || 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 ? `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 normalizeFinishedPhase( queue: 'postwork' | 'enrich', phase?: string ): string { const p = String(phase ?? '').trim().toLowerCase() if (queue === 'enrich') { switch (p) { case '': case 'assets': case 'analyze': return 'analyze' default: return p } } 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() for (const badge of realBadges) { const queue = badge.queue const rawPhase = String(badge.phase ?? '').trim().toLowerCase() const phase = normalizeFinishedPhase(queue, rawPhase) const key = `${queue}:${phase}` const badgeLabel = String(badge.label ?? '').trim() const normalizedBadgeLabel = normalizeAnalysisProgressLabel(badgeLabel) ?? badgeLabel const candidate: FinishedPostworkStepSummary = { key, queue, phase, label: normalizedBadgeLabel || 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, } const prev = byKey.get(key) if (prev) { if ((prev.ts || 0) > (candidate.ts || 0)) { byKey.set(key, preferProgressStepLabel(prev, candidate)) } else { byKey.set(key, preferProgressStepLabel(candidate, prev)) } continue } byKey.set(key, candidate) } // ✅ 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, }) } 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 ? `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) { stepMap.set(step.key, step) continue } if ((step.ts || 0) >= (prev.ts || 0)) { stepMap.set(step.key, preferProgressStepLabel(step, prev)) continue } // Auch wenn der ältere Step vom Polling kommt: // Ein vorhandenes Analyse-Prozentlabel darf nicht durch "Analyse" verloren gehen. stepMap.set(step.key, preferProgressStepLabel(prev, 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 ? `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, } } function samePostworkBadge(a: FinishedPostworkBadge, b: FinishedPostworkBadge): boolean { return ( a.file === b.file && a.assetId === b.assetId && a.queue === b.queue && a.phase === b.phase && a.state === b.state && a.label === b.label && a.error === b.error && a.position === b.position && a.waiting === b.waiting && a.running === b.running && a.maxParallel === b.maxParallel ) } function analysisProgressPercent(label?: string): number | null { const s = String(label ?? '').trim() const percentMatch = s.match(/^analyse\s+(\d{1,3})%$/i) if (percentMatch) { const n = Number(percentMatch[1]) if (!Number.isFinite(n)) return null return Math.max(0, Math.min(100, Math.round(n))) } const framesMatch = s.match(/^analyse\s+(\d+)\s*\/\s*(\d+)\s*frames?$/i) if (framesMatch) { const current = Number(framesMatch[1]) const total = Number(framesMatch[2]) if (!Number.isFinite(current) || !Number.isFinite(total) || total <= 0) { return null } return Math.max(0, Math.min(100, Math.round((current / total) * 100))) } return null } function postworkStepDisplayLabel(step: FinishedPostworkStepSummary): string { if (isAnalyzeStep(step)) { return 'Analyse' } return step.label } function runningStepStateLabel(step: FinishedPostworkStepSummary): string { if (isAnalyzeStep(step)) { const percent = analysisProgressPercent(step.label) if (percent != null) return `${percent}%` } return 'Läuft' } function normalizeAnalysisProgressLabel(label?: string): string | undefined { const percent = analysisProgressPercent(label) if (percent == null) return label return `Analyse ${percent}%` } function isAnalyzeBadge(badge?: Pick | null): boolean { return ( badge?.queue === 'enrich' && normalizeFinishedPhase('enrich', badge.phase) === 'analyze' ) } function isAnalyzeStep(step?: Pick | null): boolean { return ( step?.queue === 'enrich' && normalizeFinishedPhase('enrich', step.phase) === 'analyze' ) } function preferProgressLabel( incoming: FinishedPostworkBadge, previous?: FinishedPostworkBadge ): FinishedPostworkBadge { if (!isAnalyzeBadge(incoming) || incoming.state !== 'running') { return incoming } const normalizedIncoming: FinishedPostworkBadge = { ...incoming, label: normalizeAnalysisProgressLabel(incoming.label), } if (!previous || previous.state !== 'running' || !isAnalyzeBadge(previous)) { return normalizedIncoming } const incomingPercent = analysisProgressPercent(normalizedIncoming.label) const previousPercent = analysisProgressPercent(previous.label) if (previousPercent != null && incomingPercent == null) { return { ...normalizedIncoming, label: previous.label, } } if ( previousPercent != null && incomingPercent != null && incomingPercent < previousPercent ) { return { ...normalizedIncoming, label: previous.label, } } return normalizedIncoming } function preferProgressStepLabel( incoming: FinishedPostworkStepSummary, previous?: FinishedPostworkStepSummary ): FinishedPostworkStepSummary { if (!isAnalyzeStep(incoming) || incoming.state !== 'running') { return incoming } const normalizedIncoming: FinishedPostworkStepSummary = { ...incoming, label: normalizeAnalysisProgressLabel(incoming.label) ?? incoming.label, } if (!previous || previous.state !== 'running' || !isAnalyzeStep(previous)) { return normalizedIncoming } const incomingPercent = analysisProgressPercent(normalizedIncoming.label) const previousPercent = analysisProgressPercent(previous.label) if (previousPercent != null && incomingPercent == null) { return { ...normalizedIncoming, label: previous.label, } } if ( previousPercent != null && incomingPercent != null && incomingPercent < previousPercent ) { return { ...normalizedIncoming, label: previous.label, } } return normalizedIncoming } function samePostworkBadgeList( a?: FinishedPostworkBadge[], b?: FinishedPostworkBadge[] ): boolean { const aa = Array.isArray(a) ? a : [] const bb = Array.isArray(b) ? b : [] if (aa.length !== bb.length) return false for (let i = 0; i < aa.length; i++) { if (!samePostworkBadge(aa[i], bb[i])) return false } return true } function samePostworkBadgeMap( a?: Record, b?: Record ): boolean { const aa = a ?? {} const bb = b ?? {} const aKeys = Object.keys(aa) const bKeys = Object.keys(bb) if (aKeys.length !== bKeys.length) return false for (const key of aKeys) { if (!(key in bb)) return false if (!samePostworkBadgeList(aa[key], bb[key])) return false } return true } function postworkSummaryTitle(summary: FinishedPostworkSummary): string { switch (summary.state) { case 'running': return 'Postwork läuft' case 'queued': return 'Postwork wartet' case 'done': return 'Postwork fertig' case 'error': return 'Postwork Fehler' case 'missing': return 'Postwork fehlt' default: return 'Postwork-Status' } } function postworkSummaryIcon(summary: FinishedPostworkSummary): ReactNode { const iconClass = 'size-5 shrink-0' switch (summary.state) { case 'running': return case 'queued': return case 'done': return case 'error': return case 'missing': return default: return } } function postworkDoneCount(summary: FinishedPostworkSummary): number { return summary.steps.filter((step) => step.state === 'done').length } function postworkStepProgressValue(step: FinishedPostworkStepSummary): number { if (step.state === 'done') return 1 if (step.state === 'running' && isAnalyzeStep(step)) { const percent = analysisProgressPercent(step.label) if (percent != null) return percent / 100 } return 0 } function postworkProgressPercent(summary: FinishedPostworkSummary): number { const total = summary.steps.length if (total <= 0) return 0 const value = summary.steps.reduce( (sum, step) => sum + postworkStepProgressValue(step), 0 ) return Math.max(0, Math.min(100, Math.round((value / total) * 100))) } function postworkStepStateLabel(step: FinishedPostworkStepSummary): string { if (step.state === 'running') return runningStepStateLabel(step) if (step.state === 'queued') { return typeof step.position === 'number' && step.position > 0 ? `Wartet #${step.position}` : 'Wartet' } if (step.state === 'done') return 'Fertig' if (step.state === 'error') return 'Fehler' if (step.state === 'missing') return 'Offen' return '—' } function postworkStepTone(step: FinishedPostworkStepSummary): string { switch (step.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-600 ring-gray-200 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10' } } function postworkStepIcon(step: FinishedPostworkStepSummary): ReactNode { if (step.state === 'running') { return } if (step.state === 'queued') { return } if (step.state === 'done') { return } if (step.state === 'error') { return } return } function postworkPreviewSrc(summary: FinishedPostworkSummary): string | null { const id = String(summary.assetId || fileStemFromOutput(summary.file)).trim() if (!id) return null const base = `/api/preview?id=${encodeURIComponent(id)}&file=preview.jpg` const version = Number.isFinite(summary.ts) && summary.ts > 0 ? String(summary.ts) : '' return version ? `${base}&v=${encodeURIComponent(version)}` : base } function renderPostworkPopover(summary: FinishedPostworkSummary): ReactNode { const done = postworkDoneCount(summary) const total = summary.steps.length const progress = postworkProgressPercent(summary) const previewSrc = postworkPreviewSrc(summary) return (
{previewSrc ? ( { e.currentTarget.style.display = 'none' }} /> ) : null}
{postworkSummaryIcon(summary)}
{postworkSummaryTitle(summary)}
{summary.label}
{summary.file}
{done}/{total}
{summary.steps.map((step) => (
{postworkStepDisplayLabel(step)}
{step.state === 'error' && step.error ? (
{step.error}
) : null}
{postworkStepIcon(step)}
{postworkStepStateLabel(step)} {step.state === 'error' && step.error ? ( ) : null}
))}
) } export function renderFinishedPostworkBadge( summary?: FinishedPostworkSummary, opts?: { onClick?: () => void clickable?: boolean disablePopover?: boolean } ): 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 && renderPostworkPopover(summary)} > {badgeNode} ) } function buildSplitProgressSummary( file: string, progress?: SplitProgressOverlay ): FinishedPostworkSummary | null { if (!file || !progress) return null const rawOverall = typeof progress.overallProgress === 'number' ? progress.overallProgress : progress.phase === 'done' ? 1 : 0 const overall = Math.max(0, Math.min(1, rawOverall)) const percent = Math.round(overall * 100) const state: FinishedPostworkState = progress.phase === 'error' ? 'error' : progress.phase === 'done' ? 'done' : 'running' const label = progress.phase === 'error' ? 'Split Fehler' : progress.phase === 'done' ? 'Split fertig' : `Split ${percent}%` const stepLabel = progress.segment?.label?.trim() || progress.message?.trim() || 'Video wird gesplittet' return { file, assetId: fileStemFromOutput(file), state, label, ts: Number(progress.startedAtMs ?? Date.now()), steps: [ { key: 'postwork:split', queue: 'postwork', phase: 'split', label: stepLabel, state, error: progress.phase === 'error' ? String(progress.message ?? 'Split fehlgeschlagen') : undefined, ts: Number(progress.startedAtMs ?? Date.now()), }, ], } } export default function FinishedDownloads({ jobs, doneJobs, 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, splitProgressByFile = {}, }: 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 = 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 cardsMobileOffsetTopClass = '' const cardsMobileOffsetBottomClass = 'mb-2' // ----------------------------------------------------------------------------- // state // ----------------------------------------------------------------------------- const [mobileTeaserAudioUnlocked, setMobileTeaserAudioUnlocked] = useState(false) const [teaserKey, setTeaserKey] = useState(null) const [hoverTeaserKey, setHoverTeaserKey] = useState(null) const [deletedKeys, setDeletedKeys] = useState>(() => new Set()) const [deletingKeys, setDeletingKeys] = useState>(() => new Set()) const [keepingKeys, setKeepingKeys] = useState>(() => new Set()) const [removingKeys, setRemovingKeys] = useState>(() => new Set()) const [hiddenFiles, setHiddenFiles] = useState>(() => new Set()) const [undoing, setUndoing] = useState(false) const [bulkBusy, setBulkBusy] = useState(false) const [showOnlyCorrupt, setShowOnlyCorrupt] = useState(false) const [postworkByFile, setPostworkByFile] = useState>({}) const [generateAssetsLiveByFile, setGenerateAssetsLiveByFile] = useState>({}) const [renamedFiles, setRenamedFiles] = useState>({}) const [view, setView] = useState('table') const [mobileOptionsOpen, setMobileOptionsOpen] = useState(false) const [selectionActionsOpen, setSelectionActionsOpen] = useState(false) const [galleryCardScaleIndex, setGalleryCardScaleIndex] = useState( DEFAULT_GALLERY_CARD_SCALE_INDEX ) const selectionStoreRef = useRef(null) const selectionStore = selectionStoreRef.current ?? (selectionStoreRef.current = createFinishedSelectionStore()) const selectionVersion = useSelectionVersion(selectionStore) const galleryCardScale = GALLERY_CARD_SCALE_PRESETS[galleryCardScaleIndex]?.scale ?? GALLERY_CARD_SCALE_PRESETS[DEFAULT_GALLERY_CARD_SCALE_INDEX].scale const handleGalleryScalePresetChange = useCallback((nextIndex: number) => { setGalleryCardScaleIndex(clampPresetIndex(nextIndex)) }, []) const [allDoneJobs, setAllDoneJobs] = useState(null) const [allDoneJobsLoading, setAllDoneJobsLoading] = useState(false) const allDoneJobsRequestRef = useRef(0) const [tagFilter, setTagFilter] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [durations, setDurations] = useState>({}) const [resolutions, setResolutions] = useState>({}) const [inlinePlay, setInlinePlay] = useState<{ key: string; nonce: number } | null>(null) const [hotBusyKeys, setHotBusyKeys] = useState>(() => new Set()) const [lastAction, setLastAction] = 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 } }) const resolutionOf = useCallback((job: RecordJob): { w: number; h: number } | null => { const k = keyFor(job) const cached = resolutions[k] if (cached?.w > 0 && cached?.h > 0) { return cached } const meta = parseMetaObject((job as any)?.meta) const w = readMetaVideoWidth(meta) const h = readMetaVideoHeight(meta) if (w && h) { return { w, h } } return null }, [resolutions]) const resolutionLabelOf = useCallback((job: RecordJob): string => { return formatResolution(resolutionOf(job)) || '—' }, [resolutionOf]) // ----------------------------------------------------------------------------- // refs // ----------------------------------------------------------------------------- const pointerPosRef = useRef<{ x: number; y: number } | null>(null) const teaserHostsRef = useRef>(new Map()) const teaserIORef = useRef(null) const elToKeyRef = useRef>(new WeakMap()) const teaserKeyRafRef = useRef(null) const pendingTeaserKeyRef = useRef(null) const countHintRef = useRef({ pending: 0, t: 0, timer: 0 as any }) const swipeRefs = useRef>(new Map()) const fileToKeyRef = useRef>(new Map()) const durationsRef = useRef>({}) const durationsFlushTimerRef = useRef(null) const resolutionsRef = useRef>({}) const resolutionsFlushTimerRef = useRef(null) const removeTimersRef = useRef>(new Map()) const regenerateTaskFilesRef = useRef>(new Set()) const [doneMetaByFile, setDoneMetaByFile] = useState>({}) const refreshDoneMetaForFile = useCallback(async (file: string) => { const cleanFile = String(file || '').trim() if (!cleanFile) return try { const res = await fetch( `/api/record/done/meta?file=${encodeURIComponent(cleanFile)}`, { cache: 'no-store' } ) if (!res.ok) return const data = await res.json().catch(() => null) const meta = data?.meta if (!meta || typeof meta !== 'object') return setDoneMetaByFile((prev) => ({ ...prev, [cleanFile]: meta as RecordJobMeta, })) } catch { // ignore } }, []) const renderIntegrityBadge = useCallback((job: RecordJob) => { return renderDownloadIntegrityBadge((job as any)?.meta) }, []) const openPlayerAndPauseTeasers = useCallback((job: RecordJob, startAtSec?: number) => { onOpenPlayer(job, startAtSec) }, [onOpenPlayer]) const renderRatingOverlay = useCallback((job: RecordJob) => { return ( { openPlayerAndPauseTeasers(job, seconds) }} /> ) }, [openPlayerAndPauseTeasers]) // ----------------------------------------------------------------------------- // memoized derived data // ----------------------------------------------------------------------------- const forcePreviewMuted = Boolean(pauseTeasers) || (mobileNeedsTapForAudio && teaserAudio ? !mobileTeaserAudioUnlocked : false) const previewMuted = !Boolean(teaserAudio) || forcePreviewMuted const audibleTeaserKey = previewMuted ? null : canHover ? hoverTeaserKey : inlinePlay?.key ?? teaserKey const deferredSearchQuery = useDeferredValue(searchQuery) const activeTagSet = useMemo(() => new Set(tagFilter.map(lowerTag)), [tagFilter]) const allKnownTags = useMemo(() => { const seen = new Set() const out: string[] = [] for (const flags of Object.values(modelsByKey ?? {})) { for (const tag of parseTags(flags?.tags)) { const k = lowerTag(tag) if (!k || seen.has(k)) continue seen.add(k) out.push(tag) } } out.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) return out }, [modelsByKey]) const teaserState = useMemo( () => ({ mode: effectiveTeaserPlaybackMode, activeKey: teaserKey, hoverKey: hoverTeaserKey, audioEnabled: Boolean(teaserAudio) && !forcePreviewMuted && Boolean(audibleTeaserKey), }), [effectiveTeaserPlaybackMode, teaserKey, hoverTeaserKey, teaserAudio, forcePreviewMuted, audibleTeaserKey] ) const modelTags = useMemo(() => { const tagsByModelKey: Record = {} const tagSetByModelKey: Record> = {} for (const [k, flags] of Object.entries(modelsByKey ?? {})) { const key = lowerTag(k) const arr = parseTags(flags?.tags) tagsByModelKey[key] = arr tagSetByModelKey[key] = new Set(arr.map(lowerTag)) } return { tagsByModelKey, tagSetByModelKey } }, [modelsByKey]) const searchTokens = useMemo( () => lowerTag(deferredSearchQuery).split(/\s+/g).map((s) => s.trim()).filter(Boolean), [deferredSearchQuery] ) const searchActiveForGlobalFetch = activeTagSet.size > 0 || searchTokens.length > 0 || showOnlyCorrupt const globalFilterActive = searchActiveForGlobalFetch const mobileCardStackMode = isSmall && view === 'cards' const shouldLoadAllDoneJobs = globalFilterActive || loadMode === 'all' || mobileCardStackMode const effectiveAllMode = shouldLoadAllDoneJobs const DEFAULT_GALLERY_PAGE_SIZE = 8 const [galleryPageSize, setGalleryPageSize] = useState( DEFAULT_GALLERY_PAGE_SIZE ) const handleGalleryRecommendedPageSizeChange = useCallback((rawSize: number) => { const rounded = Math.round(Number(rawSize)) if (!Number.isFinite(rounded) || rounded <= 0) return const next = Math.max(1, Math.min(24, rounded)) setGalleryPageSize((current) => { if (current === next) return current return next }) onPageSizeChange?.(next) if (page !== 1) { onPageChange(1) } }, [onPageSizeChange, onPageChange, page]) const effectivePageSize = view === 'gallery' ? galleryPageSize : pageSize const doneJobsPage = shouldLoadAllDoneJobs ? (allDoneJobs ?? doneJobs) : doneJobs const doneTotalPage = shouldLoadAllDoneJobs ? (allDoneJobs?.length ?? doneTotal) : 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) { let jj = applyRenamedOutputLocal(j) const file = baseName(jj.output || '') const metaOverride = file ? doneMetaByFile[file] : undefined if (metaOverride) { jj = { ...jj, meta: mergeMetaPreferDone(metaOverride, (jj as any)?.meta), } } const k = keyFor(jj) map.set(k, jj) 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, doneMetaByFile]) const effectivePostworkSummaryByFile = useMemo>(() => { const next: Record = {} for (const [file, badges] of Object.entries(postworkByFile)) { const summary = summarizePostworkSteps(file, badges) if (summary) { next[file] = summary const filePlain = stripHotPrefix(file) if (filePlain && filePlain !== file) { next[filePlain] = summary } } } for (const [file, badges] of Object.entries(generateAssetsLiveByFile)) { const summary = summarizePostworkSteps(file, badges) if (!summary) continue const filePlain = stripHotPrefix(file) const existing = next[file] ?? (filePlain ? next[filePlain] : undefined) const merged = mergePostworkSummaries(summary, existing ?? null, file, summary.assetId) ?? summary next[file] = merged if (filePlain && filePlain !== file) { next[filePlain] = merged } } 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, generateAssetsLiveByFile, rows]) const effectiveBadgeSummaryByFile = useMemo>(() => { const next = { ...effectivePostworkSummaryByFile } for (const [file, progress] of Object.entries(splitProgressByFile)) { const summary = buildSplitProgressSummary(file, progress) if (!summary) continue next[file] = summary const plain = stripHotPrefix(file) if (plain && plain !== file) { next[plain] = summary } } return next }, [effectivePostworkSummaryByFile, splitProgressByFile]) const viewRows = rows 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 = lowerTag(model) const tags = modelTags.tagsByModelKey[modelKey] ?? [] const hay = lowerTag([file, stripHotPrefix(file), model, j.id, tags.join(' ')].join(' ')) for (const t of searchTokens) { if (!hay.includes(t)) return false } return true }) : base const tagged = activeTagSet.size === 0 ? searched : searched.filter((j) => { const modelKey = lowerTag(modelNameFromOutput(j.output)) const have = modelTags.tagSetByModelKey[modelKey] if (!have || have.size === 0) return false for (const t of activeTagSet) { if (!have.has(t)) return false } return true }) const corruptFiltered = tagged.filter((j) => { return matchesCorruptOnlyFilter((j as any)?.meta, showOnlyCorrupt) }) return corruptFiltered }, [ viewRows, deletedKeys, activeTagSet, modelTags, searchTokens, showOnlyCorrupt, ]) const finishedDownloadsLoading = allDoneJobsLoading const totalItemsForPagination = finishedDownloadsLoading ? 0 : effectiveAllMode ? visibleRows.length : doneTotalPage const pageRows = useMemo(() => { if (effectiveAllMode) { const start = (page - 1) * effectivePageSize const end = start + effectivePageSize return visibleRows.slice(start, end) } return visibleRows }, [visibleRows, page, effectivePageSize, effectiveAllMode]) const cardRows = mobileCardStackMode ? visibleRows : pageRows const activeVisibleRows = pageRows const emptyFolder = !finishedDownloadsLoading && !effectiveAllMode && totalItemsForPagination === 0 const emptyByFilter = !finishedDownloadsLoading && globalFilterActive && visibleRows.length === 0 const selectedCount = useMemo(() => { return selectionStore.getCount() }, [selectionStore, selectionVersion]) const selectedFiles = useMemo(() => { return selectionStore.getSelectedFiles() }, [selectionStore, selectionVersion]) const allPageSelected = useMemo(() => { return ( activeVisibleRows.length > 0 && activeVisibleRows.every((j) => selectionStore.isSelected(keyFor(j))) ) }, [activeVisibleRows, keyFor, selectionStore, selectionVersion]) const hasSearchQuery = (searchQuery || '').trim() !== '' const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen const selectionModeActive = selectedCount > 0 const selectedKeys = useMemo(() => { const next = new Set() for (const j of activeVisibleRows) { const k = keyFor(j) if (selectionStore.isSelected(k)) { next.add(k) } } return next }, [activeVisibleRows, keyFor, selectionStore, selectionVersion]) // ----------------------------------------------------------------------------- // callbacks // ----------------------------------------------------------------------------- const toggleSelected = useCallback((job: RecordJob) => { const k = keyFor(job) const file = baseName(job.output || '') if (!file) return selectionStore.toggle({ key: k, file }) }, [selectionStore]) const clearSelection = useCallback(() => { selectionStore.clear() }, [selectionStore]) const selectPageRows = useCallback((rows: RecordJob[]) => { const items = rows .map((j) => { const key = keyFor(j) const file = baseName(j.output || '') if (!file) return null return { key, file } }) .filter((x): x is { key: string; file: string } => Boolean(x)) selectionStore.selectMany(items) }, [selectionStore]) const deselectPageRows = useCallback((rows: RecordJob[]) => { selectionStore.deselectMany(rows.map((j) => keyFor(j))) }, [selectionStore, keyFor]) 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 = lowerTag(tag) setTagFilter((prev) => { const has = prev.some((t) => lowerTag(t) === k) return has ? prev.filter((t) => lowerTag(t) !== k) : [...prev, tag] }) }, []) const clearTagFilter = useCallback(() => { setTagFilter([]) }, []) const addTagFilter = useCallback((tag: string) => { const clean = String(tag ?? '').trim() if (!clean) return setTagFilter((prev) => prev.some((t) => lowerTag(t) === lowerTag(clean)) ? prev : [...prev, clean] ) }, []) const removeTagFilter = useCallback((tag: string) => { setTagFilter((prev) => prev.filter((t) => lowerTag(t) !== lowerTag(tag))) }, []) const renderTagFilterControls = (opts?: { mobile?: boolean }): ReactNode => ( ) const loadAllDoneJobs = useCallback(async (): Promise => { const res = await fetch( `/api/record/done?all=1&sort=${encodeURIComponent(sortMode)}${ includeKeep ? '&includeKeep=1' : '' }`, { cache: 'no-store' as any } ) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } const data = await res.json().catch(() => null) const items = Array.isArray(data?.items) ? (data.items as RecordJob[]) : Array.isArray(data) ? (data as RecordJob[]) : [] return items }, [sortMode, includeKeep]) 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 (!host || !v) return false const teaserHost = host.closest('[data-finished-hover-key]') ?? host.querySelector('[data-finished-hover-key]') const key = teaserHost?.dataset.finishedHoverKey ?? null const muted = !audibleTeaserKey || key !== audibleTeaserKey applyInlineVideoPolicy(v, { muted }) v.defaultMuted = muted v.muted = muted const p = v.play?.() if (p && typeof (p as any).catch === 'function') { ;(p as Promise).catch(() => {}) } return true }, [audibleTeaserKey]) 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 } const muted = !audibleTeaserKey || key !== audibleTeaserKey applyInlineVideoPolicy(v, { muted }) v.defaultMuted = muted v.muted = muted 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)) }, [audibleTeaserKey]) const openPlayer = useCallback((job: RecordJob) => { openPlayerAndPauseTeasers(job) }, [openPlayerAndPauseTeasers]) const openPlayerAt = useCallback((job: RecordJob, seconds: number) => { const s = Number.isFinite(seconds) && seconds >= 0 ? seconds : 0 openPlayerAndPauseTeasers(job, s) }, [openPlayerAndPauseTeasers]) 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 forceReleaseFileTasks = useCallback(async (file: string) => { const cleanFile = String(file || '').trim() if (!cleanFile) return false try { const res = await fetch('/api/record/release-file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ file: cleanFile }), }) if (!res.ok) return false const data = (await res.json().catch(() => null)) as any return Boolean(data?.releasedAny) } catch { return false } }, []) const withFileReleaseRetry = useCallback( async ( file: string, run: () => Promise, opts?: { close?: boolean; attempts?: number; baseDelayMs?: number } ): Promise => { const attempts = Math.max(1, opts?.attempts ?? 5) const baseDelayMs = Math.max(50, opts?.baseDelayMs ?? 220) let lastErr: unknown let taskReleaseTriggered = false 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)) { throw e } // Beim ersten "file in use" zusätzlich Backend-Tasks abbrechen if (!taskReleaseTriggered) { taskReleaseTriggered = true await releasePlayingFile(file, { close: true }) await forceReleaseFileTasks(file) await sleep(450) continue } if (attempt >= attempts) { throw e } } } throw lastErr instanceof Error ? lastErr : new Error(String(lastErr ?? 'Unbekannter Fehler')) }, [releasePlayingFile, forceReleaseFileTasks] ) 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) => { selectionStore.deselect(key) const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' const from = typeof result?.from === 'string' ? result.from : undefined if (undoToken) { setLastAction({ kind: 'delete', undoToken, originalFile: file, rowKey: key, from }) } else { setLastAction(null) } emitCountHint(-1) } }) return res.ok }, [ deletingKeys, markDeleting, onDeleteJob, withFileReleaseRetry, runFileMutation, emitCountHint, selectionStore, ] ) 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) => { selectionStore.deselect(key) 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, selectionStore, ] ) 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 }) selectionStore.replaceFile(oldFile, newFile) }, [selectionStore]) 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 selectOnly = useCallback((job: RecordJob) => { const key = keyFor(job) const file = baseName(job.output || '') if (!file) return selectionStore.clear() selectionStore.selectMany([{ key, file }]) }, [selectionStore]) 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(includeKeep ? 0 : +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) selectionStore.replaceFile(optimisticNew, oldFile) setLastAction(null) }, }) }, [ hotBusyKeys, markHotBusy, runFileMutation, applyRename, clearRenamePair, onToggleHot, withFileReleaseRetry, selectionStore, ] ) const regenerateAssetsForFile = useCallback(async (file: string) => { const cleanFile = String(file || '').trim() if (!cleanFile) return false const assetId = fileStemFromOutput(cleanFile) const now = Date.now() // Nur den ersten Schritt optimistisch als queued setzen. // Die weiteren Phasen sollen erst durch echte SSE-/Backend-Events auftauchen. setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, assetId, queue: 'postwork', state: 'queued', phase: 'meta', label: 'Meta', ts: 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), }) // Request/Enqueue ist fehlgeschlagen -> KEIN Sammelstatus, KEIN enrich:assets. // Wir markieren nur den ersten Schritt als Fehler. setPostworkByFile((prev) => ({ ...prev, [cleanFile]: [ { file: cleanFile, assetId, queue: 'postwork', state: 'error', phase: 'meta', label: 'Fehler', error: e?.message ? String(e.message) : String(e), 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 teaserHostCallbackMapRef = useRef( new Map void>() ) const integrityMetaPendingRef = useRef>(new Set()) const registerTeaserHost = useCallback((key: string) => { const existing = teaserHostCallbackMapRef.current.get(key) if (existing) return existing const cb = (el: HTMLDivElement | null) => { const prev = teaserHostsRef.current.get(key) if (prev && teaserIORef.current && prev !== el) { 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) } } teaserHostCallbackMapRef.current.set(key, cb) return cb }, []) 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 = 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, 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 = 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, animateRemove, clearSelection, emitCountHint, includeKeep, notify, ]) // ----------------------------------------------------------------------------- // effects // ----------------------------------------------------------------------------- useEffect(() => { if (!shouldLoadAllDoneJobs) { setAllDoneJobs(null) setAllDoneJobsLoading(false) return } let cancelled = false const reqId = ++allDoneJobsRequestRef.current setAllDoneJobsLoading(true) void loadAllDoneJobs() .then((items) => { if (cancelled) return if (allDoneJobsRequestRef.current !== reqId) return setAllDoneJobs(items) }) .catch(() => { if (cancelled) return if (allDoneJobsRequestRef.current !== reqId) return setAllDoneJobs([]) }) .finally(() => { if (cancelled) return if (allDoneJobsRequestRef.current !== reqId) return setAllDoneJobsLoading(false) }) return () => { cancelled = true } }, [shouldLoadAllDoneJobs, loadAllDoneJobs]) useEffect(() => { if (!showOnlyCorrupt) return for (const job of viewRows) { const file = baseName(job.output || '') if (!file) continue const state = readDownloadIntegrityState((job as any)?.meta) if (state !== 'unchecked') continue if (integrityMetaPendingRef.current.has(file)) continue integrityMetaPendingRef.current.add(file) void refreshDoneMetaForFile(file).finally(() => { integrityMetaPendingRef.current.delete(file) }) } }, [showOnlyCorrupt, viewRows, refreshDoneMetaForFile]) useEffect(() => { onPageChange(1) }, [deferredSearchQuery, tagFilter, showOnlyCorrupt, onPageChange]) useEffect(() => { if (view !== 'gallery') return const wanted = galleryPageSize if (pageSize !== wanted) { onPageSizeChange?.(wanted) } }, [view, pageSize, galleryPageSize, onPageSizeChange]) useEffect(() => { try { const raw = localStorage.getItem(GALLERY_CARD_SCALE_KEY) const parsed = Number(raw) if (!Number.isFinite(parsed)) { setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX) return } // neuer Fall: gespeicherter Index if ( Number.isInteger(parsed) && parsed >= 0 && parsed <= GALLERY_CARD_SCALE_PRESETS.length - 1 ) { setGalleryCardScaleIndex(clampPresetIndex(parsed)) return } // alter Fall: gespeicherter Scale-Wert wie 0.8 / 0.9 / 1 / 1.2 setGalleryCardScaleIndex(nearestPresetIndex(parsed)) } catch { setGalleryCardScaleIndex(DEFAULT_GALLERY_CARD_SCALE_INDEX) } }, []) useEffect(() => { try { localStorage.setItem(GALLERY_CARD_SCALE_KEY, String(galleryCardScaleIndex)) } catch {} }, [galleryCardScaleIndex]) 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 (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(() => { for (const [key, host] of teaserHostsRef.current.entries()) { const muted = !audibleTeaserKey || key !== audibleTeaserKey const videos = host.querySelectorAll('video') videos.forEach((video) => { applyInlineVideoPolicy(video, { muted }) video.defaultMuted = muted video.muted = muted }) } }, [audibleTeaserKey, view, pageRows]) const refreshPostworkState = useCallback(async () => { try { const res = await fetch('/api/tasks/status', { cache: 'no-store' }) if (!res.ok) return const data = await res.json().catch(() => null) if (!data) return const generateAssets = data?.generateAssets const gaFile = String(generateAssets?.currentFile ?? '').trim() const gaQueueRaw = String(generateAssets?.currentQueue ?? '').trim().toLowerCase() const gaPhaseRaw = String(generateAssets?.currentPhase ?? '').trim().toLowerCase() const gaLabel = String(generateAssets?.currentLabel ?? '').trim() const gaQueue: 'postwork' | 'enrich' | null = gaQueueRaw === 'postwork' ? 'postwork' : gaQueueRaw === 'enrich' ? 'enrich' : null const gaPhase = gaQueue && gaPhaseRaw ? normalizeFinishedPhase(gaQueue, gaPhaseRaw) : '' const liveTs = typeof generateAssets?.ts === 'number' && Number.isFinite(generateAssets.ts) ? generateAssets.ts : 0 const nextGenerateAssetsLiveByFile: Record = (generateAssets?.running || generateAssets?.queued) && gaFile && gaQueue && gaPhase ? { [gaFile]: [ { file: gaFile, assetId: fileStemFromOutput(gaFile), queue: gaQueue, phase: gaPhase, state: generateAssets?.running ? 'running' : 'queued', label: gaLabel || labelForStep(gaQueue, gaPhase), ts: liveTs, }, ], } : {} setGenerateAssetsLiveByFile((prev) => { const patched: Record = {} for (const [file, badges] of Object.entries(nextGenerateAssetsLiveByFile)) { const prevBadges = prev[file] ?? postworkByFile[file] ?? [] patched[file] = badges.map((badge) => { const key = `${badge.queue}:${normalizeFinishedPhase( badge.queue, String(badge.phase ?? '').trim().toLowerCase() )}` const previous = prevBadges.find((p) => { const prevKey = `${p.queue}:${normalizeFinishedPhase( p.queue, String(p.phase ?? '').trim().toLowerCase() )}` return prevKey === key }) return preferProgressLabel(badge, previous) }) } return samePostworkBadgeMap(prev, patched) ? prev : patched }) const jobs = Array.isArray(data?.regenerateAssetsJobs) ? data.regenerateAssetsJobs : [] const syncedFiles = new Set() setPostworkByFile((prev) => { let changed = false const next = { ...prev } for (const item of jobs) { const file = String(item?.file ?? '').trim() if (!file) continue syncedFiles.add(file) let nextBadges: FinishedPostworkBadge[] | undefined const assetId = String(item?.assetId ?? '').trim() || fileStemFromOutput(file) const phases = Array.isArray(item?.phases) ? item.phases : [] if (phases.length > 0) { const prevBadgesForFile = prev[file] ?? [] nextBadges = 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 } const queue = queueRaw as 'postwork' | 'enrich' const phase = normalizeFinishedPhase(queue, phaseRaw) const incoming: FinishedPostworkBadge = { file, assetId, queue, phase, state, label: normalizeAnalysisProgressLabel(String(p?.label ?? '').trim()) || labelForStep(queue, phase), 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 : 0, } const key = `${incoming.queue}:${normalizeFinishedPhase( incoming.queue, String(incoming.phase ?? '').trim().toLowerCase() )}` const previous = prevBadgesForFile.find((old) => { const oldKey = `${old.queue}:${normalizeFinishedPhase( old.queue, String(old.phase ?? '').trim().toLowerCase() )}` return oldKey === key }) return preferProgressLabel(incoming, previous) }) .filter(Boolean) as FinishedPostworkBadge[] } else { 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') { nextBadges = [ { file, assetId, queue: 'postwork', state, phase: 'meta', label: 'Meta', ts: 0, }, ] } } const prevBadges = prev[file] if (nextBadges) { if (!samePostworkBadgeList(prevBadges, nextBadges)) { next[file] = nextBadges changed = true } } else if (file in next) { delete next[file] changed = true } } // WICHTIG: stale cleanup erst NACH dem Einsammeln aller aktuellen Dateien for (const oldFile of regenerateTaskFilesRef.current) { if (!syncedFiles.has(oldFile) && oldFile in next) { delete next[oldFile] changed = true } } return changed ? next : prev }) regenerateTaskFilesRef.current = syncedFiles } catch { // ignore } }, [postworkByFile]) useEffect(() => { void refreshPostworkState() }, [refreshPostworkState]) useEffect(() => { const timer = window.setInterval(() => { void refreshPostworkState() }, 1500) return () => { window.clearInterval(timer) } }, [refreshPostworkState]) useEffect(() => { const onFocus = () => { void refreshPostworkState() } const onVisibilityChange = () => { if (document.visibilityState === 'visible') { void refreshPostworkState() } } const onPageShow = () => { void refreshPostworkState() } window.addEventListener('focus', onFocus) window.addEventListener('pageshow', onPageShow) document.addEventListener('visibilitychange', onVisibilityChange) return () => { window.removeEventListener('focus', onFocus) window.removeEventListener('pageshow', onPageShow) document.removeEventListener('visibilitychange', onVisibilityChange) } }, [refreshPostworkState]) useEffect(() => { if (!canHover) { pointerPosRef.current = null setHoverTeaserKey(null) return } let raf: number | null = null const syncHoverFromPointer = () => { raf = null const pos = pointerPosRef.current if (!pos) { setHoverTeaserKey((prev) => (prev === null ? prev : null)) return } const el = document.elementFromPoint(pos.x, pos.y) as HTMLElement | null const hoverHost = el?.closest('[data-finished-hover-key]') ?? null const nextKey = hoverHost?.dataset.finishedHoverKey ?? null setHoverTeaserKey((prev) => (prev === nextKey ? prev : nextKey)) } const scheduleSync = () => { if (raf != null) return raf = requestAnimationFrame(syncHoverFromPointer) } const clearHover = () => { pointerPosRef.current = null setHoverTeaserKey((prev) => (prev === null ? prev : null)) } const onPointerMove = (e: PointerEvent) => { pointerPosRef.current = { x: e.clientX, y: e.clientY } scheduleSync() } const onPointerOut = (e: PointerEvent) => { if (!e.relatedTarget) { clearHover() } } const onVisibilityChange = () => { if (document.visibilityState !== 'visible') { clearHover() } } window.addEventListener('pointermove', onPointerMove, { passive: true }) window.addEventListener('pointerout', onPointerOut, { passive: true }) window.addEventListener('blur', clearHover) window.addEventListener('scroll', scheduleSync, true) document.addEventListener('visibilitychange', onVisibilityChange) return () => { if (raf != null) cancelAnimationFrame(raf) window.removeEventListener('pointermove', onPointerMove) window.removeEventListener('pointerout', onPointerOut) window.removeEventListener('blur', clearHover) window.removeEventListener('scroll', scheduleSync, true) document.removeEventListener('visibilitychange', onVisibilityChange) } }, [canHover]) 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 const phase = String(detail.phase ?? '').trim().toLowerCase() if ( detail.state === 'done' && ( phase === 'sprites' || phase === 'thumb' || phase === 'teaser' || phase === 'meta' || phase === 'analyze' ) ) { void refreshDoneMetaForFile(file) } setPostworkByFile((prev) => { const cur = Array.isArray(prev[file]) ? prev[file] : [] if (detail.state === 'missing') { const detailQueue = detail.queue === 'enrich' ? 'enrich' : 'postwork' const detailPhase = normalizeFinishedPhase( detailQueue, String(detail.phase ?? '').trim().toLowerCase() ) const missingKey = `${detailQueue}:${detailPhase}` const filtered = cur.filter((item) => { const itemKey = `${item.queue}:${normalizeFinishedPhase( item.queue, String(item.phase ?? '').trim().toLowerCase() )}` return itemKey !== missingKey }) if (samePostworkBadgeList(cur, filtered)) { return prev } const next = { ...prev } if (filtered.length === 0) delete next[file] else next[file] = filtered return next } const incoming: FinishedPostworkBadge = { ...detail, label: String(detail?.label ?? '').trim() === file ? undefined : detail?.label, error: String(detail?.error ?? '').trim() || undefined, } const key = `${incoming.queue}:${normalizeFinishedPhase( incoming.queue, String(incoming.phase ?? '').trim().toLowerCase() )}` let replaced = false const merged = cur.map((item) => { const itemKey = `${item.queue}:${normalizeFinishedPhase( item.queue, String(item.phase ?? '').trim().toLowerCase() )}` if (itemKey !== key) return item replaced = true if ((item.ts ?? 0) > (incoming.ts ?? 0)) { return item } return preferProgressLabel(incoming, item) }) const nextBadges = replaced ? merged : [...merged, incoming] if (samePostworkBadgeList(cur, nextBadges)) { return prev } return { ...prev, [file]: nextBadges, } }) } window.addEventListener('finished-downloads:postwork', onPostwork as EventListener) return () => window.removeEventListener('finished-downloads:postwork', onPostwork as EventListener) }, [refreshDoneMetaForFile]) useEffect(() => { const onIntegrityRefresh = (ev: Event) => { const detail = (ev as CustomEvent<{ file?: string; all?: boolean }>).detail const file = String(detail?.file ?? '').trim() if (file) { const clean = baseName(file) if (clean) { void refreshDoneMetaForFile(clean) } return } const files = new Set( rows .map((job) => baseName(job.output || '')) .filter(Boolean) ) for (const f of files) { void refreshDoneMetaForFile(f) } } window.addEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener) return () => { window.removeEventListener('finished-downloads:integrity-refresh', onIntegrityRefresh as EventListener) } }, [rows, refreshDoneMetaForFile]) useEffect(() => { const onExternalDelete = (ev: Event) => { 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]) const desktopToolbarControlsClass = [ 'flex flex-col gap-3', ].join(' ') const desktopToolbarLeftClass = [ 'flex min-w-0 flex-col gap-2', ].join(' ') const handleToggleSelectAllPage = useCallback((checked: boolean) => { if (checked) { selectPageRows(pageRows) return } deselectPageRows(pageRows) }, [pageRows, selectPageRows, deselectPageRows]) const renderClickablePostworkBadge = useCallback( (badge?: FinishedPostworkSummary) => renderFinishedPostworkBadge(badge, { clickable: true, disablePopover: isSmall, onClick: () => { if (!badge?.file) return if (badge.state === 'running' || badge.state === 'queued') return void regenerateAssetsForFile(badge.file) }, }), [isSmall, regenerateAssetsForFile] ) const isCompactDesktop = useMediaQuery('(min-width: 640px) and (max-width: 1399px)') const keepToggleLabel = 'Behaltene Downloads anzeigen' const corruptToggleLabel = 'Nur fehlerhafte Downloads' const scrollFinishedListToTop = useCallback(() => { if (typeof document !== 'undefined') { const scrollArea = document.querySelector('[data-app-scroll-area="true"]') if (scrollArea) { scrollArea.scrollTo({ top: 0, behavior: 'auto' }) return } } if (typeof window !== 'undefined') { window.scrollTo({ top: 0, behavior: 'auto' }) } }, []) // ----------------------------------------------------------------------------- // render // ----------------------------------------------------------------------------- return ( <>
{/* Toolbar links außerhalb des Tabellen-Containers */}
{finishedDownloadsLoading ? (
Abgeschlossene Downloads werden geladen…
Bitte kurz warten, die Liste wird vom Backend geladen.
) : emptyFolder ? (
📁
Keine abgeschlossenen Downloads
Im Zielordner ist aktuell nichts vorhanden.
) : emptyByFilter ? (
Keine Treffer für die aktuellen Filter.
{tagFilter.length > 0 || (searchQuery || '').trim() !== '' || showOnlyCorrupt ? (
{tagFilter.length > 0 ? ( ) : null} {(searchQuery || '').trim() !== '' ? ( ) : null}
) : null} {showOnlyCorrupt ? ( ) : null}
) : (
{view === 'cards' && (
setMobileTeaserAudioUnlocked(true)} renderPostworkBadge={renderClickablePostworkBadge} renderIntegrityBadge={renderIntegrityBadge} renderRatingOverlay={renderRatingOverlay} />
)} {view === 'table' && ( { if (checked) selectPageRows(pageRows) else deselectPageRows(pageRows) }} jobForDetails={jobForDetails} keyFor={keyFor} baseName={baseName} lower={lowerTag} 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={openPlayerAndPauseTeasers} 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={effectiveBadgeSummaryByFile} getPostworkSummaryForOutput={getPostworkSummaryForOutput} forcePreviewMuted={forcePreviewMuted} renderPostworkBadge={renderClickablePostworkBadge} renderIntegrityBadge={renderIntegrityBadge} renderRatingOverlay={renderRatingOverlay} /> )} {view === 'gallery' && ( setMobileTeaserAudioUnlocked(true)} /> )}
)}
{!mobileCardStackMode ? (
{ flushSync(() => { setInlinePlay(null) setTeaserKey(null) setHoverTeaserKey(null) }) scrollFinishedListToTop() onPageChange(p) }} showSummary={false} prevLabel="Zurück" nextLabel="Weiter" className="mt-0" />
) : null} ) }