diff --git a/frontend/src/features/admin/media/ManualInboxDialog.tsx b/frontend/src/features/admin/media/ManualInboxDialog.tsx index ac4853d..e81cd82 100644 --- a/frontend/src/features/admin/media/ManualInboxDialog.tsx +++ b/frontend/src/features/admin/media/ManualInboxDialog.tsx @@ -21,7 +21,13 @@ import { Input } from '@/shared/ui/input' import { Label } from '@/shared/ui/label' import { toast } from '@/shared/ui/toast-store' import { importManualInbox, listManualInbox } from './api' -import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' +import { + compareParsed, + formatSeasonEpisode, + isValidRegex, + parseEpisodeName, + parseSeasonFromFolder, +} from './episode-parse' import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex' import { matchShowByName } from './match-show' @@ -67,15 +73,26 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }> 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 seasonByFolder = new Map() const map = new Map>() - for (const file of data?.files ?? []) - map.set(file.relativePath, parseEpisodeName(file.name, options)) + for (const file of data?.files ?? []) { + if (!seasonByFolder.has(file.folder)) + seasonByFolder.set(file.folder, parseSeasonFromFolder(file.folder)) + map.set( + file.relativePath, + parseEpisodeName(file.name, { + ...options, + seasonFallback: seasonByFolder.get(file.folder) ?? null, + }), + ) + } return map }, [data, seasonOverride, regexStr, regexOk]) diff --git a/frontend/src/features/admin/media/episode-parse.ts b/frontend/src/features/admin/media/episode-parse.ts index 6f0cd82..84170fe 100644 --- a/frontend/src/features/admin/media/episode-parse.ts +++ b/frontend/src/features/admin/media/episode-parse.ts @@ -3,6 +3,11 @@ type ParseOptions = { seasonOverride?: number | null /** Пользовательский regex для номера серии: 1 группа = серия, 2 группы = (сезон, серия). */ episodeRegex?: string | null + /** + * Запасной сезон (обычно распознанный из имени папки) — берётся, когда в имени файла сезона нет. + * Ручной сезон его перебивает, а сам он перебивает подстановку первого сезона по умолчанию. + */ + seasonFallback?: number | null } export type ParsedEpisode = { season: number | null; episode: number | null } @@ -58,11 +63,47 @@ export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpiso } if (opts?.seasonOverride != null) season = opts.seasonOverride + if (season == null && opts?.seasonFallback != null) season = opts.seasonFallback if (episode != null && season == null) season = 1 return { season: finiteOrNull(season), episode: finiteOrNull(episode) } } +/** + * Диапазон сезонов в имени: «Season.3-4», «S01-S03». Такая папка накрывает несколько сезонов, и + * подставлять из неё один номер нельзя — честнее не угадывать вовсе. + */ +const SEASON_RANGES = [ + /(?:s|season|сезон[ы]?)[\s._-]*\d{1,2}[\s._-]*[-–][\s._-]*(?:s|season)?\d{1,2}/i, + /\d{1,2}[\s._-]*[-–][\s._-]*\d{1,2}[\s._-]*сезон/i, +] + +/** Сезон в имени одного сегмента пути: «S02», «Season 2», «Сезон 2», «2 сезон». */ +const SEASON_PATTERNS = [ + // S02 — не часть более длинного числа (год «S2007» сезоном не считаем). + /(?:^|[^a-z0-9])s[\s._-]*(\d{1,2})(?!\d)/i, + /(?:season|сезон)[\s._-]*(\d{1,2})(?!\d)/i, + /(\d{1,2})[\s._-]*сезон/i, +] + +/** + * Сезон из имени папки, в которой лежит файл: в раздачах номер сезона обычно есть только там + * («Metalocalypse.S02.2007-2008.H264» / «01 Dethlessons.mkv»), и без этого все серии второго сезона + * уехали бы в первый. Путь разбирается с самого глубокого сегмента вверх: «Шоу/Season 2» и + * «Шоу.S02/extras» одинаково дают ответ. null — сезона нет или папка накрывает диапазон сезонов. + */ +export function parseSeasonFromFolder(folder: string): number | null { + const segments = folder.split('/').filter(Boolean).reverse() + for (const segment of segments) { + if (SEASON_RANGES.some((range) => range.test(segment))) return null + for (const pattern of SEASON_PATTERNS) { + const match = pattern.exec(segment) + if (match) return finiteOrNull(Number(match[1])) + } + } + return null +} + const pad2 = (n: number) => String(n).padStart(2, '0') /** «S14E17» либо null, если серия не распознана. */