228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
import { create } from 'zustand'
|
||
import { qk } from '@/shared/api/query-keys'
|
||
import { HttpError, refreshAccessToken } from '@/shared/api/client'
|
||
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 = {
|
||
id: string
|
||
name: string
|
||
percent: number
|
||
status: 'queued' | 'uploading' | 'done' | 'error'
|
||
}
|
||
|
||
type UploadStore = {
|
||
items: UploadItem[]
|
||
active: boolean
|
||
minimized: boolean
|
||
skipped: number
|
||
enqueue: (files: File[], options?: EnqueueOptions) => Promise<void>
|
||
cancel: (id: string) => void
|
||
cancelAll: () => void
|
||
retry: (id: string) => void
|
||
toggleMinimize: () => void
|
||
dismiss: () => void
|
||
}
|
||
|
||
/**
|
||
* Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада).
|
||
* <c>showId</c> — общий для всех файлов; <c>resolveShowId</c> — привязка на каждый файл (напр.
|
||
* автоопределение шоу по имени релиза). Приоритет у <c>resolveShowId</c>, затем общий <c>showId</c>.
|
||
*/
|
||
type EnqueueOptions = {
|
||
showId?: string
|
||
resolveShowId?: (file: File) => string | undefined
|
||
/** Загрузка с экрана «Ролики»: каждый файл после аплоада становится роликом (Show + серия). */
|
||
interstitial?: boolean
|
||
}
|
||
|
||
// Очередь и флаг живут вне React — загрузка продолжается при любой навигации.
|
||
let counter = 0
|
||
type Job = { id: string; file: File; showId?: string; interstitial?: boolean }
|
||
const queue: Job[] = []
|
||
const controllers = new Map<string, AbortController>()
|
||
const failed = new Map<string, Job>() // упавшие — для ручного повтора
|
||
let running = false
|
||
|
||
const MAX_ATTEMPTS = 3
|
||
const RETRY_DELAY_MS = 1500
|
||
|
||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||
|
||
/** Временная ли ошибка (стоит ретраить): 5xx/сеть — да; 4xx (формат/дубликат) — нет. */
|
||
function isTransient(error: unknown): boolean {
|
||
if (error instanceof HttpError) return error.status >= 500 || error.status === 0
|
||
return true
|
||
}
|
||
|
||
function isAbort(error: unknown): boolean {
|
||
return error instanceof DOMException && error.name === 'AbortError'
|
||
}
|
||
|
||
const patch = (id: string, changes: Partial<UploadItem>) =>
|
||
useUploadStore.setState((s) => ({
|
||
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) await runJob(queue.shift()!)
|
||
|
||
running = false
|
||
useUploadStore.setState({ active: false })
|
||
}
|
||
|
||
export const useUploadStore = create<UploadStore>((set) => ({
|
||
items: [],
|
||
active: false,
|
||
minimized: false,
|
||
skipped: 0,
|
||
|
||
enqueue: async (files, options) => {
|
||
// Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди.
|
||
let existing = new Set<string>()
|
||
try {
|
||
const all = await listMedia({
|
||
page: 1,
|
||
pageSize: 1000,
|
||
statuses: ['Pending', 'Processing', 'Ready'],
|
||
})
|
||
existing = new Set(all.items.map((a) => a.originalFileName))
|
||
} catch {
|
||
// положимся на серверную проверку
|
||
}
|
||
const inFlight = new Set(
|
||
useUploadStore
|
||
.getState()
|
||
.items.filter((i) => i.status !== 'error')
|
||
.map((i) => i.name),
|
||
)
|
||
|
||
const toAdd = files.filter((f) => !existing.has(f.name) && !inFlight.has(f.name))
|
||
const skipped = files.length - toAdd.length
|
||
|
||
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 })
|
||
return { id, name: file.name, percent: 0, status: 'queued' }
|
||
})
|
||
|
||
set((s) => ({
|
||
items: newItems.length ? [...s.items, ...newItems] : s.items,
|
||
skipped: s.skipped + skipped,
|
||
minimized: newItems.length ? false : s.minimized,
|
||
}))
|
||
if (newItems.length) void pump()
|
||
},
|
||
|
||
cancel: (id) => {
|
||
// Из очереди — если ещё не стартовал; активную загрузку прерываем.
|
||
const queuedIndex = queue.findIndex((j) => j.id === id)
|
||
if (queuedIndex !== -1) queue.splice(queuedIndex, 1)
|
||
controllers.get(id)?.abort()
|
||
failed.delete(id)
|
||
set((s) => ({ items: s.items.filter((i) => i.id !== id) }))
|
||
},
|
||
|
||
cancelAll: () => {
|
||
queue.length = 0
|
||
controllers.forEach((controller) => controller.abort())
|
||
// Оставляем уже завершённые/ошибочные, убираем очередь и текущую загрузку.
|
||
set((s) => ({
|
||
items: s.items.filter((i) => i.status === 'done' || i.status === 'error'),
|
||
}))
|
||
},
|
||
|
||
retry: (id) => {
|
||
const job = failed.get(id)
|
||
if (!job) return
|
||
failed.delete(id)
|
||
queue.push(job)
|
||
patch(id, { status: 'queued', percent: 0 })
|
||
void pump()
|
||
},
|
||
|
||
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
|
||
dismiss: () => {
|
||
failed.clear()
|
||
set({ items: [], skipped: 0 })
|
||
},
|
||
}))
|