Implement movie import functionality and enhance media processing
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user