From 9cefc6a1981cc9ce207f55e2e517cac93844f9e1 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 18:54:43 +0300 Subject: [PATCH] Enhance show management: add OriginalName property to ShowSummaryDto, update ListShowsQueryHandler to include OriginalName, and improve upload functionality by allowing auto-detection of shows based on file names. Update translations for better user guidance on show detection. --- .../ListShows/ListShowsQueryHandler.cs | 1 + .../TeleWave.Application/Library/ShowDtos.cs | 1 + .../admin/media/UploadToShowDialog.tsx | 73 ++++++++++++------- .../src/features/admin/media/match-show.ts | 42 +++++++++++ .../src/features/admin/media/upload-store.ts | 14 +++- frontend/src/shared/api/types.ts | 1 + frontend/src/shared/lib/i18n.ts | 12 +++ 7 files changed, 113 insertions(+), 31 deletions(-) create mode 100644 frontend/src/features/admin/media/match-show.ts diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index ecf3be6..a28bd04 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -35,6 +35,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) return new ShowSummaryDto( s.Id, s.Name, + s.OriginalName, s.Kind, s.Episodes.Count, seasons, diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index 894eb03..c8be819 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -6,6 +6,7 @@ namespace TeleWave.Application.Library; public sealed record ShowSummaryDto( Guid Id, string Name, + string? OriginalName, ShowKind Kind, int EpisodeCount, int SeasonCount, diff --git a/frontend/src/features/admin/media/UploadToShowDialog.tsx b/frontend/src/features/admin/media/UploadToShowDialog.tsx index cec309e..9d1e0b9 100644 --- a/frontend/src/features/admin/media/UploadToShowDialog.tsx +++ b/frontend/src/features/admin/media/UploadToShowDialog.tsx @@ -16,20 +16,36 @@ import { Label } from '@/shared/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { listShows } from '@/features/admin/shows/api' import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' +import { matchShowByName } from './match-show' import { useUploadStore } from './upload-store' +/** Radix Select запрещает пустое значение — под «в библиотеку» используем спец-значение. */ +const LIBRARY_VALUE = '__library__' + export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) { const { t } = useTranslation() const enqueue = useUploadStore((s) => s.enqueue) - const [showId, setShowId] = useState('') const [seasonStr, setSeasonStr] = useState('') const [regexStr, setRegexStr] = useState('') + // Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение. + const [overrides, setOverrides] = useState>({}) const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) const regexOk = isValidRegex(regexStr) const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null + // Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined). + const matchedByName = useMemo(() => { + const map = new Map() + if (shows) for (const file of files) map.set(file.name, matchShowByName(file.name, shows)) + return map + }, [files, shows]) + + /** Итоговая привязка файла: ручная правка (если есть) либо автоопределение, иначе '' (в библиотеку). */ + const assignment = (name: string): string => + name in overrides ? overrides[name] : (matchedByName.get(name) ?? '') + // Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления. const previews = useMemo(() => { const opts = { @@ -41,13 +57,12 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: .sort(compareParsed) }, [files, seasonOverride, regexStr, regexOk]) - const recognized = previews.filter((p) => p.parsed.episode != null).length + const matchedCount = previews.filter((p) => assignment(p.name)).length const confirm = () => { - if (!showId) return void enqueue( previews.map((p) => p.file), - { showId }, + { resolveShowId: (file) => assignment(file.name) || undefined }, ) onClose() } @@ -57,28 +72,11 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: {t('admin.media.toShowTitle')} - - {t('admin.media.toShowSubtitle', { count: files.length })} - + {t('admin.media.autoDetectHint')}
-
-
- - -
+
{t('admin.media.toShowPreview')} - {t('admin.media.toShowRecognized', { recognized, total: files.length })} + {t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
-
    +
      {previews.map((p) => { const label = formatSeasonEpisode(p.parsed) + const current = assignment(p.name) return ( -
    • +
    • {label ? ( {label} ) : ( {t('admin.media.toShowUnknown')} )} - + {p.name} +
    • ) })} @@ -135,7 +152,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: - diff --git a/frontend/src/features/admin/media/match-show.ts b/frontend/src/features/admin/media/match-show.ts new file mode 100644 index 0000000..c3cbf21 --- /dev/null +++ b/frontend/src/features/admin/media/match-show.ts @@ -0,0 +1,42 @@ +/** + * Сопоставление файла с шоу по названию: имя релиза (напр. «The.Simpsons.S33E01.WEBDL…») содержит + * оригинальное (или отображаемое) название шоу. Нормализуем обе строки до слов через пробел и ищем + * название как цельную последовательность слов; при нескольких совпадениях берём самое длинное + * («Star Trek Discovery» важнее «Star Trek»). + */ + +export type ShowNameRef = { id: string; name: string; originalName?: string | null } + +/** Приводит строку к «словам через пробел»: буквы/цифры сохраняем, всё прочее — разделитель. */ +function normalize(value: string): string { + return value + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' ') +} + +/** Минимальная длина названия-кандидата (в символах после нормализации) — отсекает шум. */ +const MIN_CANDIDATE_LENGTH = 2 + +/** + * Возвращает id наиболее подходящего шоу для имени файла или undefined, если совпадений нет. + * Совпадением считается вхождение названия шоу как цельной последовательности слов в имя файла. + */ +export function matchShowByName(fileName: string, shows: readonly ShowNameRef[]): string | undefined { + const haystack = ` ${normalize(fileName)} ` + let best: { id: string; length: number } | undefined + + for (const show of shows) { + for (const candidate of [show.originalName, show.name]) { + if (!candidate) continue + const norm = normalize(candidate) + if (norm.length < MIN_CANDIDATE_LENGTH) continue + if (haystack.includes(` ${norm} `) && norm.length > (best?.length ?? 0)) { + best = { id: show.id, length: norm.length } + } + } + } + + return best?.id +} diff --git a/frontend/src/features/admin/media/upload-store.ts b/frontend/src/features/admin/media/upload-store.ts index 6c3ebed..0dc9508 100644 --- a/frontend/src/features/admin/media/upload-store.ts +++ b/frontend/src/features/admin/media/upload-store.ts @@ -25,8 +25,15 @@ type UploadStore = { dismiss: () => void } -/** Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). */ -export type EnqueueOptions = { showId?: string } +/** + * Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). + * showId — общий для всех файлов; resolveShowId — привязка на каждый файл (напр. + * автоопределение шоу по имени релиза). Приоритет у resolveShowId, затем общий showId. + */ +export type EnqueueOptions = { + showId?: string + resolveShowId?: (file: File) => string | undefined +} // Очередь и флаг живут вне React — загрузка продолжается при любой навигации. let counter = 0 @@ -149,7 +156,8 @@ export const useUploadStore = create((set) => ({ const newItems: UploadItem[] = toAdd.map((file) => { const id = `u${++counter}` - queue.push({ id, file, showId: options?.showId }) + const showId = options?.resolveShowId?.(file) ?? options?.showId + queue.push({ id, file, showId }) return { id, name: file.name, percent: 0, status: 'queued' } }) diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 8d56dfa..63b809e 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -68,6 +68,7 @@ export type ShowKind = 'Series' | 'Single' export type ShowSummaryDto = { id: string name: string + originalName: string | null kind: ShowKind episodeCount: number seasonCount: number diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 8313335..83cd197 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -118,7 +118,12 @@ const resources = { uploadToShow: 'Загрузить в шоу', toShowTitle: 'Загрузить и добавить в шоу', toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.', + autoDetectLabel: 'Определять шоу по названию файла', + autoDetectHint: + 'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.', toShowShow: 'Шоу', + toShowFallback: 'Для нераспознанных', + toShowLibrary: 'В библиотеку', toShowPick: 'Выберите шоу', toShowSeason: 'Сезон (вручную)', toShowAuto: 'авто', @@ -128,6 +133,7 @@ const resources = { 'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».', toShowPreview: 'Что распознаем', toShowRecognized: 'распознано {{recognized}} из {{total}}', + toShowMatched: 'шоу распознано у {{matched}} из {{total}}', toShowUnknown: '—', toShowConfirm: 'Загрузить и добавить', uploaded: 'Файл загружен, идёт обработка', @@ -452,7 +458,12 @@ const resources = { uploadToShow: 'Upload to show', toShowTitle: 'Upload and add to show', toShowSubtitle: '{{count}} file(s) selected. After upload they are added as episodes to the chosen show.', + autoDetectLabel: 'Detect show from file name', + autoDetectHint: + 'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.', toShowShow: 'Show', + toShowFallback: 'For unrecognized', + toShowLibrary: 'To library', toShowPick: 'Pick a show', toShowSeason: 'Season (manual)', toShowAuto: 'auto', @@ -462,6 +473,7 @@ const resources = { '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', toShowRecognized: '{{recognized}} of {{total}} recognized', + toShowMatched: 'show detected for {{matched}} of {{total}}', toShowUnknown: '—', toShowConfirm: 'Upload and add', uploaded: 'File uploaded, processing started',