Files
TeleWave/frontend/src/features/admin/media/upload-store.ts
T

228 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 })
},
}))