Refactor manual inbox handling: update ImportManualInboxCommand to accept ManualImportItem objects, enhancing file import functionality with explicit season and episode numbers. Implement CleanupManualLeftoversAsync in IMediaStorage to remove unnecessary files after import. Update frontend components to support new import structure and improve user experience in manual media management.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Folder } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { HttpError } from '@/shared/api/client'
|
||||
import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
@@ -14,9 +16,11 @@ import {
|
||||
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'
|
||||
|
||||
/** Байты → «1,4 ГБ»: в ручном разборе размер — главный ориентир, что это за файл. */
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -31,8 +35,12 @@ function formatSize(bytes: number): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ручной inbox (см. `manual/`): каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox.
|
||||
* Ручной разбор `manual/`: каталог не сканируется, файлы выбирает админ и сразу указывает шоу.
|
||||
* Импортированные файлы уходят из каталога — ровно как из обычного inbox, — а спутники (субтитры,
|
||||
* nfo) удаляются, чтобы не оставалось мусора.
|
||||
*
|
||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||
* то и сохранится.
|
||||
*/
|
||||
export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -40,6 +48,9 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
const [showId, setShowId] = useState('')
|
||||
const [query, setQuery] = useState('')
|
||||
const [seasonStr, setSeasonStr] = useState('')
|
||||
const [regexStr, setRegexStr] = useState('')
|
||||
const [collapsed, setCollapsed] = useState<string[]>([])
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin', 'media', 'manual'],
|
||||
@@ -47,8 +58,66 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
})
|
||||
const { data: shows } = useQuery({ queryKey: ['admin', 'shows'], 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 recognized = selectable.filter(
|
||||
(f) => parsedByPath.get(f.relativePath)?.episode != null,
|
||||
).length
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: () => importManualInbox(selected, showId),
|
||||
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 }))
|
||||
@@ -65,88 +134,175 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
toast.error(error instanceof HttpError ? error.detail : t('common.error')),
|
||||
})
|
||||
|
||||
const files = (data?.files ?? []).filter((file) =>
|
||||
query.trim() ? file.relativePath.toLowerCase().includes(query.trim().toLowerCase()) : true,
|
||||
)
|
||||
const importable = files.filter((f) => f.isSupported && !f.alreadyImported)
|
||||
|
||||
const toggle = (path: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||
)
|
||||
|
||||
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-3xl">
|
||||
<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-wrap items-center gap-2">
|
||||
<Input
|
||||
className="max-w-xs"
|
||||
placeholder={t('common.search')}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<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={1}
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('admin.media.toShowHint')}
|
||||
{!regexOk && (
|
||||
<span className="ml-2 text-red-500">{t('admin.media.toShowRegexInvalid')}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={importable.length === 0}
|
||||
disabled={selectable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === importable.length
|
||||
selected.length === selectable.length
|
||||
? []
|
||||
: importable.map((f) => f.relativePath),
|
||||
: selectable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<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>
|
||||
|
||||
<ul className="crt-panel max-h-80 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||
{isLoading && (
|
||||
<li className="px-3 py-2 text-muted-foreground">{t('common.loading')}</li>
|
||||
<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>
|
||||
)}
|
||||
{!isLoading && files.length === 0 && (
|
||||
<li className="px-3 py-2 text-muted-foreground">{t('admin.media.manualEmpty')}</li>
|
||||
)}
|
||||
{files.map((file) => {
|
||||
const blocked = !file.isSupported || file.alreadyImported
|
||||
|
||||
{folders.map(({ folder, files }) => {
|
||||
const isCollapsed = collapsed.includes(folder)
|
||||
return (
|
||||
<li key={file.relativePath} className="flex items-center gap-2 px-3 py-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0"
|
||||
disabled={blocked}
|
||||
checked={selected.includes(file.relativePath)}
|
||||
onChange={() => toggle(file.relativePath)}
|
||||
/>
|
||||
<span
|
||||
className={`min-w-0 flex-1 truncate ${blocked ? 'text-muted-foreground' : ''}`}
|
||||
title={file.relativePath}
|
||||
>
|
||||
{file.relativePath}
|
||||
</span>
|
||||
{!file.isSupported && (
|
||||
<Badge variant="muted">{t('admin.media.manualUnsupported')}</Badge>
|
||||
<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={() =>
|
||||
setCollapsed((c) =>
|
||||
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, 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>
|
||||
)}
|
||||
{file.alreadyImported && (
|
||||
<Badge variant="muted">{t('admin.media.manualAlready')}</Badge>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatSize(file.sizeBytes)}
|
||||
</span>
|
||||
</li>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
@@ -166,7 +322,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.manualOrderHint')}
|
||||
{t('admin.media.manualCleanupHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user