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>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
ImportManualInboxResultDto,
|
||||
ManualImportItem,
|
||||
ManualInboxListDto,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
@@ -66,11 +67,14 @@ export function listManualInbox() {
|
||||
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||
}
|
||||
|
||||
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
|
||||
export function importManualInbox(relativePaths: string[], showId: string) {
|
||||
/**
|
||||
* Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox, а спутники
|
||||
* (субтитры, nfo) удаляются. Номера серий передаются явно — сохранится ровно то, что было показано.
|
||||
*/
|
||||
export function importManualInbox(items: ManualImportItem[], showId: string) {
|
||||
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||
method: 'POST',
|
||||
body: { relativePaths, showId },
|
||||
body: { items, showId },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,20 @@ export type MediaSource = 'Upload' | 'Inbox' | 'ManualInbox' | 'Generated'
|
||||
/** Файл ручного inbox: лежит в manual/ и ждёт, пока его разложат по шоу. */
|
||||
export type ManualInboxFileDto = {
|
||||
relativePath: string
|
||||
/** Каталог внутри manual/ («» — корень): по нему список группируется. */
|
||||
folder: string
|
||||
name: string
|
||||
sizeBytes: number
|
||||
isSupported: boolean
|
||||
alreadyImported: boolean
|
||||
}
|
||||
|
||||
/** Файл к разбору: номера серии уходят такими, какими их показал предпросмотр. */
|
||||
export type ManualImportItem = {
|
||||
relativePath: string
|
||||
season: number | null
|
||||
episode: number | null
|
||||
}
|
||||
|
||||
export type ManualInboxListDto = {
|
||||
files: ManualInboxFileDto[]
|
||||
/** Выдача обрезана лимитом — в каталоге есть ещё. */
|
||||
|
||||
@@ -208,11 +208,13 @@ const resources = {
|
||||
manualSelectAll: 'Выбрать все',
|
||||
manualSelected: 'Выбрано: {{count}}',
|
||||
manualEmpty: 'В папке manual пусто',
|
||||
manualUnsupported: 'формат не поддерживается',
|
||||
manualAlready: 'уже в библиотеке',
|
||||
manualRoot: 'корень manual/',
|
||||
manualRecognized: 'Распознано: {{count}} из {{total}}',
|
||||
manualCleanupHint:
|
||||
'Файлы уйдут из папки, спутники (субтитры, nfo) и опустевший каталог будут удалены.',
|
||||
manualTruncated: 'Показаны первые 500 файлов — в папке есть ещё.',
|
||||
manualPickShow: 'Выберите шоу',
|
||||
manualOrderHint: 'Номера сезона и серии определяются по именам файлов.',
|
||||
manualImport: 'Забрать в шоу',
|
||||
manualImported: 'Импортировано файлов: {{count}}',
|
||||
uploadToShow: 'Загрузить в шоу',
|
||||
@@ -317,7 +319,8 @@ const resources = {
|
||||
number: 'Номер',
|
||||
numberPlaceholder: 'не задан',
|
||||
utcOffset: 'Часовой пояс, ч',
|
||||
utcOffsetHint: 'Смещение от UTC. 3 — московское время; сетка задаётся в нём.',
|
||||
utcOffsetHint:
|
||||
'Целыми часами: 3 — Москва, 0 — UTC, −5 — Нью-Йорк. В этом времени задаётся вся сетка и показывается расписание.',
|
||||
dayStart: 'Начало вещательных суток',
|
||||
dayStartHint: 'Ночной блок до этого времени относится к предыдущему дню.',
|
||||
disabled: 'выключен',
|
||||
@@ -884,11 +887,13 @@ const resources = {
|
||||
manualSelectAll: 'Select all',
|
||||
manualSelected: 'Selected: {{count}}',
|
||||
manualEmpty: 'The manual folder is empty',
|
||||
manualUnsupported: 'unsupported format',
|
||||
manualAlready: 'already in the library',
|
||||
manualRoot: 'manual/ root',
|
||||
manualRecognized: 'Recognized: {{count}} of {{total}}',
|
||||
manualCleanupHint:
|
||||
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
|
||||
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
|
||||
manualPickShow: 'Pick a show',
|
||||
manualOrderHint: 'Season and episode numbers are taken from the file names.',
|
||||
manualImport: 'Import into show',
|
||||
manualImported: 'Files imported: {{count}}',
|
||||
uploadToShow: 'Upload to show',
|
||||
@@ -993,7 +998,8 @@ const resources = {
|
||||
number: 'Number',
|
||||
numberPlaceholder: 'not set',
|
||||
utcOffset: 'Time zone, h',
|
||||
utcOffsetHint: 'Offset from UTC. 3 is Moscow time; the grid is defined in it.',
|
||||
utcOffsetHint:
|
||||
'Whole hours: 3 is Moscow, 0 is UTC, −5 is New York. The whole grid and the schedule are expressed in this time.',
|
||||
dayStart: 'Broadcast day starts',
|
||||
dayStartHint: 'The night block before this time belongs to the previous day.',
|
||||
disabled: 'disabled',
|
||||
|
||||
Reference in New Issue
Block a user