Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { importManualInbox, listManualInbox } from './api'
|
||||
|
||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||
function formatSize(bytes: number): string {
|
||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox.
|
||||
*/
|
||||
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [showId, setShowId] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'media', 'manual'],
|
||||
queryFn: listManualInbox,
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => importManualInbox(selected, showId),
|
||||
onSuccess: (result) => {
|
||||
if (result.imported > 0)
|
||||
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
||||
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
||||
for (const failure of result.failed)
|
||||
toast.error(`${failure.relativePath}: ${failure.reason}`)
|
||||
|
||||
setSelected([])
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||
if (result.failed.length === 0) onClose()
|
||||
},
|
||||
onError: (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
const files = (data?.files ?? []).filter((file) =>
|
||||
query.trim() ? file.relativePath.toLowerCase().includes(query.trim().toLowerCase()) : true,
|
||||
)
|
||||
const importable = files.filter((f) => f.isSupported && !f.alreadyImported)
|
||||
|
||||
const toggle = (path: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={importable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === importable.length
|
||||
? []
|
||||
: importable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.manualSelected', { count: selected.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="crt-panel max-h-80 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||
{isLoading && (
|
||||
<li className="px-3 py-2 text-muted-foreground">{t('common.loading')}</li>
|
||||
)}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<li className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</li>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
const blocked = !file.isSupported || file.alreadyImported
|
||||
return (
|
||||
<li key={file.relativePath} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
disabled={blocked}
|
||||
checked={selected.includes(file.relativePath)}
|
||||
onChange={() => toggle(file.relativePath)}
|
||||
/>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${blocked ? 'text-muted-foreground' : ''}`}
|
||||
title={file.relativePath}
|
||||
>
|
||||
{file.relativePath}
|
||||
</span>
|
||||
{!file.isSupported && (
|
||||
<Badge variant="muted">{t('admin.media.manualUnsupported')}</Badge>
|
||||
)}
|
||||
{file.alreadyImported && (
|
||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatSize(file.sizeBytes)}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={showId} onValueChange={setShowId}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(shows ?? []).map((show) => (
|
||||
<SelectItem key={show.id} value={show.id}>
|
||||
{show.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.manualOrderHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
{t('admin.media.manualImport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,274 +1,282 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ListPlus, Upload } from 'lucide-react'
|
||||
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 { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
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'],
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
|
||||
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 fileInputShow = useRef<HTMLInputElement>(null)
|
||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('created', true)
|
||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
|
||||
const sortColumn = (key: string) => {
|
||||
setPage(1)
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'media', 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: ['admin', 'media', 'stats'],
|
||||
queryFn: getMediaStats,
|
||||
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, 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>
|
||||
</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 = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputShow}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
{t('admin.media.uploadToShow')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.media.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filesForShow && (
|
||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||
)}
|
||||
|
||||
<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}
|
||||
/>
|
||||
<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}
|
||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||
/>
|
||||
))}
|
||||
{data && 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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; 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="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">
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FolderInput, ListPlus, Upload } from 'lucide-react'
|
||||
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 { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
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'],
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
|
||||
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 fileInputShow = useRef<HTMLInputElement>(null)
|
||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('created', true)
|
||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
|
||||
const sortColumn = (key: string) => {
|
||||
setPage(1)
|
||||
toggle(key)
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'media', 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: ['admin', 'media', 'stats'],
|
||||
queryFn: getMediaStats,
|
||||
// Пока есть незавершённая работа — освежаем чипы очереди/обработки.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data?.queued ?? 0) + (query.state.data?.processing ?? 0) > 0 ? 4000 : 15000,
|
||||
})
|
||||
|
||||
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||
const onError = (error: unknown) =>
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error'))
|
||||
|
||||
const deleteMutation = useMutation({ mutationFn: deleteMedia, onSuccess: invalidate, 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>
|
||||
</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 = ''
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputShow}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.avi,.ts"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
{t('admin.media.manualButton')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
{t('admin.media.uploadToShow')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
{t('admin.media.upload')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filesForShow && (
|
||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||
)}
|
||||
|
||||
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(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}
|
||||
/>
|
||||
<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}
|
||||
onDelete={() => deleteMutation.mutate(asset.id)}
|
||||
/>
|
||||
))}
|
||||
{data && 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}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MediaRow({ asset, onDelete }: { asset: MediaAssetDto; 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="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">
|
||||
<Button size="sm" variant="destructive" onClick={onDelete}>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,111 +1,126 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
ImportManualInboxResultDto,
|
||||
ManualInboxListDto,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
/** Что лежит в ручном inbox (manual/) и ждёт разбора. */
|
||||
export function listManualInbox() {
|
||||
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||
}
|
||||
|
||||
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
|
||||
export function importManualInbox(relativePaths: string[], showId: string) {
|
||||
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||
method: 'POST',
|
||||
body: { relativePaths, showId },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,7 +62,27 @@ export type CreatedIdResponse = { id: string }
|
||||
|
||||
// ── Медиа ────────────────────────────────────────────────────────────────
|
||||
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
|
||||
export type MediaSource = 'Upload' | 'Inbox'
|
||||
export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
|
||||
|
||||
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
|
||||
export type ManualInboxFileDto = {
|
||||
relativePath: string
|
||||
name: string
|
||||
sizeBytes: number
|
||||
isSupported: boolean
|
||||
alreadyImported: boolean
|
||||
}
|
||||
|
||||
export type ManualInboxListDto = {
|
||||
files: ManualInboxFileDto[]
|
||||
/** Выдача обрезана лимитом — в каталоге есть ещё. */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export type ImportManualInboxResultDto = {
|
||||
imported: number
|
||||
failed: { relativePath: string; reason: string }[]
|
||||
}
|
||||
|
||||
export type MediaAssetDto = {
|
||||
id: string
|
||||
|
||||
@@ -201,6 +201,20 @@ const resources = {
|
||||
media: {
|
||||
title: 'Медиа',
|
||||
upload: 'Загрузить',
|
||||
manualButton: 'Из папки manual',
|
||||
manualTitle: 'Ручной разбор папки manual',
|
||||
manualHint:
|
||||
'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',
|
||||
manualSelectAll: 'Выбрать все',
|
||||
manualSelected: 'Выбрано: {{count}}',
|
||||
manualEmpty: 'В папке manual пусто',
|
||||
manualUnsupported: 'формат не поддерживается',
|
||||
manualAlready: 'уже в библиотеке',
|
||||
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
|
||||
manualPickShow: 'Выберите шоу',
|
||||
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
|
||||
manualImport: 'Забрать в шоу',
|
||||
manualImported: 'Импортировано файлов: {{count}}',
|
||||
uploadToShow: 'Загрузить в шоу',
|
||||
toShowTitle: 'Загрузить и добавить в шоу',
|
||||
autoDetectHint:
|
||||
@@ -863,6 +877,20 @@ const resources = {
|
||||
media: {
|
||||
title: 'Media',
|
||||
upload: 'Upload',
|
||||
manualButton: 'From manual folder',
|
||||
manualTitle: 'Manual pick from the manual folder',
|
||||
manualHint:
|
||||
'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.',
|
||||
manualSelectAll: 'Select all',
|
||||
manualSelected: 'Selected: {{count}}',
|
||||
manualEmpty: 'The manual folder is empty',
|
||||
manualUnsupported: 'unsupported format',
|
||||
manualAlready: 'already in the library',
|
||||
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
|
||||
manualPickShow: 'Pick a show',
|
||||
manualOrderHint: 'Season and episode numbers are taken from the file names.',
|
||||
manualImport: 'Import into show',
|
||||
manualImported: 'Files imported: {{count}}',
|
||||
uploadToShow: 'Upload to show',
|
||||
toShowTitle: 'Upload and add to show',
|
||||
autoDetectHint:
|
||||
|
||||
Reference in New Issue
Block a user