// frontend\src\components\ui\Downloads.tsx 'use client' import { useMemo, useState, useCallback, useEffect } from 'react' import Table, { type Column } from './Table' import Card from './Card' import Button from './Button' import ModelPreview from './ModelPreview' import type { RecordJob } from '../../types' import ProgressBar from './ProgressBar' import RecordJobActions from './RecordJobActions' import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid' import { subscribeSSE } from '../../lib/sseSingleton' type PendingWatchedRoom = WaitingModelRow & { currentShow: string // public / private / hidden / away / unknown } type WaitingModelRow = { id: string modelKey: string url: string imageUrl?: string currentShow?: string // public / private / hidden / away / unknown } type AutostartState = { paused?: boolean } type Props = { jobs: RecordJob[] pending?: PendingWatchedRoom[] modelsByKey?: Record onOpenPlayer: (job: RecordJob) => void onStopJob: (id: string) => void blurPreviews?: boolean onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise } type DownloadRow = | { kind: 'job'; job: RecordJob } | { kind: 'pending'; pending: PendingWatchedRoom } 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 toMs = (v: unknown): number => { if (typeof v === 'number' && Number.isFinite(v)) { // Heuristik: 10-stellige Unix-Sekunden -> ms 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) || // Fallback 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) { case 'stopping': return 'Stop wird angefordert…' case 'remuxing': return 'Remux zu MP4…' case 'moving': return 'Verschiebe nach Done…' case 'assets': return 'Erstelle Vorschau…' default: return '' } } async function apiJSON(url: string, init?: RequestInit): Promise { const res = await fetch(url, init) if (!res.ok) { const text = await res.text().catch(() => '') throw new Error(text || `HTTP ${res.status}`) } return res.json() as Promise } function StatusCell({ job }: { job: RecordJob }) { const phaseRaw = String((job as any)?.phase ?? '').trim() const progress = Number((job as any)?.progress ?? 0) const phaseText = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : '' const text = phaseText || String((job as any)?.status ?? '').trim().toLowerCase() // ✅ ProgressBar unabhängig vom Text // ✅ determinate nur wenn sinnvoll (0..100) const showBar = Number.isFinite(progress) && progress > 0 && progress < 100 // ✅ wenn wir in einer Phase sind, aber noch kein Progress da ist -> indeterminate const showIndeterminate = !showBar && Boolean(phaseRaw) && (!Number.isFinite(progress) || progress <= 0 || progress >= 100) return (
{showBar ? ( ) : showIndeterminate ? ( ) : (
{text}
)}
) } function DownloadsCardRow({ r, nowMs, blurPreviews, modelsByKey, stopRequestedIds, markStopRequested, onOpenPlayer, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, }: { r: DownloadRow nowMs: number blurPreviews?: boolean modelsByKey: Record stopRequestedIds: Record markStopRequested: (ids: string | string[]) => void onOpenPlayer: (job: RecordJob) => void onStopJob: (id: string) => void onToggleFavorite?: (job: RecordJob) => void | Promise onToggleLike?: (job: RecordJob) => void | Promise onToggleWatch?: (job: RecordJob) => void | Promise }) { // ---------- Pending ---------- if (r.kind === 'pending') { const p = r.pending const name = pendingModelName(p) const url = pendingUrl(p) const show = (p.currentShow || 'unknown').toLowerCase() return (
{/* subtle gradient */}
{name}
Wartend {show}
{(() => { const img = pendingImageUrl(p) return (
{img ? ( {name} { ;(e.currentTarget as HTMLImageElement).style.display = "none" }} /> ) : (
Waiting…
)}
) })()}
{url ? ( e.stopPropagation()} title={url} > {url} ) : null}
) } // ---------- Job ---------- const j = r.job const name = modelNameFromOutput(j.output) const file = baseName(j.output || '') const phase = String((j as any).phase ?? '').trim() const phaseText = phase ? (phaseLabel(phase) || phase) : '' const isStopRequested = Boolean(stopRequestedIds[j.id]) // nur UI-zwischenzustand const rawStatus = String(j.status ?? '').toLowerCase() const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested // ✅ Badge hinter Modelname: IMMER Backend-Status const statusText = rawStatus || 'unknown' // ✅ Progressbar Label: Phase (gemappt), fallback auf Status const progressLabel = phaseText || statusText const progress = Number((j as any).progress ?? 0) const showBar = Number.isFinite(progress) && progress > 0 && progress < 100 const showIndeterminate = !showBar && Boolean(phase) && (!Number.isFinite(progress) || progress <= 0 || progress >= 100) const key = name && name !== '—' ? name.toLowerCase() : '' const flags = key ? modelsByKey[key] : undefined const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) return (
onOpenPlayer(j)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onOpenPlayer(j) }} > {/* subtle gradient */}
{/* Header: kleines Preview links + Infos rechts */}
{/* Thumbnail */}
{/* Infos */}
{name}
{statusText}
{file || '—'}
{runtimeOf(j, nowMs)} {formatBytes(sizeBytesOf(j))}
{j.sourceUrl ? ( e.stopPropagation()} title={j.sourceUrl} > {j.sourceUrl} ) : null}
{(showBar || showIndeterminate) ? (
{showBar ? ( ) : ( )}
) : null} {/* Footer */}
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} >
) } const baseName = (p: string) => (p || '').replaceAll('\\', '/').trim().split('/').pop() || '' const modelNameFromOutput = (output?: string) => { const file = baseName(output || '') if (!file) return '—' const stem = file.replace(/\.[^.]+$/, '') // _MM_DD_YYYY__HH-MM-SS 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 start = Date.parse(String(j.startedAt || '')) if (!Number.isFinite(start)) return '—' const end = j.endedAt ? Date.parse(String(j.endedAt)) : nowMs if (!Number.isFinite(end)) 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]}` } export default function Downloads({ jobs, pending = [], onOpenPlayer, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, modelsByKey = {}, blurPreviews }: Props) { const [stopAllBusy, setStopAllBusy] = useState(false) const [watchedPaused, setWatchedPaused] = useState(false) const [watchedBusy, setWatchedBusy] = useState(false) const refreshWatchedState = useCallback(async () => { try { const s = await apiJSON('/api/autostart/state', { cache: 'no-store' as any }) setWatchedPaused(Boolean(s?.paused)) } catch { // wenn Endpoint (noch) nicht da ist: nichts kaputt machen } }, []) useEffect(() => { // initial: einmal fetchen (schneller first paint) void refreshWatchedState() // danach: Stream (Singleton) const unsub = subscribeSSE( '/api/autostart/state/stream', 'autostart', (data) => setWatchedPaused(Boolean((data as any)?.paused)) ) return () => { unsub() } }, [refreshWatchedState]) const pauseWatched = useCallback(async () => { if (watchedBusy || watchedPaused) return setWatchedBusy(true) try { await fetch('/api/autostart/pause', { method: 'POST' }) setWatchedPaused(true) } catch { // ignore } finally { setWatchedBusy(false) } }, [watchedBusy, watchedPaused]) const resumeWatched = useCallback(async () => { if (watchedBusy || !watchedPaused) return setWatchedBusy(true) try { await fetch('/api/autostart/resume', { method: 'POST' }) setWatchedPaused(false) } catch { // ignore } finally { setWatchedBusy(false) } }, [watchedBusy, watchedPaused]) // ✅ Merkt sich: für diese Jobs wurde "Stop" bereits angefordert (z.B. via "Alle stoppen") const [stopRequestedIds, setStopRequestedIds] = useState>({}) // ✅ Merkt sich dauerhaft: Stop wurde vom User ausgelöst (damit wir später "Stopped" anzeigen können) const [stopInitiatedIds, setStopInitiatedIds] = useState>({}) const markStopRequested = useCallback((ids: string | string[]) => { const arr = Array.isArray(ids) ? ids : [ids] // UI-Zwischenzustand ("Stoppe…") setStopRequestedIds((prev) => { const next = { ...prev } for (const id of arr) { if (id) next[id] = true } return next }) // dauerhaft merken: User hat Stop ausgelöst setStopInitiatedIds((prev) => { const next = { ...prev } for (const id of arr) { if (id) next[id] = true } return next }) }, []) // Cleanup: sobald Job nicht mehr "running ohne phase" ist, brauchen wir das Flag nicht mehr 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 phase = String((j as any).phase ?? '').trim() const isStopping = Boolean(phase) || j.status !== 'running' if (!isStopping) next[id] = true } return next }) }, [jobs]) const [nowMs, setNowMs] = useState(() => Date.now()) const hasActive = useMemo(() => { // tickt solange mind. ein Job noch nicht beendet ist return jobs.some((j) => !j.endedAt && j.status === 'running') }, [jobs]) useEffect(() => { if (!hasActive) return const t = window.setInterval(() => setNowMs(Date.now()), 1000) return () => window.clearInterval(t) }, [hasActive]) const stoppableIds = useMemo(() => { return jobs .filter((j) => { const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopping = Boolean(phase) || 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 return (
) } // pending: kein jobId → Platzhalter const p = r.pending const name = pendingModelName(p) const img = pendingImageUrl(p) if (img) { return (
{name} { ;(e.currentTarget as HTMLImageElement).style.display = "none" }} />
) } 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 phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const stopInitiated = Boolean(stopInitiatedIds[j.id]) const rawStatus = String(j.status ?? '').toLowerCase() const isStoppedFinal = rawStatus === 'stopped' || (stopInitiated && Boolean(j.endedAt)) const isStopping = Boolean(phase) || rawStatus !== 'running' || isStopRequested const statusText = rawStatus || 'unknown' return ( <>
{name} {statusText}
{f} {j.sourceUrl ? ( e.stopPropagation()} > {j.sourceUrl} ) : ( )} ) } const p = r.pending const name = pendingModelName(p) const url = pendingUrl(p) 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 = (p.currentShow || 'unknown').toLowerCase() 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) => { // actions nur für echte Jobs (Stop/Icons). Pending ist “read-only” in der Tabelle. if (r.kind !== 'job') { return
} const j = r.job const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested const key = modelNameFromOutput(j.output || '') const flags = key && key !== '—' ? modelsByKey[key.toLowerCase()] : undefined const isFav = Boolean(flags?.favorite) const isLiked = flags?.liked === true const isWatching = Boolean(flags?.watching) return (
{(() => { const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) const isStopping = Boolean(phase) || j.status !== 'running' || isStopRequested return ( ) })()}
) }, }, ] }, [blurPreviews, markStopRequested, modelsByKey, nowMs, onStopJob, onToggleFavorite, onToggleLike, onToggleWatch, stopRequestedIds, stopInitiatedIds]) const hasAnyPending = pending.length > 0 const hasJobs = jobs.length > 0 const rows = useMemo(() => { const list: DownloadRow[] = [ ...jobs.map((job) => ({ kind: 'job', job }) as const), ...pending.map((p) => ({ kind: 'pending', pending: p }) as const), ] // ✅ Neueste zuerst (Hinzugefügt am DESC) list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a)) return list }, [jobs, pending]) const stopAll = useCallback(async () => { if (stopAllBusy) return if (stoppableIds.length === 0) return setStopAllBusy(true) try { // UI sofort auf "Stoppe…" stellen markStopRequested(stoppableIds) // Stop anfordern (parallel) await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id)))) } finally { setStopAllBusy(false) } }, [stopAllBusy, stoppableIds, markStopRequested, onStopJob]) return (
{(hasAnyPending || hasJobs) ? ( <> {/* Toolbar (sticky) wie FinishedDownloads */}
{/* Title + Count */}
Downloads
{rows.length}
{/* Actions + View */}
{/* Content */} {/* Mobile: Cards */}
{rows.map((r) => ( ))}
{/* Tablet/Desktop: Tabelle */}
(r.kind === 'job' ? `job:${r.job.id}` : `pending:${pendingRowKey(r.pending)}`)} striped fullWidth stickyHeader compact={false} card onRowClick={(r) => { if (r.kind === 'job') onOpenPlayer(r.job) }} /> ) : null} {!hasAnyPending && !hasJobs ? (
⏸️
Keine laufenden Downloads
Starte oben eine URL – hier siehst du Live-Status und kannst Jobs stoppen.
) : null} ) }