Refactor regex handling and improve null checks across various components
ci / build-backend (push) Successful in 1m45s
ci / build-frontend (push) Successful in 52s
ci / tests (push) Successful in 2m3s
ci / sonar (push) Successful in 4m41s

Updated regex patterns in episode parsing and metadata handling to use String.raw for better readability and maintainability. Enhanced null checks in BlockBuilder, MediaPanel, and ShowMetadataCard components to prevent potential runtime errors. Consolidated imports in UsersPanel for cleaner code structure.
This commit is contained in:
Leonid Pershin
2026-07-27 02:09:47 +03:00
parent 088af2318e
commit b80163b6b4
11 changed files with 45 additions and 49 deletions
@@ -47,7 +47,7 @@ export function BlockBuilder({
setOver(false) setOver(false)
const item = readDragItem(event) const item = readDragItem(event)
// Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции. // Блок из блоков собрать нельзя: коллекция хранит шоу, а не вложенные коллекции.
if (!item || item.kind !== 'clip') return if (item?.kind !== 'clip') return
setItems((current) => [...current, item]) setItems((current) => [...current, item])
} }
@@ -241,7 +241,7 @@ export function MediaPanel() {
onDelete={() => deleteMutation.mutate(asset.id)} onDelete={() => deleteMutation.mutate(asset.id)}
/> />
))} ))}
{data && data.items.length === 0 && !isLoading && ( {data?.items.length === 0 && !isLoading && (
<tr> <tr>
<td className="px-4 py-3 text-muted-foreground" colSpan={6}> <td className="px-4 py-3 text-muted-foreground" colSpan={6}>
{t('admin.media.empty')} {t('admin.media.empty')}
@@ -9,14 +9,14 @@ export type ParsedEpisode = { season: number | null; episode: number | null }
/** Встроенные шаблоны: SxxEyy, NxNN, ведущий номер серии. */ /** Встроенные шаблоны: SxxEyy, NxNN, ведущий номер серии. */
function parseBuiltin(name: string): ParsedEpisode { function parseBuiltin(name: string): ParsedEpisode {
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/) const se = /[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/.exec(name)
if (se) return { season: Number(se[1]), episode: Number(se[2]) } if (se) return { season: Number(se[1]), episode: Number(se[2]) }
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i) const nx = /(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i.exec(name)
if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) } if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) }
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название». // Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/) const lead = /^\s*(\d{1,3})[\s._)\]-]/.exec(name)
return { season: null, episode: lead ? Number(lead[1]) : null } return { season: null, episode: lead ? Number(lead[1]) : null }
} }
@@ -26,9 +26,9 @@ function parseBuiltin(name: string): ParsedEpisode {
* при одной группе означает «сезон не трогаем». * при одной группе означает «сезон не трогаем».
*/ */
function parseCustom(name: string, pattern: string): ParsedEpisode | null { function parseCustom(name: string, pattern: string): ParsedEpisode | null {
let match: RegExpMatchArray | null let match: RegExpExecArray | null
try { try {
match = name.match(new RegExp(pattern, 'i')) match = new RegExp(pattern, 'i').exec(name)
} catch { } catch {
return null // невалидный regex — просто игнорируем return null // невалидный regex — просто игнорируем
} }
@@ -1,10 +1,10 @@
/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */ /** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */
export const REGEX_PRESETS: { key: string; pattern: string }[] = [ export const REGEX_PRESETS: { key: string; pattern: string }[] = [
{ key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' }, { key: 'seriesWord', pattern: String.raw`[Сс]ерия\s*(\d{1,3})` },
{ key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' }, { key: 'episodeWord', pattern: String.raw`[Ээ]пизод\s*(\d{1,3})` },
{ key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' }, { key: 'seasonEpisode', pattern: String.raw`[Ss](\d{1,2})[Ee](\d{1,3})` },
{ key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' }, { key: 'afterDash', pattern: String.raw`[-–—]\s*(\d{1,3})` },
{ key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' }, { key: 'firstNumber', pattern: String.raw`(?:^|\D)(\d{1,3})(?:\D|$)` },
] ]
/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */ /** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */
@@ -16,7 +16,7 @@ export function findNumbers(fileName: string): { index: number; start: number; t
})) }))
} }
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&') const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, String.raw`\$&`)
const isLetter = (char: string) => /\p{L}/u.test(char) const isLetter = (char: string) => /\p{L}/u.test(char)
const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char) const isLetterOrDigit = (char: string) => /[\p{L}\p{N}]/u.test(char)
@@ -44,9 +44,9 @@ export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): st
// Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу, // Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу,
// а применяется ко всей папке, где рядом может лежать и «Серия 100». // а применяется ко всей папке, где рядом может лежать и «Серия 100».
const digits = '(\\d{1,3})' const digits = String.raw`(\d{1,3})`
const before = fileName.slice(0, target.start) const before = fileName.slice(0, target.start)
if (!before.trim()) return `^\\s*${digits}` if (!before.trim()) return String.raw`^\s*${digits}`
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах // Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
// там встречается то пробел, то точка, то подчёркивание. // там встречается то пробел, то точка, то подчёркивание.
@@ -56,9 +56,9 @@ export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): st
// к первому сезону, и на «S02E05» оно бы уже не сработало. // к первому сезону, и на «S02E05» оно бы уже не сработало.
const anchor = trailingRun(anchorSource, isLetter) const anchor = trailingRun(anchorSource, isLetter)
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}` if (anchor) return `${escapeRegex(anchor)}${gap ? String.raw`[\s._-]*` : ''}${digits}`
// Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»). // Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»).
const punctuation = gap.trim().slice(-1) const punctuation = gap.trim().slice(-1)
return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}` return punctuation ? String.raw`${escapeRegex(punctuation)}\s*${digits}` : String.raw`\s${digits}`
} }
@@ -345,7 +345,7 @@ export function ShowMetadataCard({
<DialogHeader> <DialogHeader>
<DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle> <DialogTitle>{t('admin.metadata.missingTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
{missing && missing.seasons.length === 0 && ( {missing?.seasons.length === 0 && (
<p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p> <p className="text-sm text-muted-foreground">{t('admin.metadata.missingNoSeasons')}</p>
)} )}
{missing && missing.seasons.length > 0 && ( {missing && missing.seasons.length > 0 && (
@@ -14,8 +14,7 @@ import { useTableSort } from '@/shared/lib/table-sort'
import { SortHeader } from '@/shared/ui/sortable' import { SortHeader } from '@/shared/ui/sortable'
import { toast } from '@/shared/ui/toast-store' import { toast } from '@/shared/ui/toast-store'
import type { UserSummaryDto } from '@/shared/api/types' import type { UserSummaryDto } from '@/shared/api/types'
import { changeUserRole } from '@/features/admin/roles/api' import { changeUserRole, listRoles } from '@/features/admin/roles/api'
import { listRoles } from '@/features/admin/roles/api'
import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api' import { blockUser, createUser, deleteUser, listUsers, resetUserPassword, unblockUser } from './api'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
+2 -2
View File
@@ -306,8 +306,8 @@ function buildGuide(entries: PublicEpgEntryDto[]): {
const blocks: GuideBlock[] = [] const blocks: GuideBlock[] = []
for (const entry of entries) { for (const entry of entries) {
if (entry.kind !== 'Program') continue if (entry.kind !== 'Program') continue
const last = blocks[blocks.length - 1] const last = blocks.at(-1)
if (last && last.showId === entry.showId) { if (last?.showId === entry.showId) {
last.endsAtUtc = entry.endsAtUtc last.endsAtUtc = entry.endsAtUtc
} else { } else {
blocks.push({ blocks.push({
+17 -19
View File
@@ -25,25 +25,23 @@ type RequestOptions = {
} }
export async function refreshAccessToken(): Promise<boolean> { export async function refreshAccessToken(): Promise<boolean> {
if (!refreshInFlight) { refreshInFlight ??= (async () => {
refreshInFlight = (async () => { try {
try { const response = await fetch('/api/auth/refresh', {
const response = await fetch('/api/auth/refresh', { method: 'POST',
method: 'POST', credentials: 'include',
credentials: 'include', })
}) if (!response.ok) return false
if (!response.ok) return false const data = (await response.json()) as { accessToken?: unknown }
const data = (await response.json()) as { accessToken?: unknown } if (typeof data?.accessToken !== 'string') return false
if (typeof data?.accessToken !== 'string') return false setAccessToken(data.accessToken)
setAccessToken(data.accessToken) return true
return true } catch {
} catch { return false
return false } finally {
} finally { refreshInFlight = null
refreshInFlight = null }
} })()
})()
}
return refreshInFlight return refreshInFlight
} }
+1 -2
View File
@@ -237,8 +237,7 @@ export const en = {
toShowAuto: 'auto', toShowAuto: 'auto',
toShowRegex: 'Episode regex', toShowRegex: 'Episode regex',
toShowRegexInvalid: 'invalid regex', toShowRegexInvalid: 'invalid regex',
toShowHint: toShowHint: String.raw`Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\d+) for “01. Title.mkv”.`,
'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.',
toShowPreview: 'What we detect', toShowPreview: 'What we detect',
toShowMatched: 'show detected for {{matched}} of {{total}}', toShowMatched: 'show detected for {{matched}} of {{total}}',
applyToAll: 'Set for all…', applyToAll: 'Set for all…',
+1 -2
View File
@@ -238,8 +238,7 @@ export const ru = {
toShowAuto: 'авто', toShowAuto: 'авто',
toShowRegex: 'Regex серии', toShowRegex: 'Regex серии',
toShowRegexInvalid: 'некорректный regex', toShowRegexInvalid: 'некорректный regex',
toShowHint: toShowHint: String.raw`Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\d+) для «01. Название.mkv».`,
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
toShowPreview: 'Что распознаем', toShowPreview: 'Что распознаем',
toShowMatched: 'шоу распознано у {{matched}} из {{total}}', toShowMatched: 'шоу распознано у {{matched}} из {{total}}',
applyToAll: 'Задать всем…', applyToAll: 'Задать всем…',
+5 -4
View File
@@ -14,7 +14,7 @@ function applyTheme(theme: Theme) {
} }
export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) { export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
const [theme, setThemeState] = useState<Theme>( const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(THEME_STORAGE_KEY) as Theme | null) ?? 'dark', () => (localStorage.getItem(THEME_STORAGE_KEY) as Theme | null) ?? 'dark',
) )
@@ -27,13 +27,14 @@ export function ThemeProvider({ children }: Readonly<{ children: ReactNode }>) {
return () => media.removeEventListener('change', onChange) return () => media.removeEventListener('change', onChange)
}, [theme]) }, [theme])
const setTheme = useCallback((next: Theme) => { // Выбор темы переживает перезагрузку, поэтому наружу отдаём не голый сеттер, а обёртку с записью.
const changeTheme = useCallback((next: Theme) => {
localStorage.setItem(THEME_STORAGE_KEY, next) localStorage.setItem(THEME_STORAGE_KEY, next)
setThemeState(next) setTheme(next)
}, []) }, [])
// Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы. // Литерал в value пересоздавался бы на каждый рендер и перерисовывал всех потребителей темы.
const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]) const value = useMemo(() => ({ theme, setTheme: changeTheme }), [theme, changeTheme])
return <ThemeContext value={value}>{children}</ThemeContext> return <ThemeContext value={value}>{children}</ThemeContext>
} }