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 = { active: ['Pending', 'Processing'], Pending: ['Pending'], Processing: ['Processing'], all: [], Ready: ['Ready'], Failed: ['Failed'], } const statusVariant: Record = { Ready: 'default', Processing: 'muted', Pending: 'muted', Failed: 'destructive', } export function MediaPanel() { const { t } = useTranslation() const queryClient = useQueryClient() const fileInput = useRef(null) const [filter, setFilter] = useState('active') const [page, setPage] = useState(1) const { sort, toggle } = useTableSort('created', true) const [importOpen, setImportOpen] = useState(false) const [preview, setPreview] = useState(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 (

{t('admin.media.title')}

{stats && (
{t('admin.media.stats.queuedShort')}:{' '} {stats.queued} {t('admin.media.stats.processingShort')}:{' '} {stats.processing} {t('admin.media.stats.averageShort')}:{' '} {formatDuration(stats.averageProcessingSeconds)} {/* Оценка появляется только когда есть что ждать: «Осталось: —» бесполезно. */} {stats.estimatedRemainingSeconds !== null && ( {t('admin.media.stats.etaShort')}:{' '} )}
)}
{ 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 && ( )}
{importOpen && setImportOpen(false)} />}
{isLoading && ( )} {data?.items.map((asset) => ( setPreview(asset)} onRetry={() => retryMutation.mutate(asset.id)} onDelete={() => deleteMutation.mutate(asset.id)} /> ))} {data?.items.length === 0 && !isLoading && ( )}
{t('common.actions')}
{t('common.loading')}
{t('admin.media.empty')}
!open && setPreview(null)}> {preview?.originalFileName} {/* Смотрят нарезку, а не исходник: в эфир идёт именно она, и битый звук или чёрный кадр видно только здесь. Кнопка в списке — это «плей», поэтому запускаем сразу. */} {preview && }
) } /** * «Осталось: ~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 ( {asset.originalFileName} {t(`admin.media.statuses.${asset.status}`)} {formatDuration(asset.durationSeconds)} {asset.width && asset.height ? `${asset.width}×${asset.height}` : '—'} {asset.status === 'Ready' ? formatDuration(asset.processingSeconds) : '—'}
{/* Смотреть можно только нарезанное: у необработанного ассета плейлиста ещё нет. */} {asset.status === 'Ready' && ( )} {/* Файл на месте — упала только нарезка, и повторить её обычно и есть всё лечение. */} {asset.status === 'Failed' && ( )}
) }