diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index 3e2119d..fc71e30 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -1,345 +1,411 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -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 { - 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' - -/** Байты → «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 [query, setQuery] = useState('') - const [seasonStr, setSeasonStr] = useState('') - const [regexStr, setRegexStr] = useState('') - const [collapsed, setCollapsed] = useState([]) - - const { data, isLoading } = useQuery({ - queryKey: ['admin', 'media', 'manual'], - queryFn: listManualInbox, - }) - 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>() - 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 recognized = selectable.filter( - (f) => parsedByPath.get(f.relativePath)?.episode != null, - ).length - - 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: ['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 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 ( - !open && onClose()}> - - - {t('admin.media.manualTitle')} - {t('admin.media.manualHint')} - - -
-
-
- - setQuery(e.target.value)} /> -
-
- - setSeasonStr(e.target.value)} - /> -
-
- - setRegexStr(e.target.value)} - className={!regexOk ? 'border-red-500' : undefined} - /> -
-
-

- {t('admin.media.toShowHint')} - {!regexOk && ( - {t('admin.media.toShowRegexInvalid')} - )} -

- -
- - - {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')} - -
-
- - - - - -
-
- ) -} +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +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 { + 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' + +/** Байты → «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 [query, setQuery] = useState('') + const [seasonStr, setSeasonStr] = useState('') + const [regexStr, setRegexStr] = useState('') + const [collapsed, setCollapsed] = useState([]) + + const { data, isLoading } = useQuery({ + queryKey: ['admin', 'media', 'manual'], + queryFn: listManualInbox, + }) + 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>() + 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) + const parts: { text: string; number: number | null }[] = [] + let cursor = 0 + for (const number of numbers) { + if (number.start > cursor) + parts.push({ text: sample.name.slice(cursor, number.start), number: null }) + parts.push({ text: number.text, number: number.index }) + cursor = number.start + number.text.length + } + if (cursor < sample.name.length) + parts.push({ text: sample.name.slice(cursor), number: null }) + return parts + }, [sample]) + const recognized = selectable.filter( + (f) => parsedByPath.get(f.relativePath)?.episode != null, + ).length + + 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: ['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 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 ( + !open && onClose()}> + + + {t('admin.media.manualTitle')} + {t('admin.media.manualHint')} + + +
+
+
+ + 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, index) => + 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')} + +
+
+ + + + + +
+
+ ) +} diff --git a/frontend/src/features/admin/media/episode-regex.ts b/frontend/src/features/admin/media/episode-regex.ts new file mode 100644 index 0000000..e94b3ec --- /dev/null +++ b/frontend/src/features/admin/media/episode-regex.ts @@ -0,0 +1,50 @@ +/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */ +export const REGEX_PRESETS: { key: string; pattern: string }[] = [ + { key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' }, + { key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' }, + { key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' }, + { key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' }, + { key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' }, +] + +/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */ +export function findNumbers(fileName: string): { index: number; start: number; text: string }[] { + return [...fileName.matchAll(/\d+/g)].map((match, index) => ({ + index, + start: match.index ?? 0, + text: match[0], + })) +} + +const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&') + +/** + * Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом + * («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет. + * Если перед числом только разделители — якорем становятся они, а число в начале имени крепится к `^`. + */ +export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): string { + const numbers = findNumbers(fileName) + const target = numbers[occurrenceIndex] + if (!target) return '' + + // Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу, + // а применяется ко всей папке, где рядом может лежать и «Серия 100». + const digits = '(\\d{1,3})' + const before = fileName.slice(0, target.start) + if (!before.trim()) return `^\\s*${digits}` + + // Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах + // там встречается то пробел, то точка, то подчёркивание. + const gap = /[^\p{L}\p{N}]*$/u.exec(before)?.[0] ?? '' + const anchorSource = before.slice(0, before.length - gap.length) + // Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое + // к первому сезону, и на «S02E05» оно бы уже не сработало. + const anchor = /\p{L}+$/u.exec(anchorSource)?.[0] + + if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}` + + // Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»). + const punctuation = gap.trim().slice(-1) + return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}` +} diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 0fa978e..c85c9da 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -208,6 +208,17 @@ const resources = { manualSelectAll: 'Выбрать все', manualSelected: 'Выбрано: {{count}}', manualEmpty: 'В папке manual пусто', + regexPickHint: 'Кликните число в имени файла — по нему соберётся правило для всех файлов:', + regexPickTitle: 'Это номер серии', + regexPresets: 'Готовые:', + regexPresetNames: { + seriesWord: 'Серия N', + episodeWord: 'Эпизод N', + seasonEpisode: 'SxxEyy', + afterDash: 'после тире', + firstNumber: 'первое число', + }, + regexClear: 'сбросить', manualAlready: 'уже в библиотеке', manualRoot: 'корень manual/', manualRecognized: 'Распознано: {{count}} из {{total}}', @@ -887,6 +898,17 @@ const resources = { manualSelectAll: 'Select all', manualSelected: 'Selected: {{count}}', manualEmpty: 'The manual folder is empty', + regexPickHint: 'Click a number in the file name — a rule for all files is built from it:', + regexPickTitle: 'This is the episode number', + regexPresets: 'Ready-made:', + regexPresetNames: { + seriesWord: 'Серия N', + episodeWord: 'Эпизод N', + seasonEpisode: 'SxxEyy', + afterDash: 'after a dash', + firstNumber: 'first number', + }, + regexClear: 'clear', manualAlready: 'already in the library', manualRoot: 'manual/ root', manualRecognized: 'Recognized: {{count}} of {{total}}',