// frontend\src\components\ui\DownloadsCardRow.tsx 'use client' import { useMemo, useState, useRef, useEffect } from 'react' import Button from './Button' import ModelPreview from './ModelPreview' import ModelGenderIcon from './ModelGenderIcon' import ProgressBar from './ProgressBar' import RecordJobActions from './RecordJobActions' import type { RecordJob } from '../../types' import LazyMountWhenVisible from './LazyMountWhenVisible' import CountryFlag from './CountryFlag' export type PendingWatchedRoom = WaitingModelRow & { currentShow: string } type WaitingModelRow = { id: string modelKey: string url: string imageUrl?: string currentShow?: string } export type DownloadRow = | { kind: 'job'; job: RecordJob } | { kind: 'pending'; pending: PendingWatchedRoom } type ModelFlags = { favorite?: boolean liked?: boolean | null watching?: boolean roomStatus?: string imageUrl?: string gender?: string | null country?: string | null } type Props = { r: DownloadRow nowMs: number blurPreviews?: boolean modelsByKey: Record roomStatusByModelKey: Record stopRequestedIds: Record growingByJobId: Record postworkInfoOf: (job: RecordJob) => { pos?: number; total?: number } | undefined markStopRequested: (ids: string | string[]) => void onOpenPlayer: (job: RecordJob) => void onStopJob: (id: string) => void | Promise onRemoveQueuedPostworkJob?: (id: string) => void | Promise onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise } 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 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 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 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 previewInitialSrcOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { return onlineImageUrlOfJob(job, modelsByKey) } const onlineImageUrlOfJob = ( job: RecordJob, modelsByKey?: Record ): string => { const anyJ = job as any const direct = String( anyJ?.modelImageUrl ?? anyJ?.avatar ?? anyJ?.previewImageUrl ?? anyJ?.imageUrl ?? 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 } function previewRoomStatusOfJob( job: RecordJob, roomStatusByModelKey?: Record, modelsByKey?: Record, growingByJobId?: Record ): string { const postworkState = getEffectivePostworkState(job) if (postworkState === 'running' || postworkState === 'queued') { return 'Public' } return effectiveRoomStatusOfJob(job, roomStatusByModelKey, modelsByKey, growingByJobId) } 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 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 phaseLabel = (p?: string) => { switch ((p ?? '').toLowerCase()) { case 'stopping': return 'Stoppe Aufnahme…' 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 '' } } const STOP_RETRY_AFTER_MS = 15_000 const stopRequestedAtMsOf = (job: RecordJob): number => { return toMs((job as any)?.stopRequestedAtMs) } const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => { const phase = String((job as any)?.phase ?? '').trim().toLowerCase() if (phase !== 'stopping') return false const startedAt = stopRequestedAtMsOf(job) return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS } const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => { const phase = String((job as any)?.phase ?? '').trim().toLowerCase() const status = String((job as any)?.status ?? '').trim().toLowerCase() return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs) } const isStopWaitingForJob = ( job: RecordJob, localStopRequested: boolean, nowMs: number ): boolean => { return localStopRequested || isFreshStoppingPhase(job, nowMs) } const isStoppingForUi = ( job: RecordJob, localStopRequested: boolean, nowMs: number ): boolean => { const phase = String((job as any)?.phase ?? '').trim().toLowerCase() const status = String((job as any)?.status ?? '').trim().toLowerCase() const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping' return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs) } 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' ) } 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' } 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…' } export function PendingPreviewImage({ src, alt, blur, className, }: { src: string alt: string blur?: boolean className?: string }) { const rootRef = useRef(null) const [inView, setInView] = useState(false) const [nonce, setNonce] = useState(0) const [imgError, setImgError] = useState(false) useEffect(() => { const el = rootRef.current if (!el) return const obs = new IntersectionObserver( (entries) => { const entry = entries[0] setInView(Boolean(entry?.isIntersecting || entry?.intersectionRatio > 0)) }, { root: null, threshold: 0.01 } ) obs.observe(el) return () => obs.disconnect() }, []) useEffect(() => { if (!inView) return const id = window.setInterval(() => { setNonce((n) => n + 1) }, 20000) return () => window.clearInterval(id) }, [inView, src]) useEffect(() => { setImgError(false) }, [src]) const finalSrc = useMemo(() => { if (!inView) return '' const s = String(src ?? '').trim() if (!s) return '' return s.includes('?') ? `${s}&v=${nonce}` : `${s}?v=${nonce}` }, [src, nonce, inView]) return (
{inView && !imgError && finalSrc ? ( {alt} setImgError(true)} /> ) : (
)}
) } export default function DownloadsCardRow({ r, nowMs, blurPreviews, modelsByKey, roomStatusByModelKey, stopRequestedIds, growingByJobId, postworkInfoOf, markStopRequested, onOpenPlayer, onStopJob, onRemoveQueuedPostworkJob, onToggleFavorite, onToggleLike, onToggleWatch, }: Props) { if (r.kind === 'pending') { const p = r.pending const name = pendingModelName(p) const url = pendingUrl(p) const show = normalizeRoomStatus(p.currentShow) const pendingKey = String(p.modelKey ?? name).trim().toLowerCase() const pendingFlags = pendingKey ? modelsByKey[pendingKey] : undefined const img = pendingImageUrl(p) || String(pendingFlags?.imageUrl ?? '').trim() const pendingActionJob = { id: `pending:${pendingKey || name}`, output: `${name}_pending.mp4`, sourceUrl: url, status: 'pending', endedAt: null, } as unknown as RecordJob const isFav = Boolean(pendingFlags?.favorite) const isLiked = pendingFlags?.liked === true const isWatching = Boolean(pendingFlags?.watching) return (
{img ? ( ) : (
)}
{name}
Warte auf Online-Status…
{show}
{url ? ( e.stopPropagation()} title={url} > {url} ) : null}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > Wartend
) } const j = r.job const name = modelNameFromOutput(j.output) const file = baseName(j.output || '') const phase = String((j as any).phase ?? '').trim() const phaseLower = phase.toLowerCase() const isRecording = phaseLower === 'recording' const isStopRequested = Boolean(stopRequestedIds[j.id]) const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested const isStopping = isStoppingForUi(j, isStopRequested, nowMs) const showRemoveQueuedButton = isQueuedPostwork const disableStopButton = showRemoveQueuedButton ? false : isStopping const actionsBusy = showRemoveQueuedButton ? false : isStopping const roomStatus = effectiveRoomStatusOfJob( j, roomStatusByModelKey, modelsByKey, growingByJobId ) let phaseText = '' if (phaseLower === 'recording') { phaseText = 'Recording läuft…' } else if (isPostworkJob(j)) { const pwState = getEffectivePostworkState(j) if (phaseLower === 'probe' || phaseLower === 'remuxing' || phaseLower === 'moving' || phaseLower === 'assets') { phaseText = phaseLabel(phase) || phase } else if (pwState === 'queued') { phaseText = postWorkLabel(j, postworkInfoOf(j)) } else if (pwState === 'running') { phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j)) } } else { if (phaseLower === 'stopping' && canRetryStop) { phaseText = 'Stop dauert zu lange - erneut stoppen' } else { phaseText = phase ? (phaseLabel(phase) || phase) : '' } } const progressLabel = phaseText || roomStatus const progress = Number((j as any).progress ?? 0) const showBar = !isRecording && Number.isFinite(progress) && progress > 0 && progress < 100 const showIndeterminate = !isRecording && !showBar && Boolean(phase) && (!Number.isFinite(progress) || progress <= 0 || progress >= 100) const modelKey = modelKeyFromJob(j) const flags = modelKey ? modelsByKey[modelKey] : undefined const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) const directPreviewSrc = previewInitialSrcOfJob(j, modelsByKey) return (
onOpenPlayer(j)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} >
} >
{name}
{file || '—'}
{roomStatus}
{j.sourceUrl ? ( e.stopPropagation()} title={j.sourceUrl} > {j.sourceUrl} ) : null}
{(showBar || showIndeterminate) ? (
{showBar ? ( ) : ( )}
) : null}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
{runtimeOf(j, nowMs)} {formatBytes(sizeBytesOf(j))}
) }