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:
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user