// frontend\src\components\ui\Downloads.tsx 'use client' import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import Table, { type Column } from './Table' import Card from './Card' import Button from './Button' import ModelPreview from './ModelPreview' import ModelGenderIcon from './ModelGenderIcon' import type { RecordJob } from '../../types' import ProgressBar from './ProgressBar' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { useMediaQuery } from '../../lib/useMediaQuery' import DownloadsCardRow, { PendingPreviewImage, type DownloadRow, type PendingWatchedRoom, } from './DownloadsCardRow' import SwipeActionRow from './SwipeActionRow' import RecordJobActions from './RecordJobActions' import LazyMountWhenVisible from './LazyMountWhenVisible' import CountryFlag from './CountryFlag' type AutostartState = { paused?: boolean pausedByUser?: boolean pausedByDisk?: boolean } type Props = { jobs: RecordJob[] pending?: PendingWatchedRoom[] autostartState?: AutostartState onRefreshAutostartState?: () => Promise | void modelsByKey?: Record< string, { favorite?: boolean liked?: boolean | null watching?: boolean roomStatus?: string imageUrl?: string gender?: string | null country?: string | null } > roomStatusByModelKey?: Record onOpenPlayer: (job: RecordJob) => void onStopJob: (id: string) => void | Promise onStopAllJobs?: () => void | Promise onRemoveQueuedPostworkJob?: (id: string) => void | Promise blurPreviews?: boolean onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise onAddToDownloads?: (job: RecordJob) => boolean | void | Promise onRemovePending?: (pending: PendingWatchedRoom) => void | Promise enableConcurrentDownloadsLimit?: boolean maxConcurrentDownloads?: number } const countryOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { const anyJ = job as any const modelKey = modelKeyFromJob(job) return String( modelsByKey?.[modelKey]?.country ?? anyJ?.country ?? anyJ?.model?.country ?? anyJ?.room?.country ?? anyJ?.meta?.country ?? anyJ?.meta?.model?.country ?? '' ).trim() } const countryOfPending = ( p: PendingWatchedRoom, modelsByKey?: Record ): string => { const anyP = p as any const pendingKey = String(anyP?.modelKey ?? pendingModelName(p)).trim().toLowerCase() return String( modelsByKey?.[pendingKey]?.country ?? anyP?.country ?? anyP?.room?.country ?? '' ).trim() } const isChaturbateJob = ( job: RecordJob, modelsByKey?: Record ): boolean => { const anyJ = job as any const src = String(anyJ?.sourceUrl ?? anyJ?.SourceURL ?? '').toLowerCase() const chat = String(anyJ?.modelChatRoomUrl ?? '').toLowerCase() const img = onlineImageUrlOfJob(job, modelsByKey).toLowerCase() return ( src.includes('chaturbate.com') || chat.includes('chaturbate.com') || img.includes('highwebmedia') || img.includes('chaturbate') ) } const previewInitialSrcOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { if (!isChaturbateJob(job, modelsByKey)) return '' return onlineImageUrlOfJob(job, modelsByKey) } const pendingModelName = (p: PendingWatchedRoom) => { const anyP = p as any return ( anyP.modelName ?? anyP.model ?? anyP.modelKey ?? anyP.username ?? anyP.name ?? '—' ) } const pendingUrl = (p: PendingWatchedRoom) => { const anyP = p as any return anyP.sourceUrl ?? anyP.url ?? anyP.roomUrl ?? '' } const pendingImageUrl = (p: PendingWatchedRoom) => { const anyP = p as any return String(anyP.imageUrl ?? anyP.image_url ?? '').trim() } const pendingRowKey = (p: PendingWatchedRoom) => { const anyP = p as any return String(anyP.key ?? anyP.id ?? pendingModelName(p)) } const normalizeRoomStatus = (v: unknown): string => { const s = String(v ?? '').trim().toLowerCase() switch (s) { case 'public': return 'Public' case 'private': return 'Private' case 'hidden': return 'Hidden' case 'away': return 'Away' case 'offline': return 'Offline' default: return 'Unknown' } } const modelKeyFromJob = (job: RecordJob): string => { const j = job as any const rawUrl = String(j.sourceUrl ?? '').trim() if (rawUrl) { const m = rawUrl.match(/chaturbate\.com\/([^/?#]+)/i) if (m?.[1]) { try { return decodeURIComponent(m[1]).trim().toLowerCase() } catch { return String(m[1]).trim().toLowerCase() } } } return String(modelNameFromOutput(j.output || '')).trim().toLowerCase() } const onlineImageUrlOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { const anyJ = job as any const direct = String( anyJ?.modelImageUrl ?? anyJ?.model?.imageUrl ?? '' ).trim() if (direct) return direct const modelKey = modelKeyFromJob(job) if (!modelKey) return '' return String(modelsByKey?.[modelKey]?.imageUrl ?? '').trim() } const roomStatusOfJob = ( job: RecordJob, roomStatusByModelKey?: Record, modelsByKey?: Record ): string => { const j = job as any const rawFromJob = j.currentShow ?? j.roomStatus ?? j.modelStatus ?? j.show ?? j.statusText ?? j.model?.currentShow ?? j.model?.roomStatus ?? j.model?.status ?? j.room?.currentShow ?? j.room?.status if (rawFromJob != null && String(rawFromJob).trim() !== '') { return normalizeRoomStatus(rawFromJob) } const modelKey = modelKeyFromJob(job) if (modelKey && roomStatusByModelKey?.[modelKey]) { return normalizeRoomStatus(roomStatusByModelKey[modelKey]) } if (modelKey && modelsByKey?.[modelKey]?.roomStatus) { return normalizeRoomStatus(modelsByKey[modelKey].roomStatus) } return 'Unknown' } function effectiveRoomStatusOfJob( job: RecordJob, roomStatusByModelKey?: Record, modelsByKey?: Record, growingByJobId?: Record ): string { const raw = roomStatusOfJob(job, roomStatusByModelKey, modelsByKey) if (raw !== 'Unknown') return raw const anyJ = job as any const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase() const statusLower = String(job.status ?? '').trim().toLowerCase() const isActiveRecording = !job.endedAt && statusLower === 'running' && (phaseLower === '' || phaseLower === 'recording') if (isActiveRecording && growingByJobId?.[job.id]) { return 'Public' } return raw } const roomStatusTone = (status: string): string => { switch (status) { case 'Public': return 'bg-emerald-500/15 text-emerald-900 ring-emerald-500/30 dark:bg-emerald-400/10 dark:text-emerald-200 dark:ring-emerald-400/25' case 'Private': return 'bg-fuchsia-500/15 text-fuchsia-900 ring-fuchsia-500/30 dark:bg-fuchsia-400/10 dark:text-fuchsia-200 dark:ring-fuchsia-400/25' case 'Hidden': return 'bg-slate-500/15 text-slate-900 ring-slate-500/30 dark:bg-slate-400/10 dark:text-slate-200 dark:ring-slate-400/25' case 'Away': return 'bg-amber-500/15 text-amber-900 ring-amber-500/30 dark:bg-amber-400/10 dark:text-amber-200 dark:ring-amber-400/25' case 'Offline': return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' case 'Unknown': default: return 'bg-gray-900/5 text-gray-800 ring-gray-900/10 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10' } } const toMs = (v: unknown): number => { if (typeof v === 'number' && Number.isFinite(v)) { return v < 1_000_000_000_000 ? v * 1000 : v } if (typeof v === 'string') { const ms = Date.parse(v) return Number.isFinite(ms) ? ms : 0 } if (v instanceof Date) return v.getTime() return 0 } const addedAtMsOf = (r: DownloadRow): number => { if (r.kind === 'job') { const j = r.job as any return ( toMs(j.addedAt) || toMs(j.createdAt) || toMs(j.enqueuedAt) || toMs(j.queuedAt) || toMs(j.requestedAt) || toMs(j.startedAt) || 0 ) } const p = r.pending as any return ( toMs(p.addedAt) || toMs(p.createdAt) || toMs(p.enqueuedAt) || toMs(p.queuedAt) || toMs(p.requestedAt) || 0 ) } const phaseLabel = (p?: string) => { switch ((p ?? '').toLowerCase()) { case 'stopping': return 'Stop wird angefordert…' case 'probe': return 'Analysiere Datei (Dauer/Streams)…' case 'remuxing': return 'Konvertiere Container zu MP4…' case 'moving': return 'Verschiebe nach Done…' case 'assets': return 'Erstelle Vorschau/Thumbnails…' case 'analyze': return 'AI analysiert Segmente…' case 'postwork': return 'Nacharbeiten laufen…' default: return '' } } function postWorkLabel( job: RecordJob, override?: { pos?: number; total?: number } ): string { const anyJ = job as any const pw = anyJ.postWork const effectiveState = getEffectivePostworkState(job) if (effectiveState === 'running') { const running = typeof pw?.running === 'number' && pw.running > 0 ? pw.running : 1 const maxP = typeof pw?.maxParallel === 'number' ? pw.maxParallel : 0 return maxP > 0 ? `Nacharbeiten laufen… (${running}/${maxP} parallel)` : 'Nacharbeiten laufen…' } if (effectiveState === 'queued') { const posServer = typeof pw?.position === 'number' ? pw.position : 0 const waitingServer = typeof pw?.waiting === 'number' ? pw.waiting : 0 const totalServer = Math.max(waitingServer, posServer) const pos = typeof override?.pos === 'number' && Number.isFinite(override.pos) && override.pos > 0 ? override.pos : posServer const total = typeof override?.total === 'number' && Number.isFinite(override.total) && override.total > 0 ? override.total : totalServer return pos > 0 && total > 0 ? `Warte auf Nacharbeiten… ${pos} / ${total}` : 'Warte auf Nacharbeiten…' } return 'Warte auf Nacharbeiten…' } function StatusCell({ job, postworkInfo, }: { job: RecordJob postworkInfo?: { pos?: number; total?: number } }) { const anyJ = job as any const phaseRaw = String(anyJ?.phase ?? '').trim() const phase = phaseRaw.toLowerCase() const progress = Number(anyJ?.progress ?? 0) const isRecording = phase === 'recording' const isPwJob = isPostworkJob(job) const pwState = getEffectivePostworkState(job) let text = '' if (isRecording) { text = 'Recording läuft…' } else if (isPwJob) { if (phase === 'probe' || phase === 'remuxing' || phase === 'moving' || phase === 'assets') { text = phaseLabel(phaseRaw) || phaseRaw } else if (pwState === 'queued') { text = postWorkLabel(job, postworkInfo) } else if (pwState === 'running') { text = phaseLabel(phaseRaw) || postWorkLabel(job, postworkInfo) } } if (!text) { text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase() } const showBar = !isRecording && Number.isFinite(progress) && progress > 0 && progress < 100 const showIndeterminate = !isRecording && !showBar && Boolean(text) && (!Number.isFinite(progress) || progress <= 0 || progress >= 100) return (
{showBar ? ( ) : showIndeterminate ? ( ) : (
{text}
)}
) } const baseName = (p: string) => (p || '').replaceAll('\\', '/').trim().split('/').pop() || '' const modelNameFromOutput = (output?: string) => { const file = baseName(output || '') if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') const m = stem.match(/^(.*?)_\d{1,2}_\d{1,2}_\d{4}__\d{1,2}-\d{2}-\d{2}$/) if (m?.[1]) return m[1] const i = stem.lastIndexOf('_') return i > 0 ? stem.slice(0, i) : stem } const formatDuration = (ms: number): string => { if (!Number.isFinite(ms) || ms <= 0) return '—' const total = Math.floor(ms / 1000) const h = Math.floor(total / 3600) const m = Math.floor((total % 3600) / 60) const s = total % 60 if (h > 0) return `${h}h ${m}m` if (m > 0) return `${m}m ${s}s` return `${s}s` } const runtimeOf = (j: RecordJob, nowMs: number) => { const anyJ = j as any const start = toMs(anyJ.startedAtMs) || toMs(j.startedAt) if (!Number.isFinite(start) || start <= 0) return '—' const end = j.endedAt ? (toMs(anyJ.endedAtMs) || toMs(j.endedAt)) : nowMs if (!Number.isFinite(end) || end <= 0) return '—' return formatDuration(end - start) } 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 } const formatBytes = (bytes: number | null): string => { if (!bytes || !Number.isFinite(bytes) || bytes <= 0) return '—' const units = ['B', 'KB', 'MB', 'GB', 'TB'] let v = bytes let i = 0 while (v >= 1024 && i < units.length - 1) { v /= 1024 i++ } const digits = i === 0 ? 0 : v >= 10 ? 1 : 2 const s = v.toFixed(digits).replace(/\.0+$/, '') return `${s} ${units[i]}` } const isPostworkJob = (job: RecordJob): boolean => { const anyJ = job as any const phase = String(anyJ.phase ?? '').trim() const pw = anyJ.postWork const pwKey = String(anyJ.postWorkKey ?? '').trim() if (pwKey) return true if (pw && (pw.state === 'queued' || pw.state === 'running')) return true if (job.endedAt && phase) return true if (phase === 'postwork') return true return false } const getEffectivePostworkState = (job: RecordJob): 'running' | 'queued' | 'none' => { const anyJ = job as any const phase = String(anyJ.phase ?? '').trim().toLowerCase() const pw = anyJ.postWork const pwState = String(pw?.state ?? '').trim().toLowerCase() const hasPwKey = String(anyJ.postWorkKey ?? '').trim() !== '' if (!isPostworkJob(job)) return 'none' if (isTerminalStatus(anyJ?.status)) return 'none' if (pwState === 'queued') return 'queued' if (pwState === 'running') return 'running' if ( phase === 'postwork' || phase === 'probe' || phase === 'remuxing' || phase === 'moving' || phase === 'assets' ) { return 'running' } if ( typeof pw?.position === 'number' && Number.isFinite(pw.position) && pw.position > 0 ) { return 'queued' } if ( typeof pw?.running === 'number' && Number.isFinite(pw.running) && pw.running > 0 && (!Number.isFinite(pw?.position) || pw.position <= 0) ) { return 'running' } if (hasPwKey) { return 'queued' } if (anyJ.endedAt || job.endedAt) return 'queued' return 'none' } const isTerminalStatus = (status?: unknown) => { const s = String(status ?? '').trim().toLowerCase() return ( s === 'stopped' || s === 'finished' || s === 'failed' || s === 'done' || s === 'completed' || s === 'canceled' || s === 'cancelled' ) } function DiskEmergencyBadge() { return ( Speicherplatz-Notbremse aktiv ) } export default function Downloads({ jobs, pending = [], autostartState, onRefreshAutostartState, onOpenPlayer, onStopJob, onStopAllJobs, onToggleFavorite, onToggleLike, onToggleWatch, onAddToDownloads, onRemovePending, onRemoveQueuedPostworkJob, modelsByKey = {}, roomStatusByModelKey = {}, blurPreviews, enableConcurrentDownloadsLimit = false, maxConcurrentDownloads = 20, }: Props) { const isDesktop = useMediaQuery('(min-width: 640px)', true) const [stopAllBusy, setStopAllBusy] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false) const [, setWatchedPausedByUser] = useState(false) const [watchedPausedByDisk, setWatchedPausedByDisk] = useState(false) const watchedPausedRef = useRef(null) const watchedPausedByUserRef = useRef(null) const watchedPausedByDiskRef = useRef(null) const [watchedBusy, setWatchedBusy] = useState(false) const refreshWatchedState = useCallback(async () => { try { await onRefreshAutostartState?.() } catch { // ignore } }, [onRefreshAutostartState]) useEffect(() => { const nextPaused = Boolean(autostartState?.paused) const nextPausedByUser = Boolean(autostartState?.pausedByUser) const nextPausedByDisk = Boolean(autostartState?.pausedByDisk) const unchanged = watchedPausedRef.current === nextPaused && watchedPausedByUserRef.current === nextPausedByUser && watchedPausedByDiskRef.current === nextPausedByDisk if (unchanged) return watchedPausedRef.current = nextPaused watchedPausedByUserRef.current = nextPausedByUser watchedPausedByDiskRef.current = nextPausedByDisk setWatchedPaused(nextPaused) setWatchedPausedByUser(nextPausedByUser) setWatchedPausedByDisk(nextPausedByDisk) }, [autostartState]) const pauseWatched = useCallback(async () => { if (watchedBusy || watchedPaused) return setWatchedBusy(true) try { const res = await fetch('/api/autostart/pause', { method: 'POST' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) await refreshWatchedState() } catch { // ignore } finally { setWatchedBusy(false) } }, [watchedBusy, watchedPaused, refreshWatchedState]) const resumeWatched = useCallback(async () => { if (watchedBusy || !watchedPaused || watchedPausedByDisk) return setWatchedBusy(true) try { const res = await fetch('/api/autostart/resume', { method: 'POST' }) if (!res.ok) throw new Error(`HTTP ${res.status}`) await refreshWatchedState() } catch { // ignore } finally { setWatchedBusy(false) } }, [watchedBusy, watchedPaused, watchedPausedByDisk, refreshWatchedState]) const [stopRequestedIds, setStopRequestedIds] = useState>({}) const [stopInitiatedIds, setStopInitiatedIds] = useState>({}) const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState>({}) const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState>({}) const markStopRequested = useCallback((ids: string | string[]) => { const arr = Array.isArray(ids) ? ids : [ids] setStopRequestedIds((prev) => { const next = { ...prev } for (const id of arr) { if (id) next[id] = true } return next }) setStopInitiatedIds((prev) => { const next = { ...prev } for (const id of arr) { if (id) next[id] = true } return next }) }, []) const markRemovePendingRequested = useCallback((key: string) => { if (!key) return setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true })) }, []) const clearRemovePendingRequested = useCallback((key: string) => { if (!key) return setRemovePendingRequestedKeys((prev) => { if (!prev[key]) return prev const next = { ...prev } delete next[key] return next }) }, []) const markRemoveQueuedPostworkRequested = useCallback((id: string) => { if (!id) return setRemoveQueuedPostworkRequestedIds((prev) => ({ ...prev, [id]: true })) }, []) const clearRemoveQueuedPostworkRequested = useCallback((id: string) => { if (!id) return setRemoveQueuedPostworkRequestedIds((prev) => { if (!prev[id]) return prev const next = { ...prev } delete next[id] return next }) }, []) useEffect(() => { setStopRequestedIds((prev) => { const keys = Object.keys(prev) if (keys.length === 0) return prev const next: Record = {} for (const id of keys) { const j = jobs.find((x) => x.id === id) if (!j) continue const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' if (isQueuedPostwork) { continue } const phaseLower = String((j as any).phase ?? '').trim().toLowerCase() const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isStopping = isBusyPhase || j.status !== 'running' if (isStopping) { next[id] = true } } return next }) }, [jobs]) useEffect(() => { setRemoveQueuedPostworkRequestedIds((prev) => { const keys = Object.keys(prev) if (keys.length === 0) return prev const liveIds = new Set(jobs.map((j) => String(j.id ?? '').trim())) const next: Record = {} for (const id of keys) { if (liveIds.has(id)) next[id] = true } return Object.keys(next).length === keys.length ? prev : next }) }, [jobs]) useEffect(() => { setRemovePendingRequestedKeys((prev) => { const keys = Object.keys(prev) if (keys.length === 0) return prev const liveKeys = new Set(pending.map((p) => pendingRowKey(p))) const next: Record = {} for (const key of keys) { if (liveKeys.has(key)) next[key] = true } return Object.keys(next).length === keys.length ? prev : next }) }, [pending]) const [nowMs, setNowMs] = useState(() => Date.now()) const prevSizeBytesRef = useRef>({}) const [growingByJobId, setGrowingByJobId] = useState>({}) const hasActive = useMemo(() => { return jobs.some((j) => !j.endedAt && j.status === 'running') }, [jobs]) const activeDownloadCount = useMemo(() => { return jobs.filter((j) => { if (isPostworkJob(j)) return false if ((j as any).endedAt) return false return String(j.status ?? '').trim().toLowerCase() === 'running' }).length }, [jobs]) const concurrentLimitHintVisible = enableConcurrentDownloadsLimit const concurrentLimitReached = enableConcurrentDownloadsLimit && activeDownloadCount >= maxConcurrentDownloads useEffect(() => { setGrowingByJobId((prev) => { const next: Record = { ...prev } const seen = new Set() let changed = false for (const job of jobs) { const id = String(job.id ?? '').trim() if (!id) continue seen.add(id) const anyJ = job as any const phaseLower = String(anyJ?.phase ?? '').trim().toLowerCase() const statusLower = String(job.status ?? '').trim().toLowerCase() const isActiveRecording = !job.endedAt && statusLower === 'running' && (phaseLower === '' || phaseLower === 'recording') const currentSize = sizeBytesOf(job) ?? 0 const prevSize = prevSizeBytesRef.current[id] ?? 0 const grewNow = currentSize > prevSize const nextGrowing = isActiveRecording ? (prev[id] === true || grewNow) : false if (next[id] !== nextGrowing) { next[id] = nextGrowing changed = true } prevSizeBytesRef.current[id] = currentSize } for (const id of Object.keys(prevSizeBytesRef.current)) { if (!seen.has(id)) { delete prevSizeBytesRef.current[id] } } for (const id of Object.keys(next)) { if (!seen.has(id)) { delete next[id] changed = true } } return changed ? next : prev }) }, [jobs, nowMs]) const postworkQueueInfoById = useMemo(() => { const infoById = new Map() const enqueueMsOf = (job: RecordJob): number => { const anyJ = job as any const pw = anyJ.postWork return ( toMs(pw?.enqueuedAt) || toMs(anyJ.enqueuedAt) || toMs(anyJ.queuedAt) || toMs(anyJ.createdAt) || toMs(anyJ.addedAt) || toMs(job.endedAt) || toMs(job.startedAt) || 0 ) } const runningSortMsOf = (job: RecordJob): number => { const anyJ = job as any return ( toMs(anyJ.updatedAt) || toMs(anyJ.phaseUpdatedAt) || toMs(anyJ.progressUpdatedAt) || toMs(anyJ.postWork?.updatedAt) || enqueueMsOf(job) ) } const running: RecordJob[] = [] const queued: RecordJob[] = [] for (const j of jobs) { if (!isPostworkJob(j)) continue if (isTerminalStatus((j as any)?.status)) continue const state = getEffectivePostworkState(j) if (state === 'running') { running.push(j) } else if (state === 'queued') { queued.push(j) } } running.sort((a, b) => runningSortMsOf(b) - runningSortMsOf(a)) queued.sort((a, b) => enqueueMsOf(a) - enqueueMsOf(b)) const runningCount = running.length const queuedCount = queued.length const total = runningCount + queuedCount for (let i = 0; i < running.length; i++) { const id = String((running[i] as any)?.id ?? '') if (!id) continue infoById.set(id, { pos: i + 1, total }) } for (let i = 0; i < queued.length; i++) { const id = String((queued[i] as any)?.id ?? '') if (!id) continue infoById.set(id, { pos: i + 1, total: queuedCount }) } return infoById }, [jobs]) const postworkInfoOf = useCallback( (job: RecordJob) => { const id = String((job as any)?.id ?? '') return id ? postworkQueueInfoById.get(id) : undefined }, [postworkQueueInfoById] ) useEffect(() => { if (!hasActive) return const t = window.setInterval(() => setNowMs(Date.now()), 5000) return () => window.clearInterval(t) }, [hasActive]) const stoppableIds = useMemo(() => { return jobs .filter((j) => { if (isPostworkJob(j)) return false if ((j as any).endedAt) return false const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const phaseLower = phase.trim().toLowerCase() const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested return !isStopping }) .map((j) => j.id) }, [jobs, stopRequestedIds]) const columns = useMemo[]>(() => { return [ { key: 'preview', header: 'Vorschau', widthClassName: 'w-[96px]', cell: (r) => { if (r.kind === 'job') { const j = r.job const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey) return (
} >
) } const p = r.pending const name = pendingModelName(p) const img = pendingImageUrl(p) if (img) { return (
) } return (
) }, }, { key: 'model', header: 'Modelname', widthClassName: 'w-[170px]', cell: (r) => { if (r.kind === 'job') { const j = r.job const f = baseName(j.output || '') const name = modelNameFromOutput(j.output) const modelKey = modelKeyFromJob(j) const modelMeta = modelKey ? modelsByKey[modelKey] : undefined const roomStatus = effectiveRoomStatusOfJob( j, roomStatusByModelKey, modelsByKey, growingByJobId ) return ( <>
{name} {roomStatus}
{f} {j.sourceUrl ? ( e.stopPropagation()} > {j.sourceUrl} ) : ( )} ) } const p = r.pending const name = pendingModelName(p) const url = pendingUrl(p) const pendingKey = String((p as any)?.modelKey ?? name).trim().toLowerCase() const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined return ( <>
{name}
{url ? ( e.stopPropagation()} > {url} ) : ( )} ) }, }, { key: 'status', header: 'Status', widthClassName: 'w-[260px] min-w-[240px]', cell: (r) => { if (r.kind === 'job') { const j = r.job return } const p = r.pending const show = normalizeRoomStatus(p.currentShow) return (
{show}
) }, }, { key: 'runtime', header: 'Dauer', widthClassName: 'w-[90px]', cell: (r) => { if (r.kind === 'job') return runtimeOf(r.job, nowMs) return '—' }, }, { key: 'size', header: 'Größe', align: 'right', widthClassName: 'w-[110px]', cell: (r) => { if (r.kind === 'job') { return ( {formatBytes(sizeBytesOf(r.job))} ) } return ( ) }, }, { key: 'actions', header: 'Aktion', srOnlyHeader: true, align: 'right', widthClassName: 'w-[320px] min-w-[300px]', cell: (r) => { if (r.kind !== 'job') { const pendingKey = String( (r.pending as any)?.modelKey ?? pendingModelName(r.pending) ).trim().toLowerCase() const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined const pendingActionJob = { id: `pending:${pendingKey || pendingRowKey(r.pending)}`, output: `${pendingModelName(r.pending)}_pending.mp4`, sourceUrl: pendingUrl(r.pending), status: 'pending', endedAt: null, } as unknown as RecordJob const isFav = Boolean(pendingFlags?.favorite) const isLiked = pendingFlags?.liked === true const isWatching = Boolean(pendingFlags?.watching) const removeKey = pendingRowKey(r.pending) const isRemovingPending = Boolean(removePendingRequestedKeys[removeKey]) return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
) } const j = r.job const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const phaseLower = phase.trim().toLowerCase() const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id]) const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping const key = modelKeyFromJob(j) const flags = key ? modelsByKey[key] : undefined const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
) }, }, ] }, [ blurPreviews, markStopRequested, modelsByKey, roomStatusByModelKey, growingByJobId, nowMs, onStopJob, onRemoveQueuedPostworkJob, onRemovePending, stopRequestedIds, stopInitiatedIds, postworkInfoOf, onAddToDownloads, ]) const downloadJobRows = useMemo(() => { const seen = new Set() const list = jobs .filter((j) => { if (isPostworkJob(j)) return false if (isTerminalStatus((j as any)?.status)) return false if (Boolean((j as any)?.endedAt)) return false const id = String((j as any)?.id ?? '').trim() if (!id) return false if (seen.has(id)) return false seen.add(id) return true }) .map((job) => ({ kind: 'job', job }) as const) list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) return list }, [jobs]) const postworkRows = useMemo(() => { const list = jobs .filter((j) => { if (!isPostworkJob(j)) return false if (isTerminalStatus((j as any)?.status)) return false return true }) .map((job) => ({ kind: 'job', job }) as const) const stateRank = (j: RecordJob): number => { const state = getEffectivePostworkState(j) if (state === 'running') return 0 if (state === 'queued') return 1 return 2 } const queuePosOf = (j: RecordJob): number => { const id = String((j as any)?.id ?? '') const info = id ? postworkQueueInfoById.get(id) : undefined return typeof info?.pos === 'number' && Number.isFinite(info.pos) ? info.pos : Number.MAX_SAFE_INTEGER } const runningSortMsOf = (j: RecordJob): number => { const anyJ = j as any return ( toMs(anyJ.updatedAt) || toMs(anyJ.phaseUpdatedAt) || toMs(anyJ.progressUpdatedAt) || toMs(anyJ.postWork?.updatedAt) || addedAtMsOf({ kind: 'job', job: j }) ) } list.sort((a, b) => { const aj = a.job const bj = b.job const sr = stateRank(aj) - stateRank(bj) if (sr !== 0) return sr if (getEffectivePostworkState(aj) === 'running' && getEffectivePostworkState(bj) === 'running') { const ra = runningSortMsOf(aj) const rb = runningSortMsOf(bj) if (ra !== rb) return rb - ra } const pa = queuePosOf(aj) const pb = queuePosOf(bj) if (pa !== pb) return pa - pb return addedAtMsOf(a) - addedAtMsOf(b) }) return list }, [jobs, postworkQueueInfoById]) const pendingRows = useMemo(() => { const list = pending.map((p) => ({ kind: 'pending', pending: p }) as const) list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) return list }, [pending]) const totalCount = downloadJobRows.length + postworkRows.length + pendingRows.length const stopAll = useCallback(async () => { if (stopAllBusy) return if (stoppableIds.length === 0) return setStopAllBusy(true) try { markStopRequested(stoppableIds) if (onStopAllJobs) { await onStopAllJobs() } else { await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id)))) } } finally { setStopAllBusy(false) } }, [stopAllBusy, stoppableIds, markStopRequested, onStopAllJobs, onStopJob]) const rowClassName = useCallback((r: DownloadRow) => { if (r.kind === 'pending') { const key = pendingRowKey(r.pending) const removing = Boolean(removePendingRequestedKeys[key]) return [ 'transition-all duration-300', removing && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse', ] .filter(Boolean) .join(' ') } const j = r.job const stopRequested = Boolean(stopRequestedIds[j.id]) const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id]) const isQueuedPostwork = getEffectivePostworkState(j) === 'queued' return [ 'transition-all duration-300', stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse', removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse', ] .filter(Boolean) .join(' ') }, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds]) return (
{/* Mobile */}
Downloads
{totalCount}
{concurrentLimitHintVisible ? (
Downloads: {activeDownloadCount}/{maxConcurrentDownloads}
) : null}
{/* Desktop */}
Downloads
{totalCount}
{concurrentLimitHintVisible ? (
Downloads: {activeDownloadCount}/{maxConcurrentDownloads} {concurrentLimitReached ? ' · Limit erreicht' : ''}
) : null}
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? ( <> {!isDesktop ? (
{downloadJobRows.length > 0 ? ( <>
Downloads ({downloadJobRows.length}) {watchedPausedByDisk ? : null}
{downloadJobRows.map((r) => { if (r.kind !== 'job') return null const j = r.job const phase = String((j as any).phase ?? '').trim().toLowerCase() const isStopRequested = Boolean(stopRequestedIds[j.id]) const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' const isBusyPhase = phase !== '' && phase !== 'recording' const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested return ( { if (isStopping || isQueuedPostwork) return markStopRequested(j.id) await onStopJob(j.id) }} >
) })} ) : null} {pendingRows.length > 0 ? ( <>
Wartend ({pendingRows.length})
{pendingRows.map((r) => { if (r.kind !== 'pending') return null const pKey = pendingRowKey(r.pending) const isRemovingPending = Boolean(removePendingRequestedKeys[pKey]) return ( { if (isRemovingPending) return markRemovePendingRequested(pKey) try { await onRemovePending?.(r.pending) } catch { clearRemovePendingRequested(pKey) } }} >
) })} ) : null} {postworkRows.length > 0 ? ( <>
Nacharbeiten ({postworkRows.length})
{postworkRows.map((r) => { if (r.kind !== 'job') return null const j = r.job const phase = String((j as any).phase ?? '').trim().toLowerCase() const isStopRequested = Boolean(stopRequestedIds[j.id]) const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' const isBusyPhase = phase !== '' && phase !== 'recording' const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id]) const label = isQueuedPostwork ? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen') : (isStopping ? 'Stoppe…' : 'Stoppen') const color = isQueuedPostwork ? 'red' : 'indigo' const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping return ( { if (isQueuedPostwork) { if (isRemovingQueuedPostwork) return markRemoveQueuedPostworkRequested(j.id) try { await onRemoveQueuedPostworkJob?.(j.id) } catch { clearRemoveQueuedPostworkRequested(j.id) } return } if (isStopping) return markStopRequested(j.id) await onStopJob(j.id) }} >
) })} ) : null}
) : (
{downloadJobRows.length > 0 ? (
Downloads ({downloadJobRows.length}) {watchedPausedByDisk ? : null}
(r.kind === 'job' ? `dl:job:${r.job.id}` : `dl:pending:${pendingRowKey(r.pending)}`)} striped fullWidth stickyHeader compact={false} card onRowClick={(r) => { if (r.kind === 'job') onOpenPlayer(r.job) }} /> ) : null} {postworkRows.length > 0 ? (
Nacharbeiten ({postworkRows.length})
(r.kind === 'job' ? `pw:job:${r.job.id}` : `pw:pending:${pendingRowKey(r.pending)}`)} striped fullWidth stickyHeader compact={false} card onRowClick={(r) => { if (r.kind === 'job') onOpenPlayer(r.job) }} /> ) : null} {pendingRows.length > 0 ? (
Wartend ({pendingRows.length})
(r.kind === 'job' ? `wa:job:${r.job.id}` : `wa:pending:${pendingRowKey(r.pending)}`)} striped fullWidth stickyHeader compact={false} card /> ) : null} )} ) : (
⏸️
Keine laufenden Downloads
Starte oben eine URL – hier siehst du Live-Status und kannst Jobs stoppen.
)} ) }