Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning.

This commit is contained in:
Leonid Pershin
2026-07-25 18:44:28 +03:00
parent 358adbff21
commit 8718e8b6bf
36 changed files with 2921 additions and 97 deletions
@@ -18,6 +18,7 @@ import type {
BumperTextVariantDto,
BumperTrigger,
ChannelShowDto,
HourWindow,
OverrideMode,
ScheduleEntryDto,
} from '@/shared/api/types'
@@ -381,6 +382,13 @@ function cssColor(value: string): string {
return v
}
/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */
function clampChance(value: string): number {
const n = Number(value)
if (Number.isNaN(n)) return 0
return Math.min(1, Math.max(0, n))
}
function BumperCard({
channel,
onSaved,
@@ -461,6 +469,9 @@ function BumperCard({
<SelectContent>
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="WeightedRandom">
{t('admin.channels.bumperSelectionWeighted')}
</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</SelectItem>
@@ -489,6 +500,34 @@ function BumperCard({
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperShowChangeChance')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={bumper.showChangeChance}
onChange={(e) => setField('showChangeChance', clampChance(e.target.value))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperShowChangeChanceHint')}
</span>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperEpisodeChangeChance')}</Label>
<Input
type="number"
min={0}
max={1}
step={0.05}
value={bumper.episodeChangeChance}
onChange={(e) => setField('episodeChangeChance', clampChance(e.target.value))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperEpisodeChangeChanceHint')}
</span>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
@@ -737,6 +776,7 @@ function BumperVariantEditor({
line1: variant.line1,
line2: variant.line2,
trigger: variant.trigger,
weight: variant.weight,
})
useEffect(() => {
@@ -748,6 +788,7 @@ function BumperVariantEditor({
line1: variant.line1,
line2: variant.line2,
trigger: variant.trigger,
weight: variant.weight,
})
}, [variant])
@@ -805,6 +846,19 @@ function BumperVariantEditor({
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperVariantWeight')}</Label>
<Input
type="number"
min={0}
max={1000}
value={form.weight}
onChange={(e) => set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))}
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperVariantWeightHint')}
</span>
</div>
</div>
<div className="grid gap-2 sm:grid-cols-2">
@@ -1188,64 +1242,176 @@ function ChannelShowRow({
const [blockMode, setBlockMode] = useState<BlockMode>(row.blockMode)
const [blockValue, setBlockValue] = useState(row.blockValue)
const [isEnabled, setIsEnabled] = useState(row.isEnabled)
const [expanded, setExpanded] = useState(false)
const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier)
const [hours, setHours] = useState<HourWindow[]>(row.preferredHours)
const save = useMutation({
mutationFn: () =>
updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }),
updateChannelShow(channelId, row.id, {
weight,
blockMode,
blockValue,
isEnabled,
preferredWeightMultiplier: multiplier,
preferredHours: hours.filter((h) => h.startHour < h.endHour),
}),
onSuccess: onChanged,
onError,
})
const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }])
const setHour = (i: number, patch: Partial<HourWindow>) =>
setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w)))
const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i))
return (
<tr className="border-b border-border last:border-0">
<td className="py-2">{row.showName}</td>
<td className="py-2">
<Input
type="number"
min={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
className="h-8 w-16"
/>
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<>
<tr className="border-b border-border last:border-0">
<td className="py-2">{row.showName}</td>
<td className="py-2">
<Input
type="number"
min={1}
value={blockValue}
onChange={(e) => setBlockValue(Number(e.target.value))}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
className="h-8 w-16"
/>
</div>
</td>
<td className="py-2">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
</td>
<td className="py-2">
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
<RemoveButton
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
</td>
<td className="py-2">
<div className="flex items-center gap-1">
<Select value={blockMode} onValueChange={(v) => setBlockMode(v as BlockMode)}>
<SelectTrigger className="h-8 w-36 whitespace-nowrap">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Count">{t('admin.channels.blockCount')}</SelectItem>
<SelectItem value="Duration">{t('admin.channels.blockDuration')}</SelectItem>
</SelectContent>
</Select>
<Input
type="number"
min={1}
value={blockValue}
onChange={(e) => setBlockValue(Number(e.target.value))}
className="h-8 w-16"
/>
</div>
</td>
<td className="py-2">
<input
type="checkbox"
checked={isEnabled}
onChange={(e) => setIsEnabled(e.target.checked)}
/>
</div>
</td>
</tr>
</td>
<td className="py-2">
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="ghost"
className="whitespace-nowrap"
onClick={() => setExpanded((v) => !v)}
>
{t('admin.channels.preferredHours')}
{hours.length > 0 ? ` (${hours.length})` : ''}
</Button>
<Button size="sm" variant="outline" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
<RemoveButton
onClick={() => removeChannelShow(channelId, row.id).then(onChanged).catch(onError)}
/>
</div>
</td>
</tr>
{expanded && (
<tr className="border-b border-border last:border-0">
<td colSpan={5} className="bg-muted/30 py-3">
<div className="flex flex-col gap-3 pl-1">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">
{t('admin.channels.preferredMultiplier')}
</Label>
<Input
type="number"
min={1}
max={100}
value={multiplier}
onChange={(e) => setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))}
className="h-8 w-20"
/>
<span className="text-xs text-muted-foreground">
{t('admin.channels.preferredHoursHint')}
</span>
</div>
{hours.length === 0 && (
<p className="text-xs text-muted-foreground">{t('admin.channels.preferredNone')}</p>
)}
{hours.map((w, i) => (
<div key={i} className="flex items-center gap-2">
<HourSelect
value={w.startHour}
from={0}
to={23}
onChange={(v) => setHour(i, { startHour: v })}
/>
<span className="text-muted-foreground"></span>
<HourSelect
value={w.endHour}
from={1}
to={24}
onChange={(v) => setHour(i, { endHour: v })}
/>
{w.startHour >= w.endHour && (
<span className="text-xs text-destructive">
{t('admin.channels.preferredBadRange')}
</span>
)}
<Button size="sm" variant="ghost" onClick={() => removeHour(i)}>
{t('common.delete')}
</Button>
</div>
))}
<div>
<Button size="sm" variant="outline" onClick={addHour}>
{t('admin.channels.preferredAddWindow')}
</Button>
</div>
</div>
</td>
</tr>
)}
</>
)
}
/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */
function HourSelect({
value,
from,
to,
onChange,
}: {
value: number
from: number
to: number
onChange: (v: number) => void
}) {
const options = Array.from({ length: to - from + 1 }, (_, i) => from + i)
return (
<Select value={String(value)} onValueChange={(v) => onChange(Number(v))}>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((h) => (
<SelectItem key={h} value={String(h)}>
{String(h).padStart(2, '0')}:00
</SelectItem>
))}
</SelectContent>
</Select>
)
}
@@ -1386,7 +1552,18 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<Badge variant="muted">{t('air.bumper')}</Badge>
<span className="flex min-w-0 items-center gap-2">
<Badge variant="muted" className="shrink-0">
{t('air.bumper')}
</Badge>
{(e.bumperName || e.bumperText) && (
<span className="min-w-0 truncate text-muted-foreground">
{e.bumperName}
{e.bumperName && e.bumperText ? ' · ' : ''}
{e.bumperText}
</span>
)}
</span>
) : (
<span>
{e.showName ?? '—'}
+10 -1
View File
@@ -8,6 +8,7 @@ import type {
ChannelDto,
ChannelSummaryDto,
CreatedIdResponse,
HourWindow,
OverrideMode,
ScheduleEntryDto,
} from '@/shared/api/types'
@@ -52,7 +53,14 @@ export function addChannelShow(id: string, body: ChannelShowBody) {
export function updateChannelShow(
id: string,
channelShowId: string,
body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean },
body: {
weight: number
blockMode: BlockMode
blockValue: number
isEnabled: boolean
preferredWeightMultiplier: number
preferredHours: HourWindow[]
},
) {
return apiRequest<void>(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body })
}
@@ -147,6 +155,7 @@ export type BumperVariantBody = {
line1: string
line2: string
trigger: BumperTrigger
weight: number
}
export function addBumperVariant(id: string, templateId: string, name: string) {
+16 -1
View File
@@ -127,7 +127,7 @@ 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'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom'
export type BumperTextKind = 'NowNext' | 'Free'
export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both'
@@ -136,6 +136,10 @@ export type BumperSettings = {
font: BumperFont
minIntervalMinutes: number
selection: BumperSelection
/** Вероятность заставки на смене шоу (0..1). */
showChangeChance: number
/** Вероятность заставки между блоками одного шоу (0..1). */
episodeChangeChance: number
}
/** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */
@@ -149,6 +153,8 @@ export type BumperTextVariantDto = {
line1: string
line2: string
trigger: BumperTrigger
/** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */
weight: number
}
export type BumperTemplateDto = {
@@ -173,6 +179,9 @@ export type ChannelSummaryDto = {
isEnabled: boolean
}
/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */
export type HourWindow = { startHour: number; endHour: number }
export type ChannelShowDto = {
id: string
showId: string
@@ -182,6 +191,9 @@ export type ChannelShowDto = {
blockValue: number
isEnabled: boolean
nextEpisodeIndex: number
/** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */
preferredWeightMultiplier: number
preferredHours: HourWindow[]
}
export type ChannelAdDto = {
@@ -227,6 +239,9 @@ export type ScheduleEntryDto = {
showName: string | null
episodeIndex: number | null
seasonEpisode: string | null
/** Для заставок: имя подблока и его текст — для метки в расписании. */
bumperName: string | null
bumperText: string | null
}
// ── Публичный эфир ─────────────────────────────────────────────────────────
+28
View File
@@ -203,8 +203,13 @@ const resources = {
bumperSelection: 'Выбор блока',
bumperSelectionRotation: 'По кругу',
bumperSelectionRandom: 'Случайно',
bumperSelectionWeighted: 'Случайно взвешенный',
bumperSelectionAlwaysFirst: 'Всегда первый',
bumperMinInterval: 'Мин. интервал, мин',
bumperShowChangeChance: 'Вероятность на смене шоу',
bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда',
bumperEpisodeChangeChance: 'Вероятность между сериями',
bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями',
bumperFont: 'Шрифт',
bumperFontSans: 'Гротеск',
bumperFontSerif: 'Антиква',
@@ -234,6 +239,8 @@ const resources = {
bumperTriggerOnShowChange: 'При смене шоу',
bumperTriggerBetweenEpisodes: 'Между сериями',
bumperTriggerBoth: 'Оба',
bumperVariantWeight: 'Вес',
bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)',
bumperDefault: 'по умолчанию',
bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)',
@@ -262,6 +269,13 @@ const resources = {
blockDuration: 'По времени',
episodes: 'серий',
minutes: 'минут',
preferredHours: 'Часы',
preferredMultiplier: 'Множитель веса',
preferredHoursHint:
'В выбранные часы вес шоу умножается — оно чаще попадает в эфир. Время в UTC.',
preferredNone: 'Окна не заданы — предпочтений по времени нет.',
preferredAddWindow: 'Добавить окно',
preferredBadRange: 'начало ≥ конца',
ads: 'Реклама',
pickAd: 'Выберите ролик',
noAds: 'Пул рекламы пуст',
@@ -523,8 +537,13 @@ const resources = {
bumperSelection: 'Block selection',
bumperSelectionRotation: 'Rotation',
bumperSelectionRandom: 'Random',
bumperSelectionWeighted: 'Weighted random',
bumperSelectionAlwaysFirst: 'Always first',
bumperMinInterval: 'Min interval, min',
bumperShowChangeChance: 'Chance on show change',
bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never',
bumperEpisodeChangeChance: 'Chance between episodes',
bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions',
bumperFont: 'Font',
bumperFontSans: 'Sans',
bumperFontSerif: 'Serif',
@@ -554,6 +573,8 @@ const resources = {
bumperTriggerOnShowChange: 'Show change',
bumperTriggerBetweenEpisodes: 'Between episodes',
bumperTriggerBoth: 'Both',
bumperVariantWeight: 'Weight',
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)',
bumperDefault: 'default',
bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)',
@@ -582,6 +603,13 @@ const resources = {
blockDuration: 'By time',
episodes: 'episodes',
minutes: 'minutes',
preferredHours: 'Hours',
preferredMultiplier: 'Weight multiplier',
preferredHoursHint:
'During the selected hours the shows weight is multiplied, so it airs more often. Times are UTC.',
preferredNone: 'No windows set — no time preference.',
preferredAddWindow: 'Add window',
preferredBadRange: 'start ≥ end',
ads: 'Ads',
pickAd: 'Pick an ad',
noAds: 'Ad pool is empty',