Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах. Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки, ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера. Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check` для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и `prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет вывод и красит CI на файлах, которых никто не трогал. `.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с настройками csharpier и Prettier намеренно — оба его читают. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0442056367
commit
0606ea3e6e
@@ -1,455 +1,461 @@
|
||||
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 }: Readonly<{ 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>
|
||||
)
|
||||
}
|
||||
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 }: Readonly<{ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AlertCircle, Check, ChevronDown, ChevronUp, Clock, Loader2, RotateCw, X } from 'lucide-react'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/shared/lib/cn'
|
||||
import { type UploadItem, useUploadStore } from './upload-store'
|
||||
|
||||
|
||||
@@ -57,7 +57,8 @@ export function UploadToShowDialog({
|
||||
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||
const previews = useMemo(() => {
|
||||
const opts = {
|
||||
seasonOverride: seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
return files
|
||||
@@ -157,7 +158,9 @@ export function UploadToShowDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={LIBRARY_VALUE}>{t('admin.media.toShowLibrary')}</SelectItem>
|
||||
<SelectItem value={LIBRARY_VALUE}>
|
||||
{t('admin.media.toShowLibrary')}
|
||||
</SelectItem>
|
||||
{shows?.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
|
||||
@@ -23,7 +23,10 @@ const MIN_CANDIDATE_LENGTH = 2
|
||||
* Возвращает id наиболее подходящего шоу для имени файла или undefined, если совпадений нет.
|
||||
* Совпадением считается вхождение названия шоу как цельной последовательности слов в имя файла.
|
||||
*/
|
||||
export function matchShowByName(fileName: string, shows: readonly ShowNameRef[]): string | undefined {
|
||||
export function matchShowByName(
|
||||
fileName: string,
|
||||
shows: readonly ShowNameRef[],
|
||||
): string | undefined {
|
||||
const haystack = ` ${normalize(fileName)} `
|
||||
let best: { id: string; length: number } | undefined
|
||||
|
||||
|
||||
Reference in New Issue
Block a user