456 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|