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.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -88,6 +88,17 @@ export function deleteMedia(id: string) {
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
/**
|
||||
* Возвращает упавшую обработку в очередь. Без `id` — все упавшие разом: падают они обычно пачкой
|
||||
* и по одной причине, и чинят их тоже пачкой.
|
||||
*/
|
||||
export function retryMedia(id?: string) {
|
||||
return apiRequest<{ requeued: number }>(
|
||||
id ? `/admin/media/${id}/retry` : '/admin/media/retry-failed',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
}
|
||||
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
|
||||
@@ -298,6 +298,9 @@ export const en = {
|
||||
title: 'Media',
|
||||
upload: 'Upload',
|
||||
preview: 'Preview',
|
||||
retry: 'Retry',
|
||||
retryAll: 'Retry failed',
|
||||
retryQueued: 'Requeued: {{count}}',
|
||||
manualButton: 'From manual folder',
|
||||
importButton: 'Import',
|
||||
importTitle: 'Media import',
|
||||
|
||||
@@ -299,6 +299,9 @@ export const ru = {
|
||||
title: 'Медиа',
|
||||
upload: 'Загрузить',
|
||||
preview: 'Просмотр',
|
||||
retry: 'Перезапустить',
|
||||
retryAll: 'Перезапустить упавшие',
|
||||
retryQueued: 'Возвращено в очередь: {{count}}',
|
||||
manualButton: 'Из папки manual',
|
||||
importButton: 'Импорт',
|
||||
importTitle: 'Импорт медиа',
|
||||
|
||||
Reference in New Issue
Block a user