Files
TeleWave/frontend/src/features/admin/media/ManualInboxDialog.tsx
T

456 lines
20 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { listShows } from '@/features/admin/shows/api'
import { qk } from '@/shared/api/query-keys'
import type { ManualInboxFileDto } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Badge } from '@/shared/ui/badge'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { Label } from '@/shared/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { toast } from '@/shared/ui/toast-store'
import { importManualInbox, listManualInbox } from './api'
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
import { matchShowByName } from './match-show'
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
function formatSize(bytes: number): string {
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
}
/**
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
* nfo) удаляются, чтобы не оставалось мусора.
*
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
* то и сохранится.
*/
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [selected, setSelected] = useState<string[]>([])
const [showId, setShowId] = useState('')
// Ручной выбор шоу отключает автоопределение: перебивать решение человека нельзя.
const [showPicked, setShowPicked] = useState(false)
const [query, setQuery] = useState('')
const [seasonStr, setSeasonStr] = useState('')
const [regexStr, setRegexStr] = useState('')
const [collapsed, setCollapsed] = useState<string[]>([])
const { data, isLoading } = useQuery({
queryKey: qk.media.manual,
queryFn: listManualInbox,
})
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
const regexOk = isValidRegex(regexStr)
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
const parsedByPath = useMemo(() => {
const options = {
seasonOverride:
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
episodeRegex: regexOk ? regexStr : null,
}
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
return map
}, [data, seasonOverride, regexStr, regexOk])
const folders = useMemo(() => {
const q = query.trim().toLowerCase()
const matched = (data?.files ?? []).filter((file) =>
q ? file.relativePath.toLowerCase().includes(q) : true,
)
const grouped = new Map<string, ManualInboxFileDto[]>()
for (const file of matched) {
const list = grouped.get(file.folder) ?? []
list.push(file)
grouped.set(file.folder, list)
}
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
return [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([folder, files]) => ({
folder,
files: [...files].sort((a, b) =>
compareParsed(
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
),
),
}))
}, [data, query, parsedByPath])
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
const sample = selectable[0] ?? folders[0]?.files[0]
const sampleParts = useMemo(() => {
if (!sample) return []
const numbers = findNumbers(sample.name)
// start — позиция куска в имени файла: она уникальна в пределах образца и годится как key,
// в отличие от индекса (куски одинакового текста встречаются в имени по нескольку раз).
const parts: { start: number; text: string; number: number | null }[] = []
let cursor = 0
for (const number of numbers) {
if (number.start > cursor)
parts.push({
start: cursor,
text: sample.name.slice(cursor, number.start),
number: null,
})
parts.push({ start: number.start, text: number.text, number: number.index })
cursor = number.start + number.text.length
}
if (cursor < sample.name.length)
parts.push({ start: cursor, text: sample.name.slice(cursor), number: null })
return parts
}, [sample])
/**
* Автоопределение шоу по имени релиза — то же, что в загрузке в шоу. Сначала пробуем имя файла,
* затем имя папки: в раздачах название сериала обычно есть и там, и там («Mr.Pickles.S01.1080p»).
*/
const detectedShowId = useMemo(
() =>
shows && sample
? (matchShowByName(sample.name, shows) ?? matchShowByName(sample.folder, shows))
: undefined,
[shows, sample],
)
useEffect(() => {
if (showPicked || showId || !detectedShowId) return
setShowId(detectedShowId)
}, [detectedShowId, showPicked, showId])
const autoDetected = !showPicked && !!detectedShowId && showId === detectedShowId
const recognized = selectable.filter(
(f) => parsedByPath.get(f.relativePath)?.episode != null,
).length
const onError = useApiError()
const importMutation = useMutation({
mutationFn: () =>
importManualInbox(
selected.map((relativePath) => {
const parsed = parsedByPath.get(relativePath)
return {
relativePath,
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
episode: parsed?.episode ?? null,
}
}),
showId,
),
onSuccess: (result) => {
if (result.imported > 0)
toast.success(t('admin.media.manualImported', { count: result.imported }))
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
for (const failure of result.failed)
toast.error(`${failure.relativePath}: ${failure.reason}`)
setSelected([])
void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (result.failed.length === 0) onClose()
},
onError,
})
const toggle = (path: string) =>
setSelected((current) =>
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
)
const toggleCollapsed = (folder: string) =>
setCollapsed((current) =>
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
)
const toggleFolder = (files: ManualInboxFileDto[]) => {
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
const allSelected = paths.every((p) => selected.includes(p))
setSelected((current) =>
allSelected
? current.filter((p) => !paths.includes(p))
: [...new Set([...current, ...paths])],
)
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.manualShow')}</Label>
<Select
value={showId}
onValueChange={(value) => {
setShowPicked(true)
setShowId(value)
}}
>
<SelectTrigger>
<SelectValue placeholder={t('admin.media.manualPickShow')} />
</SelectTrigger>
<SelectContent>
{(shows ?? []).map((show) => (
<SelectItem key={show.id} value={show.id}>
{show.name}
</SelectItem>
))}
</SelectContent>
</Select>
{autoDetected && (
<p className="text-xs text-muted-foreground">
{t('admin.media.manualDetected')}
</p>
)}
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('common.search')}</Label>
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowSeason')}</Label>
{/* Ноль — законный сезон: в нём живут спецвыпуски и пилоты. */}
<Input
type="number"
min={0}
placeholder={t('admin.media.toShowAuto')}
value={seasonStr}
onChange={(e) => setSeasonStr(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.media.toShowRegex')}</Label>
<Input
placeholder="^(\d+)"
value={regexStr}
onChange={(e) => setRegexStr(e.target.value)}
className={!regexOk ? 'border-red-500' : undefined}
/>
</div>
</div>
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
{sample && (
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">
{t('admin.media.regexPickHint')}
</span>
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
{sampleParts.map((part) =>
part.number === null ? (
<span key={part.start} className="text-muted-foreground">
{part.text}
</span>
) : (
<button
key={part.start}
type="button"
title={t('admin.media.regexPickTitle')}
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
>
{part.text}
</button>
),
)}
</div>
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">
{t('admin.media.regexPresets')}
</span>
{REGEX_PRESETS.map((preset) => (
<button
key={preset.key}
type="button"
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
onClick={() => setRegexStr(preset.pattern)}
>
{t(`admin.media.regexPresetNames.${preset.key}`)}
</button>
))}
{regexStr && (
<button
type="button"
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
onClick={() => setRegexStr('')}
>
{t('admin.media.regexClear')}
</button>
)}
</div>
</div>
)}
<div className="flex flex-wrap items-center gap-2 text-xs">
<Button
size="sm"
variant="outline"
disabled={selectable.length === 0}
onClick={() =>
setSelected(
selected.length === selectable.length
? []
: selectable.map((f) => f.relativePath),
)
}
>
{t('admin.media.manualSelectAll')}
</Button>
<span className="text-muted-foreground">
{t('admin.media.manualSelected', { count: selected.length })}
</span>
<span className="text-muted-foreground">
{t('admin.media.manualRecognized', {
count: recognized,
total: selectable.length,
})}
</span>
</div>
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
{!isLoading && folders.length === 0 && (
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
)}
{folders.map(({ folder, files }) => {
const isCollapsed = collapsed.includes(folder)
return (
<div key={folder || '/'} className="border-b border-border last:border-0">
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => toggleCollapsed(folder)}
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
<input
type="checkbox"
className="shrink-0"
checked={files
.filter((f) => !f.alreadyImported)
.every((f) => selected.includes(f.relativePath))}
onChange={() => toggleFolder(files)}
/>
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
{folder || t('admin.media.manualRoot')}
</span>
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
</div>
{!isCollapsed && (
<ul className="divide-y divide-border">
{files.map((file) => {
const label = formatSeasonEpisode(
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
)
return (
<li
key={file.relativePath}
className="flex items-center gap-2 px-3 py-1.5 pl-9"
>
<input
type="checkbox"
className="shrink-0"
disabled={file.alreadyImported}
checked={selected.includes(file.relativePath)}
onChange={() => toggle(file.relativePath)}
/>
{label ? (
<Badge className="shrink-0">{label}</Badge>
) : (
<Badge variant="muted" className="shrink-0">
{t('admin.media.toShowUnknown')}
</Badge>
)}
<span
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
title={file.name}
>
{file.name}
</span>
{file.alreadyImported && (
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
)}
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatSize(file.sizeBytes)}
</span>
</li>
)
})}
</ul>
)}
</div>
)
})}
</div>
{data?.truncated && (
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
)}
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
</div>
<DialogFooter>
<Button size="sm" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
size="sm"
disabled={selected.length === 0 || !showId || importMutation.isPending}
onClick={() => importMutation.mutate()}
>
{t('admin.media.manualImport')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}