Enhance EntryTraceDialog component by refactoring data display logic into dedicated summary functions for improved readability and maintainability. Update GridTab to streamline checkbox state management with a new toggle function. Refactor RulesCard to simplify window removal logic. Adjust CollectionsPanel, GenresPanel, GroupsPanel, RolesPanel, ShowsPanel, and UsersPanel to import sorting utilities from a centralized location, enhancing code organization. Update ThemeProvider to utilize a shared theme context for better consistency across the application.
This commit is contained in:
@@ -188,6 +188,11 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
current.includes(path) ? current.filter((p) => p !== path) : [...current, path],
|
||||
)
|
||||
|
||||
const toggleCollapsed = (folder: string) =>
|
||||
setCollapsed((current) =>
|
||||
current.includes(folder) ? current.filter((f) => f !== folder) : [...current, folder],
|
||||
)
|
||||
|
||||
const toggleFolder = (files: ManualInboxFileDto[]) => {
|
||||
const paths = files.filter((f) => !f.alreadyImported).map((f) => f.relativePath)
|
||||
const allSelected = paths.every((p) => selected.includes(p))
|
||||
@@ -355,11 +360,7 @@ export function ManualInboxDialog({ onClose }: { onClose: () => void }) {
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={() =>
|
||||
setCollapsed((c) =>
|
||||
c.includes(folder) ? c.filter((f) => f !== folder) : [...c, folder],
|
||||
)
|
||||
}
|
||||
onClick={() => toggleCollapsed(folder)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
@@ -9,8 +9,10 @@ import { Badge, type BadgeProps } from '@/shared/ui/badge'
|
||||
import { Button } from '@/shared/ui/button'
|
||||
import { Pager } from '@/shared/ui/pager'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
|
||||
import { SortHeader, useTableSort } from '@/shared/ui/sortable'
|
||||
import { useTableSort } from '@/shared/lib/table-sort'
|
||||
import { SortHeader } from '@/shared/ui/sortable'
|
||||
import { deleteMedia, getMediaStats, listMedia } from './api'
|
||||
import { formatDuration } from './format'
|
||||
import { ManualInboxDialog } from './ManualInboxDialog'
|
||||
import { UploadToShowDialog } from './UploadToShowDialog'
|
||||
import { useUploadStore } from './upload-store'
|
||||
@@ -28,16 +30,6 @@ const filterStatuses: Record<MediaFilter, MediaAssetStatus[]> = {
|
||||
Failed: ['Failed'],
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
|
||||
const statusVariant: Record<MediaAssetStatus, BadgeProps['variant']> = {
|
||||
Ready: 'default',
|
||||
Processing: 'muted',
|
||||
|
||||
@@ -7,54 +7,60 @@ type ParseOptions = {
|
||||
|
||||
export type ParsedEpisode = { season: number | null; episode: number | null }
|
||||
|
||||
/** Встроенные шаблоны: SxxEyy, NxNN, ведущий номер серии. */
|
||||
function parseBuiltin(name: string): ParsedEpisode {
|
||||
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
|
||||
if (se) return { season: Number(se[1]), episode: Number(se[2]) }
|
||||
|
||||
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
|
||||
if (nx) return { season: Number(nx[1]), episode: Number(nx[2]) }
|
||||
|
||||
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
|
||||
const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/)
|
||||
return { season: null, episode: lead ? Number(lead[1]) : null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Пользовательский regex: 1 группа = серия, 2 группы = (сезон, серия). <c>null</c> — шаблон не
|
||||
* сработал (или невалиден), распознанное встроенными шаблонами остаётся как есть. <c>season: null</c>
|
||||
* при одной группе означает «сезон не трогаем».
|
||||
*/
|
||||
function parseCustom(name: string, pattern: string): ParsedEpisode | null {
|
||||
let match: RegExpMatchArray | null
|
||||
try {
|
||||
match = name.match(new RegExp(pattern, 'i'))
|
||||
} catch {
|
||||
return null // невалидный regex — просто игнорируем
|
||||
}
|
||||
if (!match) return null
|
||||
if (match.length >= 3 && match[1] != null && match[2] != null) {
|
||||
return { season: Number(match[1]), episode: Number(match[2]) }
|
||||
}
|
||||
return match[1] != null ? { season: null, episode: Number(match[1]) } : null
|
||||
}
|
||||
|
||||
const finiteOrNull = (value: number | null) =>
|
||||
value != null && Number.isFinite(value) ? value : null
|
||||
|
||||
/**
|
||||
* Пытается распознать сезон/серию из имени файла. Сначала встроенные шаблоны (SxxEyy, NxNN), затем —
|
||||
* пользовательский regex (перебивает серию, а при двух группах и сезон), в конце — ручной сезон.
|
||||
* Если серия распознана, а сезон нет — сезон считается первым.
|
||||
*/
|
||||
export function parseEpisodeName(name: string, opts?: ParseOptions): ParsedEpisode {
|
||||
let season: number | null = null
|
||||
let episode: number | null = null
|
||||
|
||||
const se = name.match(/[Ss](\d{1,2})[ ._-]*[Ee](\d{1,3})/)
|
||||
if (se) {
|
||||
season = Number(se[1])
|
||||
episode = Number(se[2])
|
||||
} else {
|
||||
const nx = name.match(/(?:^|[^\d])(\d{1,2})x(\d{1,3})(?:[^\d]|$)/i)
|
||||
if (nx) {
|
||||
season = Number(nx[1])
|
||||
episode = Number(nx[2])
|
||||
} else {
|
||||
// Ведущий номер серии: «01. Название», «02 - Название», «03_Название», «4) Название».
|
||||
const lead = name.match(/^\s*(\d{1,3})[\s._)\]-]/)
|
||||
if (lead) episode = Number(lead[1])
|
||||
}
|
||||
}
|
||||
let { season, episode } = parseBuiltin(name)
|
||||
|
||||
const rawRegex = opts?.episodeRegex?.trim()
|
||||
if (rawRegex) {
|
||||
try {
|
||||
const match = name.match(new RegExp(rawRegex, 'i'))
|
||||
if (match) {
|
||||
if (match.length >= 3 && match[1] != null && match[2] != null) {
|
||||
season = Number(match[1])
|
||||
episode = Number(match[2])
|
||||
} else if (match[1] != null) {
|
||||
episode = Number(match[1])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// невалидный regex — просто игнорируем
|
||||
}
|
||||
const custom = rawRegex ? parseCustom(name, rawRegex) : null
|
||||
if (custom) {
|
||||
episode = custom.episode
|
||||
if (custom.season != null) season = custom.season
|
||||
}
|
||||
|
||||
if (opts?.seasonOverride != null) season = opts.seasonOverride
|
||||
if (episode != null && season == null) season = 1
|
||||
|
||||
if (episode != null && !Number.isFinite(episode)) episode = null
|
||||
if (season != null && !Number.isFinite(season)) season = null
|
||||
return { season, episode }
|
||||
return { season: finiteOrNull(season), episode: finiteOrNull(episode) }
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Длительность в «ч:мм:сс» (часы — только когда есть); null — прочерк. */
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) return '—'
|
||||
const total = Math.round(seconds)
|
||||
const h = Math.floor(total / 3600)
|
||||
const m = Math.floor((total % 3600) / 60)
|
||||
const s = total % 60
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { queryClient } from '@/shared/api/query-client'
|
||||
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'
|
||||
|
||||
export type UploadItem = {
|
||||
@@ -67,71 +68,81 @@ const patch = (id: string, changes: Partial<UploadItem>) =>
|
||||
items: s.items.map((i) => (i.id === id ? { ...i, ...changes } : i)),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Стоит ли повторить попытку: отмена — нет; истёкший access-токен (XHR идёт мимо авто-refresh) —
|
||||
* обновляем и повторяем сразу; прочие временные сбои — после паузы.
|
||||
*/
|
||||
async function shouldRetry(error: unknown, attempt: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (isAbort(error) || signal.aborted) return false
|
||||
if (attempt >= MAX_ATTEMPTS) return false
|
||||
if (error instanceof HttpError && error.status === 401) return await refreshAccessToken()
|
||||
if (!isTransient(error)) return false
|
||||
await delay(RETRY_DELAY_MS)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Аплоад с ретраями временных сбоев (например, 502 от прокси) — до MAX_ATTEMPTS попыток. */
|
||||
async function uploadWithRetries(
|
||||
job: Job,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ created: CreatedIdResponse | null; lastError: unknown }> {
|
||||
let lastError: unknown = null
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
patch(job.id, { status: 'uploading', percent: 0 })
|
||||
try {
|
||||
const created = await uploadMedia(job.file, (percent) => patch(job.id, { percent }), signal)
|
||||
return { created, lastError: null }
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (!(await shouldRetry(error, attempt, signal))) break
|
||||
}
|
||||
}
|
||||
return { created: null, lastError }
|
||||
}
|
||||
|
||||
/** Что делаем со свежим ассетом: привязка к шоу серией (порядок — как в очереди) либо ролик. */
|
||||
async function linkUploaded(job: Job, assetId: string) {
|
||||
if (job.showId) {
|
||||
try {
|
||||
await addEpisode(job.showId, assetId)
|
||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||
}
|
||||
} else if (job.interstitial) {
|
||||
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
||||
try {
|
||||
await importInterstitials([assetId])
|
||||
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось завести ролик`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runJob(job: Job) {
|
||||
const controller = new AbortController()
|
||||
controllers.set(job.id, controller)
|
||||
const { created, lastError } = await uploadWithRetries(job, controller.signal)
|
||||
controllers.delete(job.id)
|
||||
|
||||
if (created) {
|
||||
patch(job.id, { status: 'done', percent: 100 })
|
||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
await linkUploaded(job, created.id)
|
||||
} else if (!isAbort(lastError) && !controller.signal.aborted) {
|
||||
// Отмена — тихо: элемент уже убран из списка; всё остальное оставляем для ручного повтора.
|
||||
failed.set(job.id, job)
|
||||
patch(job.id, { status: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function pump() {
|
||||
if (running) return
|
||||
running = true
|
||||
useUploadStore.setState({ active: true, minimized: false })
|
||||
|
||||
while (queue.length > 0) {
|
||||
const job = queue.shift()!
|
||||
const controller = new AbortController()
|
||||
controllers.set(job.id, controller)
|
||||
|
||||
let created: { id: string } | null = null
|
||||
let lastError: unknown = null
|
||||
// Ретраим временные сбои (например, 502 от прокси) — до MAX_ATTEMPTS попыток.
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
patch(job.id, { status: 'uploading', percent: 0 })
|
||||
try {
|
||||
created = await uploadMedia(
|
||||
job.file,
|
||||
(percent) => patch(job.id, { percent }),
|
||||
controller.signal,
|
||||
)
|
||||
lastError = null
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (isAbort(error) || controller.signal.aborted) break
|
||||
// Истёк access-токен (XHR идёт мимо авто-refresh) — обновляем и повторяем сразу.
|
||||
if (error instanceof HttpError && error.status === 401 && attempt < MAX_ATTEMPTS) {
|
||||
if (await refreshAccessToken()) continue
|
||||
break
|
||||
}
|
||||
if (attempt < MAX_ATTEMPTS && isTransient(error)) await delay(RETRY_DELAY_MS)
|
||||
else break
|
||||
}
|
||||
}
|
||||
controllers.delete(job.id)
|
||||
|
||||
if (created) {
|
||||
patch(job.id, { status: 'done', percent: 100 })
|
||||
void queryClient.invalidateQueries({ queryKey: qk.media.all })
|
||||
|
||||
// Привязка к шоу: добавляем свежий ассет серией (порядок — как в очереди).
|
||||
if (job.showId) {
|
||||
try {
|
||||
await addEpisode(job.showId, created.id)
|
||||
void queryClient.invalidateQueries({ queryKey: qk.shows.all })
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось добавить в шоу`)
|
||||
}
|
||||
} else if (job.interstitial) {
|
||||
// Ролик заводится сразу после аплоада: длительность подтянется, когда ассет обработается.
|
||||
try {
|
||||
await importInterstitials([created.id])
|
||||
void queryClient.invalidateQueries({ queryKey: qk.interstitials.all })
|
||||
} catch {
|
||||
toast.error(`${job.file.name}: не удалось завести ролик`)
|
||||
}
|
||||
}
|
||||
} else if (isAbort(lastError) || controller.signal.aborted) {
|
||||
// Отмена — тихо: элемент уже убран из списка.
|
||||
} else {
|
||||
failed.set(job.id, job) // сохраняем для ручного повтора
|
||||
patch(job.id, { status: 'error' })
|
||||
}
|
||||
}
|
||||
while (queue.length > 0) await runJob(queue.shift()!)
|
||||
|
||||
running = false
|
||||
useUploadStore.setState({ active: false })
|
||||
|
||||
Reference in New Issue
Block a user