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.

This commit is contained in:
Leonid Pershin
2026-07-25 18:54:43 +03:00
parent 8718e8b6bf
commit 9cefc6a198
7 changed files with 113 additions and 31 deletions
@@ -35,6 +35,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
return new ShowSummaryDto( return new ShowSummaryDto(
s.Id, s.Id,
s.Name, s.Name,
s.OriginalName,
s.Kind, s.Kind,
s.Episodes.Count, s.Episodes.Count,
seasons, seasons,
@@ -6,6 +6,7 @@ namespace TeleWave.Application.Library;
public sealed record ShowSummaryDto( public sealed record ShowSummaryDto(
Guid Id, Guid Id,
string Name, string Name,
string? OriginalName,
ShowKind Kind, ShowKind Kind,
int EpisodeCount, int EpisodeCount,
int SeasonCount, int SeasonCount,
@@ -16,20 +16,36 @@ import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listShows } from '@/features/admin/shows/api' import { listShows } from '@/features/admin/shows/api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse' import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
import { matchShowByName } from './match-show'
import { useUploadStore } from './upload-store' import { useUploadStore } from './upload-store'
/** Radix Select запрещает пустое значение — под «в библиотеку» используем спец-значение. */
const LIBRARY_VALUE = '__library__'
export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) { export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose: () => void }) {
const { t } = useTranslation() const { t } = useTranslation()
const enqueue = useUploadStore((s) => s.enqueue) const enqueue = useUploadStore((s) => s.enqueue)
const [showId, setShowId] = useState('')
const [seasonStr, setSeasonStr] = useState('') const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('') const [regexStr, setRegexStr] = useState('')
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
const [overrides, setOverrides] = useState<Record<string, string>>({})
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows }) const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: listShows })
const regexOk = isValidRegex(regexStr) const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
// Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined).
const matchedByName = useMemo(() => {
const map = new Map<string, string | undefined>()
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 previews = useMemo(() => {
const opts = { const opts = {
@@ -41,13 +57,12 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
.sort(compareParsed) .sort(compareParsed)
}, [files, seasonOverride, regexStr, regexOk]) }, [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 = () => { const confirm = () => {
if (!showId) return
void enqueue( void enqueue(
previews.map((p) => p.file), previews.map((p) => p.file),
{ showId }, { resolveShowId: (file) => assignment(file.name) || undefined },
) )
onClose() onClose()
} }
@@ -57,28 +72,11 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle> <DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
<DialogDescription> <DialogDescription>{t('admin.media.autoDetectHint')}</DialogDescription>
{t('admin.media.toShowSubtitle', { count: files.length })}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-1">
<Label>{t('admin.media.toShowShow')}</Label>
<Select value={showId} onValueChange={setShowId}>
<SelectTrigger>
<SelectValue placeholder={t('admin.media.toShowPick')} />
</SelectTrigger>
<SelectContent>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label> <Label>{t('admin.media.toShowSeason')}</Label>
<Input <Input
@@ -109,22 +107,41 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium">{t('admin.media.toShowPreview')}</span> <span className="font-medium">{t('admin.media.toShowPreview')}</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t('admin.media.toShowRecognized', { recognized, total: files.length })} {t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
</span> </span>
</div> </div>
<ul className="crt-panel max-h-64 divide-y divide-border overflow-y-auto rounded-md text-sm"> <ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
{previews.map((p) => { {previews.map((p) => {
const label = formatSeasonEpisode(p.parsed) const label = formatSeasonEpisode(p.parsed)
const current = assignment(p.name)
return ( return (
<li key={p.name} className="flex items-center gap-3 px-3 py-1.5"> <li key={p.name} className="flex items-center gap-2 px-3 py-1.5">
{label ? ( {label ? (
<Badge>{label}</Badge> <Badge>{label}</Badge>
) : ( ) : (
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge> <Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
)} )}
<span className="truncate" title={p.name}> <span className="min-w-0 flex-1 truncate" title={p.name}>
{p.name} {p.name}
</span> </span>
<Select
value={current || LIBRARY_VALUE}
onValueChange={(v) =>
setOverrides((o) => ({ ...o, [p.name]: v === LIBRARY_VALUE ? '' : v }))
}
>
<SelectTrigger className="h-8 w-44 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
{shows?.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</li> </li>
) )
})} })}
@@ -135,7 +152,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
<Button variant="outline" size="sm" onClick={onClose}> <Button variant="outline" size="sm" onClick={onClose}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
<Button size="sm" disabled={!showId} onClick={confirm}> <Button size="sm" onClick={confirm}>
{t('admin.media.toShowConfirm')} {t('admin.media.toShowConfirm')}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -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
}
@@ -25,8 +25,15 @@ type UploadStore = {
dismiss: () => void dismiss: () => void
} }
/** Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). */ /**
export type EnqueueOptions = { showId?: string } * Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада).
* <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
* автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
*/
export type EnqueueOptions = {
showId?: string
resolveShowId?: (file: File) => string | undefined
}
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации. // Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0 let counter = 0
@@ -149,7 +156,8 @@ export const useUploadStore = create<UploadStore>((set) => ({
const newItems: UploadItem[] = toAdd.map((file) => { const newItems: UploadItem[] = toAdd.map((file) => {
const id = `u${++counter}` 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' } return { id, name: file.name, percent: 0, status: 'queued' }
}) })
+1
View File
@@ -68,6 +68,7 @@ export type ShowKind = 'Series' | 'Single'
export type ShowSummaryDto = { export type ShowSummaryDto = {
id: string id: string
name: string name: string
originalName: string | null
kind: ShowKind kind: ShowKind
episodeCount: number episodeCount: number
seasonCount: number seasonCount: number
+12
View File
@@ -118,7 +118,12 @@ const resources = {
uploadToShow: 'Загрузить в шоу', uploadToShow: 'Загрузить в шоу',
toShowTitle: 'Загрузить и добавить в шоу', toShowTitle: 'Загрузить и добавить в шоу',
toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.', toShowSubtitle: 'Файлов выбрано: {{count}}. После загрузки они добавятся сериями в выбранное шоу.',
autoDetectLabel: 'Определять шоу по названию файла',
autoDetectHint:
'Каждый файл привяжется к шоу, чьё оригинальное (или отображаемое) название есть в имени релиза, напр. «The.Simpsons.S33E01…» → The Simpsons.',
toShowShow: 'Шоу', toShowShow: 'Шоу',
toShowFallback: 'Для нераспознанных',
toShowLibrary: 'В библиотеку',
toShowPick: 'Выберите шоу', toShowPick: 'Выберите шоу',
toShowSeason: 'Сезон (вручную)', toShowSeason: 'Сезон (вручную)',
toShowAuto: 'авто', toShowAuto: 'авто',
@@ -128,6 +133,7 @@ const resources = {
'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».', 'Сезон и regex — необязательны: обычно номера распознаются сами (см. ниже). Regex: 1 группа = серия, 2 группы = сезон и серия. Пример: ^(\\d+) для «01. Название.mkv».',
toShowPreview: 'Что распознаем', toShowPreview: 'Что распознаем',
toShowRecognized: 'распознано {{recognized}} из {{total}}', toShowRecognized: 'распознано {{recognized}} из {{total}}',
toShowMatched: 'шоу распознано у {{matched}} из {{total}}',
toShowUnknown: '—', toShowUnknown: '—',
toShowConfirm: 'Загрузить и добавить', toShowConfirm: 'Загрузить и добавить',
uploaded: 'Файл загружен, идёт обработка', uploaded: 'Файл загружен, идёт обработка',
@@ -452,7 +458,12 @@ const resources = {
uploadToShow: 'Upload to show', uploadToShow: 'Upload to show',
toShowTitle: 'Upload and add to show', toShowTitle: 'Upload and add to show',
toShowSubtitle: '{{count}} file(s) selected. After upload they are added as episodes to the chosen 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', toShowShow: 'Show',
toShowFallback: 'For unrecognized',
toShowLibrary: 'To library',
toShowPick: 'Pick a show', toShowPick: 'Pick a show',
toShowSeason: 'Season (manual)', toShowSeason: 'Season (manual)',
toShowAuto: 'auto', 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”.', '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',
toShowRecognized: '{{recognized}} of {{total}} recognized', toShowRecognized: '{{recognized}} of {{total}} recognized',
toShowMatched: 'show detected for {{matched}} of {{total}}',
toShowUnknown: '—', toShowUnknown: '—',
toShowConfirm: 'Upload and add', toShowConfirm: 'Upload and add',
uploaded: 'File uploaded, processing started', uploaded: 'File uploaded, processing started',