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