Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s

This commit is contained in:
Leonid Pershin
2026-07-26 14:50:32 +03:00
parent 8494eb5e6a
commit f699576582
54 changed files with 5857 additions and 1631 deletions
+99 -1
View File
@@ -18,7 +18,24 @@ export type AuthResponse = {
export type RegistrationStatus = { enabled: boolean }
export type SiteSettings = { registrationEnabled: boolean; preferredAudioLanguages: string }
export type SiteSettings = {
registrationEnabled: boolean
preferredAudioLanguages: string
/** Переключение каналов по номерам у зрителя; сетка каналов остаётся всегда. */
channelNumbersEnabled: boolean
}
/** Угол экрана для логотипа-оверлея. */
export type LogoCorner = 'TopLeft' | 'TopRight' | 'BottomLeft' | 'BottomRight'
/** Как канал выглядит у зрителя — всё опционально и по умолчанию выключено (см. 6.8). */
export type ViewerSettings = {
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
}
export type RoleDto = {
id: string
@@ -360,6 +377,7 @@ export type ChannelDto = {
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
viewer: ViewerSettings
}
// ── Сетка канала (шаблон → слои → слоты) ──────────────────────────────────
@@ -466,6 +484,10 @@ export type AudienceWindow = { from: string; to: string; maxAudience: ShowAudien
export type PlanningRules = {
maxAudienceByTime?: AudienceWindow[] | null
maxRepeatsInWindow?: { windowDays: number; max: number } | null
/** Пост-проверки: считаются по готовой ленте и только предупреждают. */
maxBreakMinutesPerHour?: number | null
maxGenreSharePercent?: number | null
maxFallbackSharePercent?: number | null
}
export type ScheduleTemplateDto = {
@@ -493,6 +515,9 @@ export type PlanningWarningKind =
| 'RepeatSourceEmpty'
| 'FallbackEmpty'
| 'CandidatesFiltered'
| 'BreakLimitExceeded'
| 'GenreShareExceeded'
| 'FallbackShareExceeded'
export type PlanningWarningDto = {
kind: PlanningWarningKind
@@ -502,6 +527,72 @@ export type PlanningWarningDto = {
export type ApplyResultDto = { added: number; warnings: PlanningWarningDto[] }
/** Что изменится в эфире, если применить правила сейчас (см. 6.6). */
export type ScheduleChangeDto = {
startsAtUtc: string
before: string | null
after: string | null
/** Попадает в ближайшие сутки — подсвечивается отдельно. */
soon: boolean
}
export type ScheduleDiffDto = {
total: number
changed: number
changedSoon: number
changes: ScheduleChangeDto[]
}
/** Цепочка происхождения записи — «почему это здесь» (см. 6.5). */
export type EntryTraceDto = {
entryId: string
startsAtUtc: string
endsAtUtc: string
showName: string | null
episodeIndex: number | null
layerName: string | null
layerPriority: number | null
slotTitle: string | null
slotKind: SlotKind | null
slotWeekday: number | null
slotTargetStart: string | null
slotDurationMinutes: number | null
groupName: string | null
groupItemCount: number | null
strategy: SlotStrategyType | null
cooldownDays: number | null
candidatesAfterCooldown: number | null
driftMinutes: number
snapped: boolean
junctionName: string | null
}
export type CopyTemplateResultDto = {
layers: number
slots: number
junctions: number
/** Врезки-заставки, для которых на канале-приёмнике не нашлось блока с таким же именем. */
droppedBumperRefs: number
}
/** Проверки сетки по правилам, до генерации (см. 5.1). */
export type TemplateIssueKind =
| 'GroupEmpty'
| 'GroupTooSmall'
| 'GridGap'
| 'SlotOverlap'
| 'CooldownUnreachable'
| 'AudienceConflict'
| 'GroupMissing'
export type TemplateIssueDto = {
kind: TemplateIssueKind
severity: 'Warning' | 'Error'
layerId: string | null
slotId: string | null
details: string
}
/** Что попало в ленту предпросмотра. `Bumper` приходит без ассета — он рендерится при применении. */
export type PlannedItemKind = 'Program' | 'Fallback' | 'SignOff' | 'Ad' | 'Promo' | 'Bumper'
@@ -543,9 +634,16 @@ export type PublicChannelDto = {
id: string
slug: string
name: string
/** Номер канала для переключения по номерам или null. */
number: number | null
currentShowId: string | null
currentShowName: string | null
currentShowPosterImageId: string | null
logoImageId: string | null
logoCorner: LogoCorner
logoOpacity: number
showClock: boolean
analogFilterStrength: number
}
export type PublicEpgEntryDto = {
+131 -2
View File
@@ -66,6 +66,7 @@ const resources = {
noChannels: 'Пока нет доступных каналов. Загляните позже.',
offline: 'Канал сейчас не в эфире',
offlineHint: 'Нет расписания или контента. Загляните позже.',
numbersHint: '↑ / ↓ — переключение каналов по номерам',
retry: 'Повторить',
},
settings: {
@@ -378,7 +379,65 @@ const resources = {
previewDays_one: '{{count}} сутки',
previewDays_few: '{{count}} суток',
previewDays_many: '{{count}} суток',
previewTabs: { programme: 'Программа', tape: 'Лента' },
previewTabs: { programme: 'Программа', tape: 'Лента', problems: 'Проблемы' },
noProblems: 'Проблем нет',
andMore: 'и ещё {{count}}',
heatmap: 'Повторы: шоу × сутки',
heatmapTotal: 'всего',
issues: 'Проверки: ошибок {{errors}}, предупреждений {{warnings}}',
goToSlot: 'к слоту',
issueKinds: {
GroupEmpty: 'Пустая группа',
GroupTooSmall: 'Мало контента',
GridGap: 'Дыра в сетке',
SlotOverlap: 'Слоты пересекаются',
CooldownUnreachable: 'Недостижимое остывание',
AudienceConflict: 'Возрастной конфликт',
GroupMissing: 'Группа не выбрана',
},
viewer: 'Как выглядит у зрителя',
viewerHint:
'Оверлеи рисуются поверх картинки на клиенте — видео не перекодируется. Всё по умолчанию выключено.',
logo: 'Логотип',
noLogo: 'нет',
pickLogo: 'Выбрать логотип',
logoCorner: 'Угол',
logoOpacity: 'Прозрачность',
corners: {
TopLeft: 'Слева вверху',
TopRight: 'Справа вверху',
BottomLeft: 'Слева внизу',
BottomRight: 'Справа внизу',
},
showClock: 'Показывать часы',
analogFilter: 'Аналоговый фильтр',
analogFilterHint: 'Сила 0..1. Ноль — выключен; переборщить очень легко.',
whyHere: 'Почему это здесь',
priority: 'приоритет',
traceLayer: 'Слой',
traceSlot: 'Слот',
traceGroup: 'Группа',
traceStrategy: 'Стратегия',
traceJunction: 'Врезки',
traceDrift: 'дрейф {{minutes}} мин',
traceSnapped: 'старт округлён',
traceCooldown: 'остывание {{days}} дн.',
traceCandidates: 'кандидатов после остывания: {{count}}',
diffSummary: 'Затронет {{total}} записей, изменятся {{changed}}',
diffSoon: 'В ближайшие сутки изменится записей: {{count}}',
diffNoChanges: 'Эфир не изменится',
copyTemplate: 'Копировать сетку',
pickTargetChannel: 'Выберите канал',
copyTemplateHint:
'Слои, слоты, стыки и правила уедут на выбранный канал, его прежняя сетка заменится. Группы общие и не копируются.',
templateCopied: 'Скопировано: слоёв {{layers}}, слотов {{slots}}',
copyDroppedBumpers: 'Врезок без блока заставки: {{count}} — донастройте руками',
postChecks: 'Пост-проверки',
breakLimit: 'Потолок врезок в час, мин',
genreShare: 'Потолок доли жанра за сутки, %',
fallbackShare: 'Потолок доли фона, %',
postChecksHint:
'Пост-проверки считаются по готовой ленте и только предупреждают — ничего не переигрывается.',
previewKinds: {
Program: 'Программа',
Fallback: 'Фон',
@@ -454,6 +513,9 @@ const resources = {
RepeatSourceEmpty: 'Нечего повторять',
FallbackEmpty: 'Нечем закрыть паузы',
CandidatesFiltered: 'Возрастной потолок отсёк всех',
BreakLimitExceeded: 'Врезок в часе больше потолка',
GenreShareExceeded: 'Доля жанра выше нормы',
FallbackShareExceeded: 'Фона в эфире больше нормы',
},
title: 'Каналы',
@@ -565,6 +627,9 @@ const resources = {
'Когда выключено — новые пользователи не могут регистрироваться сами, учётки заводит только администратор.',
registrationLabel: 'Разрешить регистрацию на сайте',
preferredAudio: 'Предпочитаемые озвучки',
channelNumbers: 'Переключение каналов по номерам',
channelNumbersHint:
'Зритель переключает каналы стрелками, как на телевизоре. Сетка каналов остаётся всегда.',
preferredAudioHint:
'Коды языков через запятую в порядке приоритета (напр. «rus, eng»). Если в файле есть дорожка с таким языком — при обработке выбирается она (по порядку); иначе — выбор ffmpeg по умолчанию. Применяется к новым обработкам.',
},
@@ -974,7 +1039,65 @@ const resources = {
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
previewDays_one: '{{count}} day',
previewDays_other: '{{count}} days',
previewTabs: { programme: 'Programme', tape: 'Tape' },
previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
noProblems: 'No problems',
andMore: 'and {{count}} more',
heatmap: 'Repeats: show × day',
heatmapTotal: 'total',
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
goToSlot: 'to slot',
issueKinds: {
GroupEmpty: 'Empty group',
GroupTooSmall: 'Too little content',
GridGap: 'Gap in the grid',
SlotOverlap: 'Slots overlap',
CooldownUnreachable: 'Unreachable cooldown',
AudienceConflict: 'Age conflict',
GroupMissing: 'No group selected',
},
viewer: 'How viewers see it',
viewerHint:
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
logo: 'Logo',
noLogo: 'none',
pickLogo: 'Pick a logo',
logoCorner: 'Corner',
logoOpacity: 'Opacity',
corners: {
TopLeft: 'Top left',
TopRight: 'Top right',
BottomLeft: 'Bottom left',
BottomRight: 'Bottom right',
},
showClock: 'Show a clock',
analogFilter: 'Analog filter',
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
whyHere: 'Why is this here',
priority: 'priority',
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',
traceSnapped: 'start snapped',
traceCooldown: 'cooldown {{days}} d.',
traceCandidates: 'candidates after cooldown: {{count}}',
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
diffSoon: 'Entries changing within 24 hours: {{count}}',
diffNoChanges: 'The air will not change',
copyTemplate: 'Copy grid',
pickTargetChannel: 'Pick a channel',
copyTemplateHint:
'Layers, slots, junctions and rules move to the chosen channel, replacing its grid. Groups are shared and not copied.',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
postChecks: 'Post-checks',
breakLimit: 'Breaks per hour cap, min',
genreShare: 'Genre share per day cap, %',
fallbackShare: 'Background share cap, %',
postChecksHint:
'Post-checks run against the finished tape and only warn — nothing is replanned.',
previewKinds: {
Program: 'Programme',
Fallback: 'Background',
@@ -1045,6 +1168,9 @@ const resources = {
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
FallbackShareExceeded: 'Background share above the norm',
},
title: 'Channels',
@@ -1155,6 +1281,9 @@ const resources = {
registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration',
channelNumbers: 'Switch channels by number',
channelNumbersHint:
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
preferredAudio: 'Preferred audio tracks',
preferredAudioHint:
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',