From 4774083dc90cace5796197f4f57b3772e7053ff5 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Mon, 27 Jul 2026 03:51:52 +0300 Subject: [PATCH] Enhance episode parsing with season fallback and folder-based season extraction Added a new option for seasonFallback in the episode parsing logic to allow for a default season when not specified in the file name. Implemented a function to extract season information from folder names, improving the accuracy of episode parsing when season data is not present in the file name. Updated the ManualInboxDialog to utilize this new functionality, ensuring a more robust handling of media files. --- .../admin/media/ManualInboxDialog.tsx | 23 +++++++++-- .../src/features/admin/media/episode-parse.ts | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) 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, если серия не распознана. */