Enhance movie import process and metadata handling
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.
This commit is contained in:
+219
-239
@@ -9,14 +9,7 @@ import type { ManualInboxFileDto } from '@/shared/api/types'
|
||||
import { useApiError } from '@/shared/lib/use-api-error'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { DialogFooter } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { toast } from '@/shared/ui/toast-store'
|
||||
@@ -51,7 +44,7 @@ function formatSize(bytes: number): string {
|
||||
* Номера сезона и серии распознаются здесь же и в таком виде и уходят на сервер: что показано,
|
||||
* то и сохранится.
|
||||
*/
|
||||
export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
export function ManualInboxPanel({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<string[]>([])
|
||||
@@ -231,243 +224,230 @@ export function ManualInboxDialog({ onClose }: Readonly<{ onClose: () => void }>
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.manualTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.manualHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.media.manualHint')}</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<ShowPicker
|
||||
value={showId}
|
||||
shows={shows}
|
||||
placeholder={t('admin.media.manualPickShow')}
|
||||
onChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
/>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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={0}
|
||||
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>
|
||||
{!regexOk && (
|
||||
<p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Шоу — первое решение в этом диалоге: остальные поля лишь помогают разложить файлы. */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{t('admin.media.manualShow')}</Label>
|
||||
<ShowPicker
|
||||
value={showId}
|
||||
shows={shows}
|
||||
placeholder={t('admin.media.manualPickShow')}
|
||||
onChange={(value) => {
|
||||
setShowPicked(true)
|
||||
setShowId(value)
|
||||
}}
|
||||
/>
|
||||
{autoDetected && (
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualDetected')}</p>
|
||||
)}
|
||||
|
||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||
{sample && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPickHint')}
|
||||
</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part) =>
|
||||
part.number === null ? (
|
||||
<span key={part.start} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={part.start}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('admin.media.regexPresets')}
|
||||
</span>
|
||||
{REGEX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr(preset.pattern)}
|
||||
>
|
||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||
</button>
|
||||
))}
|
||||
{regexStr && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr('')}
|
||||
>
|
||||
{t('admin.media.regexClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={selectable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === selectable.length
|
||||
? []
|
||||
: selectable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{folders.map(({ folder, files }) => {
|
||||
const isCollapsed = collapsed.includes(folder)
|
||||
return (
|
||||
<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={() => toggleCollapsed(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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<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={0}
|
||||
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>
|
||||
{!regexOk && <p className="text-xs text-red-500">{t('admin.media.toShowRegexInvalid')}</p>}
|
||||
|
||||
{/* Конструктор: указать число прямо в имени файла проще, чем сочинить regex руками. */}
|
||||
{sample && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">{t('admin.media.regexPickHint')}</span>
|
||||
<div className="flex flex-wrap items-center gap-0.5 font-mono text-xs">
|
||||
{sampleParts.map((part) =>
|
||||
part.number === null ? (
|
||||
<span key={part.start} className="text-muted-foreground">
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={part.start}
|
||||
type="button"
|
||||
title={t('admin.media.regexPickTitle')}
|
||||
className="rounded border border-primary/60 bg-primary/10 px-1 text-primary hover:bg-primary/25"
|
||||
onClick={() => setRegexStr(buildEpisodeRegex(sample.name, part.number!))}
|
||||
>
|
||||
{part.text}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('admin.media.regexPresets')}</span>
|
||||
{REGEX_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.key}
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr(preset.pattern)}
|
||||
>
|
||||
{t(`admin.media.regexPresetNames.${preset.key}`)}
|
||||
</button>
|
||||
))}
|
||||
{regexStr && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground hover:border-primary hover:text-primary"
|
||||
onClick={() => setRegexStr('')}
|
||||
>
|
||||
{t('admin.media.regexClear')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
variant="outline"
|
||||
disabled={selectable.length === 0}
|
||||
onClick={() =>
|
||||
setSelected(
|
||||
selected.length === selectable.length ? [] : selectable.map((f) => f.relativePath),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('admin.media.manualImport')}
|
||||
{t('admin.media.manualSelectAll')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{folders.map(({ folder, files }) => {
|
||||
const isCollapsed = collapsed.includes(folder)
|
||||
return (
|
||||
<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={() => toggleCollapsed(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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{data?.truncated && (
|
||||
<p className="text-xs text-amber-500">{t('admin.media.manualTruncated')}</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">{t('admin.media.manualCleanupHint')}</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selected.length === 0 || !showId || importMutation.isPending}
|
||||
onClick={() => importMutation.mutate()}
|
||||
>
|
||||
{t('admin.media.manualImport')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { ManualInboxPanel } from './ManualInboxPanel'
|
||||
import { MovieImportPanel } from './MovieImportPanel'
|
||||
import { UploadToShowPanel } from './UploadToShowPanel'
|
||||
|
||||
/** Способы пополнить библиотеку. «В шоу» появляется только когда файлы уже выбраны. */
|
||||
type Tab = 'movies' | 'manual' | 'toShow'
|
||||
|
||||
/**
|
||||
* Одно окно на все способы завести медиа: фильмы пачкой, серии из `manual/` в шоу и загрузка
|
||||
* выбранных файлов в шоу. Раньше это были три кнопки и три окна — а выбор между ними делается
|
||||
* один раз и по одному признаку: что за контент кладём.
|
||||
*/
|
||||
export function MediaImportDialog({
|
||||
files,
|
||||
onClose,
|
||||
}: Readonly<{
|
||||
/** Файлы, выбранные в проводнике до открытия окна: тогда сразу открывается вкладка «в шоу». */
|
||||
files: File[] | null
|
||||
onClose: () => void
|
||||
}>) {
|
||||
const { t } = useTranslation()
|
||||
const [tab, setTab] = useState<Tab>(files ? 'toShow' : 'movies')
|
||||
|
||||
const tabs: Tab[] = files ? ['toShow', 'movies', 'manual'] : ['movies', 'manual']
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-[min(96vw,80rem)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.importTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.importHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setTab(item)}
|
||||
className={`-mb-px border-b-2 px-3 py-1.5 text-sm ${
|
||||
tab === item
|
||||
? 'border-primary text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.media.importTabs.${item}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'movies' && <MovieImportPanel onClose={onClose} />}
|
||||
{tab === 'manual' && <ManualInboxPanel onClose={onClose} />}
|
||||
{tab === 'toShow' && files && <UploadToShowPanel files={files} onClose={onClose} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Clapperboard, FolderInput, ListPlus, Upload } from 'lucide-react'
|
||||
import { FolderInput, 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'
|
||||
@@ -14,9 +14,7 @@ import { useTableSort } from '@/shared/lib/table-sort'
|
||||
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 { MediaImportDialog } from './MediaImportDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
@@ -47,9 +45,9 @@ export function MediaPanel() {
|
||||
const [filter, setFilter] = useState<MediaFilter>('active')
|
||||
const [page, setPage] = useState(1)
|
||||
const { sort, toggle } = useTableSort('created', true)
|
||||
// Файлы, выбранные для загрузки в шоу: с ними окно импорта открывается сразу на нужной вкладке.
|
||||
const [filesForShow, setFilesForShow] = useState<File[] | null>(null)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const [moviesOpen, setMoviesOpen] = useState(false)
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
|
||||
const sortColumn = (key: string) => {
|
||||
@@ -180,21 +178,16 @@ export function MediaPanel() {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) setFilesForShow(Array.from(files))
|
||||
if (files && files.length > 0) {
|
||||
setFilesForShow(Array.from(files))
|
||||
setImportOpen(true)
|
||||
}
|
||||
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)}>
|
||||
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<FolderInput className="h-4 w-4" />
|
||||
{t('admin.media.manualButton')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputShow.current?.click()}>
|
||||
<ListPlus className="h-4 w-4" />
|
||||
{t('admin.media.uploadToShow')}
|
||||
{t('admin.media.importButton')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => fileInput.current?.click()}>
|
||||
<Upload className="h-4 w-4" />
|
||||
@@ -203,13 +196,16 @@ export function MediaPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filesForShow && (
|
||||
<UploadToShowDialog files={filesForShow} onClose={() => setFilesForShow(null)} />
|
||||
{importOpen && (
|
||||
<MediaImportDialog
|
||||
files={filesForShow}
|
||||
onClose={() => {
|
||||
setImportOpen(false)
|
||||
setFilesForShow(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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">
|
||||
<thead className="border-b border-border text-left text-muted-foreground">
|
||||
|
||||
+191
-188
@@ -7,14 +7,7 @@ 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 { 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'
|
||||
@@ -78,7 +71,7 @@ function manualRows(
|
||||
* Во втором случае имена разбираются **до** загрузки — иначе, чтобы узнать, что половина строк
|
||||
* не распозналась, пришлось бы сначала залить десятки гигабайт.
|
||||
*/
|
||||
export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
export function MovieImportPanel({ onClose }: Readonly<{ onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const onError = useApiError()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -186,194 +179,204 @@ export function MovieImportDialog({ onClose }: Readonly<{ onClose: () => void }>
|
||||
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 (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.movies.title')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.movies.hint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<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)}
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{t(`admin.movies.sources.${item}`)}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button size="sm" variant="ghost" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={selectedCount === 0 || importing.isPending}
|
||||
onClick={() => importing.mutate()}
|
||||
{(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)}
|
||||
>
|
||||
{t('admin.movies.import', { count: selectedCount })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { matchShowByName } from './match-show'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
export function UploadToShowDialog({
|
||||
files,
|
||||
onClose,
|
||||
}: Readonly<{ files: File[]; onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
const [seasonStr, setSeasonStr] = useState('')
|
||||
const [regexStr, setRegexStr] = useState('')
|
||||
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
// Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined).
|
||||
const matchedByName = useMemo(() => {
|
||||
const map = new Map<string, string | undefined>()
|
||||
if (shows) for (const file of files) map.set(file.name, matchShowByName(file.name, shows))
|
||||
return map
|
||||
}, [files, shows])
|
||||
|
||||
/** Итоговая привязка файла: ручная правка (если есть) либо автоопределение, иначе '' (в библиотеку). */
|
||||
const assignment = (name: string): string =>
|
||||
name in overrides ? overrides[name] : (matchedByName.get(name) ?? '')
|
||||
|
||||
/** Проставить одно шоу (или «в библиотеку») всем файлам разом. */
|
||||
const applyToAll = (showId: string) =>
|
||||
setOverrides(Object.fromEntries(files.map((f) => [f.name, showId])))
|
||||
|
||||
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||
const previews = useMemo(() => {
|
||||
const opts = {
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
return files
|
||||
.map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) }))
|
||||
.sort(compareParsed)
|
||||
}, [files, seasonOverride, regexStr, regexOk])
|
||||
|
||||
const matchedCount = previews.filter((p) => assignment(p.name)).length
|
||||
|
||||
const confirm = () => {
|
||||
void enqueue(
|
||||
previews.map((p) => p.file),
|
||||
{ resolveShowId: (file) => assignment(file.name) || undefined },
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('admin.media.toShowTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('admin.media.autoDetectHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<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 justify-between gap-2 text-sm">
|
||||
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
|
||||
</span>
|
||||
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
|
||||
поэтому на кнопке всегда её собственная подпись. */}
|
||||
<ShowPicker
|
||||
value=""
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
placeholder={t('admin.media.applyToAll')}
|
||||
className="h-8 w-52"
|
||||
onChange={applyToAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||
{previews.map((p) => {
|
||||
const label = formatSeasonEpisode(p.parsed)
|
||||
const current = assignment(p.name)
|
||||
return (
|
||||
<li key={p.name} className="flex items-center gap-2 px-3 py-1.5">
|
||||
{label ? (
|
||||
<Badge>{label}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate" title={p.name}>
|
||||
{p.name}
|
||||
</span>
|
||||
<ShowPicker
|
||||
value={current}
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
className="h-8 w-56 shrink-0"
|
||||
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirm}>
|
||||
{t('admin.media.toShowConfirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { qk } from '@/shared/api/query-keys'
|
||||
import { Badge } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { DialogFooter } from '@/shared/ui/dialog'
|
||||
import { Input } from '@/shared/ui/input'
|
||||
import { Label } from '@/shared/ui/label'
|
||||
import { listShows } from '@/features/admin/shows/api'
|
||||
import { ShowPicker } from '@/features/admin/shows/ShowPicker'
|
||||
import { compareParsed, formatSeasonEpisode, isValidRegex, parseEpisodeName } from './episode-parse'
|
||||
import { matchShowByName } from './match-show'
|
||||
import { useUploadStore } from './upload-store'
|
||||
|
||||
export function UploadToShowPanel({
|
||||
files,
|
||||
onClose,
|
||||
}: Readonly<{ files: File[]; onClose: () => void }>) {
|
||||
const { t } = useTranslation()
|
||||
const enqueue = useUploadStore((s) => s.enqueue)
|
||||
const [seasonStr, setSeasonStr] = useState('')
|
||||
const [regexStr, setRegexStr] = useState('')
|
||||
// Ручные правки привязки: имя файла → id шоу ('' — явно в библиотеку). Иначе берётся автоопределение.
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: shows } = useQuery({ queryKey: qk.shows.all, queryFn: () => listShows() })
|
||||
|
||||
const regexOk = isValidRegex(regexStr)
|
||||
const seasonOverride = seasonStr.trim() ? Number(seasonStr) : null
|
||||
|
||||
// Автоопределение шоу по имени файла: id распознанного шоу для каждого файла (или undefined).
|
||||
const matchedByName = useMemo(() => {
|
||||
const map = new Map<string, string | undefined>()
|
||||
if (shows) for (const file of files) map.set(file.name, matchShowByName(file.name, shows))
|
||||
return map
|
||||
}, [files, shows])
|
||||
|
||||
/** Итоговая привязка файла: ручная правка (если есть) либо автоопределение, иначе '' (в библиотеку). */
|
||||
const assignment = (name: string): string =>
|
||||
name in overrides ? overrides[name] : (matchedByName.get(name) ?? '')
|
||||
|
||||
/** Проставить одно шоу (или «в библиотеку») всем файлам разом. */
|
||||
const applyToAll = (showId: string) =>
|
||||
setOverrides(Object.fromEntries(files.map((f) => [f.name, showId])))
|
||||
|
||||
// Предпросмотр: что распарсим для каждого файла при текущих настройках, в порядке добавления.
|
||||
const previews = useMemo(() => {
|
||||
const opts = {
|
||||
seasonOverride:
|
||||
seasonOverride != null && Number.isFinite(seasonOverride) ? seasonOverride : null,
|
||||
episodeRegex: regexOk ? regexStr : null,
|
||||
}
|
||||
return files
|
||||
.map((file) => ({ file, name: file.name, parsed: parseEpisodeName(file.name, opts) }))
|
||||
.sort(compareParsed)
|
||||
}, [files, seasonOverride, regexStr, regexOk])
|
||||
|
||||
const matchedCount = previews.filter((p) => assignment(p.name)).length
|
||||
|
||||
const confirm = () => {
|
||||
void enqueue(
|
||||
previews.map((p) => p.file),
|
||||
{ resolveShowId: (file) => assignment(file.name) || undefined },
|
||||
)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{t('admin.media.autoDetectHint')}</p>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<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 justify-between gap-2 text-sm">
|
||||
<span className="font-medium">{t('admin.media.toShowPreview')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">
|
||||
{t('admin.media.toShowMatched', { matched: matchedCount, total: files.length })}
|
||||
</span>
|
||||
{/* Значение не храним: выбор здесь — это действие «проставить всем», а не состояние,
|
||||
поэтому на кнопке всегда её собственная подпись. */}
|
||||
<ShowPicker
|
||||
value=""
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
placeholder={t('admin.media.applyToAll')}
|
||||
className="h-8 w-52"
|
||||
onChange={applyToAll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="crt-panel max-h-72 divide-y divide-border overflow-y-auto rounded-md text-sm">
|
||||
{previews.map((p) => {
|
||||
const label = formatSeasonEpisode(p.parsed)
|
||||
const current = assignment(p.name)
|
||||
return (
|
||||
<li key={p.name} className="flex items-center gap-2 px-3 py-1.5">
|
||||
{label ? (
|
||||
<Badge>{label}</Badge>
|
||||
) : (
|
||||
<Badge variant="muted">{t('admin.media.toShowUnknown')}</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate" title={p.name}>
|
||||
{p.name}
|
||||
</span>
|
||||
<ShowPicker
|
||||
value={current}
|
||||
shows={shows}
|
||||
noneLabel={t('admin.media.toShowLibrary')}
|
||||
className="h-8 w-56 shrink-0"
|
||||
onChange={(showId) => setOverrides((o) => ({ ...o, [p.name]: showId }))}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button size="sm" onClick={confirm}>
|
||||
{t('admin.media.toShowConfirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user