nsfwapp/frontend/src/components/ui/Downloads.tsx
2026-03-18 16:10:34 +01:00

1560 lines
49 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 type { RecordJob } from '../../types'
import ProgressBar from './ProgressBar'
import { PauseIcon, PlayIcon } from '@heroicons/react/24/solid'
import { useMediaQuery } from '../../lib/useMediaQuery'
import DownloadsCardRow, {
type DownloadRow,
type PendingWatchedRoom,
} from './DownloadsCardRow'
import SwipeActionRow from './SwipeActionRow'
import RecordJobActions from './RecordJobActions'
type AutostartState = {
paused?: boolean
pausedByUser?: boolean
pausedByDisk?: boolean
}
type Props = {
jobs: RecordJob[]
pending?: PendingWatchedRoom[]
autostartState?: AutostartState
onRefreshAutostartState?: () => Promise<void> | void
modelsByKey?: Record<
string,
{
favorite?: boolean
liked?: boolean | null
watching?: boolean
roomStatus?: string
}
>
roomStatusByModelKey?: Record<string, string>
onOpenPlayer: (job: RecordJob) => void
onStopJob: (id: string) => void | Promise<void>
onRemoveQueuedPostworkJob?: (id: string) => void | Promise<void>
blurPreviews?: boolean
onToggleFavorite?: (job: RecordJob) => void | Promise<void>
onToggleLike?: (job: RecordJob) => void | Promise<void>
onToggleWatch?: (job: RecordJob) => void | Promise<void>
onAddToDownloads?: (job: RecordJob) => boolean | void | Promise<boolean | void>
onRemovePending?: (pending: PendingWatchedRoom) => void | Promise<void>
}
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 roomStatusOfJob = (
job: RecordJob,
roomStatusByModelKey?: Record<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>
): 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<string, string>,
modelsByKey?: Record<string, { roomStatus?: string }>,
growingByJobId?: Record<string, boolean>
): 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 absUrlMaybe = (u?: string | null): string => {
const s = String(u ?? '').trim()
if (!s) return ''
if (/^https?:\/\//i.test(s)) return s
if (s.startsWith('/')) return s
return `/${s}`
}
const jobThumbsJPGCandidates = (job: RecordJob): string[] => {
const j = job as any
const direct = [
j.thumbsJPGUrl,
j.thumbsUrl,
j.previewThumbsUrl,
j.thumbnailSheetUrl,
]
const base = [
j.previewBaseUrl ? `${String(j.previewBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.assetBaseUrl ? `${String(j.assetBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
j.thumbsBaseUrl ? `${String(j.thumbsBaseUrl).replace(/\/+$/, '')}/preview.jpg` : '',
]
return [...direct, ...base]
.map((x) => absUrlMaybe(String(x ?? '')))
.filter(Boolean)
}
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 (
<div className="min-w-0">
{showBar ? (
<ProgressBar
label={text}
value={Math.max(0, Math.min(100, progress))}
showPercent
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : showIndeterminate ? (
<ProgressBar
label={text}
indeterminate
size="sm"
className="w-full min-w-0 sm:min-w-[220px]"
/>
) : (
<div className="truncate">
<span className="font-medium">{text}</span>
</div>
)}
</div>
)
}
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 (
<span
className="
inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold
bg-red-100 text-red-800 ring-1 ring-red-200
dark:bg-red-500/15 dark:text-red-200 dark:ring-red-400/25
"
title="Speicherplatz-Notbremse aktiv: Autostart gesperrt und Downloads wurden gestoppt"
>
Speicherplatz-Notbremse aktiv
</span>
)
}
export default function Downloads({
jobs,
pending = [],
autostartState,
onRefreshAutostartState,
onOpenPlayer,
onStopJob,
onToggleFavorite,
onToggleLike,
onToggleWatch,
onAddToDownloads,
onRemovePending,
onRemoveQueuedPostworkJob,
modelsByKey = {},
roomStatusByModelKey = {},
blurPreviews,
}: 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<boolean | null>(null)
const watchedPausedByUserRef = useRef<boolean | null>(null)
const watchedPausedByDiskRef = useRef<boolean | null>(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<Record<string, true>>({})
const [stopInitiatedIds, setStopInitiatedIds] = useState<Record<string, true>>({})
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
})
}, [])
useEffect(() => {
setStopRequestedIds((prev) => {
const keys = Object.keys(prev)
if (keys.length === 0) return prev
const next: Record<string, true> = {}
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])
const [nowMs, setNowMs] = useState(() => Date.now())
const prevSizeBytesRef = useRef<Record<string, number>>({})
const [growingByJobId, setGrowingByJobId] = useState<Record<string, boolean>>({})
const hasActive = useMemo(() => {
return jobs.some((j) => !j.endedAt && j.status === 'running')
}, [jobs])
useEffect(() => {
setGrowingByJobId((prev) => {
const next: Record<string, boolean> = { ...prev }
const seen = new Set<string>()
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<string, { pos: number; total: number }>()
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()), 1000)
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<Column<DownloadRow>[]>(() => {
return [
{
key: 'preview',
header: 'Vorschau',
widthClassName: 'w-[96px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md">
<ModelPreview
jobId={j.id}
blur={blurPreviews}
roomStatus={effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)}
alignStartAt={j.startedAt}
alignEndAt={j.endedAt ?? null}
alignEveryMs={10_000}
fastRetryMs={1000}
fastRetryMax={25}
fastRetryWindowMs={60_000}
thumbsCandidates={jobThumbsJPGCandidates(j)}
className="w-full h-full"
/>
</div>
)
}
const p = r.pending
const name = pendingModelName(p)
const img = pendingImageUrl(p)
if (img) {
return (
<div className="grid w-[96px] h-[60px] overflow-hidden rounded-md bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
<img
src={img}
alt={name}
className={[
'h-full w-full object-cover',
blurPreviews ? 'blur-md' : '',
].join(' ')}
loading="lazy"
referrerPolicy="no-referrer"
onError={(e) => {
;(e.currentTarget as HTMLImageElement).style.display = 'none'
}}
/>
</div>
)
}
return (
<div className="h-[60px] w-[96px] rounded-md bg-gray-100 dark:bg-white/10 grid place-items-center text-sm text-gray-500">
</div>
)
},
},
{
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 roomStatus = effectiveRoomStatusOfJob(
j,
roomStatusByModelKey,
modelsByKey,
growingByJobId
)
return (
<>
<div className="flex items-center gap-2 min-w-0">
<span className="min-w-0 block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
<span
className={[
'shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1',
roomStatusTone(roomStatus),
].join(' ')}
title={roomStatus}
>
{roomStatus}
</span>
</div>
<span className="block max-w-[220px] truncate" title={j.output}>
{f}
</span>
{j.sourceUrl ? (
<a
href={j.sourceUrl}
target="_blank"
rel="noreferrer"
title={j.sourceUrl}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{j.sourceUrl}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
}
const p = r.pending
const name = pendingModelName(p)
const url = pendingUrl(p)
return (
<>
<span className="block max-w-[170px] truncate font-medium" title={name}>
{name}
</span>
{url ? (
<a
href={url}
target="_blank"
rel="noreferrer"
title={url}
className="block max-w-[260px] truncate text-indigo-600 dark:text-indigo-400 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{url}
</a>
) : (
<span className="block max-w-[260px] truncate text-gray-500 dark:text-gray-400">
</span>
)}
</>
)
},
},
{
key: 'status',
header: 'Status',
widthClassName: 'w-[260px] min-w-[240px]',
cell: (r) => {
if (r.kind === 'job') {
const j = r.job
return <StatusCell job={j} postworkInfo={postworkInfoOf(j)} />
}
const p = r.pending
const show = normalizeRoomStatus(p.currentShow)
return (
<div className="min-w-0">
<div className="truncate">
<span className="font-medium">
{show}
</span>
</div>
</div>
)
},
},
{
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 (
<span className="tabular-nums text-sm text-gray-900 dark:text-white">
{formatBytes(sizeBytesOf(r.job))}
</span>
)
}
return (
<span className="tabular-nums text-sm text-gray-500 dark:text-gray-400">
</span>
)
},
},
{
key: 'actions',
header: 'Aktion',
srOnlyHeader: true,
align: 'right',
widthClassName: 'w-[320px] min-w-[300px]',
cell: (r) => {
if (r.kind !== 'job') {
return (
<div className="flex justify-end pr-2">
<Button
size="sm"
variant="primary"
color="red"
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
await onRemovePending?.(r.pending)
}}
>
Entfernen
</Button>
</div>
)
}
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 isBusyPhase = phaseLower !== '' && phaseLower !== 'recording'
const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested
const disableStopButton = isQueuedPostwork ? false : 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 (
<div
className="flex flex-wrap items-center justify-end gap-2 pr-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<RecordJobActions
job={j}
variant="table"
busy={isQueuedPostwork ? false : isStopping}
isFavorite={isFav}
isLiked={isLiked}
isWatching={isWatching}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
order={['watch', 'favorite', 'like', 'details']}
className="flex items-center gap-1"
/>
<Button
size="sm"
variant="primary"
color={isQueuedPostwork ? 'red' : 'indigo'}
disabled={disableStopButton}
className="shrink-0"
onClick={async (e) => {
e.stopPropagation()
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
{isQueuedPostwork ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')}
</Button>
</div>
)
},
},
]
}, [
blurPreviews,
markStopRequested,
modelsByKey,
roomStatusByModelKey,
growingByJobId,
nowMs,
onStopJob,
onRemoveQueuedPostworkJob,
onRemovePending,
stopRequestedIds,
stopInitiatedIds,
postworkInfoOf,
onAddToDownloads,
])
const downloadJobRows = useMemo<DownloadRow[]>(() => {
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
return true
})
.map((job) => ({ kind: 'job', job }) as const)
list.sort((a, b) => addedAtMsOf(b) - addedAtMsOf(a))
return list
}, [jobs])
const postworkRows = useMemo<DownloadRow[]>(() => {
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<DownloadRow[]>(() => {
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)
await Promise.allSettled(stoppableIds.map((id) => Promise.resolve(onStopJob(id))))
} finally {
setStopAllBusy(false)
}
}, [stopAllBusy, stoppableIds, markStopRequested, onStopJob])
return (
<div className="grid gap-3">
<div className="sticky top-[56px] z-20">
<div
className="
rounded-xl border border-gray-200/70 bg-white/80 shadow-sm
backdrop-blur supports-[backdrop-filter]:bg-white/60
dark:border-white/10 dark:bg-gray-950/60 dark:supports-[backdrop-filter]:bg-gray-950/40
"
>
<div className="flex items-center justify-between gap-2 p-3">
<div className="min-w-0 flex items-center gap-2">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
Downloads
</div>
<span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-900 dark:bg-white/10 dark:text-gray-200">
{totalCount}
</span>
</div>
<div className="shrink-0 flex items-center gap-2">
<Button
size="sm"
variant={watchedPaused ? 'secondary' : 'primary'}
disabled={watchedBusy || watchedPausedByDisk}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (watchedPausedByDisk) return
void (watchedPaused ? resumeWatched() : pauseWatched())
}}
className="hidden sm:inline-flex"
title={
watchedPausedByDisk
? 'Autostart durch Speicherplatz-Notbremse gesperrt'
: watchedPaused
? 'Autostart fortsetzen'
: 'Autostart pausieren'
}
leadingIcon={
watchedPaused
? <PauseIcon className="size-4 shrink-0" />
: <PlayIcon className="size-4 shrink-0" />
}
>
Autostart
</Button>
<Button
size="sm"
variant="primary"
disabled={stopAllBusy || stoppableIds.length === 0}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
void stopAll()
}}
className="hidden sm:inline-flex"
title={stoppableIds.length === 0 ? 'Nichts zu stoppen' : 'Alle laufenden stoppen'}
>
{stopAllBusy ? 'Stoppe alle…' : `Alle stoppen (${stoppableIds.length})`}
</Button>
</div>
</div>
</div>
</div>
{(downloadJobRows.length > 0 || postworkRows.length > 0 || pendingRows.length > 0) ? (
<>
{!isDesktop ? (
<div className="mt-3 grid gap-4">
{downloadJobRows.length > 0 ? (
<>
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
{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 (
<SwipeActionRow
key={`dl:${j.id}`}
actionLabel="Stoppen"
actionColor="indigo"
disabled={isStopping || isQueuedPostwork}
onAction={async () => {
if (isStopping || isQueuedPostwork) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</SwipeActionRow>
)
})}
</>
) : null}
{pendingRows.length > 0 ? (
<>
<div className="mt-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
Wartend ({pendingRows.length})
</div>
{pendingRows.map((r) => {
if (r.kind !== 'pending') return null
return (
<SwipeActionRow
key={`pending:${pendingRowKey(r.pending)}`}
actionLabel="Entfernen"
actionColor="red"
disabled={!onRemovePending}
onAction={async () => {
await onRemovePending?.(r.pending)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</SwipeActionRow>
)
})}
</>
) : null}
{postworkRows.length > 0 ? (
<>
<div className="mb-2 text-sm font-semibold text-gray-900 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
{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 label = isQueuedPostwork ? 'Entfernen' : 'Stoppen'
const color = isQueuedPostwork ? 'red' : 'indigo'
const disabled = isQueuedPostwork ? false : isStopping
return (
<SwipeActionRow
key={`pw:${j.id}`}
actionLabel={label}
actionColor={color}
disabled={disabled}
onAction={async () => {
if (isQueuedPostwork) {
await onRemoveQueuedPostworkJob?.(j.id)
return
}
if (isStopping) return
markStopRequested(j.id)
await onStopJob(j.id)
}}
>
<DownloadsCardRow
r={r}
nowMs={nowMs}
blurPreviews={blurPreviews}
modelsByKey={modelsByKey}
roomStatusByModelKey={roomStatusByModelKey}
postworkInfoOf={postworkInfoOf}
stopRequestedIds={stopRequestedIds}
growingByJobId={growingByJobId}
markStopRequested={markStopRequested}
onOpenPlayer={onOpenPlayer}
onStopJob={onStopJob}
onRemoveQueuedPostworkJob={onRemoveQueuedPostworkJob}
onToggleFavorite={onToggleFavorite}
onToggleLike={onToggleLike}
onToggleWatch={onToggleWatch}
/>
</SwipeActionRow>
)
})}
</>
) : null}
</div>
) : (
<div className="mt-3 space-y-4">
{downloadJobRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-gray-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 flex flex-wrap items-center gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
<span>Downloads ({downloadJobRows.length})</span>
{watchedPausedByDisk ? <DiskEmergencyBadge /> : null}
</div>
<Table
rows={downloadJobRows}
columns={columns}
getRowKey={(r) => (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)
}}
/>
</div>
) : null}
{postworkRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-amber-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-900 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Nacharbeiten ({postworkRows.length})
</div>
<Table
rows={postworkRows}
columns={columns}
getRowKey={(r) => (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)
}}
/>
</div>
) : null}
{pendingRows.length > 0 ? (
<div className="overflow-x-auto rounded-2xl border border-slate-200/80 bg-white/80 p-2 shadow-sm dark:border-white/10 dark:bg-transparent dark:p-0">
<div className="mb-2 rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm font-semibold text-slate-800 shadow-sm dark:border-white/10 dark:bg-white/5 dark:text-white">
Wartend ({pendingRows.length})
</div>
<Table
rows={pendingRows}
columns={columns}
getRowKey={(r) => (r.kind === 'job' ? `wa:job:${r.job.id}` : `wa:pending:${pendingRowKey(r.pending)}`)}
striped
fullWidth
stickyHeader
compact={false}
card
/>
</div>
) : null}
</div>
)}
</>
) : (
<Card grayBody>
<div className="flex items-center gap-3">
<div className="grid h-10 w-10 place-items-center rounded-xl bg-white/70 ring-1 ring-gray-200 dark:bg-white/5 dark:ring-white/10">
<span className="text-lg"></span>
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white">
Keine laufenden Downloads
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Starte oben eine URL hier siehst du Live-Status und kannst Jobs stoppen.
</div>
</div>
</div>
</Card>
)}
</div>
)
}