Files
TeleWave/frontend/src/features/admin/media/MediaPanel.tsx
T
Leonid Pershin 7163a0b937
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped
Implement media retry functionality and enhance error handling
Added endpoints for retrying failed media processing, allowing users to requeue media assets that encountered errors. Introduced error messages for scenarios where a media asset cannot be retried due to its status. Updated the MaintenanceBackgroundService to remove orphaned episodes and recompute group statistics, ensuring data integrity. Enhanced the frontend to support retry actions, including bulk retry options for failed media. Updated localization strings to reflect new features in both English and Russian.
2026-07-31 03:58:01 +03:00

363 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, Play, RotateCcw, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/shared/ui/dialog'
import { HlsVideo } from '@/shared/ui/hls-video'
import { Pager } from '@/shared/ui/pager'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl, retryMedia } from './api'
import { formatDuration, splitEta } from './format'
import { MediaImportDialog } from './MediaImportDialog'
import { useUploadStore } from './upload-store'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'Pending' | 'Processing' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
Pending: ['Pending'],
Processing: ['Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
}
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
Ready: 'default',
Processing: 'muted',
Pending: 'muted',
Failed: 'destructive',
}
export function MediaPanel() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInput = useRef<HTMLInputElement>(null)
const [filter, setFilter] = useState<MediaFilter>('active')
const [page, setPage] = useState(1)
const { sort, toggle } = useTableSort('created', true)
const [importOpen, setImportOpen] = useState(false)
const [preview, setPreview] = useState<MediaAssetDto | null>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
setPage(1)
toggle(key)
}
const { data, isLoading, refetch } = useQuery({
queryKey: qk.media.list(filter, page, sort.key, sort.desc),
queryFn: () =>
listMedia({
page,
pageSize: PAGE_SIZE,
statuses: filterStatuses[filter],
sort: sort.key,
desc: sort.desc,
}),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
? 4000
: false,
})
const { data: stats } = useQuery({
queryKey: qk.media.stats,
queryFn: getMediaStats,
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
refetchInterval: (query) =>
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
})
/**
* Сводка очереди опрашивается всегда, и она же служит датчиком: изменилась пара
* «в очереди / в обработке» — значит какая-то запись сменила статус, и список пора перечитать.
* Без этого список обновлялся бы, только пока активные записи видны на текущей странице: при
* фильтре «Готовые» или пустом списке опрос выключался, и новый файл из inbox не появлялся.
*/
const activity = `${stats?.queued ?? 0}:${stats?.processing ?? 0}`
const lastActivity = useRef(activity)
useEffect(() => {
if (lastActivity.current === activity) return
// Очередь опустела — говорим об этом вслух: обработка идёт минутами, и следить за чипами,
// ожидая нуля, никто не должен. Сравниваем с прошлым состоянием, чтобы не поздравлять
// с пустой очередью при каждом заходе на экран.
const wasBusy = lastActivity.current !== '0:0'
lastActivity.current = activity
if (wasBusy && activity === '0:0') toast.success(t('admin.media.queueDrained'))
void refetch()
}, [activity, refetch, t])
const invalidate = () => queryClient.invalidateQueries({ queryKey: qk.media.all })
const onError = useApiError()
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, onError })
const retryMutation = useMutation({
mutationFn: (id?: string) => retryMedia(id),
onSuccess: (result) => {
invalidate()
toast.success(t('admin.media.retryQueued', { count: result.requeued }))
},
onError,
})
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<Select
value={filter}
onValueChange={(v) => {
setPage(1)
setFilter(v as MediaFilter)
}}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</SelectItem>
<SelectItem value="Pending">{t('admin.media.statuses.Pending')}</SelectItem>
<SelectItem value="Processing">{t('admin.media.statuses.Processing')}</SelectItem>
<SelectItem value="all">{t('admin.media.filterAll')}</SelectItem>
<SelectItem value="Ready">{t('admin.media.statuses.Ready')}</SelectItem>
<SelectItem value="Failed">{t('admin.media.statuses.Failed')}</SelectItem>
</SelectContent>
</Select>
{stats && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span title={t('admin.media.stats.queued')}>
{t('admin.media.stats.queuedShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.queued}</span>
</span>
<span title={t('admin.media.stats.processing')}>
{t('admin.media.stats.processingShort')}:{' '}
<span className="text-foreground tabular-nums">{stats.processing}</span>
</span>
<span title={t('admin.media.stats.average')}>
{t('admin.media.stats.averageShort')}:{' '}
<span className="text-foreground tabular-nums">
{formatDuration(stats.averageProcessingSeconds)}
</span>
</span>
{/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */}
{stats.estimatedRemainingSeconds !== null && (
<span title={t('admin.media.stats.eta')}>
{t('admin.media.stats.etaShort')}:{' '}
<span className="text-foreground tabular-nums">
<EtaValue seconds={stats.estimatedRemainingSeconds} />
</span>
</span>
)}
</div>
)}
</div>
<div className="flex items-center gap-3">
<input
ref={fileInput}
type="file"
accept="video/*,.mkv,.avi,.ts"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files
if (files && files.length > 0) void enqueue(Array.from(files))
e.target.value = ''
}}
/>
{/* Массовый перезапуск виден только на фильтре ошибок: падают пачкой по одной причине,
и чинить их поштучно — девять кликов вместо одного. */}
{filter === 'Failed' && (data?.items.length ?? 0) > 0 && (
<Button
size="sm"
variant="outline"
disabled={retryMutation.isPending}
onClick={() => retryMutation.mutate(undefined)}
>
<RotateCcw className="h-4 w-4" />
{t('admin.media.retryAll')}
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<FolderInput className="h-4 w-4" />
{t('admin.media.importButton')}
</Button>
<Button size="sm" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.media.upload')}
</Button>
</div>
</div>
{importOpen && <MediaImportDialog onClose={() => setImportOpen(false)} />}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
<thead className="border-b border-border text-left text-muted-foreground">
<tr>
<SortHeader
label={t('admin.media.name')}
sortKey="name"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.status')}
sortKey="status"
sort={sort}
onToggle={sortColumn}
className="whitespace-nowrap"
/>
<SortHeader
label={t('admin.media.duration')}
sortKey="duration"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.resolution')}
sortKey="resolution"
sort={sort}
onToggle={sortColumn}
/>
<SortHeader
label={t('admin.media.processingTime')}
sortKey="processing"
sort={sort}
onToggle={sortColumn}
/>
<th className="px-4 py-2 font-medium">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('common.loading')}
</td>
</tr>
)}
{data?.items.map((asset) => (
<MediaRow
key={asset.id}
asset={asset}
onPreview={() => setPreview(asset)}
onRetry={() => retryMutation.mutate(asset.id)}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
{data?.items.length === 0 && !isLoading && (
<tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')}
</td>
</tr>
)}
</tbody>
</table>
</div>
<Pager
page={page}
totalPages={data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1}
onChange={setPage}
/>
<Dialog open={preview !== null} onOpenChange={(open) => !open && setPreview(null)}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="truncate">{preview?.originalFileName}</DialogTitle>
</DialogHeader>
{/* Смотрят нарезку, а не исходник: в эфир идёт именно она, и битый звук или чёрный кадр
видно только здесь. Кнопка в списке — это «плей», поэтому запускаем сразу. */}
{preview && <HlsVideo src={mediaPreviewUrl(preview.id)} autoPlay />}
</DialogContent>
</Dialog>
</div>
)
}
/**
* «Осталось: ~2 ч 40 мин». Тильда и округление до минут намеренные: это оценка по среднему времени,
* и точная до секунды подпись читалась бы как обещание.
*/
function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
const { t } = useTranslation()
const parts = splitEta(seconds)
if (!parts) return <></>
if (parts.hours === 0 && parts.minutes === 0) return <>{t('admin.media.stats.etaSoon')}</>
const hours = parts.hours > 0 ? `${parts.hours} ${t('admin.media.stats.hoursShort')}` : ''
const minutes = parts.minutes > 0 ? `${parts.minutes} ${t('admin.media.stats.minutesShort')}` : ''
return <>~{[hours, minutes].filter(Boolean).join(' ')}</>
}
function MediaRow({
asset,
onPreview,
onRetry,
onDelete,
}: Readonly<{
asset: MediaAssetDto
onPreview: () => void
onRetry: () => void
onDelete: () => void
}>) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
<td className="px-4 py-2">{asset.originalFileName}</td>
<td className="whitespace-nowrap px-4 py-2">
<Badge variant={statusVariant[asset.status]} title={asset.errorMessage ?? undefined}>
{t(`admin.media.statuses.${asset.status}`)}
</Badge>
</td>
<td className="px-4 py-2 text-muted-foreground">{formatDuration(asset.durationSeconds)}</td>
<td className="px-4 py-2 text-muted-foreground">
{asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'}
</td>
<td className="px-4 py-2 text-muted-foreground tabular-nums">
{asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
</td>
<td className="px-4 py-2">
<div className="flex items-center gap-2">
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста ещё нет. */}
{asset.status === 'Ready' && (
<Button
size="sm"
variant="outline"
title={t('admin.media.preview')}
onClick={onPreview}
>
<Play className="h-4 w-4" />
</Button>
)}
{/* Файл на месте — упала только нарезка, и повторить её обычно и есть всё лечение. */}
{asset.status === 'Failed' && (
<Button size="sm" variant="outline" title={t('admin.media.retry')} onClick={onRetry}>
<RotateCcw className="h-4 w-4" />
</Button>
)}
<Button size="sm" variant="destructive" onClick={onDelete}>
{t('common.delete')}
</Button>
</div>
</td>
</tr>
)
}