Update scheduling parameters and refactor channel endpoints: extend HorizonDays to 7 and RetentionDays to 90 in appsettings.json. Consolidate channel-related endpoint logic by removing obsolete files and enhancing the ShowEndpoints with audience and genre management capabilities. Improve error handling and streamline command handlers for channel operations.
build / backend (push) Successful in 7m40s
build / frontend (push) Failing after 39s
tests / backend-tests (push) Successful in 6m9s

This commit is contained in:
Leonid Pershin
2026-07-26 13:32:13 +03:00
parent c4ef954dea
commit 66040a8841
272 changed files with 27944 additions and 8699 deletions
+244 -245
View File
@@ -1,245 +1,244 @@
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
BumperSettings,
BumperTextKind,
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CreatedIdResponse,
HourWindow,
OverrideMode,
OverrideRecurrence,
ScheduleEntryDto,
} from '@/shared/api/types'
export function listChannels() {
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
}
export function getChannel(id: string) {
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
}
export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
export type ChannelSettingsBody = {
name: string
isEnabled: boolean
adInsertion: AdInsertion
adsPerBreak: number
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
export type ChannelShowBody = {
showId: string
weight: number
blockMode: BlockMode
blockValue: number
}
export function addChannelShow(id: string, body: ChannelShowBody) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/shows`, { method: 'POST', body })
}
export function updateChannelShow(
id: string,
channelShowId: string,
body: {
weight: number
blockMode: BlockMode
blockValue: number
isEnabled: boolean
preferredWeightMultiplier: number
preferredHours: HourWindow[]
},
) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
}
export function removeChannelShow(id: string, channelShowId: string) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'DELETE' })
}
export function addChannelAd(id: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/ads`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeChannelAd(id: string, channelAdId: string) {
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
}
export type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} 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)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export type OverrideBody = {
mode: OverrideMode
recurrence: OverrideRecurrence
startsAtUtc?: string | null
endsAtUtc?: string | null
dayOfWeek?: number | null
startMinute?: number | null
endMinute?: number | null
shows: { showId: string; weight: number }[]
}
export function createOverride(id: string, body: OverrideBody) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/overrides`, { method: 'POST', body })
}
export function deleteOverride(id: string, overrideId: string) {
return apiRequest<void>(`/admin/channels/${id}/overrides/${overrideId}`, { method: 'DELETE' })
}
export function regenerateSchedule(id: string) {
return apiRequest<void>(`/admin/channels/${id}/regenerate`, { method: 'POST' })
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
}
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
ApplyResultDto,
BumperSettings,
BumperTextKind,
BumperTrigger,
ChannelDto,
ChannelSummaryDto,
CreatedIdResponse,
LayerApplicability,
ScheduleEntryDto,
ScheduleTemplateDto,
SlotDto,
} from '@/shared/api/types'
export function listChannels() {
return apiRequest<ChannelSummaryDto[]>('/admin/channels')
}
export function getChannel(id: string) {
return apiRequest<ChannelDto>(`/admin/channels/${id}`)
}
export function createChannel(body: { name: string; slug: string }) {
return apiRequest<CreatedIdResponse>('/admin/channels', { method: 'POST', body })
}
export type ChannelSettingsBody = {
name: string
isEnabled: boolean
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
export function updateChannelSettings(id: string, body: ChannelSettingsBody) {
return apiRequest<void>(`/admin/channels/${id}/settings`, { method: 'PUT', body })
}
/** Номер канала и его время: смещение от UTC и начало вещательных суток. */
export function updateChannelTime(
id: string,
body: { number: number | null; utcOffsetMinutes: number; dayStartTime: string },
) {
return apiRequest<void>(`/admin/channels/${id}/time`, { method: 'PUT', body })
}
// ── Сетка канала ──────────────────────────────────────────────────────────
export function getChannelTemplate(channelId: string) {
return apiRequest<ScheduleTemplateDto>(`/admin/channels/${channelId}/template`)
}
/** Применяет правила к эфиру: пересобирает будущий хвост. Правка слотов эфир не двигает. */
export function applyChannelTemplate(channelId: string) {
return apiRequest<ApplyResultDto>(`/admin/channels/${channelId}/template/apply`, {
method: 'POST',
})
}
export function updateTemplate(
templateId: string,
body: { name: string; fallbackGroupId: string | null },
) {
return apiRequest<void>(`/admin/templates/${templateId}`, { method: 'PUT', body })
}
export function createLayer(templateId: string, body: { name: string; priority: number }) {
return apiRequest<CreatedIdResponse>(`/admin/templates/${templateId}/layers`, {
method: 'POST',
body,
})
}
export function updateLayer(
layerId: string,
body: {
name: string
priority: number
applicability: LayerApplicability | null
isEnabled: boolean
},
) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'PUT', body })
}
export function deleteLayer(layerId: string) {
return apiRequest<void>(`/admin/layers/${layerId}`, { method: 'DELETE' })
}
/** Тело слота: то же для создания и правки (см. SlotInput на сервере). */
export type SlotBody = Omit<SlotDto, 'id' | 'layerId' | 'groupName'>
export function createSlot(layerId: string, body: SlotBody) {
return apiRequest<CreatedIdResponse>(`/admin/layers/${layerId}/slots`, { method: 'POST', body })
}
export function updateSlot(slotId: string, body: SlotBody) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'PUT', body })
}
export function deleteSlot(slotId: string) {
return apiRequest<void>(`/admin/slots/${slotId}`, { method: 'DELETE' })
}
export type BumperTemplateStyleBody = {
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
}
export function addBumperTemplate(id: string, name: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/bumper/templates`, {
method: 'POST',
body: { name },
})
}
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
export function removeBumperTemplate(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'DELETE',
})
}
/** Загрузка сырого файла блока (звук/фон): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperTemplateFile(
id: string,
templateId: string,
kind: 'audio' | 'background',
file: File,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const query = new URLSearchParams({ fileName: file.name })
xhr.open(
'PUT',
`/api/admin/channels/${id}/bumper/templates/${templateId}/${kind}?${query.toString()}`,
)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve()
} 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)
})
}
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export type BumperVariantBody = {
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
return apiRequest<CreatedIdResponse>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants`,
{ method: 'POST', body: { name } },
)
}
export function updateBumperVariant(
id: string,
templateId: string,
variantId: string,
body: BumperVariantBody,
) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'PUT', body },
)
}
export function removeBumperVariant(id: string, templateId: string, variantId: string) {
return apiRequest<void>(
`/admin/channels/${id}/bumper/templates/${templateId}/variants/${variantId}`,
{ method: 'DELETE' },
)
}
/** Привязать фон-картинку блока по ссылке на изображение из реестра (галерея). */
export function setBumperTemplateBackground(id: string, templateId: string, imageId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'PUT',
body: { imageId },
})
}
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит примеры всех подблоков блока (сервер собирает ffmpeg-клипы). */
export function renderBumperPreviews(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string, variantId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/${variantId}/index.m3u8`
}
export function getSchedule(id: string, from: Date, to: Date) {
const query = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() })
return apiRequest<ScheduleEntryDto[]>(`/admin/channels/${id}/schedule?${query.toString()}`)
}