From 7af624200397412477fb5cb1689e5f416d8c2fef Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 15:48:41 +0300 Subject: [PATCH] Enhance manual inbox dialog: add regex presets and hints for episode number extraction, improve user interface for file selection, and update translations for better user guidance. Refactor state management and query handling to streamline the import process and enhance overall user experience in manual media management. --- .../admin/media/ManualInboxDialog.tsx | 756 ++++++++++-------- .../src/features/admin/media/episode-regex.ts | 50 ++ frontend/src/shared/lib/i18n.ts | 22 + 3 files changed, 483 insertions(+), 345 deletions(-) create mode 100644 frontend/src/features/admin/media/episode-regex.ts 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}}',