Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
build / backend (push) Successful in 1m11s
build / frontend (push) Successful in 34s
tests / backend-tests (push) Successful in 1m38s

This commit is contained in:
Leonid Pershin
2026-07-26 15:41:31 +03:00
parent 74bca20a3c
commit a95c0540d9
13 changed files with 585 additions and 90 deletions
@@ -1,8 +1,10 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api'
import { HttpError } from '@/shared/api/client'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
@@ -14,9 +16,11 @@ import {
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { importManualInbox, listManualInbox } from './api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
function formatSize(bytes: number): string {
@@ -31,8 +35,12 @@ function formatSize(bytes: number): string {
}
/**
* Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox.
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
* nfo) удаляются, чтобы не оставалось мусора.
*
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
* то и сохранится.
*/
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
@@ -40,6 +48,9 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const [selected, setSelected] = useState<string[]>([])
const [showId, setShowId] = useState('')
const [query, setQuery] = useState('')
const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('')
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({
queryKey: ['admin', 'media', 'manual'],
@@ -47,8 +58,66 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
const parsedByPath = useMemo(() => {
const options = {
seasonOverride:
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
episodeRegex: regexOk ? regexStr : null,
}
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
return map
}, [data, seasonOverride, regexStr, regexOk])
const folders = useMemo(() => {
const q = query.trim().toLowerCase()
const matched = (data?.files ?? []).filter((file) =>
q ? file.relativePath.toLowerCase().includes(q) : true,
)
const grouped = new Map<string, ManualInboxFileDto[]>()
for (const file of matched) {
const list = grouped.get(file.folder) ?? []
list.push(file)
grouped.set(file.folder, list)
}
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
return [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([folder, files]) => ({
folder,
files: [...files].sort((a, b) =>
compareParsed(
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
),
),
}))
}, [data, query, parsedByPath])
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
const recognized = selectable.filter(
(f) => parsedByPath.get(f.relativePath)?.episode != null,
).length
const importMutation = useMutation({
mutationFn: () => importManualInbox(selected, showId),
mutationFn: () =>
importManualInbox(
selected.map((relativePath) => {
const parsed = parsedByPath.get(relativePath)
return {
relativePath,
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
episode: parsed?.episode ?? null,
}
}),
showId,
),
onSuccess: (result) => {
if (result.imported > 0)
toast.success(t('admin.media.manualImported', { count: result.imported }))
@@ -65,88 +134,175 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
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],
)
const toggleFolder = (files: ManualInboxFileDto[]) => {
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
const allSelected = paths.every((p) => selected.includes(p))
setSelected((current) =>
allSelected
? current.filter((p) => !paths.includes(p))
: [...new Set([...current, ...paths])],
)
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogContent className="max-w-4xl">
<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)}
/>
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('common.search')}</Label>
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
<Input
type="number"
min={1}
placeholder={t('admin.media.toShowAuto')}
value={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowRegex')}</Label>
<Input
placeholder="^(\d+)"
value={regexStr}
onChange={(e) => setRegexStr(e.target.value)}
className={!regexOk ? 'border-red-500' : undefined}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('admin.media.toShowHint')}
{!regexOk && (
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
)}
</p>
<div className="flex flex-wrap items-center gap-2 text-xs">
<Button
size="sm"
variant="outline"
disabled={importable.length === 0}
disabled={selectable.length === 0}
onClick={() =>
setSelected(
selected.length === importable.length
selected.length === selectable.length
? []
: importable.map((f) => f.relativePath),
: selectable.map((f) => f.relativePath),
)
}
>
{t('admin.media.manualSelectAll')}
</Button>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground">
{t('admin.media.manualSelected', { count: selected.length })}
</span>
<span className="text-muted-foreground">
{t('admin.media.manualRecognized', {
count: recognized,
total: selectable.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>
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
{!isLoading && folders.length === 0 && (
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
)}
{!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
{folders.map(({ folder, files }) => {
const isCollapsed = collapsed.includes(folder)
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>
<div key={folder || '/'} className="border-b border-border last:border-0">
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() =>
setCollapsed((c) =>
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
)
}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<input
type="checkbox"
className="shrink-0"
checked={files
.filter((f) => !f.alreadyImported)
.every((f) => selected.includes(f.relativePath))}
onChange={() => toggleFolder(files)}
/>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
{folder || t('admin.media.manualRoot')}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
</div>
{!isCollapsed && (
<ul className="divide-y divide-border">
{files.map((file) => {
const label = formatSeasonEpisode(
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
)
return (
<li
key={file.relativePath}
className="flex items-center gap-2 px-3 py-1.5 pl-9"
>
<input
type="checkbox"
className="shrink-0"
disabled={file.alreadyImported}
checked={selected.includes(file.relativePath)}
onChange={() => toggle(file.relativePath)}
/>
{label ? (
<Badge className="shrink-0">{label}</Badge>
) : (
<Badge variant="muted" className="shrink-0">
{t('admin.media.toShowUnknown')}
</Badge>
)}
<span
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
title={file.name}
>
{file.name}
</span>
{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>
)}
{file.alreadyImported && (
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
)}
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatSize(file.sizeBytes)}
</span>
</li>
</div>
)
})}
</ul>
</div>
{data?.truncated && (
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
@@ -166,7 +322,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
</SelectContent>
</Select>
<span className="text-xs text-muted-foreground">
{t('admin.media.manualOrderHint')}
{t('admin.media.manualCleanupHint')}
</span>
</div>
</div>
+7 -3
View File
@@ -2,6 +2,7 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
ImportManualInboxResultDto,
ManualImportItem,
ManualInboxListDto,
MediaAssetDto,
MediaAssetStatus,
@@ -66,11 +67,14 @@ export function listManualInbox() {
return apiRequest<ManualInboxListDto>('/admin/media/manual')
}
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
export function importManualInbox(relativePaths: string[], showId: string) {
/**
* Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники
* (субтитры, nfo) удаляются. Номера серий передаются явно — сохранится ровно то, что было показано.
*/
export function importManualInbox(items: ManualImportItem[], showId: string) {
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
method: 'POST',
body: { relativePaths, showId },
body: { items, showId },
})
}
+9 -1
View File
@@ -67,12 +67,20 @@ export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
export type ManualInboxFileDto = {
relativePath: string
/** Каталог внутри manual/ («» — корень): по нему список группируется. */
folder: string
name: string
sizeBytes: number
isSupported: boolean
alreadyImported: boolean
}
/** Файл к разбору: номера серии уходят такими, какими их показал предпросмотр. */
export type ManualImportItem = {
relativePath: string
season: number | null
episode: number | null
}
export type ManualInboxListDto = {
files: ManualInboxFileDto[]
/** Выдача обрезана лимитом — в каталоге есть ещё. */
+12 -6
View File
@@ -208,11 +208,13 @@ const resources = {
manualSelectAll: 'Выбрать все',
manualSelected: 'Выбрано: {{count}}',
manualEmpty: 'В папке manual пусто',
manualUnsupported: 'формат не поддерживается',
manualAlready: 'уже в библиотеке',
manualRoot: 'корень manual/',
manualRecognized: 'Распознано: {{count}} из {{total}}',
manualCleanupHint:
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
manualPickShow: 'Выберите шоу',
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
manualImport: 'Забрать в шоу',
manualImported: 'Импортировано файлов: {{count}}',
uploadToShow: 'Загрузить в шоу',
@@ -317,7 +319,8 @@ const resources = {
number: 'Номер',
numberPlaceholder: 'не задан',
utcOffset: 'Часовой пояс, ч',
utcOffsetHint: 'Смещение от UTC. 3 — московское время; сетка задаётся в нём.',
utcOffsetHint:
'Целыми часами: 3 — Москва, 0 — UTC, −5 — Нью-Йорк. В этом времени задаётся вся сетка и показывается расписание.',
dayStart: 'Начало вещательных суток',
dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.',
disabled: 'выключен',
@@ -884,11 +887,13 @@ const resources = {
manualSelectAll: 'Select all',
manualSelected: 'Selected: {{count}}',
manualEmpty: 'The manual folder is empty',
manualUnsupported: 'unsupported format',
manualAlready: 'already in the library',
manualRoot: 'manual/ root',
manualRecognized: 'Recognized: {{count}} of {{total}}',
manualCleanupHint:
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
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',
@@ -993,7 +998,8 @@ const resources = {
number: 'Number',
numberPlaceholder: 'not set',
utcOffset: 'Time zone, h',
utcOffsetHint: 'Offset from UTC. 3 is Moscow time; the grid is defined in it.',
utcOffsetHint:
'Whole hours: 3 is Moscow, 0 is UTC, 5 is New York. The whole grid and the schedule are expressed in this time.',
dayStart: 'Broadcast day starts',
dayStartHint: 'The night block before this time belongs to the previous day.',
disabled: 'disabled',