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
@@ -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<Record<string, string>>({})
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<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 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:
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
<DialogDescription>
{t('admin.media.toShowSubtitle', { count: files.length })}
</DialogDescription>
<DialogDescription>{t('admin.media.autoDetectHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid gap-3 sm:grid-cols-3">
<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="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
<Input
@@ -109,22 +107,41 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
<div className="flex items-center justify-between text-sm">
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
<span className="text-muted-foreground">
{t('admin.media.toShowRecognized', { recognized, total: files.length })}
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
</span>
</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) => {
const label = formatSeasonEpisode(p.parsed)
const current = assignment(p.name)
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 ? (
<Badge>{label}</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}
</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>
)
})}
@@ -135,7 +152,7 @@ export function UploadToShowDialog({ files, onClose }: { files: File[]; onClose:
<Button variant="outline" size="sm" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button size="sm" disabled={!showId} onClick={confirm}>
<Button size="sm" onClick={confirm}>
{t('admin.media.toShowConfirm')}
</Button>
</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
}
/** Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). */
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 — загрузка продолжается при любой навигации.
let counter = 0
@@ -149,7 +156,8 @@ export const useUploadStore = create<UploadStore>((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' }
})