Enhance episode parsing with season fallback and folder-based season extraction
ci / build-backend (push) Successful in 1m56s
ci / build-frontend (push) Successful in 42s
ci / tests (push) Successful in 2m7s
ci / sonar (push) Successful in 8m32s

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.
This commit is contained in:
Leonid Pershin
2026-07-27 03:51:52 +03:00
parent 67e8b941bb
commit 4774083dc9
2 changed files with 61 additions and 3 deletions
@@ -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<string, number | null>()
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
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])
@@ -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, если серия не распознана. */