Refactor Channel endpoints and data models: replace jingle functionality with bumper templates, update related commands and handlers, and enhance API routes for managing bumper templates. Remove obsolete jingle-related code and adjust channel data structures to support new bumper template features.

This commit is contained in:
Leonid Pershin
2026-07-25 11:02:16 +03:00
parent 27571a4ab6
commit 84c2867062
60 changed files with 2805 additions and 1045 deletions
@@ -1,15 +1,17 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Link } from '@tanstack/react-router'
import { type ReactNode, useEffect, useState } from 'react'
import Hls from 'hls.js'
import { type ReactNode, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDown, ChevronLeft, RefreshCw } from 'lucide-react'
import { HttpError } from '@/shared/api/client'
import { getAccessToken, HttpError } from '@/shared/api/client'
import type {
AdInsertion,
BlockMode,
BumperFont,
BumperMode,
BumperSelection,
BumperSettings,
BumperTemplateDto,
ChannelShowDto,
OverrideMode,
ScheduleEntryDto,
@@ -24,23 +26,26 @@ import { toast } from '@/shared/ui/toast-store'
import { listMedia } from '@/features/admin/media/api'
import { listShows } from '@/features/admin/shows/api'
import {
addBumperTemplate,
addChannelAd,
addChannelJingle,
addChannelShow,
clearBumperBackground,
clearBumperMusic,
bumperPreviewPlaylistUrl,
clearBumperTemplateAudio,
clearBumperTemplateBackground,
createOverride,
deleteOverride,
getChannel,
getSchedule,
regenerateSchedule,
removeBumperTemplate,
removeChannelAd,
removeChannelJingle,
removeChannelShow,
renderBumperPreview,
updateBumperTemplate,
updateChannelSettings,
updateChannelShow,
uploadBumperBackground,
uploadBumperMusic,
uploadBumperTemplateAudio,
uploadBumperTemplateBackground,
} from './api'
function formatTime(iso: string) {
@@ -117,6 +122,8 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
<SettingsCard channel={channel} readyAssets={ready?.items ?? []} onSaved={invalidate} onError={onError} />
<BumperCard channel={channel} onSaved={invalidate} onError={onError} />
{/* Шоу канала */}
<CollapsibleCard title={t('admin.channels.shows')} contentClassName="flex flex-col gap-3">
<AddShowForm
@@ -183,34 +190,6 @@ export function ChannelDetail({ channelId }: { channelId: string }) {
</ul>
</CollapsibleCard>
{/* Джинглы-отбивки (статичные заставки) */}
<CollapsibleCard title={t('admin.channels.jingles')} contentClassName="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>
</CollapsibleCard>
{/* Override'ы / марафоны */}
<CollapsibleCard title={t('admin.channels.overrides')} contentClassName="flex flex-col gap-3">
<OverrideForm
@@ -293,20 +272,13 @@ 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])
@@ -317,8 +289,9 @@ function SettingsCard({
isEnabled,
adInsertion,
adsPerBreak,
bumpersEnabled,
bumper,
// Заставки правятся в отдельной карточке — здесь передаём сохранённые значения без изменений.
bumpersEnabled: channel.bumpersEnabled,
bumper: channel.bumper,
fillerAssetId: fillerAssetId || null,
}),
onSuccess: () => {
@@ -384,31 +357,6 @@ 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')}
@@ -425,68 +373,92 @@ function cssColor(value: string): string {
return v
}
function BumperSettingsFields({
channelId,
bumper,
setField,
function BumperCard({
channel,
onSaved,
onError,
}: {
channelId: string
bumper: BumperSettings
setField: <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) => void
channel: import('@/shared/api/types').ChannelDto
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [bumpersEnabled, setBumpersEnabled] = useState(channel.bumpersEnabled)
const [bumper, setBumper] = useState<BumperSettings>(channel.bumper)
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 setField = <K extends keyof BumperSettings>(key: K, value: BumperSettings[K]) =>
setBumper((prev) => ({ ...prev, [key]: value }))
// Оформление/подписи/шрифт нужны только динамическим заставкам.
const showDynamicStyle = bumper.mode !== 'Static'
useEffect(() => {
setBumpersEnabled(channel.bumpersEnabled)
setBumper(channel.bumper)
}, [channel])
// Общие настройки заставок сохраняются тем же эндпоинтом, что и настройки канала — остальные
// поля берём из канала без изменений (они правятся в своей карточке).
const save = useMutation({
mutationFn: () =>
updateChannelSettings(channel.id, {
name: channel.name,
isEnabled: channel.isEnabled,
adInsertion: channel.adInsertion,
adsPerBreak: channel.adsPerBreak,
bumpersEnabled,
bumper,
fillerAssetId: channel.fillerAssetId,
}),
onSuccess: () => {
toast.success(t('settings.saved'))
onSaved()
},
onError,
})
const addTemplate = useMutation({
mutationFn: () => addBumperTemplate(channel.id, ''),
onSuccess: onSaved,
onError,
})
const templates = [...channel.bumperTemplates].sort((a, b) => a.position - b.position)
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>
<CollapsibleCard title={t('admin.channels.bumpers')} contentClassName="flex flex-col gap-4">
<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>
{/* Общие настройки */}
<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)}>
<Label>{t('admin.channels.bumperSelection')}</Label>
<Select
value={bumper.selection}
onValueChange={(v) => setField('selection', v as BumperSelection)}
>
<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>
<SelectItem value="Rotation">{t('admin.channels.bumperSelectionRotation')}</SelectItem>
<SelectItem value="Random">{t('admin.channels.bumperSelectionRandom')}</SelectItem>
<SelectItem value="AlwaysFirst">
{t('admin.channels.bumperSelectionAlwaysFirst')}
</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)}>
@@ -499,6 +471,16 @@ function BumperSettingsFields({
</SelectContent>
</Select>
</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.bumperNowLabel')}</Label>
<Input
@@ -515,21 +497,6 @@ function BumperSettingsFields({
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
@@ -539,40 +506,259 @@ function BumperSettingsFields({
/>
{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}
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
{/* Блоки заставок */}
<div className="flex items-center justify-between border-t border-border pt-4">
<p className="text-sm font-medium">{t('admin.channels.bumperTemplates')}</p>
<Button
size="sm"
variant="outline"
disabled={addTemplate.isPending}
onClick={() => addTemplate.mutate()}
>
{t('admin.channels.bumperAddTemplate')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.channels.bumperTemplatesHint')}</p>
<div className="flex flex-col gap-3">
{templates.map((template) => (
<BumperTemplateEditor
key={template.id}
channelId={channel.id}
template={template}
onChanged={onSaved}
onError={onError}
/>
))}
</div>
</CollapsibleCard>
)
}
function BumperTemplateEditor({
channelId,
template,
onChanged,
onError,
}: {
channelId: string
template: BumperTemplateDto
onChanged: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const [name, setName] = useState(template.name)
const [colors, setColors] = useState({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
useEffect(() => {
setName(template.name)
setColors({
backgroundColor: template.backgroundColor,
backgroundColor2: template.backgroundColor2,
accentColor: template.accentColor,
textColor: template.textColor,
})
}, [template])
const save = useMutation({
mutationFn: () =>
updateBumperTemplate(channelId, template.id, { name: name.trim(), ...colors }),
onSuccess: () => {
toast.success(t('settings.saved'))
onChanged()
},
onError,
})
const remove = useMutation({
mutationFn: () => removeBumperTemplate(channelId, template.id),
onSuccess: onChanged,
onError,
})
const colorFields: { key: keyof typeof colors; 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') },
]
return (
<div className="flex flex-col gap-3 rounded-md border border-border bg-muted/30 p-4">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{template.name}</span>
{template.isDefault && <Badge variant="muted">{t('admin.channels.bumperDefault')}</Badge>}
<span className="text-xs text-muted-foreground">
{template.hasAudio && template.audioDurationSeconds != null
? `${Math.round(template.audioDurationSeconds)} ${t('admin.channels.bumperSeconds')}`
: t('admin.channels.bumperDefaultDuration')}
</span>
</div>
)}
{!template.isDefault && (
<Button
size="sm"
variant="destructive"
disabled={remove.isPending}
onClick={() => remove.mutate()}
>
{t('common.delete')}
</Button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label>{t('admin.channels.bumperTemplateName')}</Label>
<Input value={name} maxLength={64} onChange={(e) => setName(e.target.value)} />
</div>
{colorFields.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(colors[key]) }}
/>
<Input
value={colors[key]}
onChange={(e) => setColors((c) => ({ ...c, [key]: e.target.value }))}
/>
</div>
</div>
))}
</div>
<div className="grid gap-3 border-t border-border pt-3 sm:grid-cols-2">
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="audio"
label={t('admin.channels.bumperAudio')}
hint={t('admin.channels.bumperAudioHint')}
has={template.hasAudio}
accept="audio/*"
upload={uploadBumperTemplateAudio}
clear={clearBumperTemplateAudio}
onSaved={onChanged}
onError={onError}
/>
<BumperFileUpload
channelId={channelId}
templateId={template.id}
kind="background"
label={t('admin.channels.bumperBackground')}
hint={t('admin.channels.bumperBackgroundHint')}
has={template.hasBackground}
accept="image/*"
upload={uploadBumperTemplateBackground}
clear={clearBumperTemplateBackground}
onSaved={onChanged}
onError={onError}
/>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<BumperPreviewPlayer channelId={channelId} templateId={template.id} onError={onError} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={save.isPending} onClick={() => save.mutate()}>
{t('common.save')}
</Button>
</div>
</div>
)
}
function BumperPreviewPlayer({
channelId,
templateId,
onError,
}: {
channelId: string
templateId: string
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const [ready, setReady] = useState(false)
const [bust, setBust] = useState(0)
const render = useMutation({
mutationFn: () => renderBumperPreview(channelId, templateId),
onSuccess: () => {
setBust(Date.now())
setReady(true)
},
onError,
})
// Грузим отрендеренный превью-плейлист через hls.js, добавляя Bearer-токен (admin-роут под JWT).
useEffect(() => {
if (!ready) return
const video = videoRef.current
if (!video) return
const src = `${bumperPreviewPlaylistUrl(channelId, templateId)}?t=${bust}`
let hls: Hls | null = null
if (Hls.isSupported()) {
hls = new Hls({
xhrSetup: (xhr) => {
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
},
})
hls.loadSource(src)
hls.attachMedia(video)
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = src
}
return () => {
hls?.destroy()
}
}, [ready, bust, channelId, templateId])
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={render.isPending}
onClick={() => render.mutate()}
>
{render.isPending
? t('admin.channels.bumperPreviewRendering')
: t('admin.channels.bumperPreview')}
</Button>
<span className="text-xs text-muted-foreground">
{t('admin.channels.bumperPreviewHint')}
</span>
</div>
{ready && (
<video
ref={videoRef}
controls
playsInline
className="aspect-video w-full max-w-sm rounded-md border border-border bg-black"
/>
)}
</>
)
}
function BumperFileUpload({
channelId,
templateId,
kind,
label,
hint,
@@ -584,26 +770,27 @@ function BumperFileUpload({
onError,
}: {
channelId: string
templateId: string
kind: string
label: string
hint: string
has: boolean
accept: string
upload: (id: string, file: File) => Promise<void>
clear: (id: string) => Promise<void>
upload: (id: string, templateId: string, file: File) => Promise<void>
clear: (id: string, templateId: string) => Promise<void>
onSaved: () => void
onError: (e: unknown) => void
}) {
const { t } = useTranslation()
const inputId = `bumper-${kind}-${channelId}`
const inputId = `bumper-${kind}-${templateId}`
const uploadMutation = useMutation({
mutationFn: (file: File) => upload(channelId, file),
mutationFn: (file: File) => upload(channelId, templateId, file),
onSuccess: onSaved,
onError,
})
const clearMutation = useMutation({
mutationFn: () => clear(channelId),
mutationFn: () => clear(channelId, templateId),
onSuccess: onSaved,
onError,
})
@@ -832,50 +1019,6 @@ 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,
+56 -16
View File
@@ -70,23 +70,48 @@ 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`, {
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: { mediaAssetId },
body: { name },
})
}
export function removeChannelJingle(id: string, channelJingleId: string) {
return apiRequest<void>(`/admin/channels/${id}/jingles/${channelJingleId}`, { method: 'DELETE' })
export function updateBumperTemplate(id: string, templateId: string, body: BumperTemplateStyleBody) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}`, {
method: 'PUT',
body,
})
}
/** Загрузка сырого файла заставки (фон/музыка): тело — файл, имя — в query (как в uploadMedia). */
function uploadBumperFile(id: string, kind: 'background' | 'music', file: File): Promise<void> {
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/${kind}?${query.toString()}`)
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 = () => {
@@ -108,20 +133,35 @@ function uploadBumperFile(id: string, kind: 'background' | 'music', file: File):
})
}
export function uploadBumperBackground(id: string, file: File) {
return uploadBumperFile(id, 'background', file)
export function uploadBumperTemplateAudio(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'audio', file)
}
export function uploadBumperMusic(id: string, file: File) {
return uploadBumperFile(id, 'music', file)
export function uploadBumperTemplateBackground(id: string, templateId: string, file: File) {
return uploadBumperTemplateFile(id, templateId, 'background', file)
}
export function clearBumperBackground(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/background`, { method: 'DELETE' })
export function clearBumperTemplateAudio(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/audio`, {
method: 'DELETE',
})
}
export function clearBumperMusic(id: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/music`, { method: 'DELETE' })
export function clearBumperTemplateBackground(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/background`, {
method: 'DELETE',
})
}
/** Синхронно рендерит пример заставки блока (сервер собирает ffmpeg-клип). */
export function renderBumperPreview(id: string, templateId: string) {
return apiRequest<void>(`/admin/channels/${id}/bumper/templates/${templateId}/preview`, {
method: 'POST',
})
}
export function bumperPreviewPlaylistUrl(id: string, templateId: string) {
return `/api/admin/channels/${id}/bumper/templates/${templateId}/preview/index.m3u8`
}
export type OverrideBody = {
+17 -16
View File
@@ -117,22 +117,30 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes'
export type OverrideMode = 'Exclusive' | 'Boost'
export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper'
export type BumperFont = 'Sans' | 'Serif'
export type BumperMode = 'Dynamic' | 'Static' | 'Both'
export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst'
/** Общие для канала настройки заставок (стиль/звук — на каждом блоке, см. BumperTemplateDto). */
export type BumperSettings = {
mode: BumperMode
durationSeconds: number
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
font: BumperFont
nowLabel: string
nextLabel: string
minIntervalMinutes: number
onlyBetweenDifferentShows: boolean
selection: BumperSelection
}
export type BumperTemplateDto = {
id: string
position: number
isDefault: boolean
name: string
backgroundColor: string
backgroundColor2: string
accentColor: string
textColor: string
hasBackground: boolean
hasMusic: boolean
hasAudio: boolean
audioDurationSeconds: number | null
}
export type ChannelSummaryDto = {
@@ -160,13 +168,6 @@ export type ChannelAdDto = {
position: number
}
export type ChannelJingleDto = {
id: string
mediaAssetId: string
assetName: string | null
position: number
}
export type OverrideShowDto = { showId: string; showName: string; weight: number }
export type ProgrammingOverrideDto = {
@@ -186,10 +187,10 @@ export type ChannelDto = {
adsPerBreak: number
bumpersEnabled: boolean
bumper: BumperSettings
bumperTemplates: BumperTemplateDto[]
fillerAssetId: string | null
shows: ChannelShowDto[]
ads: ChannelAdDto[]
jingles: ChannelJingleDto[]
overrides: ProgrammingOverrideDto[]
}
+41 -29
View File
@@ -183,10 +183,13 @@ const resources = {
betweenBlocks: 'Между блоками',
betweenEpisodes: 'Между сериями',
adsPerBreak: 'Роликов подряд',
bumpersLabel: 'ТВ-заставки на переходах',
bumpers: 'ТВ-заставки',
bumpersLabel: 'Заставки на переходах',
bumpersHint: 'Короткая заставка «Сейчас / Далее» между разными шоу',
bumperStyle: 'Оформление заставки',
bumperDuration: 'Длительность, с',
bumperSelection: 'Выбор блока',
bumperSelectionRotation: 'По кругу',
bumperSelectionRandom: 'Случайно',
bumperSelectionAlwaysFirst: 'Всегда первый',
bumperMinInterval: 'Мин. интервал, мин',
bumperFont: 'Шрифт',
bumperFontSans: 'Гротеск',
@@ -198,22 +201,25 @@ const resources = {
bumperAccent: 'Акцент',
bumperText: 'Текст',
bumperOnlyDifferent: 'Только на смене шоу (не внутри марафона)',
bumperMode: 'Режим заставок',
bumperModeDynamic: 'Динамические «Сейчас/Далее»',
bumperModeStatic: 'Только джинглы',
bumperModeBoth: 'Чередовать',
bumperBackground: 'Фон',
bumperBackgroundHint: 'Картинка или видео-петля; иначе — анимированный градиент',
bumperMusic: 'Музыка',
bumperMusicHint: 'Аудиофайл-подложка; иначе — синтезированный джингл',
bumperTemplates: 'Блоки заставок',
bumperTemplatesHint:
'Каждый блок — свой звук и оформление. Первый блок дефолтный, его нельзя удалить. Длительность заставки — по длине звука.',
bumperAddTemplate: 'Добавить блок',
bumperTemplateName: 'Название',
bumperDefault: 'по умолчанию',
bumperSeconds: 'с',
bumperDefaultDuration: '≈8 с (джингл)',
bumperAudio: 'Звук',
bumperAudioHint: 'Звук заставки; иначе — синтезированный джингл',
bumperPreview: 'Отрендерить пример',
bumperPreviewRendering: 'Рендерим…',
bumperPreviewHint: 'Пример со звуком и анимацией (примерные названия шоу). Использует сохранённые настройки.',
bumperBackground: 'Фон-картинка',
bumperBackgroundHint: 'Картинка фона; иначе — постер шоу или градиент',
bumperFileLoaded: 'загружено',
bumperFileDefault: 'по умолчанию',
bumperUpload: 'Загрузить',
bumperReset: 'Сбросить',
jingles: 'Джинглы-отбивки',
jinglesHint: 'Готовые ролики для статичных заставок; крутятся по кругу на переходах (режимы «Только джинглы» и «Чередовать»).',
pickJingle: 'Выберите ролик',
noJingles: 'Пул джинглов пуст',
filler: 'Заглушка',
noFiller: 'Без заглушки',
shows: 'Шоу канала',
@@ -462,10 +468,13 @@ const resources = {
betweenBlocks: 'Between blocks',
betweenEpisodes: 'Between episodes',
adsPerBreak: 'Ads per break',
bumpers: 'TV bumpers',
bumpersLabel: 'Transition bumpers',
bumpersHint: 'Short “Now / Next” bumper between different shows',
bumperStyle: 'Bumper style',
bumperDuration: 'Duration, s',
bumperSelection: 'Block selection',
bumperSelectionRotation: 'Rotation',
bumperSelectionRandom: 'Random',
bumperSelectionAlwaysFirst: 'Always first',
bumperMinInterval: 'Min interval, min',
bumperFont: 'Font',
bumperFontSans: 'Sans',
@@ -477,22 +486,25 @@ const resources = {
bumperAccent: 'Accent',
bumperText: 'Text',
bumperOnlyDifferent: 'Only on show change (not within a marathon)',
bumperMode: 'Bumper mode',
bumperModeDynamic: 'Dynamic “Now / Next”',
bumperModeStatic: 'Jingles only',
bumperModeBoth: 'Alternate',
bumperBackground: 'Background',
bumperBackgroundHint: 'Image or video loop; otherwise an animated gradient',
bumperMusic: 'Music',
bumperMusicHint: 'Audio bed file; otherwise a synthesized jingle',
bumperTemplates: 'Bumper blocks',
bumperTemplatesHint:
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
bumperAddTemplate: 'Add block',
bumperTemplateName: 'Name',
bumperDefault: 'default',
bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)',
bumperAudio: 'Sound',
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render sample',
bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Sample with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperFileLoaded: 'loaded',
bumperFileDefault: 'default',
bumperUpload: 'Upload',
bumperReset: 'Reset',
jingles: 'Jingles',
jinglesHint: 'Pre-made clips for static bumpers; rotated on transitions (in “Jingles only” and “Alternate” modes).',
pickJingle: 'Pick a clip',
noJingles: 'Jingle pool is empty',
filler: 'Filler',
noFiller: 'No filler',
shows: 'Channel shows',