Refactored the movie import functionality to ensure that the title from the source is used for the show name, while the file name is retained as the original title. Improved the handling of metadata during the import process, allowing for better integration of original titles from various sources. Updated related classes and methods to streamline the import workflow and enhance user experience. Added tests to verify the correct assignment of titles and original names during the import process. Updated documentation to reflect these changes.
383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
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 { DialogFooter } 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 MovieImportPanel({ 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 allSelected = rows.length > 0 && selectedCount === rows.length
|
|
const canScan = sources.size > 0 && Boolean(activeProvider) && !scan.isPending
|
|
|
|
const toggleAll = (selected: boolean) =>
|
|
setRows((current) => current.map((row) => ({ ...row, selected })))
|
|
|
|
return (
|
|
<>
|
|
<p className="text-sm text-muted-foreground">{t('admin.movies.hint')}</p>
|
|
|
|
<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-[60vh] overflow-auto rounded border border-border">
|
|
<table className="w-full table-fixed text-sm">
|
|
<thead className="sticky top-0 bg-background text-xs uppercase text-muted-foreground">
|
|
<tr>
|
|
<th className="w-8 p-2">
|
|
<input
|
|
type="checkbox"
|
|
title={t('admin.movies.toggleAll')}
|
|
disabled={rows.length === 0}
|
|
checked={allSelected}
|
|
// Часть строк отмечена — галочка «в промежутке»: иначе по ней не понять,
|
|
// что клик сейчас сделает.
|
|
ref={(input) => {
|
|
if (input) input.indeterminate = !allSelected && selectedCount > 0
|
|
}}
|
|
onChange={(e) => toggleAll(e.target.checked)}
|
|
/>
|
|
</th>
|
|
<th className="w-[26%] p-2 text-left">{t('admin.movies.file')}</th>
|
|
<th className="w-[38%] 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-0 p-2 font-mono text-xs" title={row.name}>
|
|
<div className="truncate">{row.name}</div>
|
|
<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 min-w-0 flex-1"
|
|
value={row.title}
|
|
onChange={(e) => patch(row.name, { title: e.target.value })}
|
|
/>
|
|
<Input
|
|
className="h-8 w-20 shrink-0"
|
|
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>
|
|
</>
|
|
)
|
|
}
|