import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ChevronDown, ChevronRight, Folder } from 'lucide-react' import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { listShows } from '@/features/admin/shows/api' import { qk } from '@/shared/api/query-keys' import type { ManualInboxFileDto } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' 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 { 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' import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex' import { matchShowByName } from './match-show' /** Байты → «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]}` } /** * Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу. * Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры, * nfo) удаляются, чтобы не оставалось мусора. * * Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано, * то и сохранится. */ export function ManualInboxDialog({ onClose }: { onClose: () => void }) { const { t } = useTranslation() const queryClient = useQueryClient() const [selected, setSelected] = useState([]) const [showId, setShowId] = useState('') // Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя. const [showPicked, setShowPicked] = useState(false) const [query, setQuery] = useState('') const [seasonStr, setSeasonStr] = useState('') const [regexStr, setRegexStr] = useState('') const [collapsed, setCollapsed] = useState([]) const { data, isLoading } = useQuery({ queryKey: qk.media.manual, queryFn: listManualInbox, }) const { data: shows } = useQuery({ queryKey: qk.shows.all, 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>() 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() 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 sample = selectable[0] ?? folders[0]?.files[0] const sampleParts = useMemo(() => { if (!sample) return [] const numbers = findNumbers(sample.name) // start — позиция куска в имени файла: она уникальна в пределах образца и годится как key, // в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз). const parts: { start: number; text: string; number: number | null }[] = [] let cursor = 0 for (const number of numbers) { if (number.start > cursor) parts.push({ start: cursor, text: sample.name.slice(cursor, number.start), number: null, }) parts.push({ start: number.start, text: number.text, number: number.index }) cursor = number.start + number.text.length } if (cursor < sample.name.length) parts.push({ start: cursor, text: sample.name.slice(cursor), number: null }) return parts }, [sample]) /** * Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла, * затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»). */ const detectedShowId = useMemo( () => shows && sample ? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows)) : undefined, [shows, sample], ) useEffect(() => { if (showPicked || showId || !detectedShowId) return setShowId(detectedShowId) }, [detectedShowId, showPicked, showId]) const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId const recognized = selectable.filter( (f) => parsedByPath.get(f.relativePath)?.episode != null, ).length const onError = useApiError() const importMutation = useMutation({ 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 })) // Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге. for (const failure of result.failed) toast.error(`${failure.relativePath}: ${failure.reason}`) setSelected([]) void queryClient.invalidateQueries({ queryKey: qk.media.all }) void queryClient.invalidateQueries({ queryKey: qk.shows.all }) if (result.failed.length === 0) onClose() }, onError, }) const toggle = (path: string) => setSelected((current) => current.includes(path) ? current.filter((p) => p !== path) : [...current, path], ) const toggleCollapsed = (folder: string) => setCollapsed((current) => current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder], ) 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 ( !open && onClose()}> {t('admin.media.manualTitle')} {t('admin.media.manualHint')}
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
{autoDetected && (

{t('admin.media.manualDetected')}

)}
setQuery(e.target.value)} />
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */} setSeasonStr(e.target.value)} />
setRegexStr(e.target.value)} className={!regexOk ? 'border-red-500' : undefined} />
{!regexOk &&

{t('admin.media.toShowRegexInvalid')}

} {/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */} {sample && (
{t('admin.media.regexPickHint')}
{sampleParts.map((part) => part.number === null ? ( {part.text} ) : ( ), )}
{t('admin.media.regexPresets')} {REGEX_PRESETS.map((preset) => ( ))} {regexStr && ( )}
)}
{t('admin.media.manualSelected', { count: selected.length })} {t('admin.media.manualRecognized', { count: recognized, total: selectable.length, })}
{isLoading &&

{t('common.loading')}

} {!isLoading && folders.length === 0 && (

{t('admin.media.manualEmpty')}

)} {folders.map(({ folder, files }) => { const isCollapsed = collapsed.includes(folder) return (
!f.alreadyImported) .every((f) => selected.includes(f.relativePath))} onChange={() => toggleFolder(files)} /> {folder || t('admin.media.manualRoot')} {files.length}
{!isCollapsed && (
    {files.map((file) => { const label = formatSeasonEpisode( parsedByPath.get(file.relativePath) ?? { season: null, episode: null }, ) return (
  • toggle(file.relativePath)} /> {label ? ( {label} ) : ( {t('admin.media.toShowUnknown')} )} {file.name} {file.alreadyImported && ( {t('admin.media.manualAlready')} )} {formatSize(file.sizeBytes)}
  • ) })}
)}
) })}
{data?.truncated && (

{t('admin.media.manualTruncated')}

)}

{t('admin.media.manualCleanupHint')}

) }