Enhance manual inbox dialog: add regex presets and hints for episode number extraction, improve user interface for file selection, and update translations for better user guidance. Refactor state management and query handling to streamline the import process and enhance overall user experience in manual media management.
This commit is contained in:
@@ -1,345 +1,411 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { listShows } from '@/features/admin/shows/api'
|
import { listShows } from '@/features/admin/shows/api'
|
||||||
import { HttpError } from '@/shared/api/client'
|
import { HttpError } from '@/shared/api/client'
|
||||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { Button } from '@/shared/ui/button'
|
import { Button } from '@/shared/ui/button'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/shared/ui/dialog'
|
} from '@/shared/ui/dialog'
|
||||||
import { Input } from '@/shared/ui/input'
|
import { Input } from '@/shared/ui/input'
|
||||||
import { Label } from '@/shared/ui/label'
|
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 { toast } from '@/shared/ui/toast-store'
|
import { toast } from '@/shared/ui/toast-store'
|
||||||
import { importManualInbox, listManualInbox } from './api'
|
import { importManualInbox, listManualInbox } from './api'
|
||||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||||
|
import { buildEpisodeRegex, findNumbers, REGEX_PRESETS } from './episode-regex'
|
||||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
|
||||||
function formatSize(bytes: number): string {
|
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||||
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
function formatSize(bytes: number): string {
|
||||||
let value = bytes
|
const units = ['Б', 'КБ', 'МБ', 'ГБ', 'ТБ']
|
||||||
let unit = 0
|
let value = bytes
|
||||||
while (value >= 1024 && unit < units.length - 1) {
|
let unit = 0
|
||||||
value /= 1024
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
unit++
|
value /= 1024
|
||||||
}
|
unit++
|
||||||
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
}
|
||||||
}
|
return `${value.toFixed(value >= 10 || unit === 0 ? 0 : 1)} ${units[unit]}`
|
||||||
|
}
|
||||||
/**
|
|
||||||
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
/**
|
||||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||||
* nfo) удаляются, чтобы не оставалось мусора.
|
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
||||||
*
|
* nfo) удаляются, чтобы не оставалось мусора.
|
||||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
*
|
||||||
* то и сохранится.
|
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||||
*/
|
* то и сохранится.
|
||||||
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
*/
|
||||||
const { t } = useTranslation()
|
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||||
const queryClient = useQueryClient()
|
const { t } = useTranslation()
|
||||||
const [selected, setSelected] = useState<string[]>([])
|
const queryClient = useQueryClient()
|
||||||
const [showId, setShowId] = useState('')
|
const [selected, setSelected] = useState<string[]>([])
|
||||||
const [query, setQuery] = useState('')
|
const [showId, setShowId] = useState('')
|
||||||
const [seasonStr, setSeasonStr] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [regexStr, setRegexStr] = useState('')
|
const [seasonStr, setSeasonStr] = useState('')
|
||||||
const [collapsed, setCollapsed] = useState<string[]>([])
|
const [regexStr, setRegexStr] = useState('')
|
||||||
|
const [collapsed, setCollapsed] = useState<string[]>([])
|
||||||
const { data, isLoading } = useQuery({
|
|
||||||
queryKey: ['admin', 'media', 'manual'],
|
const { data, isLoading } = useQuery({
|
||||||
queryFn: listManualInbox,
|
queryKey: ['admin', 'media', 'manual'],
|
||||||
})
|
queryFn: listManualInbox,
|
||||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
})
|
||||||
|
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], queryFn: () => listShows() })
|
||||||
const regexOk = isValidRegex(regexStr)
|
|
||||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
const regexOk = isValidRegex(regexStr)
|
||||||
|
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||||
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
|
||||||
const parsedByPath = useMemo(() => {
|
// Распознанные номера считаются один раз на всё: их показывает список и их же уходит на сервер.
|
||||||
const options = {
|
const parsedByPath = useMemo(() => {
|
||||||
seasonOverride:
|
const options = {
|
||||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
seasonOverride:
|
||||||
episodeRegex: regexOk ? regexStr : null,
|
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))
|
const map = new Map<string, ReturnType<typeof parseEpisodeName>>()
|
||||||
return map
|
for (const file of data?.files ?? []) map.set(file.relativePath, parseEpisodeName(file.name, options))
|
||||||
}, [data, seasonOverride, regexStr, regexOk])
|
return map
|
||||||
|
}, [data, seasonOverride, regexStr, regexOk])
|
||||||
const folders = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase()
|
const folders = useMemo(() => {
|
||||||
const matched = (data?.files ?? []).filter((file) =>
|
const q = query.trim().toLowerCase()
|
||||||
q ? file.relativePath.toLowerCase().includes(q) : true,
|
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 grouped = new Map<string, ManualInboxFileDto[]>()
|
||||||
const list = grouped.get(file.folder) ?? []
|
for (const file of matched) {
|
||||||
list.push(file)
|
const list = grouped.get(file.folder) ?? []
|
||||||
grouped.set(file.folder, list)
|
list.push(file)
|
||||||
}
|
grouped.set(file.folder, list)
|
||||||
|
}
|
||||||
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
|
||||||
return [...grouped.entries()]
|
// Внутри папки — в порядке серий: так сразу видно пропуски и что регулярка сработала.
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
return [...grouped.entries()]
|
||||||
.map(([folder, files]) => ({
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
folder,
|
.map(([folder, files]) => ({
|
||||||
files: [...files].sort((a, b) =>
|
folder,
|
||||||
compareParsed(
|
files: [...files].sort((a, b) =>
|
||||||
{ name: a.name, parsed: parsedByPath.get(a.relativePath) ?? { season: null, episode: null } },
|
compareParsed(
|
||||||
{ name: b.name, parsed: parsedByPath.get(b.relativePath) ?? { season: null, episode: null } },
|
{ 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])
|
}))
|
||||||
|
}, [data, query, parsedByPath])
|
||||||
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
|
||||||
const recognized = selectable.filter(
|
const selectable = folders.flatMap((g) => g.files.filter((f) => !f.alreadyImported))
|
||||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
|
||||||
).length
|
// Образец для конструктора — первый файл списка: по нему и указывают, где номер серии.
|
||||||
|
const sample = selectable[0] ?? folders[0]?.files[0]
|
||||||
const importMutation = useMutation({
|
const sampleParts = useMemo(() => {
|
||||||
mutationFn: () =>
|
if (!sample) return []
|
||||||
importManualInbox(
|
const numbers = findNumbers(sample.name)
|
||||||
selected.map((relativePath) => {
|
const parts: { text: string; number: number | null }[] = []
|
||||||
const parsed = parsedByPath.get(relativePath)
|
let cursor = 0
|
||||||
return {
|
for (const number of numbers) {
|
||||||
relativePath,
|
if (number.start > cursor)
|
||||||
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
parts.push({ text: sample.name.slice(cursor, number.start), number: null })
|
||||||
episode: parsed?.episode ?? null,
|
parts.push({ text: number.text, number: number.index })
|
||||||
}
|
cursor = number.start + number.text.length
|
||||||
}),
|
}
|
||||||
showId,
|
if (cursor < sample.name.length)
|
||||||
),
|
parts.push({ text: sample.name.slice(cursor), number: null })
|
||||||
onSuccess: (result) => {
|
return parts
|
||||||
if (result.imported > 0)
|
}, [sample])
|
||||||
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
const recognized = selectable.filter(
|
||||||
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||||
for (const failure of result.failed)
|
).length
|
||||||
toast.error(`${failure.relativePath}: ${failure.reason}`)
|
|
||||||
|
const importMutation = useMutation({
|
||||||
setSelected([])
|
mutationFn: () =>
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
importManualInbox(
|
||||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
selected.map((relativePath) => {
|
||||||
if (result.failed.length === 0) onClose()
|
const parsed = parsedByPath.get(relativePath)
|
||||||
},
|
return {
|
||||||
onError: (error: unknown) =>
|
relativePath,
|
||||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
season: parsed?.episode != null ? (parsed.season ?? 1) : null,
|
||||||
})
|
episode: parsed?.episode ?? null,
|
||||||
|
}
|
||||||
const toggle = (path: string) =>
|
}),
|
||||||
setSelected((current) =>
|
showId,
|
||||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
),
|
||||||
)
|
onSuccess: (result) => {
|
||||||
|
if (result.imported > 0)
|
||||||
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
toast.success(t('admin.media.manualImported', { count: result.imported }))
|
||||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
// Отказы показываем по одному: у каждого своя причина, и файл остаётся в каталоге.
|
||||||
const allSelected = paths.every((p) => selected.includes(p))
|
for (const failure of result.failed)
|
||||||
setSelected((current) =>
|
toast.error(`${failure.relativePath}: ${failure.reason}`)
|
||||||
allSelected
|
|
||||||
? current.filter((p) => !paths.includes(p))
|
setSelected([])
|
||||||
: [...new Set([...current, ...paths])],
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'media'] })
|
||||||
)
|
void queryClient.invalidateQueries({ queryKey: ['admin', 'shows'] })
|
||||||
}
|
if (result.failed.length === 0) onClose()
|
||||||
|
},
|
||||||
return (
|
onError: (error: unknown) =>
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||||
<DialogContent className="max-w-4xl">
|
})
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
const toggle = (path: string) =>
|
||||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
setSelected((current) =>
|
||||||
</DialogHeader>
|
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||||
|
)
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
||||||
<div className="flex flex-col gap-1.5">
|
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
||||||
<Label>{t('common.search')}</Label>
|
const allSelected = paths.every((p) => selected.includes(p))
|
||||||
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
setSelected((current) =>
|
||||||
</div>
|
allSelected
|
||||||
<div className="flex flex-col gap-1.5">
|
? current.filter((p) => !paths.includes(p))
|
||||||
<Label>{t('admin.media.toShowSeason')}</Label>
|
: [...new Set([...current, ...paths])],
|
||||||
<Input
|
)
|
||||||
type="number"
|
}
|
||||||
min={1}
|
|
||||||
placeholder={t('admin.media.toShowAuto')}
|
return (
|
||||||
value={seasonStr}
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
onChange={(e) => setSeasonStr(e.target.value)}
|
<DialogContent className="max-w-4xl">
|
||||||
/>
|
<DialogHeader>
|
||||||
</div>
|
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||||
<div className="flex flex-col gap-1.5">
|
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||||
<Label>{t('admin.media.toShowRegex')}</Label>
|
</DialogHeader>
|
||||||
<Input
|
|
||||||
placeholder="^(\d+)"
|
<div className="flex flex-col gap-3">
|
||||||
value={regexStr}
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
onChange={(e) => setRegexStr(e.target.value)}
|
<div className="flex flex-col gap-1.5">
|
||||||
className={!regexOk ? 'border-red-500' : undefined}
|
<Label>{t('common.search')}</Label>
|
||||||
/>
|
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex flex-col gap-1.5">
|
||||||
<p className="text-xs text-muted-foreground">
|
<Label>{t('admin.media.toShowSeason')}</Label>
|
||||||
{t('admin.media.toShowHint')}
|
<Input
|
||||||
{!regexOk && (
|
type="number"
|
||||||
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
min={1}
|
||||||
)}
|
placeholder={t('admin.media.toShowAuto')}
|
||||||
</p>
|
value={seasonStr}
|
||||||
|
onChange={(e) => setSeasonStr(e.target.value)}
|
||||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
/>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<div className="flex flex-col gap-1.5">
|
||||||
variant="outline"
|
<Label>{t('admin.media.toShowRegex')}</Label>
|
||||||
disabled={selectable.length === 0}
|
<Input
|
||||||
onClick={() =>
|
placeholder="^(\d+)"
|
||||||
setSelected(
|
value={regexStr}
|
||||||
selected.length === selectable.length
|
onChange={(e) => setRegexStr(e.target.value)}
|
||||||
? []
|
className={!regexOk ? 'border-red-500' : undefined}
|
||||||
: selectable.map((f) => f.relativePath),
|
/>
|
||||||
)
|
</div>
|
||||||
}
|
</div>
|
||||||
>
|
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||||
{t('admin.media.manualSelectAll')}
|
|
||||||
</Button>
|
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||||
<span className="text-muted-foreground">
|
{sample && (
|
||||||
{t('admin.media.manualSelected', { count: selected.length })}
|
<div className="flex flex-col gap-1.5">
|
||||||
</span>
|
<span className="text-xs text-muted-foreground">
|
||||||
<span className="text-muted-foreground">
|
{t('admin.media.regexPickHint')}
|
||||||
{t('admin.media.manualRecognized', {
|
</span>
|
||||||
count: recognized,
|
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||||
total: selectable.length,
|
{sampleParts.map((part, index) =>
|
||||||
})}
|
part.number === null ? (
|
||||||
</span>
|
<span key={index} className="text-muted-foreground">
|
||||||
</div>
|
{part.text}
|
||||||
|
</span>
|
||||||
<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>}
|
<button
|
||||||
{!isLoading && folders.length === 0 && (
|
key={index}
|
||||||
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
type="button"
|
||||||
)}
|
title={t('admin.media.regexPickTitle')}
|
||||||
|
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||||
{folders.map(({ folder, files }) => {
|
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||||
const isCollapsed = collapsed.includes(folder)
|
>
|
||||||
return (
|
{part.text}
|
||||||
<div key={folder || '/'} className="border-b border-border last:border-0">
|
</button>
|
||||||
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
),
|
||||||
<button
|
)}
|
||||||
type="button"
|
</div>
|
||||||
className="text-muted-foreground hover:text-foreground"
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
onClick={() =>
|
<span className="text-xs text-muted-foreground">
|
||||||
setCollapsed((c) =>
|
{t('admin.media.regexPresets')}
|
||||||
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
|
</span>
|
||||||
)
|
{REGEX_PRESETS.map((preset) => (
|
||||||
}
|
<button
|
||||||
>
|
key={preset.key}
|
||||||
{isCollapsed ? (
|
type="button"
|
||||||
<ChevronRight className="h-4 w-4" />
|
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)}
|
||||||
<ChevronDown className="h-4 w-4" />
|
>
|
||||||
)}
|
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||||
</button>
|
</button>
|
||||||
<input
|
))}
|
||||||
type="checkbox"
|
{regexStr && (
|
||||||
className="shrink-0"
|
<button
|
||||||
checked={files
|
type="button"
|
||||||
.filter((f) => !f.alreadyImported)
|
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||||
.every((f) => selected.includes(f.relativePath))}
|
onClick={() => setRegexStr('')}
|
||||||
onChange={() => toggleFolder(files)}
|
>
|
||||||
/>
|
{t('admin.media.regexClear')}
|
||||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
</button>
|
||||||
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
)}
|
||||||
{folder || t('admin.media.manualRoot')}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
)}
|
||||||
</div>
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||||
{!isCollapsed && (
|
<Button
|
||||||
<ul className="divide-y divide-border">
|
size="sm"
|
||||||
{files.map((file) => {
|
variant="outline"
|
||||||
const label = formatSeasonEpisode(
|
disabled={selectable.length === 0}
|
||||||
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
onClick={() =>
|
||||||
)
|
setSelected(
|
||||||
return (
|
selected.length === selectable.length
|
||||||
<li
|
? []
|
||||||
key={file.relativePath}
|
: selectable.map((f) => f.relativePath),
|
||||||
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
)
|
||||||
>
|
}
|
||||||
<input
|
>
|
||||||
type="checkbox"
|
{t('admin.media.manualSelectAll')}
|
||||||
className="shrink-0"
|
</Button>
|
||||||
disabled={file.alreadyImported}
|
<span className="text-muted-foreground">
|
||||||
checked={selected.includes(file.relativePath)}
|
{t('admin.media.manualSelected', { count: selected.length })}
|
||||||
onChange={() => toggle(file.relativePath)}
|
</span>
|
||||||
/>
|
<span className="text-muted-foreground">
|
||||||
{label ? (
|
{t('admin.media.manualRecognized', {
|
||||||
<Badge className="shrink-0">{label}</Badge>
|
count: recognized,
|
||||||
) : (
|
total: selectable.length,
|
||||||
<Badge variant="muted" className="shrink-0">
|
})}
|
||||||
{t('admin.media.toShowUnknown')}
|
</span>
|
||||||
</Badge>
|
</div>
|
||||||
)}
|
|
||||||
<span
|
<div className="crt-panel max-h-80 overflow-y-auto rounded-md text-sm">
|
||||||
className={`min-w-0 flex-1 truncate ${file.alreadyImported ? 'text-muted-foreground' : ''}`}
|
{isLoading && <p className="px-3 py-2 text-muted-foreground">{t('common.loading')}</p>}
|
||||||
title={file.name}
|
{!isLoading && folders.length === 0 && (
|
||||||
>
|
<p className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</p>
|
||||||
{file.name}
|
)}
|
||||||
</span>
|
|
||||||
{file.alreadyImported && (
|
{folders.map(({ folder, files }) => {
|
||||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
const isCollapsed = collapsed.includes(folder)
|
||||||
)}
|
return (
|
||||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
<div key={folder || '/'} className="border-b border-border last:border-0">
|
||||||
{formatSize(file.sizeBytes)}
|
<div className="flex items-center gap-2 bg-muted/30 px-3 py-1.5">
|
||||||
</span>
|
<button
|
||||||
</li>
|
type="button"
|
||||||
)
|
className="text-muted-foreground hover:text-foreground"
|
||||||
})}
|
onClick={() =>
|
||||||
</ul>
|
setCollapsed((c) =>
|
||||||
)}
|
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
})}
|
>
|
||||||
</div>
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
{data?.truncated && (
|
) : (
|
||||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
<ChevronDown className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
</button>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<input
|
||||||
<Select value={showId} onValueChange={setShowId}>
|
type="checkbox"
|
||||||
<SelectTrigger className="w-72">
|
className="shrink-0"
|
||||||
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
checked={files
|
||||||
</SelectTrigger>
|
.filter((f) => !f.alreadyImported)
|
||||||
<SelectContent>
|
.every((f) => selected.includes(f.relativePath))}
|
||||||
{(shows ?? []).map((show) => (
|
onChange={() => toggleFolder(files)}
|
||||||
<SelectItem key={show.id} value={show.id}>
|
/>
|
||||||
{show.name}
|
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
</SelectItem>
|
<span className="min-w-0 flex-1 truncate font-medium" title={folder}>
|
||||||
))}
|
{folder || t('admin.media.manualRoot')}
|
||||||
</SelectContent>
|
</span>
|
||||||
</Select>
|
<span className="shrink-0 text-xs text-muted-foreground">{files.length}</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
</div>
|
||||||
{t('admin.media.manualCleanupHint')}
|
|
||||||
</span>
|
{!isCollapsed && (
|
||||||
</div>
|
<ul className="divide-y divide-border">
|
||||||
</div>
|
{files.map((file) => {
|
||||||
|
const label = formatSeasonEpisode(
|
||||||
<DialogFooter>
|
parsedByPath.get(file.relativePath) ?? { season: null, episode: null },
|
||||||
<Button size="sm" variant="outline" onClick={onClose}>
|
)
|
||||||
{t('common.cancel')}
|
return (
|
||||||
</Button>
|
<li
|
||||||
<Button
|
key={file.relativePath}
|
||||||
size="sm"
|
className="flex items-center gap-2 px-3 py-1.5 pl-9"
|
||||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
>
|
||||||
onClick={() => importMutation.mutate()}
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
{t('admin.media.manualImport')}
|
className="shrink-0"
|
||||||
</Button>
|
disabled={file.alreadyImported}
|
||||||
</DialogFooter>
|
checked={selected.includes(file.relativePath)}
|
||||||
</DialogContent>
|
onChange={() => toggle(file.relativePath)}
|
||||||
</Dialog>
|
/>
|
||||||
)
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Select value={showId} onValueChange={setShowId}>
|
||||||
|
<SelectTrigger className="w-72">
|
||||||
|
<SelectValue placeholder={t('admin.media.manualPickShow')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(shows ?? []).map((show) => (
|
||||||
|
<SelectItem key={show.id} value={show.id}>
|
||||||
|
{show.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.media.manualCleanupHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Готовые шаблоны для частых раскладок имён. Подпись переводится в UI по ключу. */
|
||||||
|
export const REGEX_PRESETS: { key: string; pattern: string }[] = [
|
||||||
|
{ key: 'seriesWord', pattern: '[Сс]ерия\\s*(\\d{1,3})' },
|
||||||
|
{ key: 'episodeWord', pattern: '[Ээ]пизод\\s*(\\d{1,3})' },
|
||||||
|
{ key: 'seasonEpisode', pattern: '[Ss](\\d{1,2})[Ee](\\d{1,3})' },
|
||||||
|
{ key: 'afterDash', pattern: '[-–—]\\s*(\\d{1,3})' },
|
||||||
|
{ key: 'firstNumber', pattern: '(?:^|\\D)(\\d{1,3})(?:\\D|$)' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Числа в имени файла: позиция и текст — по ним строится кликабельный образец. */
|
||||||
|
export function findNumbers(fileName: string): { index: number; start: number; text: string }[] {
|
||||||
|
return [...fileName.matchAll(/\d+/g)].map((match, index) => ({
|
||||||
|
index,
|
||||||
|
start: match.index ?? 0,
|
||||||
|
text: match[0],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\/-]/g, '\\$&')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Строит regex по указанному пользователем числу в имени файла. Якорем берётся слово перед числом
|
||||||
|
* («Серия 01» → `Серия\s*(\d{1,3})`): позиция числа в разных файлах гуляет, а слово рядом — нет.
|
||||||
|
* Если перед числом только разделители — якорем становятся они, а число в начале имени крепится к `^`.
|
||||||
|
*/
|
||||||
|
export function buildEpisodeRegex(fileName: string, occurrenceIndex: number): string {
|
||||||
|
const numbers = findNumbers(fileName)
|
||||||
|
const target = numbers[occurrenceIndex]
|
||||||
|
if (!target) return ''
|
||||||
|
|
||||||
|
// Всегда до трёх цифр — как во встроенных шаблонах: правило строится по одному файлу,
|
||||||
|
// а применяется ко всей папке, где рядом может лежать и «Серия 100».
|
||||||
|
const digits = '(\\d{1,3})'
|
||||||
|
const before = fileName.slice(0, target.start)
|
||||||
|
if (!before.trim()) return `^\\s*${digits}`
|
||||||
|
|
||||||
|
// Разделители между якорем и числом описываем классом, а не буквально: в соседних файлах
|
||||||
|
// там встречается то пробел, то точка, то подчёркивание.
|
||||||
|
const gap = /[^\p{L}\p{N}]*$/u.exec(before)?.[0] ?? ''
|
||||||
|
const anchorSource = before.slice(0, before.length - gap.length)
|
||||||
|
// Якорь — только буквы: захвати он цифры, «S01E07» дало бы правило `S01E(\d)`, прибитое
|
||||||
|
// к первому сезону, и на «S02E05» оно бы уже не сработало.
|
||||||
|
const anchor = /\p{L}+$/u.exec(anchorSource)?.[0]
|
||||||
|
|
||||||
|
if (anchor) return `${escapeRegex(anchor)}${gap ? '[\\s._-]*' : ''}${digits}`
|
||||||
|
|
||||||
|
// Слова перед числом нет — цепляемся за последний разделитель («- 05», «(05)»).
|
||||||
|
const punctuation = gap.trim().slice(-1)
|
||||||
|
return punctuation ? `${escapeRegex(punctuation)}\\s*${digits}` : `\\s${digits}`
|
||||||
|
}
|
||||||
@@ -208,6 +208,17 @@ const resources = {
|
|||||||
manualSelectAll: 'Выбрать все',
|
manualSelectAll: 'Выбрать все',
|
||||||
manualSelected: 'Выбрано: {{count}}',
|
manualSelected: 'Выбрано: {{count}}',
|
||||||
manualEmpty: 'В папке manual пусто',
|
manualEmpty: 'В папке manual пусто',
|
||||||
|
regexPickHint: 'Кликните число в имени файла — по нему соберётся правило для всех файлов:',
|
||||||
|
regexPickTitle: 'Это номер серии',
|
||||||
|
regexPresets: 'Готовые:',
|
||||||
|
regexPresetNames: {
|
||||||
|
seriesWord: 'Серия N',
|
||||||
|
episodeWord: 'Эпизод N',
|
||||||
|
seasonEpisode: 'SxxEyy',
|
||||||
|
afterDash: 'после тире',
|
||||||
|
firstNumber: 'первое число',
|
||||||
|
},
|
||||||
|
regexClear: 'сбросить',
|
||||||
manualAlready: 'уже в библиотеке',
|
manualAlready: 'уже в библиотеке',
|
||||||
manualRoot: 'корень manual/',
|
manualRoot: 'корень manual/',
|
||||||
manualRecognized: 'Распознано: {{count}} из {{total}}',
|
manualRecognized: 'Распознано: {{count}} из {{total}}',
|
||||||
@@ -887,6 +898,17 @@ const resources = {
|
|||||||
manualSelectAll: 'Select all',
|
manualSelectAll: 'Select all',
|
||||||
manualSelected: 'Selected: {{count}}',
|
manualSelected: 'Selected: {{count}}',
|
||||||
manualEmpty: 'The manual folder is empty',
|
manualEmpty: 'The manual folder is empty',
|
||||||
|
regexPickHint: 'Click a number in the file name — a rule for all files is built from it:',
|
||||||
|
regexPickTitle: 'This is the episode number',
|
||||||
|
regexPresets: 'Ready-made:',
|
||||||
|
regexPresetNames: {
|
||||||
|
seriesWord: 'Серия N',
|
||||||
|
episodeWord: 'Эпизод N',
|
||||||
|
seasonEpisode: 'SxxEyy',
|
||||||
|
afterDash: 'after a dash',
|
||||||
|
firstNumber: 'first number',
|
||||||
|
},
|
||||||
|
regexClear: 'clear',
|
||||||
manualAlready: 'already in the library',
|
manualAlready: 'already in the library',
|
||||||
manualRoot: 'manual/ root',
|
manualRoot: 'manual/ root',
|
||||||
manualRecognized: 'Recognized: {{count}} of {{total}}',
|
manualRecognized: 'Recognized: {{count}} of {{total}}',
|
||||||
|
|||||||
Reference in New Issue
Block a user