Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
This commit is contained in:
@@ -1,111 +1,126 @@
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
|
||||
import type {
|
||||
CreatedIdResponse,
|
||||
ImportManualInboxResultDto,
|
||||
ManualInboxListDto,
|
||||
MediaAssetDto,
|
||||
MediaAssetStatus,
|
||||
MediaStatsDto,
|
||||
PagedList,
|
||||
} from '@/shared/api/types'
|
||||
|
||||
export type ListMediaParams = {
|
||||
page: number
|
||||
pageSize: number
|
||||
statuses?: MediaAssetStatus[]
|
||||
search?: string
|
||||
sort?: string
|
||||
desc?: boolean
|
||||
}
|
||||
|
||||
export function listMedia(params: ListMediaParams) {
|
||||
const query = new URLSearchParams({
|
||||
page: String(params.page),
|
||||
pageSize: String(params.pageSize),
|
||||
})
|
||||
for (const status of params.statuses ?? []) query.append('status', status)
|
||||
if (params.search) query.set('search', params.search)
|
||||
if (params.sort) query.set('sort', params.sort)
|
||||
if (params.desc) query.set('desc', 'true')
|
||||
return apiRequest<PagedList<MediaAssetDto>>(`/admin/media?${query.toString()}`)
|
||||
}
|
||||
|
||||
export function getMediaStats() {
|
||||
return apiRequest<MediaStatsDto>('/admin/media/stats')
|
||||
}
|
||||
|
||||
/**
|
||||
* Дозагружает ВСЕ страницы медиа (для пикеров с клиентской фильтрацией/сортировкой — кандидаты серий,
|
||||
* пул рекламы), чтобы ничего не терялось молча за фиксированным pageSize. Есть предохранитель `cap`:
|
||||
* если элементов больше — возвращаем `truncated: true`, и UI показывает предупреждение (а не делает вид,
|
||||
* что список полон).
|
||||
*/
|
||||
export async function listAllMedia(
|
||||
params: Omit<ListMediaParams, 'page' | 'pageSize'> & { cap?: number },
|
||||
): Promise<{ items: MediaAssetDto[]; total: number; truncated: boolean }> {
|
||||
const pageSize = 200
|
||||
const cap = params.cap ?? 5000
|
||||
const items: MediaAssetDto[] = []
|
||||
let total = 0
|
||||
for (let page = 1; ; page++) {
|
||||
const res = await listMedia({
|
||||
page,
|
||||
pageSize,
|
||||
statuses: params.statuses,
|
||||
search: params.search,
|
||||
})
|
||||
total = res.total
|
||||
items.push(...res.items)
|
||||
if (res.items.length === 0 || items.length >= total || items.length >= cap) break
|
||||
}
|
||||
return { items, total, truncated: items.length < total }
|
||||
}
|
||||
|
||||
/** Что лежит в ручном inbox (manual/) и ждёт разбора. */
|
||||
export function listManualInbox() {
|
||||
return apiRequest<ManualInboxListDto>('/admin/media/manual')
|
||||
}
|
||||
|
||||
/** Забирает файлы из manual/ в шоу: файлы уходят из каталога, как и из обычного inbox. */
|
||||
export function importManualInbox(relativePaths: string[], showId: string) {
|
||||
return apiRequest<ImportManualInboxResultDto>('/admin/media/manual/import', {
|
||||
method: 'POST',
|
||||
body: { relativePaths, showId },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMedia(id: string) {
|
||||
return apiRequest<void>(`/admin/media/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Потоковая загрузка файла (сырое тело + fileName в query). Через XHR ради индикатора прогресса.
|
||||
*/
|
||||
export function uploadMedia(
|
||||
file: File,
|
||||
onProgress?: (percent: number) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreatedIdResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Aborted', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const query = new URLSearchParams({ fileName: file.name })
|
||||
xhr.open('POST', `/api/admin/media?${query.toString()}`)
|
||||
|
||||
const token = getAccessToken()
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
|
||||
|
||||
signal?.addEventListener('abort', () => xhr.abort())
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'))
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && onProgress)
|
||||
onProgress(Math.round((event.loaded / event.total) * 100))
|
||||
}
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(JSON.parse(xhr.responseText) as CreatedIdResponse)
|
||||
} else {
|
||||
let detail = `HTTP ${xhr.status}`
|
||||
try {
|
||||
const problem = JSON.parse(xhr.responseText) as { detail?: string; title?: string }
|
||||
detail = problem.detail ?? problem.title ?? detail
|
||||
} catch {
|
||||
/* пусто */
|
||||
}
|
||||
reject(new HttpError({ detail }, xhr.status))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new HttpError({ title: 'Network error' }, 0))
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user