Implement movie import functionality and enhance media processing
ci / build-backend (push) Successful in 1m34s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 1m43s
ci / sonar (push) Successful in 6m45s

Added new endpoints for matching and importing movies, allowing for automated processing of film files from the inbox. Introduced logic to parse file names into titles and years, and integrated metadata matching to streamline the import process. Updated the MediaOptions to include a configuration for automatic movie creation from recognized files. Enhanced the InboxScanner to attempt movie attachment upon asset registration, improving user experience and efficiency. Updated documentation to reflect these new features and their usage.
This commit is contained in:
Leonid Pershin
2026-07-28 03:02:30 +03:00
parent e48838b69d
commit d859a9894c
25 changed files with 2144 additions and 4 deletions
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FolderInput, ListPlus, Upload } from 'lucide-react'
import { Clapperboard, FolderInput, ListPlus, Upload } from 'lucide-react'
import { qk } from '@/shared/api/query-keys'
import type { MediaAssetDto, MediaAssetStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
@@ -15,6 +15,7 @@ import { SortHeader } from '@/shared/ui/sortable'
import { deleteMedia, getMediaStats, listMedia } from './api'
import { formatDuration, splitEta } from './format'
import { ManualInboxDialog } from './ManualInboxDialog'
import { MovieImportDialog } from './MovieImportDialog'
import { UploadToShowDialog } from './UploadToShowDialog'
import { useUploadStore } from './upload-store'
@@ -48,6 +49,7 @@ export function MediaPanel() {
const { sort, toggle } = useTableSort('created', true)
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
const [manualOpen, setManualOpen] = useState(false)
const [moviesOpen, setMoviesOpen] = useState(false)
const enqueue = useUploadStore((s) => s.enqueue)
const sortColumn = (key: string) => {
@@ -182,6 +184,10 @@ export function MediaPanel() {
e.target.value = ''
}}
/>
<Button size="sm" variant="outline" onClick={() => setMoviesOpen(true)}>
<Clapperboard className="h-4 w-4" />
{t('admin.media.moviesButton')}
</Button>
<Button size="sm" variant="outline" onClick={() => setManualOpen(true)}>
<FolderInput className="h-4 w-4" />
{t('admin.media.manualButton')}
@@ -202,6 +208,7 @@ export function MediaPanel() {
)}
{manualOpen && <ManualInboxDialog onClose={() => setManualOpen(false)} />}
{moviesOpen && <MovieImportDialog onClose={() => setMoviesOpen(false)} />}
<div className="crt-panel overflow-x-auto rounded-md">
<table className="w-full text-sm">
@@ -0,0 +1,379 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Check, HelpCircle, Search, Upload } from 'lucide-react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { getMetadataProviders } from '@/features/admin/shows/api'
import { qk } from '@/shared/api/query-keys'
import type { MovieImportItem, MovieMatchDto, MovieMatchStatus } from '@/shared/api/types'
import { useApiError } from '@/shared/lib/use-api-error'
import { Button } from '@/shared/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/shared/ui/dialog'
import { Input } from '@/shared/ui/input'
import { toast } from '@/shared/ui/toast-store'
import { importMovies, listManualInbox, matchMovie, matchMovies } from './api'
import { useUploadStore } from './upload-store'
type Source = 'manual' | 'disk'
/** Строка таблицы: разбор от сервера плюс то, что человек в ней поправил. */
type Row = MovieMatchDto & {
/** Ключ строки — имя файла или папки; по нему же строка опознаётся при импорте. */
selected: boolean
chosenExternalId: string | null
title: string
year: string
}
const STATUS_TONE: Record<MovieMatchStatus, string> = {
Confident: 'text-emerald-500',
Uncertain: 'text-amber-500',
NotFound: 'text-red-500',
AlreadyInLibrary: 'text-muted-foreground',
}
function toRow(match: MovieMatchDto): Row {
return {
...match,
// Уже заведённое и ненайденное по умолчанию не отмечаем: первое создало бы дубль,
// второе — шоу без метаданных, которого человек не просил.
selected: match.status === 'Confident' || match.status === 'Uncertain',
chosenExternalId: match.pickedExternalId,
title: match.parsedTitle,
year: match.parsedYear?.toString() ?? '',
}
}
/**
* Файлы `manual/` строками: папка релиза — это один фильм, а не набор файлов. Внутри папки берём
* самый большой видеофайл (остальное — сэмплы и трейлеры), а показываем имя папки: оно чище,
* чем «video.mkv» внутри.
*/
function manualRows(
files: { relativePath: string; folder: string; name: string; sizeBytes: number }[],
) {
const rows = new Map<string, { name: string; relativePath: string; sizeBytes: number }>()
for (const file of files) {
const key = file.folder || file.relativePath
const name = file.folder || file.name
const current = rows.get(key)
if (!current || file.sizeBytes > current.sizeBytes)
rows.set(key, { name, relativePath: file.relativePath, sizeBytes: file.sizeBytes })
}
return [...rows.values()]
}
/**
* Разбор фильмов: имя файла → название и год → кандидат источника → шоу с метаданными.
*
* Источника два и оба идут одной таблицей: файлы из `manual/` на сервере и выбранные в браузере.
* Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк
* не распозналась, пришлось бы сначала залить десятки гигабайт.
*/
export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
const { t } = useTranslation()
const onError = useApiError()
const queryClient = useQueryClient()
const [source, setSource] = useState<Source>('manual')
const [rows, setRows] = useState<Row[]>([])
const [files, setFiles] = useState<File[]>([])
const fileInput = useRef<HTMLInputElement>(null)
const enqueue = useUploadStore((s) => s.enqueue)
const { data: providers } = useQuery({
queryKey: qk.metadata.providers,
queryFn: getMetadataProviders,
})
const [provider, setProvider] = useState('')
const activeProvider = provider || providers?.[0] || ''
const { data: manual } = useQuery({
queryKey: qk.media.manual,
queryFn: listManualInbox,
enabled: source === 'manual',
})
const manualFiles = useMemo(() => manualRows(manual?.files ?? []), [manual])
/** Имя строки → чем её импортировать: путь в manual/ либо файл из браузера. */
const sources = useMemo(() => {
const map = new Map<string, { relativePath?: string; file?: File }>()
if (source === 'manual')
for (const item of manualFiles) map.set(item.name, { relativePath: item.relativePath })
else for (const file of files) map.set(file.name, { file })
return map
}, [source, manualFiles, files])
const scan = useMutation({
mutationFn: () => matchMovies([...sources.keys()], activeProvider),
onSuccess: (matches) => setRows(matches.map(toRow)),
onError,
})
const research = useMutation({
mutationFn: (row: Row) =>
matchMovie(row.name, row.title, row.year ? Number(row.year) : null, activeProvider),
onSuccess: (match) =>
setRows((current) =>
current.map((row) => (row.name === match.name ? { ...toRow(match), selected: true } : row)),
),
onError,
})
const importing = useMutation({
mutationFn: async () => {
const chosen = rows.filter((row) => row.selected)
// Файлы с диска грузятся по одному и заводятся сразу после аплоада: ждать всю пачку,
// чтобы увидеть первый фильм в библиотеке, незачем.
if (source === 'disk') {
const plans = new Map(
chosen.map((row) => [
row.name,
{
title: row.title,
year: row.year ? Number(row.year) : null,
externalId: row.chosenExternalId,
},
]),
)
await enqueue(
chosen.map((row) => sources.get(row.name)?.file).filter((f): f is File => Boolean(f)),
{ resolveMovie: (file) => plans.get(file.name), provider: activeProvider },
)
return null
}
const items: MovieImportItem[] = chosen.map((row) => ({
relativePath: sources.get(row.name)?.relativePath ?? null,
assetId: null,
title: row.title,
year: row.year ? Number(row.year) : null,
externalId: row.chosenExternalId,
}))
return importMovies(items, activeProvider)
},
onSuccess: (result) => {
void queryClient.invalidateQueries({ queryKey: qk.media.all })
void queryClient.invalidateQueries({ queryKey: qk.media.manual })
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
if (!result) {
toast.success(t('admin.movies.uploadStarted'))
onClose()
return
}
toast.success(
t('admin.movies.imported', { created: result.created, enriched: result.enriched }),
)
for (const failure of result.failed) toast.error(`${failure.title}: ${failure.reason}`)
setRows((current) => current.filter((row) => !row.selected))
},
onError,
})
const patch = (name: string, changes: Partial<Row>) =>
setRows((current) => current.map((row) => (row.name === name ? { ...row, ...changes } : row)))
const selectedCount = rows.filter((row) => row.selected).length
const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending
return (
<Dialog open onOpenChange={onClose}>
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>{t('admin.movies.title')}</DialogTitle>
<DialogDescription>{t('admin.movies.hint')}</DialogDescription>
</DialogHeader>
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="flex gap-1">
{(['manual', 'disk'] as Source[]).map((item) => (
<button
key={item}
type="button"
onClick={() => {
setSource(item)
setRows([])
}}
className={`rounded border px-2 py-1 text-xs ${
source === item
? 'border-primary text-primary'
: 'border-border text-muted-foreground'
}`}
>
{t(`admin.movies.sources.${item}`)}
</button>
))}
</div>
{source === 'disk' && (
<>
<input
ref={fileInput}
type="file"
multiple
accept="video/*,.mkv,.avi,.mp4,.m4v,.mov,.ts,.mpg,.mpeg,.wmv,.flv"
className="hidden"
onChange={(e) => {
setFiles([...(e.target.files ?? [])])
setRows([])
}}
/>
<Button size="sm" variant="outline" onClick={() => fileInput.current?.click()}>
<Upload className="h-4 w-4" />
{t('admin.movies.pickFiles')}
</Button>
</>
)}
{(providers?.length ?? 0) > 1 && (
<select
className="h-8 rounded-md border border-border bg-transparent px-2 text-sm"
value={activeProvider}
onChange={(e) => setProvider(e.target.value)}
>
{providers?.map((key) => (
<option key={key} value={key}>
{key}
</option>
))}
</select>
)}
<Button size="sm" disabled={!canScan} onClick={() => scan.mutate()}>
<Search className="h-4 w-4" />
{t('admin.movies.scan', { count: sources.size })}
</Button>
{!activeProvider && (
<span className="flex items-center gap-1.5 text-xs text-amber-500">
<AlertTriangle className="h-3.5 w-3.5" />
{t('admin.movies.noProvider')}
</span>
)}
</div>
<div className="max-h-[55vh] overflow-auto rounded border border-border">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
<tr>
<th className="w-8 p-2" />
<th className="p-2 text-left">{t('admin.movies.file')}</th>
<th className="p-2 text-left">{t('admin.movies.parsed')}</th>
<th className="p-2 text-left">{t('admin.movies.candidate')}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.name} className="border-t border-border align-top">
<td className="p-2">
<input
type="checkbox"
checked={row.selected}
onChange={(e) => patch(row.name, { selected: e.target.checked })}
/>
</td>
<td className="max-w-[18rem] truncate p-2 font-mono text-xs" title={row.name}>
{row.name}
<div
className={`mt-1 flex items-center gap-1 text-[11px] ${STATUS_TONE[row.status]}`}
>
{row.status === 'Confident' && <Check className="h-3 w-3" />}
{row.status === 'Uncertain' && <HelpCircle className="h-3 w-3" />}
{row.status === 'NotFound' && <AlertTriangle className="h-3 w-3" />}
{t(`admin.movies.statuses.${row.status}`)}
{row.existingShowName && <span>· {row.existingShowName}</span>}
</div>
</td>
<td className="p-2">
<div className="flex items-center gap-1.5">
<Input
className="h-8"
value={row.title}
onChange={(e) => patch(row.name, { title: e.target.value })}
/>
<Input
className="h-8 w-20"
placeholder={t('admin.movies.year')}
value={row.year}
onChange={(e) => patch(row.name, { year: e.target.value })}
/>
<Button
size="sm"
variant="ghost"
disabled={research.isPending || !row.title.trim()}
onClick={() => research.mutate(row)}
title={t('admin.movies.research')}
>
<Search className="h-4 w-4" />
</Button>
</div>
</td>
<td className="p-2">
{row.candidates.length === 0 ? (
<span className="text-xs text-muted-foreground">
{t('admin.movies.empty')}
</span>
) : (
<select
className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-sm"
value={row.chosenExternalId ?? ''}
onChange={(e) =>
patch(row.name, { chosenExternalId: e.target.value || null })
}
>
<option value="">{t('admin.movies.noCandidate')}</option>
{row.candidates.map((candidate) => (
<option key={candidate.externalId} value={candidate.externalId}>
{candidate.title}
{candidate.year ? ` (${candidate.year})` : ''}
</option>
))}
</select>
)}
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td colSpan={4} className="p-6 text-center text-sm text-muted-foreground">
{scan.isPending ? t('admin.movies.scanning') : t('admin.movies.nothingYet')}
</td>
</tr>
)}
</tbody>
</table>
</div>
{rows.length > 0 && (
<p className="text-xs text-muted-foreground">{t('admin.movies.collectionsHint')}</p>
)}
<DialogFooter>
<Button size="sm" variant="ghost" onClick={onClose}>
{t('common.close')}
</Button>
<Button
size="sm"
disabled={selectedCount === 0 || importing.isPending}
onClick={() => importing.mutate()}
>
{t('admin.movies.import', { count: selectedCount })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+33
View File
@@ -2,6 +2,9 @@ import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
CreatedIdResponse,
ImportManualInboxResultDto,
ImportMoviesResultDto,
MovieImportItem,
MovieMatchDto,
ManualImportItem,
ManualInboxListDto,
MediaAssetDto,
@@ -128,3 +131,33 @@ export function uploadMedia(
xhr.send(file)
})
}
// ── Разбор фильмов ────────────────────────────────────────────────────────
/**
* Разбирает имена и ищет кандидатов в источнике. Имена шлём с клиента, потому что источника два:
* каталог `manual/` и файлы, выбранные в браузере, — а разбирать их надо одинаково и **до** того,
* как что-то поедет на сервер.
*/
export function matchMovies(names: string[], provider: string) {
return apiRequest<MovieMatchDto[]>('/admin/media/movies/match', {
method: 'POST',
body: { names, provider },
})
}
/** Перезапрос одной строки: админ поправил название (транслит, другое написание). */
export function matchMovie(name: string, title: string, year: number | null, provider: string) {
return apiRequest<MovieMatchDto>('/admin/media/movies/match-one', {
method: 'POST',
body: { name, title, year, provider },
})
}
/** Заводит фильмы: шоу на файл, метаданные по выбранному кандидату, файл — в очередь обработки. */
export function importMovies(items: MovieImportItem[], provider: string) {
return apiRequest<ImportMoviesResultDto>('/admin/media/movies/import', {
method: 'POST',
body: { items, provider },
})
}
@@ -6,7 +6,7 @@ import { importInterstitials } from '@/features/admin/interstitials/api'
import { addEpisode } from '@/features/admin/shows/api'
import { toast } from '@/shared/ui/toast-store'
import type { CreatedIdResponse } from '@/shared/api/types'
import { listMedia, uploadMedia } from './api'
import { importMovies, listMedia, uploadMedia } from './api'
export type UploadItem = {
id: string
@@ -38,11 +38,28 @@ type EnqueueOptions = {
resolveShowId?: (file: File) => string | undefined
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
interstitial?: boolean
/**
* Разбор фильмов: по каждому файлу заводится своё шоу с выбранными в таблице названием и
* кандидатом источника. Возвращает undefined — файл грузится как обычно, без шоу.
*/
resolveMovie?: (file: File) => MoviePlan | undefined
/** Источник метаданных для заведения фильмов — тот, что выбран в таблице разбора. */
provider?: string
}
/** Что известно про фильм к моменту загрузки файла: строку таблицы уже подтвердили. */
export type MoviePlan = { title: string; year: number | null; externalId: string | null }
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
let counter = 0
type Job = { id: string; file: File; showId?: string; interstitial?: boolean }
type Job = {
id: string
file: File
showId?: string
interstitial?: boolean
movie?: MoviePlan
provider?: string
}
const queue: Job[] = []
const controllers = new Map<string, AbortController>()
const failed = new Map<string, Job>() // упавшие — для ручного повтора
@@ -109,6 +126,26 @@ async function linkUploaded(job: Job, assetId: string) {
} catch {
toast.error(`${job.file.name}: не удалось добавить в шоу`)
}
} else if (job.movie && job.provider) {
// Фильм заводится сразу после аплоада: строку уже подтвердили в таблице разбора, и ждать
// окончания всей пачки незачем — библиотека наполняется по мере загрузки.
try {
await importMovies(
[
{
relativePath: null,
assetId,
title: job.movie.title,
year: job.movie.year,
externalId: job.movie.externalId,
},
],
job.provider,
)
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
} catch {
toast.error(`${job.file.name}: не удалось завести фильм`)
}
} else if (job.interstitial) {
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
try {
@@ -180,7 +217,14 @@ export const useUploadStore = create<UploadStore>((set) => ({
const newItems: UploadItem[] = toAdd.map((file) => {
const id = `u${++counter}`
const showId = options?.resolveShowId?.(file) ?? options?.showId
queue.push({ id, file, showId, interstitial: options?.interstitial })
queue.push({
id,
file,
showId,
interstitial: options?.interstitial,
movie: options?.resolveMovie?.(file),
provider: options?.provider,
})
return { id, name: file.name, percent: 0, status: 'queued' }
})