Implement media retry functionality and enhance error handling
ci / build-backend (push) Successful in 1m18s
ci / build-frontend (push) Failing after 15s
ci / tests (push) Skipped
ci / sonar (push) Skipped

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.
This commit is contained in:
Leonid Pershin
2026-07-31 03:58:01 +03:00
parent 789357481d
commit 7163a0b937
16 changed files with 594 additions and 52 deletions
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, Play, Upload } from 'lucide-react'
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'
@@ -14,7 +14,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
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 } from './api'
import { deleteMedia, getMediaStats, listMedia, mediaPreviewUrl, retryMedia } from './api'
import { formatDuration, splitEta } from './format'
import { MediaImportDialog } from './MediaImportDialog'
import { useUploadStore } from './upload-store'
@@ -104,6 +104,15 @@ export function MediaPanel() {
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">
@@ -170,6 +179,19 @@ export function MediaPanel() {
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')}
@@ -234,6 +256,7 @@ export function MediaPanel() {
key={asset.id}
asset={asset}
onPreview={() => setPreview(asset)}
onRetry={() => retryMutation.mutate(asset.id)}
onDelete={() => deleteMutation.mutate(asset.id)}
/>
))}
@@ -286,8 +309,14 @@ function EtaValue({ seconds }: Readonly<{ seconds: number }>) {
function MediaRow({
asset,
onPreview,
onRetry,
onDelete,
}: Readonly<{ asset: MediaAssetDto; onPreview: () => void; onDelete: () => void }>) {
}: Readonly<{
asset: MediaAssetDto
onPreview: () => void
onRetry: () => void
onDelete: () => void
}>) {
const { t } = useTranslation()
return (
<tr className="border-b border-border last:border-0">
@@ -317,6 +346,12 @@ function MediaRow({
<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>