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 cancel: (id: string) => void cancelAll: () => void retry: (id: string) => void toggleMinimize: () => void dismiss: () => void } /** * Доп-опции загрузки: привязка загружаемых файлов к шоу (добавляются сериями после аплоада). * showId — общий для всех файлов; resolveShowId — привязка на каждый файл (напр. * автоопределение шоу по имени релиза). Приоритет у resolveShowId, затем общий showId. */ 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() const failed = new Map() // упавшие — для ручного повтора 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) => 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 { 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((set) => ({ items: [], active: false, minimized: false, skipped: 0, enqueue: async (files, options) => { // Пропускаем дубликаты: уже в библиотеке (не проваленные) и уже в текущей очереди. let existing = new Set() 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 }) }, }))