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' }
})
+38
View File
@@ -81,6 +81,44 @@ export type ManualImportItem = {
episode: number | null
}
/** Кандидат из источника метаданных — то же, что показывает поиск на карточке шоу. */
export type MetadataCandidateDto = {
externalId: string
title: string
year: number | null
overview: string | null
posterUrl: string | null
}
export type MovieMatchStatus = 'Confident' | 'Uncertain' | 'NotFound' | 'AlreadyInLibrary'
/** Строка разбора фильмов: что распозналось в имени и что по нему нашлось. */
export type MovieMatchDto = {
name: string
parsedTitle: string
parsedYear: number | null
status: MovieMatchStatus
candidates: MetadataCandidateDto[]
pickedExternalId: string | null
existingShowId: string | null
existingShowName: string | null
}
/** Фильм к заведению: файл берётся из manual/ (relativePath) либо уже загружен (assetId). */
export type MovieImportItem = {
relativePath: string | null
assetId: string | null
title: string
year: number | null
externalId: string | null
}
export type ImportMoviesResultDto = {
created: number
enriched: number
failed: { title: string; reason: string }[]
}
export type ManualInboxListDto = {
files: ManualInboxFileDto[]
/** Выдача обрезана лимитом — в каталоге есть ещё. */
+29
View File
@@ -245,10 +245,39 @@ export const en = {
create: 'Create',
created: 'User created',
},
movies: {
title: 'Movie import',
hint: 'The file name is parsed into a title and a year, and the movie is looked up in the metadata source. Every file becomes its own show; fix the rows where the match is not exact.',
sources: { manual: 'From manual folder', disk: 'From my disk' },
pickFiles: 'Pick files',
scan: 'Parse ({{count}})',
scanning: 'Searching the source…',
nothingYet: 'Pick a source and press "Parse".',
noProvider: 'No metadata source configured — there is nothing to search with.',
file: 'File',
parsed: 'Title and year',
candidate: 'Candidate',
year: 'Year',
research: 'Search again',
empty: 'nothing found',
noCandidate: 'no metadata',
import: 'Create ({{count}})',
imported: 'Movies created: {{created}}, with metadata: {{enriched}}',
uploadStarted: 'Files are uploading — movies appear as they finish',
collectionsHint:
'Franchises turn into collections after the import — the suggestions wait on the Collections tab.',
statuses: {
Confident: 'exact match',
Uncertain: 'check this',
NotFound: 'not found — type the title',
AlreadyInLibrary: 'already in the library',
},
},
media: {
title: 'Media',
upload: 'Upload',
manualButton: 'From manual folder',
moviesButton: 'Movies',
manualTitle: 'Manual pick from the manual folder',
manualHint:
'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.',
+29
View File
@@ -246,10 +246,39 @@ export const ru = {
create: 'Создать',
created: 'Пользователь создан',
},
movies: {
title: 'Разбор фильмов',
hint: 'Имя файла разбирается на название и год, по ним ищется фильм в источнике метаданных. Каждый файл становится отдельным шоу; строки, где совпадение неточное, поправьте руками.',
sources: { manual: 'Из папки manual', disk: 'С моего диска' },
pickFiles: 'Выбрать файлы',
scan: 'Разобрать ({{count}})',
scanning: 'Ищем в источнике…',
nothingYet: 'Выберите источник и нажмите «Разобрать».',
noProvider: 'Источник метаданных не настроен — без него искать нечем.',
file: 'Файл',
parsed: 'Название и год',
candidate: 'Кандидат',
year: 'Год',
research: 'Искать заново',
empty: 'ничего не нашлось',
noCandidate: 'без метаданных',
import: 'Завести ({{count}})',
imported: 'Заведено фильмов: {{created}}, с метаданными: {{enriched}}',
uploadStarted: 'Файлы пошли на загрузку — фильмы заведутся по мере готовности',
collectionsHint:
'Франшизы после импорта соберутся в коллекции — предложения ждут на вкладке «Коллекции».',
statuses: {
Confident: 'совпадение точное',
Uncertain: 'проверьте',
NotFound: 'не найдено — впишите название',
AlreadyInLibrary: 'уже в библиотеке',
},
},
media: {
title: 'Медиа',
upload: 'Загрузить',
manualButton: 'Из папки manual',
moviesButton: 'Фильмы',
manualTitle: 'Ручной разбор папки manual',
manualHint:
'Файлы из manual/ не разбираются сканером — выберите нужные и укажите шоу. Импортированные файлы уходят из папки, как и из inbox.',