import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { AlertTriangle, Check, HelpCircle, Search, Upload } from 'lucide-react' import { useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { getMetadataProviders } from '@/features/admin/shows/api' import { qk } from '@/shared/api/query-keys' import type { MovieImportItem, MovieMatchDto, MovieMatchStatus } from '@/shared/api/types' import { useApiError } from '@/shared/lib/use-api-error' import { Button } from '@/shared/ui/button' import { DialogFooter } from '@/shared/ui/dialog' import { Input } from '@/shared/ui/input' import { toast } from '@/shared/ui/toast-store' import { importMovies, listManualInbox, matchMovie, matchMovies } from './api' import { useUploadStore } from './upload-store' type Source = 'manual' | 'disk' /** Строка таблицы: разбор от сервера плюс то, что человек в ней поправил. */ type Row = MovieMatchDto & { /** Ключ строки — имя файла или папки; по нему же строка опознаётся при импорте. */ selected: boolean chosenExternalId: string | null title: string year: string } const STATUS_TONE: Record = { Confident: 'text-emerald-500', Uncertain: 'text-amber-500', NotFound: 'text-red-500', AlreadyInLibrary: 'text-muted-foreground', } function toRow(match: MovieMatchDto): Row { return { ...match, // Уже заведённое и ненайденное по умолчанию не отмечаем: первое создало бы дубль, // второе — шоу без метаданных, которого человек не просил. selected: match.status === 'Confident' || match.status === 'Uncertain', chosenExternalId: match.pickedExternalId, title: match.parsedTitle, year: match.parsedYear?.toString() ?? '', } } /** * Файлы `manual/` строками: папка релиза — это один фильм, а не набор файлов. Внутри папки берём * самый большой видеофайл (остальное — сэмплы и трейлеры), а показываем имя папки: оно чище, * чем «video.mkv» внутри. */ function manualRows( files: { relativePath: string; folder: string; name: string; sizeBytes: number }[], ) { const rows = new Map() for (const file of files) { const key = file.folder || file.relativePath const name = file.folder || file.name const current = rows.get(key) if (!current || file.sizeBytes > current.sizeBytes) rows.set(key, { name, relativePath: file.relativePath, sizeBytes: file.sizeBytes }) } return [...rows.values()] } /** * Разбор фильмов: имя файла → название и год → кандидат источника → шоу с метаданными. * * Источника два и оба идут одной таблицей: файлы из `manual/` на сервере и выбранные в браузере. * Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк * не распозналась, пришлось бы сначала залить десятки гигабайт. */ export function MovieImportPanel({ onClose }: Readonly<{ onClose: () => void }>) { const { t } = useTranslation() const onError = useApiError() const queryClient = useQueryClient() const [source, setSource] = useState('manual') const [rows, setRows] = useState([]) const [files, setFiles] = useState([]) const fileInput = useRef(null) const enqueue = useUploadStore((s) => s.enqueue) const { data: providers } = useQuery({ queryKey: qk.metadata.providers, queryFn: getMetadataProviders, }) const [provider, setProvider] = useState('') const activeProvider = provider || providers?.[0] || '' const { data: manual } = useQuery({ queryKey: qk.media.manual, queryFn: listManualInbox, enabled: source === 'manual', }) const manualFiles = useMemo(() => manualRows(manual?.files ?? []), [manual]) /** Имя строки → чем её импортировать: путь в manual/ либо файл из браузера. */ const sources = useMemo(() => { const map = new Map() if (source === 'manual') for (const item of manualFiles) map.set(item.name, { relativePath: item.relativePath }) else for (const file of files) map.set(file.name, { file }) return map }, [source, manualFiles, files]) const scan = useMutation({ mutationFn: () => matchMovies([...sources.keys()], activeProvider), onSuccess: (matches) => setRows(matches.map(toRow)), onError, }) const research = useMutation({ mutationFn: (row: Row) => matchMovie(row.name, row.title, row.year ? Number(row.year) : null, activeProvider), onSuccess: (match) => setRows((current) => current.map((row) => (row.name === match.name ? { ...toRow(match), selected: true } : row)), ), onError, }) const importing = useMutation({ mutationFn: async () => { const chosen = rows.filter((row) => row.selected) // Файлы с диска грузятся по одному и заводятся сразу после аплоада: ждать всю пачку, // чтобы увидеть первый фильм в библиотеке, незачем. if (source === 'disk') { const plans = new Map( chosen.map((row) => [ row.name, { title: row.title, year: row.year ? Number(row.year) : null, externalId: row.chosenExternalId, }, ]), ) await enqueue( chosen.map((row) => sources.get(row.name)?.file).filter((f): f is File => Boolean(f)), { resolveMovie: (file) => plans.get(file.name), provider: activeProvider }, ) return null } const items: MovieImportItem[] = chosen.map((row) => ({ relativePath: sources.get(row.name)?.relativePath ?? null, assetId: null, title: row.title, year: row.year ? Number(row.year) : null, externalId: row.chosenExternalId, })) return importMovies(items, activeProvider) }, onSuccess: (result) => { void queryClient.invalidateQueries({ queryKey: qk.media.all }) void queryClient.invalidateQueries({ queryKey: qk.media.manual }) void queryClient.invalidateQueries({ queryKey: qk.shows.all }) if (!result) { toast.success(t('admin.movies.uploadStarted')) onClose() return } toast.success( t('admin.movies.imported', { created: result.created, enriched: result.enriched }), ) for (const failure of result.failed) toast.error(`${failure.title}: ${failure.reason}`) setRows((current) => current.filter((row) => !row.selected)) }, onError, }) const patch = (name: string, changes: Partial) => setRows((current) => current.map((row) => (row.name === name ? { ...row, ...changes } : row))) const selectedCount = rows.filter((row) => row.selected).length const allSelected = rows.length > 0 && selectedCount === rows.length const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending const toggleAll = (selected: boolean) => setRows((current) => current.map((row) => ({ ...row, selected }))) return ( <>

{t('admin.movies.hint')}

{(['manual', 'disk'] as Source[]).map((item) => ( ))}
{source === 'disk' && ( <> { setFiles([...(e.target.files ?? [])]) setRows([]) }} /> )} {(providers?.length ?? 0) > 1 && ( )} {!activeProvider && ( {t('admin.movies.noProvider')} )}
{rows.map((row) => ( ))} {rows.length === 0 && ( )}
{ if (input) input.indeterminate = !allSelected && selectedCount > 0 }} onChange={(e) => toggleAll(e.target.checked)} /> {t('admin.movies.file')} {t('admin.movies.parsed')} {t('admin.movies.candidate')}
patch(row.name, { selected: e.target.checked })} />
{row.name}
{row.status === 'Confident' && } {row.status === 'Uncertain' && } {row.status === 'NotFound' && } {t(`admin.movies.statuses.${row.status}`)} {row.existingShowName && · {row.existingShowName}}
patch(row.name, { title: e.target.value })} /> patch(row.name, { year: e.target.value })} />
{row.candidates.length === 0 ? ( {t('admin.movies.empty')} ) : ( )}
{scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')}
{rows.length > 0 && (

{t('admin.movies.collectionsHint')}

)} ) }