Add TV bumpers functionality: introduce configuration options for bumpers in .env.example and appsettings.json, enhance ChannelEndpoints to manage jingles and bumper assets, and update Channel and ScheduleEntry models to support bumper logic. Implement validation for bumper settings and integrate bumper handling in scheduling logic.

This commit is contained in:
Leonid Pershin
2026-07-25 00:45:22 +03:00
parent 622bf1e440
commit 0cfc72166a
60 changed files with 5073 additions and 33 deletions
@@ -7,6 +7,9 @@ import { HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
BumperFont,
BumperMode,
BumperSettings,
ChannelShowDto,
OverrideMode,
ScheduleEntryDto,
@@ -22,16 +25,22 @@ import { listMedia } from '@/features/admin/media/api'
import { listShows } from '@/features/admin/shows/api'
import {
addChannelAd,
addChannelJingle,
addChannelShow,
clearBumperBackground,
clearBumperMusic,
createOverride,
deleteOverride,
getChannel,
getSchedule,
regenerateSchedule,
removeChannelAd,
removeChannelJingle,
removeChannelShow,
updateChannelSettings,
updateChannelShow,
uploadBumperBackground,
uploadBumperMusic,
} from './api'
function formatTime(iso: string) {
@@ -184,6 +193,39 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</CardContent>
</Card>
{/* Джинглы-отбивки (статичные заставки) */}
<Card>
<CardHeader>
<CardTitle>{t('admin.channels.jingles')}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">{t('admin.channels.jinglesHint')}</p>
<AddJingleForm
channelId={channelId}
options={(ready?.items ?? [])
.filter((a) => !channel.jingles.some((j) => j.mediaAssetId === a.id))
.map((a) => ({ id: a.id, name: a.originalFileName }))}
onAdded={invalidate}
onError={onError}
/>
<ul className="flex flex-col divide-y divide-border">
{channel.jingles.map((j) => (
<li key={j.id} className="flex items-center justify-between py-2 text-sm">
<span>{j.assetName ?? '—'}</span>
<RemoveButton
onClick={() =>
removeChannelJingle(channelId, j.id).then(invalidate).catch(onError)
}
/>
</li>
))}
{channel.jingles.length === 0 && (
<li className="py-2 text-muted-foreground">{t('admin.channels.noJingles')}</li>
)}
</ul>
</CardContent>
</Card>
{/* Override'ы / марафоны */}
<Card>
<CardHeader>
@@ -245,13 +287,20 @@ function SettingsCard({
const [isEnabled, setIsEnabled] = useState(channel.isEnabled)
const [adInsertion, setAdInsertion] = useState<AdInsertion>(channel.adInsertion)
const [adsPerBreak, setAdsPerBreak] = useState(channel.adsPerBreak)
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
const [fillerAssetId, setFillerAssetId] = useState(channel.fillerAssetId ?? '')
const setBumperField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
useEffect(() => {
setName(channel.name)
setIsEnabled(channel.isEnabled)
setAdInsertion(channel.adInsertion)
setAdsPerBreak(channel.adsPerBreak)
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
setFillerAssetId(channel.fillerAssetId ?? '')
}, [channel])
@@ -262,6 +311,8 @@ function SettingsCard({
isEnabled,
adInsertion,
adsPerBreak,
bumpersEnabled,
bumper,
fillerAssetId: fillerAssetId || null,
}),
onSuccess: () => {
@@ -327,6 +378,31 @@ function SettingsCard({
/>
{t('admin.channels.enabledLabel')}
</label>
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
className="mt-1"
checked={bumpersEnabled}
onChange={(e) => setBumpersEnabled(e.target.checked)}
/>
<span>
{t('admin.channels.bumpersLabel')}
<span className="block text-xs text-muted-foreground">
{t('admin.channels.bumpersHint')}
</span>
</span>
</label>
{bumpersEnabled && (
<div className="sm:col-span-2">
<BumperSettingsFields
channelId={channel.id}
bumper={bumper}
setField={setBumperField}
onSaved={onSaved}
onError={onError}
/>
</div>
)}
<div className="flex items-end justify-end sm:col-span-2">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
@@ -337,6 +413,236 @@ function SettingsCard({
)
}
/** ffmpeg-цвет (0xRRGGBB / имя) → CSS для превью-плашки. */
function cssColor(value: string): string {
const v = value.trim()
if (v.startsWith('0x')) return `#${v.slice(2)}`
return v
}
function BumperSettingsFields({
channelId,
bumper,
setField,
onSaved,
onError,
}: {
channelId: string
bumper: BumperSettings
setField: <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) => void
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const colors: { key: keyof BumperSettings; label: string }[] = [
{ key: 'backgroundColor', label: t('admin.channels.bumperBg') },
{ key: 'backgroundColor2', label: t('admin.channels.bumperBg2') },
{ key: 'accentColor', label: t('admin.channels.bumperAccent') },
{ key: 'textColor', label: t('admin.channels.bumperText') },
]
// Оформление/подписи/шрифт нужны только динамическим заставкам.
const showDynamicStyle = bumper.mode !== 'Static'
return (
<div className="flex flex-col gap-4 rounded-md border border-border bg-muted/30 p-4">
<p className="text-sm font-medium">{t('admin.channels.bumperStyle')}</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMode')}</Label>
<Select value={bumper.mode} onValueChange={(v) => setField('mode', v as BumperMode)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Dynamic">{t('admin.channels.bumperModeDynamic')}</SelectItem>
<SelectItem value="Static">{t('admin.channels.bumperModeStatic')}</SelectItem>
<SelectItem value="Both">{t('admin.channels.bumperModeBoth')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperDuration')}</Label>
<Input
type="number"
min={2}
max={30}
value={bumper.durationSeconds}
onChange={(e) => setField('durationSeconds', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperMinInterval')}</Label>
<Input
type="number"
min={0}
max={1440}
value={bumper.minIntervalMinutes}
onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperFont')}</Label>
<Select value={bumper.font} onValueChange={(v) => setField('font', v as BumperFont)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Sans">{t('admin.channels.bumperFontSans')}</SelectItem>
<SelectItem value="Serif">{t('admin.channels.bumperFontSerif')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNowLabel')}</Label>
<Input
value={bumper.nowLabel}
maxLength={64}
onChange={(e) => setField('nowLabel', e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperNextLabel')}</Label>
<Input
value={bumper.nextLabel}
maxLength={64}
onChange={(e) => setField('nextLabel', e.target.value)}
/>
</div>
{colors.map(({ key, label }) => (
<div key={key} className="flex flex-col gap-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<span
className="h-8 w-8 shrink-0 rounded border border-border"
style={{ backgroundColor: cssColor(bumper[key] as string) }}
/>
<Input
value={bumper[key] as string}
onChange={(e) => setField(key, e.target.value as never)}
/>
</div>
</div>
))}
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={bumper.onlyBetweenDifferentShows}
onChange={(e) => setField('onlyBetweenDifferentShows', e.target.checked)}
/>
{t('admin.channels.bumperOnlyDifferent')}
</label>
{showDynamicStyle && (
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={bumper.hasBackground}
accept="image/*,video/mp4,video/webm,video/quicktime,video/x-matroska"
clear={clearBumperBackground}
upload={uploadBumperBackground}
onSaved={onSaved}
onError={onError}
/>
<BumperFileUpload
channelId={channelId}
kind="music"
label={t('admin.channels.bumperMusic')}
hint={t('admin.channels.bumperMusicHint')}
has={bumper.hasMusic}
accept="audio/*"
clear={clearBumperMusic}
upload={uploadBumperMusic}
onSaved={onSaved}
onError={onError}
/>
</div>
)}
</div>
)
}
function BumperFileUpload({
channelId,
kind,
label,
hint,
has,
accept,
upload,
clear,
onSaved,
onError,
}: {
channelId: string
kind: string
label: string
hint: string
has: boolean
accept: string
upload: (id: string, file: File) => Promise<void>
clear: (id: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${channelId}`
const uploadMutation = useMutation({
mutationFn: (file: File) => upload(channelId, file),
onSuccess: onSaved,
onError,
})
const clearMutation = useMutation({
mutationFn: () => clear(channelId),
onSuccess: onSaved,
onError,
})
const busy = uploadMutation.isPending || clearMutation.isPending
return (
<div className="flex flex-col gap-1.5">
<Label>
{label}{' '}
<span className={has ? 'text-emerald-500' : 'text-muted-foreground'}>
{has ? `· ${t('admin.channels.bumperFileLoaded')}` : `· ${t('admin.channels.bumperFileDefault')}`}
</span>
</Label>
<span className="text-xs text-muted-foreground">{hint}</span>
<div className="flex items-center gap-2">
<input
id={inputId}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) uploadMutation.mutate(file)
e.target.value = ''
}}
/>
<Button
size="sm"
variant="outline"
disabled={busy}
onClick={() => document.getElementById(inputId)?.click()}
>
{t('admin.channels.bumperUpload')}
</Button>
{has && (
<Button size="sm" variant="ghost" disabled={busy} onClick={() => clearMutation.mutate()}>
{t('admin.channels.bumperReset')}
</Button>
)}
</div>
</div>
)
}
function AddShowForm({
channelId,
options,
@@ -521,6 +827,50 @@ function AddAdForm({
)
}
function AddJingleForm({
channelId,
options,
onAdded,
onError,
}: {
channelId: string
options: { id: string; name: string }[]
onAdded: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [assetId, setAssetId] = useState('')
const add = useMutation({
mutationFn: () => addChannelJingle(channelId, assetId),
onSuccess: () => {
setAssetId('')
onAdded()
},
onError,
})
return (
<div className="flex flex-wrap items-end gap-2">
<Select value={assetId} onValueChange={setAssetId}>
<SelectTrigger className="max-w-md">
<SelectValue placeholder={t('admin.channels.pickJingle')} />
</SelectTrigger>
<SelectContent>
{options.map((o) => (
<SelectItem key={o.id} value={o.id}>
{o.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm" disabled={!assetId || add.isPending} onClick={() => add.mutate()}>
{t('common.create')}
</Button>
</div>
)
}
function OverrideForm({
channelId,
options,
@@ -611,6 +961,8 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) {
<span className="w-28 shrink-0 text-muted-foreground">{formatTime(e.startsAtUtc)}</span>
{e.kind === 'Ad' ? (
<Badge variant="muted">{t('air.ad')}</Badge>
) : e.kind === 'Bumper' ? (
<Badge variant="muted">{t('air.bumper')}</Badge>
) : (
<span>
{e.showName ?? '—'}
+58 -1
View File
@@ -1,7 +1,8 @@
import { apiRequest } from '@/shared/api/client'
import { apiRequest, getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
BumperSettings,
ChannelDto,
ChannelSummaryDto,
CreatedIdResponse,
@@ -26,6 +27,8 @@ export type ChannelSettingsBody = {
isEnabled: boolean
adInsertion: AdInsertion
adsPerBreak: number
bumpersEnabled: boolean
bumper: BumperSettings
fillerAssetId: string | null
}
@@ -67,6 +70,60 @@ export function removeChannelAd(id: string, channelAdId: string) {
return apiRequest<void>(`/admin/channels/${id}/ads/${channelAdId}`, { method: 'DELETE' })
}
export function addChannelJingle(id: string, mediaAssetId: string) {
return apiRequest<CreatedIdResponse>(`/admin/channels/${id}/jingles`, {
method: 'POST',
body: { mediaAssetId },
})
}
export function removeChannelJingle(id: string, channelJingleId: string) {
return apiRequest<void>(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' })
}
/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperFile(id: string, kind: 'background' | 'music', 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/${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 uploadBumperBackground(id: string, file: File) {
return uploadBumperFile(id, 'background', file)
}
export function uploadBumperMusic(id: string, file: File) {
return uploadBumperFile(id, 'music', file)
}
export function clearBumperBackground(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' })
}
export function clearBumperMusic(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' })
}
export type OverrideBody = {
mode: OverrideMode
startsAtUtc: string