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
+477 -294
View File
@@ -1,294 +1,477 @@
export type ApiError = {
title: string
detail: string
status: number
}
export type CurrentUser = {
id: string
userName: string
role: string
}
export type AuthResponse = {
accessToken: string
expiresAt: string
user: CurrentUser
}
export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
export type RoleDto = {
id: string
name: string
isSystem: boolean
}
export type UserSummaryDto = {
id: string
userName: string
role: string
isBlocked: boolean
createdAt: string
}
export type PagedList<T> = {
items: T[]
total: number
page: number
pageSize: number
}
export type CreatedIdResponse = { id: string }
// ── Медиа ────────────────────────────────────────────────────────────────
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
export type MediaSource = 'Upload' | 'Inbox'
export type MediaAssetDto = {
id: string
originalFileName: string
source: MediaSource
status: MediaAssetStatus
durationSeconds: number | null
width: number | null
height: number | null
errorMessage: string | null
processingSeconds: number | null
createdAt: string
}
export type MediaStatsDto = {
queued: number
processing: number
averageProcessingSeconds: number | null
}
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
export type ShowKind = 'Series' | 'Single'
/** Категория аудитории: обычное / детское / взрослое. */
export type ShowAudience = 'General' | 'Kids' | 'Adult'
export type ShowSummaryDto = {
id: string
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
episodeCount: number
seasonCount: number
year: number | null
hasPoster: boolean
createdAt: string
}
export type MetadataCandidate = {
externalId: string
title: string
year: number | null
overview: string | null
posterUrl: string | null
}
export type SeasonGapDto = {
season: number
expected: number | null
loaded: number
missing: number[]
}
export type MissingEpisodesReport = {
seasons: SeasonGapDto[]
}
export type EpisodeDto = {
id: string
mediaAssetId: string
position: number
assetName: string | null
assetStatus: MediaAssetStatus | null
durationSeconds: number | null
season: number | null
episode: number | null
title: string | null
overview: string | null
stillImageId: string | null
}
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
export type ImageDto = {
id: string
category: ImageCategory
originalFileName: string | null
createdAt: string
}
export type ShowDto = {
id: string
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
description: string | null
metadataProvider: string | null
metadataExternalId: string | null
year: number | null
posterImageId: string | null
episodes: EpisodeDto[]
}
// ── Каналы ────────────────────────────────────────────────────────────────
export type BlockMode = 'Count' | 'Duration'
export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
export type OverrideMode = 'Exclusive' | 'Boost'
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
export type BumperFont = 'Sans' | 'Serif'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free'
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
export type BumperSettings = {
font: BumperFont
minIntervalMinutes: number
selection: BumperSelection
/** Вероятность заставки на смене шоу (0..1). */
showChangeChance: number
/** Вероятность заставки между блоками одного шоу (0..1). */
episodeChangeChance: number
}
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
export type BumperTextVariantDto = {
id: string
position: number
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
weight: number
}
export type BumperTemplateDto = {
id: string
position: number
isDefault: boolean
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
backgroundImageId: string | null
hasAudio: boolean
audioDurationSeconds: number | null
variants: BumperTextVariantDto[]
}
export type ChannelSummaryDto = {
id: string
name: string
slug: string
isEnabled: boolean
}
/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */
export type HourWindow = { startHour: number; endHour: number }
export type ChannelShowDto = {
id: string
showId: string
showName: string
weight: number
blockMode: BlockMode
blockValue: number
isEnabled: boolean
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
preferredWeightMultiplier: number
preferredHours: HourWindow[]
}
export type ChannelAdDto = {
id: string
mediaAssetId: string
assetName: string | null
position: number
}
export type OverrideShowDto = { showId: string; showName: string; weight: number }
export type OverrideRecurrence = 'OneTime' | 'Weekly'
export type ProgrammingOverrideDto = {
id: string
mode: OverrideMode
recurrence: OverrideRecurrence
startsAtUtc: string | null
endsAtUtc: string | null
/** Weekly: день недели 0=Вс..6=Сб; окно минут суток (UTC). */
dayOfWeek: number | null
startMinute: number | null
endMinute: number | null
shows: OverrideShowDto[]
}
export type ChannelDto = {
id: string
name: string
slug: string
isEnabled: boolean
adInsertion: AdInsertion
adsPerBreak: number
bumpersEnabled: boolean
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
shows: ChannelShowDto[]
ads: ChannelAdDto[]
overrides: ProgrammingOverrideDto[]
}
export type ScheduleEntryDto = {
id: string
kind: ScheduleEntryKind
mediaAssetId: string
startsAtUtc: string
endsAtUtc: string
showId: string | null
showName: string | null
episodeIndex: number | null
seasonEpisode: string | null
/** Для заставок: имя подблока и его текст — для метки в расписании. */
bumperName: string | null
bumperText: string | null
}
// ── Публичный эфир ─────────────────────────────────────────────────────────
export type PublicChannelDto = {
id: string
slug: string
name: string
currentShowId: string | null
currentShowName: string | null
currentShowPosterImageId: string | null
}
export type PublicEpgEntryDto = {
kind: ScheduleEntryKind
startsAtUtc: string
endsAtUtc: string
showId: string | null
showName: string | null
showPosterImageId: string | null
episodeId: string | null
episodeTitle: string | null
episodeOverview: string | null
episodeStillImageId: string | null
}
export type ApiError = {
title: string
detail: string
status: number
}
export type CurrentUser = {
id: string
userName: string
role: string
}
export type AuthResponse = {
accessToken: string
expiresAt: string
user: CurrentUser
}
export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
export type RoleDto = {
id: string
name: string
isSystem: boolean
}
export type UserSummaryDto = {
id: string
userName: string
role: string
isBlocked: boolean
createdAt: string
}
export type PagedList<T> = {
items: T[]
total: number
page: number
pageSize: number
}
export type CreatedIdResponse = { id: string }
// ── Медиа ────────────────────────────────────────────────────────────────
export type MediaAssetStatus = 'Pending' | 'Processing' | 'Ready' | 'Failed'
export type MediaSource = 'Upload' | 'Inbox'
export type MediaAssetDto = {
id: string
originalFileName: string
source: MediaSource
status: MediaAssetStatus
durationSeconds: number | null
width: number | null
height: number | null
errorMessage: string | null
processingSeconds: number | null
createdAt: string
}
export type MediaStatsDto = {
queued: number
processing: number
averageProcessingSeconds: number | null
}
// ── Библиотека (жанры) ─────────────────────────────────────────────────────
export type GenreDto = {
id: string
name: string
slug: string
sortOrder: number
isSystem: boolean
/** Варианты написания для сопоставления с метаданными провайдеров. */
aliases: string[]
/** Сколько шоу используют жанр — при ненулевом удаление заблокировано. */
showCount: number
}
// ── Библиотека (шоу) ───────────────────────────────────────────────────────
export type ShowKind = 'Series' | 'Single'
/** Возрастная категория, по возрастанию строгости — порядок значим для правил планировщика. */
export type ShowAudience = 'Kids' | 'Family' | 'Teen' | 'General' | 'Adult'
/** Тот же порядок для селектов и списков. */
export const SHOW_AUDIENCES: ShowAudience[] = ['Kids', 'Family', 'Teen', 'General', 'Adult']
export type ShowSummaryDto = {
id: string
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
episodeCount: number
seasonCount: number
year: number | null
hasPoster: boolean
createdAt: string
/** Основной жанр — в списке показывается только он. */
primaryGenre: string | null
}
export type ShowGenreDto = {
id: string
name: string
isPrimary: boolean
}
// ── Библиотека (коллекции) ─────────────────────────────────────────────────
export type CollectionSummaryDto = {
id: string
name: string
description: string | null
posterImageId: string | null
/** Сколько частей в коллекции. */
itemCount: number
/** Сколько единиц воспроизведения суммарно — у сериала внутри коллекции их больше одной. */
unitCount: number
createdAt: string
}
export type CollectionItemDto = {
showId: string
position: number
showName: string
showKind: ShowKind
showAudience: ShowAudience
episodeCount: number
year: number | null
posterImageId: string | null
}
export type CollectionDto = {
id: string
name: string
description: string | null
posterImageId: string | null
createdAt: string
items: CollectionItemDto[]
}
/** Коллекция, в которую входит шоу — для блока на экране шоу. */
export type ShowCollectionRefDto = {
id: string
name: string
position: number
}
// ── Группы контента (планировщик) ──────────────────────────────────────────
export type GroupElementKind = 'Show' | 'Collection'
/** Правило быстрого набора состава. Пустые поля не ограничивают. */
export type GroupFilter = {
elementKinds?: GroupElementKind[] | null
showKinds?: ShowKind[] | null
genreIds?: string[] | null
maxAudience?: ShowAudience | null
yearMin?: number | null
yearMax?: number | null
unitMinutesMin?: number | null
unitMinutesMax?: number | null
}
export type GroupSummaryDto = {
id: string
name: string
description: string | null
itemCount: number
unitCount: number
totalDurationSeconds: number
hasFilter: boolean
statsComputedAt: string | null
createdAt: string
}
export type GroupItemDto = {
id: string
elementKind: GroupElementKind
elementId: string
elementName: string
weight: number
position: number
unitCount: number
showKind: ShowKind | null
audience: ShowAudience | null
year: number | null
posterImageId: string | null
}
export type GroupDto = {
id: string
name: string
description: string | null
filter: GroupFilter | null
itemCount: number
unitCount: number
totalDurationSeconds: number
statsComputedAt: string | null
items: GroupItemDto[]
}
/** Кандидат, найденный правилом набора. */
export type GroupCandidateDto = {
elementKind: GroupElementKind
elementId: string
elementName: string
unitCount: number
showKind: ShowKind | null
audience: ShowAudience | null
year: number | null
posterImageId: string | null
alreadyInGroup: boolean
}
export type MetadataCandidate = {
externalId: string
title: string
year: number | null
overview: string | null
posterUrl: string | null
}
export type SeasonGapDto = {
season: number
expected: number | null
loaded: number
missing: number[]
}
export type MissingEpisodesReport = {
seasons: SeasonGapDto[]
}
export type EpisodeDto = {
id: string
mediaAssetId: string
position: number
assetName: string | null
assetStatus: MediaAssetStatus | null
durationSeconds: number | null
season: number | null
episode: number | null
title: string | null
overview: string | null
stillImageId: string | null
}
export type ImageCategory = 'Library' | 'ShowPoster' | 'EpisodeStill' | 'BumperBackground'
export type ImageDto = {
id: string
category: ImageCategory
originalFileName: string | null
createdAt: string
}
export type ShowDto = {
id: string
name: string
originalName: string | null
kind: ShowKind
audience: ShowAudience
description: string | null
metadataProvider: string | null
metadataExternalId: string | null
year: number | null
posterImageId: string | null
episodes: EpisodeDto[]
genres: ShowGenreDto[]
collections: ShowCollectionRefDto[]
}
// ── Каналы ────────────────────────────────────────────────────────────────
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' | 'Fallback' | 'SignOff'
export type BumperFont = 'Sans' | 'Serif'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free'
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
/** Общие для канала настройки заставок (стиль/звук/текст — на блоках и подблоках). */
export type BumperSettings = {
font: BumperFont
minIntervalMinutes: number
selection: BumperSelection
/** Вероятность заставки на смене шоу (0..1). */
showChangeChance: number
/** Вероятность заставки между блоками одного шоу (0..1). */
episodeChangeChance: number
}
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
export type BumperTextVariantDto = {
id: string
position: number
name: string
kind: BumperTextKind
nowLabel: string
nextLabel: string
line1: string
line2: string
trigger: BumperTrigger
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
weight: number
}
export type BumperTemplateDto = {
id: string
position: number
isDefault: boolean
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
backgroundImageId: string | null
hasAudio: boolean
audioDurationSeconds: number | null
variants: BumperTextVariantDto[]
}
export type ChannelSummaryDto = {
id: string
name: string
slug: string
isEnabled: boolean
}
export type ChannelDto = {
id: string
name: string
slug: string
isEnabled: boolean
/** Номер канала для переключения по номерам (null — не задан). */
number: number | null
/** Смещение времени канала от UTC, минуты (180 — московское). */
utcOffsetMinutes: number
/** Начало вещательных суток в времени канала («06:00:00»). */
dayStartTime: string
templateId: string | null
bumpersEnabled: boolean
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
}
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
export type Daypart = 'Morning' | 'Day' | 'Prime' | 'Night'
export type SlotKind = 'Content' | 'Repeat' | 'SignOff'
export type SlotBlockMode = 'Count' | 'Duration' | 'FillSlot'
export type OverflowPolicy = 'ContinueNext' | 'ExtendSlot' | 'SkipIfNotFits'
export type SlotStrategyType = 'Sequential' | 'RandomWithCooldown' | 'Fixed'
export type CooldownFallback = 'OldestFirst' | 'IgnoreCooldown'
export type SlotStrategy = {
type: SlotStrategyType
restartOnEnd: boolean
cooldownDays: number
fallback: CooldownFallback
fixedElementId?: string | null
}
/** Что повторяет слот-повтор: точка в уже записанной ленте того же канала. */
export type RepeatSource = { daysAgo: number; time: string; durationMinutes: number }
export type SlotDto = {
id: string
layerId: string
/** День недели вещательных суток (0=Вс..6=Сб) или null — каждый день. */
weekday: number | null
targetStart: string
targetDurationMinutes: number
title: string
daypart: Daypart
slotKind: SlotKind
groupId: string | null
groupName: string | null
strategy: SlotStrategy | null
repeatSource: RepeatSource | null
blockMode: SlotBlockMode
blockValue: number
overflowPolicy: OverflowPolicy
isAnchor: boolean
maxDriftMinutes: number
/** Округление старта до кратного N минут (5/10/15/30) или null. */
snapToMinutes: number | null
}
export type DateRange = { from: string; to: string }
export type AnnualRange = { fromMonth: number; fromDay: number; toMonth: number; toDay: number }
/** Когда действует слой. Пустая применимость — слой действует всегда. */
export type LayerApplicability = {
weekdays?: number[] | null
dateRanges?: DateRange[] | null
annualRanges?: AnnualRange[] | null
specificDates?: string[] | null
}
export type GridLayerDto = {
id: string
name: string
priority: number
isEnabled: boolean
isBackground: boolean
applicability: LayerApplicability | null
slots: SlotDto[]
}
export type ScheduleTemplateDto = {
id: string
channelId: string
name: string
fallbackGroupId: string | null
revision: number
appliedRevision: number
/** Есть ли правки правил, не применённые к эфиру. */
hasPendingChanges: boolean
utcOffsetMinutes: number
dayStartTime: string
layers: GridLayerDto[]
}
export type PlanningWarningKind =
| 'SlotEmpty'
| 'DriftExceeded'
| 'CooldownExhausted'
| 'RepeatSourceEmpty'
| 'FallbackEmpty'
export type PlanningWarningDto = {
kind: PlanningWarningKind
slotId: string | null
details: string
}
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
export type ScheduleEntryDto = {
id: string
kind: ScheduleEntryKind
mediaAssetId: string
startsAtUtc: string
endsAtUtc: string
showId: string | null
showName: string | null
episodeIndex: number | null
seasonEpisode: string | null
/** Для заставок: имя подблока и его текст — для метки в расписании. */
bumperName: string | null
bumperText: string | null
}
// ── Публичный эфир ─────────────────────────────────────────────────────────
export type PublicChannelDto = {
id: string
slug: string
name: string
currentShowId: string | null
currentShowName: string | null
currentShowPosterImageId: string | null
}
export type PublicEpgEntryDto = {
kind: ScheduleEntryKind
startsAtUtc: string
endsAtUtc: string
showId: string | null
showName: string | null
showPosterImageId: string | null
episodeId: string | null
episodeTitle: string | null
episodeOverview: string | null
episodeStillImageId: string | null
}
File diff suppressed because it is too large Load Diff