Refactor media asset handling: update ListMediaAssetsQuery to accept an array of statuses, modify MediaEndpoints to handle multiple statuses, and enhance MediaProcessingQueue for improved signal handling. Update frontend components to support new status filtering and improve user experience with asset selection.

This commit is contained in:
Leonid Pershin
2026-07-24 19:22:22 +03:00
parent 8281832e1d
commit 6e7db4a6a9
12 changed files with 303 additions and 66 deletions
@@ -54,7 +54,7 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
queryFn: () => listMedia({ page: 1, pageSize: 100, statuses: ['Ready'] }),
})
const { data: schedule } = useQuery({
queryKey: ['admin', 'channels', channelId, 'schedule'],
@@ -6,11 +6,21 @@ import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { Badge, type BadgeProps } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { deleteMedia, listMedia, uploadMedia } from './api'
const PAGE_SIZE = 20
type MediaFilter = 'active' | 'all' | 'Ready' | 'Failed'
const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
active: ['Pending', 'Processing'],
all: [],
Ready: ['Ready'],
Failed: ['Failed'],
}
export function formatDuration(seconds: number | null): string {
if (seconds == null) return '—'
const total = Math.round(seconds)
@@ -35,10 +45,11 @@ export function MediaPanel() {
const [upload, setUpload] = useState<{ current: number; total: number; percent: number } | null>(
null,
)
const [filter, setFilter] = useState<MediaFilter>('active')
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media'],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE }),
queryKey: ['admin', 'media', filter],
queryFn: () => listMedia({ page: 1, pageSize: PAGE_SIZE, statuses: filterStatuses[filter] }),
// Пока что-то обрабатывается — обновляем чаще, чтобы статус ехал в UI.
refetchInterval: (query) =>
query.state.data?.items.some((a) => a.status === 'Processing' || a.status === 'Pending')
@@ -77,7 +88,20 @@ export function MediaPanel() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="crt-glow text-xl font-semibold">{t('admin.media.title')}</h2>
<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) => setFilter(v as MediaFilter)}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t('admin.media.filterActive')}</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>
</div>
<div className="flex items-center gap-3">
{upload != null && (
<span className="text-sm text-muted-foreground">
+2 -2
View File
@@ -9,7 +9,7 @@ import type {
export type ListMediaParams = {
page: number
pageSize: number
status?: MediaAssetStatus
statuses?: MediaAssetStatus[]
search?: string
}
@@ -18,7 +18,7 @@ export function listMedia(params: ListMediaParams) {
page: String(params.page),
pageSize: String(params.pageSize),
})
if (params.status) query.set('status', params.status)
for (const status of params.statuses ?? []) query.append('status', status)
if (params.search) query.set('search', params.search)
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
}
+122 -30
View File
@@ -1,21 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronLeft } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import type { MediaAssetDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { formatDuration } from '@/features/admin/media/MediaPanel'
import { addEpisode, getShow, removeEpisode } from './api'
const pad2 = (n: number) => String(n).padStart(2, '0')
/** Достаёт сезон/серию из имени файла: SxxEyy либо NxNN. Нужно для сортировки в правильный порядок. */
function parseEpisode(name: string): { season: number; episode: number } | null {
const m1 = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
if (m1) return { season: Number(m1[1]), episode: Number(m1[2]) }
const m2 = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
if (m2) return { season: Number(m2[1]), episode: Number(m2[2]) }
return null
}
type Candidate = { asset: MediaAssetDto; se: { season: number; episode: number } | null }
export function ShowDetail({ showId }: { showId: string }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [assetId, setAssetId] = useState('')
const [filter, setFilter] = useState('')
const [deselected, setDeselected] = useState<Set<string>>(new Set())
const [adding, setAdding] = useState<{ current: number; total: number } | null>(null)
const { data: show, isLoading } = useQuery({
queryKey: ['admin', 'shows', showId],
@@ -23,30 +39,70 @@ export function ShowDetail({ showId }: { showId: string }) {
})
const { data: ready } = useQuery({
queryKey: ['admin', 'media', 'ready'],
queryFn: () => listMedia({ page: 1, pageSize: 100, status: 'Ready' }),
queryFn: () => listMedia({ page: 1, pageSize: 500, statuses: ['Ready'] }),
})
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'shows', showId] })
const onError = (error: unknown) =>
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
const addMutation = useMutation({
mutationFn: () => addEpisode(showId, assetId),
onSuccess: () => {
setAssetId('')
invalidate()
},
onError,
})
const removeMutation = useMutation({
mutationFn: (episodeId: string) => removeEpisode(showId, episodeId),
onSuccess: invalidate,
onError,
})
// Кандидаты: готовые ассеты, ещё не добавленные в шоу, отфильтрованные по строке и упорядоченные
// по распознанному номеру сезона/серии (нераспознанные — в конец по имени).
const candidates = useMemo<Candidate[]>(() => {
if (!show) return []
const existing = new Set(show.episodes.map((e) => e.mediaAssetId))
const term = filter.trim().toLowerCase()
return (ready?.items ?? [])
.filter((a) => !existing.has(a.id))
.filter((a) => !term || a.originalFileName.toLowerCase().includes(term))
.map((asset) => ({ asset, se: parseEpisode(asset.originalFileName) }))
.sort((a, b) => {
if (a.se && b.se)
return a.se.season - b.se.season || a.se.episode - b.se.episode
if (a.se) return -1
if (b.se) return 1
return a.asset.originalFileName.localeCompare(b.asset.originalFileName)
})
}, [show, ready, filter])
const selected = candidates.filter((c) => !deselected.has(c.asset.id))
if (isLoading || !show) return <p className="text-muted-foreground">{t('common.loading')}</p>
const canAdd = show.kind !== 'Single' || show.episodes.length === 0
const isSingle = show.kind === 'Single'
const canAdd = !isSingle || show.episodes.length === 0
const toggle = (id: string) =>
setDeselected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const bulkAdd = async () => {
// Полнометражке — максимум одна серия.
const items = isSingle ? selected.slice(0, 1) : selected
let added = 0
for (let i = 0; i < items.length; i++) {
setAdding({ current: i + 1, total: items.length })
try {
await addEpisode(showId, items[i].asset.id)
added++
} catch (error) {
onError(error)
}
}
setAdding(null)
setDeselected(new Set())
void invalidate()
if (added > 0) toast.success(t('admin.shows.addedCount', { count: added }))
}
return (
<div className="flex flex-col gap-4">
@@ -65,22 +121,58 @@ export function ShowDetail({ showId }: { showId: string }) {
</div>
{canAdd && (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.shows.pickAsset')} />
</SelectTrigger>
<SelectContent>
{ready?.items.map((asset) => (
<SelectItem key={asset.id} value={asset.id}>
{asset.originalFileName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || addMutation.isPending} onClick={() => addMutation.mutate()}>
{t('admin.shows.addEpisode')}
</Button>
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2">
<Input
className="max-w-md"
placeholder={t('admin.shows.filterAssets')}
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
<Button size="sm" variant="outline" onClick={() => setDeselected(new Set())}>
{t('admin.shows.selectAll')}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setDeselected(new Set(candidates.map((c) => c.asset.id)))}
>
{t('admin.shows.deselectAll')}
</Button>
<Button size="sm" disabled={selected.length === 0 || adding != null} onClick={() => void bulkAdd()}>
{adding
? `${adding.current}/${adding.total}`
: `${t('admin.shows.addSelected')} (${isSingle ? Math.min(1, selected.length) : selected.length})`}
</Button>
</div>
<div className="crt-panel max-h-72 overflow-y-auto rounded-md">
{candidates.length === 0 ? (
<p className="px-4 py-3 text-sm text-muted-foreground">{t('admin.shows.noMatches')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{candidates.map(({ asset, se }) => (
<li key={asset.id}>
<label className="flex cursor-pointer items-center gap-3 px-4 py-2 hover:bg-muted">
<input
type="checkbox"
checked={!deselected.has(asset.id)}
onChange={() => toggle(asset.id)}
/>
{se ? (
<Badge>
S{pad2(se.season)}E{pad2(se.episode)}
</Badge>
) : (
<Badge variant="muted"></Badge>
)}
<span className="truncate">{asset.originalFileName}</span>
</label>
</li>
))}
</ul>
)}
</div>
</div>
)}
+16
View File
@@ -106,6 +106,8 @@ const resources = {
upload: 'Загрузить',
uploaded: 'Файл загружен, идёт обработка',
uploadedCount: 'Загружено файлов: {{count}}',
filterActive: 'Активные',
filterAll: 'Все',
name: 'Файл',
status: 'Статус',
duration: 'Длительность',
@@ -128,6 +130,12 @@ const resources = {
addEpisode: 'Добавить серию',
pickAsset: 'Выберите файл',
noEpisodes: 'Серий пока нет',
filterAssets: 'Фильтр по имени, напр. Family.Guy.S16',
selectAll: 'Выбрать все',
deselectAll: 'Снять все',
addSelected: 'Добавить выбранные',
noMatches: 'Нет подходящих файлов',
addedCount: 'Добавлено серий: {{count}}',
},
channels: {
title: 'Каналы',
@@ -275,6 +283,8 @@ const resources = {
upload: 'Upload',
uploaded: 'File uploaded, processing started',
uploadedCount: 'Uploaded files: {{count}}',
filterActive: 'Active',
filterAll: 'All',
name: 'File',
status: 'Status',
duration: 'Duration',
@@ -297,6 +307,12 @@ const resources = {
addEpisode: 'Add episode',
pickAsset: 'Pick a file',
noEpisodes: 'No episodes yet',
filterAssets: 'Filter by name, e.g. Family.Guy.S16',
selectAll: 'Select all',
deselectAll: 'Clear',
addSelected: 'Add selected',
noMatches: 'No matching files',
addedCount: 'Episodes added: {{count}}',
},
channels: {
title: 'Channels',