diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs index f681b43..76079d3 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/BumperPlaceholders.cs @@ -95,11 +95,12 @@ public static partial class BumperPlaceholders /// Какие плейсхолдеры встречаются в текстах — по ним планировщик решает, что грузить. public static IReadOnlySet TokensIn(IEnumerable texts) { - var used = new HashSet(StringComparer.Ordinal); - foreach (var text in texts.Where(t => !string.IsNullOrWhiteSpace(t))) - foreach (Match match in TokenPattern().Matches(text)) - used.Add(match.Groups[1].Value); - return used; + var matches = texts + .Where(t => !string.IsNullOrWhiteSpace(t)) + .SelectMany(text => TokenPattern().Matches(text)) + .Select(match => match.Groups[1].Value); + + return new HashSet(matches, StringComparer.Ordinal); } /// Плейсхолдеры текста, которых нет в списке допустимых. diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs index ff4bda2..d997967 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperFacts.cs @@ -59,7 +59,7 @@ internal sealed class BumperFacts var slotTitles = tokens.Contains("slot") ? await LoadSlotTitlesAsync(dbContext, items, cancellationToken) - : new Dictionary(); + : []; return new BumperFacts(items, channel, shows, slotTitles); } diff --git a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs index 05097a0..abc176e 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/BumperResolver.cs @@ -100,7 +100,7 @@ public sealed class BumperResolver( variant.Id, linesJson, posterShowId, - Signature(template, variant.Id, linesJson, posterShowId) + ComputeSignature(template, variant.Id, linesJson, posterShowId) ) ) ); @@ -210,7 +210,7 @@ public sealed class BumperResolver( /// и пары шоу в ней нет намеренно: одинаковая заставка на трёх каналах рендерится один раз, а /// {channel} в тексте разводит их сам собой. /// - private static string Signature( + private static string ComputeSignature( BumperTemplate template, Guid variantId, string linesJson, diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index 283e497..9324c45 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -447,13 +447,13 @@ public sealed class GridScheduleGenerator( ); } - return elements.Count == 0 - ? null - : new PlanningJunction( - id, - elements, - template.MaxTotalSeconds is { } seconds ? TimeSpan.FromSeconds(seconds) : null - ); + if (elements.Count == 0) + return null; + + var cap = template.MaxTotalSeconds is { } seconds + ? TimeSpan.FromSeconds(seconds) + : (TimeSpan?)null; + return new PlanningJunction(id, elements, cap); } /// diff --git a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs index 5e93f43..c43804b 100644 --- a/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs +++ b/backend/src/TeleWave.Application/Programming/Templates/Generate/GridPlanner.cs @@ -360,9 +360,28 @@ public sealed class GridPlanner( PlanRun Run ) { + /// + /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти + /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается + /// к текущему, чтобы в сетке не появлялось огрызков. + /// + private (int Duration, int Units) Measure(GroupCandidate group, int available) + { + var unit = UnitMinutes(group); + + // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком. + if (Band.UnitsPerBlock <= 0 || unit == 0) + return (Fit(Band.BlockMinutes > 0 ? Band.BlockMinutes : available, available), 0); + + var duration = Fit(Math.Max(1, Band.UnitsPerBlock) * unit, available); + // Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, + // решает контент. + return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); + } + public PlannedSlot Content(GroupCandidate group, int available) { - var (duration, units) = Measure(Band, group, available); + var (duration, units) = Measure(group, available); Run.Reserve(group, units, duration, Target.AiringsPerWeek); return Build( @@ -442,29 +461,6 @@ public sealed class GridPlanner( private static int RepeatMinutes(GridBand band) => band.BlockMinutes > 0 ? band.BlockMinutes : 120; - /// - /// Длина слота и число единиц в нём. Блок из N единиц занимает ровно столько, сколько эти - /// единицы идут, — округление до пяти минут; хвост короче минимального слота приклеивается - /// к текущему, чтобы в сетке не появлялось огрызков. - /// - private static (int Duration, int Units) Measure( - GridBand band, - GroupCandidate group, - int available - ) - { - var unit = UnitMinutes(group); - - // Полоса, которая меряется временем (марафон, ночь), берёт свой кусок целиком. - if (band.UnitsPerBlock <= 0 || unit == 0) - return (Fit(band.BlockMinutes > 0 ? band.BlockMinutes : available, available), 0); - - var duration = Fit(Math.Max(1, band.UnitsPerBlock) * unit, available); - // Число единиц — по фактической длине слота: полоса задаёт замысел, а сколько влезло, - // решает контент. - return (duration, Math.Max(1, (int)Math.Round(duration / (double)unit))); - } - /// /// Длина слота: желаемая, но не больше свободного места. Остаток короче минимального слота /// приклеивается к текущему — огрызок в сетке читается как ошибка, а не как решение. Остаток diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs index ffc6b7f..8c63277 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -63,6 +63,18 @@ public sealed record JunctionInsert( TimeSpan Duration ); +/// +/// Накопители прогона, нужные раскладке стыка: история врезок (интервалы и ротация единиц), +/// жребий и сама собираемая лента. Принадлежат прогону целиком, а не отдельному стыку, поэтому +/// едут одним параметром — плоским списком сигнатура перестаёт +/// читаться. +/// +public sealed record JunctionRun( + JunctionHistory History, + IRandomSource Random, + List Items +); + /// /// Раскладка врезок стыка: реклама, промо, заставка, заполнитель. /// @@ -80,15 +92,15 @@ public static class JunctionFiller DateTimeOffset cursor, DateTimeOffset limit, JunctionPlacement placement, - JunctionHistory history, - IRandomSource random, - List items, + JunctionRun run, PlanTrace? trace ) { if (junction is null || junction.Elements.Count == 0) return cursor; + var (history, random, items) = run; + var eligible = junction .Elements.Where(e => Passes(e, cursor, placement, history, random)) .ToList(); diff --git a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs index a172067..ab2c865 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/SchedulePlanner.cs @@ -31,6 +31,11 @@ public static class SchedulePlanner public List Warnings { get; } = []; public JunctionHistory Junctions { get; } = new(); + private JunctionRun? _junctionRun; + + /// Накопители, которые нужны раскладке стыка: история, жребий и сама лента. + public JunctionRun JunctionRun => _junctionRun ??= new(Junctions, Random, Items); + /// Смещение времени канала — врезки со своим окном суток считают его по нему. public TimeSpan ChannelOffset { get; } = TimeSpan.FromMinutes(input.UtcOffsetMinutes); @@ -260,9 +265,7 @@ public static class SchedulePlanner ElementChanged: run.PreviousShowId != unit.ShowId, run.ChannelOffset ), - run.Junctions, - run.Random, - run.Items, + run.JunctionRun, slotTrace ); @@ -294,9 +297,7 @@ public static class SchedulePlanner ElementChanged: true, run.ChannelOffset ), - run.Junctions, - run.Random, - run.Items, + run.JunctionRun, slotTrace ); diff --git a/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs index c25da67..01368b2 100644 --- a/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs +++ b/backend/src/TeleWave.Infrastructure/Media/FileSystemStorageInspector.cs @@ -152,7 +152,7 @@ public sealed class FileSystemStorageInspector( return new StorageAreaUsage(StorageArea.Other, measurement.Bytes, measurement.Files); } - private StorageAreaUsage Area( + private static StorageAreaUsage Area( StorageArea area, string directory, CancellationToken cancellationToken diff --git a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs index 8ee6890..12a44b5 100644 --- a/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Broadcast/BumperPreviewTests.cs @@ -171,7 +171,7 @@ public class BumperPreviewTests .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); Assert.True(result.IsSuccess); - var spec = Specs(renderer).First(); + var spec = Specs(renderer)[0]; Assert.Equal("/data/images/bg.png", spec.BackgroundFile); // Канала нет — образцы заглушечные, но кадр всё равно собирается. Assert.Equal("Первое шоу", spec.Lines[1].Text); @@ -201,7 +201,7 @@ public class BumperPreviewTests .Handle(new RenderBumperPreviewCommand(template.Id, null), CancellationToken.None); // Жанра у образца нет — строка схлопнулась, а не оставила дыру в кадре. - var spec = Specs(renderer).First(); + var spec = Specs(renderer)[0]; Assert.Single(spec.Lines); } } diff --git a/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx index e2c26e1..df6ca28 100644 --- a/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx +++ b/frontend/src/features/admin/bumpers/components/BumperLinesEditor.tsx @@ -5,6 +5,7 @@ import type { BumperLineColor, BumperLineDto, BumperLineStyle } from '@/shared/a import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { cn } from '@/shared/lib/cn' +import { useKeyedList } from '@/shared/lib/keyed-list' import { hasVolatileToken, PLACEHOLDERS, resolveSample, unknownTokens } from '../placeholders' const STYLES: BumperLineStyle[] = ['Label', 'Title', 'Caption'] @@ -44,45 +45,71 @@ function presets(t: (key: string) => string): { key: string; lines: BumperLineDt * Строки заставки: порядок перетаскиванием, палитра плейсхолдеров под фокусированным полем, * пресеты кнопкой. Ошибка ввода (незнакомый плейсхолдер) видна сразу — сервер её всё равно * отвергнет, но узнавать об этом при сохранении неудобно. + * + * Список свой, со стабильными ключами строк (см. useKeyedList), а наружу уезжает только значение: + * при индексных ключах удаление строки из середины уводило бы фокус в соседнюю. Пересев на другой + * подблок, редактор пересоздаётся по `key` — поэтому props читаются только на первом рендере. */ export function BumperLinesEditor({ - lines, + lines: initial, onChange, }: Readonly<{ lines: BumperLineDto[] onChange: (lines: BumperLineDto[]) => void }>) { const { t } = useTranslation() - const focused = useRef(null) - const inputs = useRef<(HTMLInputElement | null)[]>([]) - const dragged = useRef(null) + const { rows, reset, add, remove, patch } = useKeyedList(initial) + const focused = useRef(null) + const inputs = useRef(new Map()) + const dragged = useRef(null) - const patch = (index: number, part: Partial) => - onChange(lines.map((line, i) => (i === index ? { ...line, ...part } : line))) - - const add = () => { - const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' } - onChange([...lines, line].slice(0, MAX_LINES)) + const setLine = (key: string, part: Partial) => { + patch(key, (line) => ({ ...line, ...part })) + // Правка уезжает наверх сразу: сохраняет подблок родитель, у него же лежит остальная форма. + onChange(rows.map((row) => (row.key === key ? { ...row.value, ...part } : row.value))) } - const remove = (index: number) => onChange(lines.filter((_, i) => i !== index)) + const addLine = () => { + if (rows.length >= MAX_LINES) return + const line: BumperLineDto = { style: 'Title', color: 'Text', text: '' } + add(line) + onChange([...rows.map((row) => row.value), line]) + } - const move = (from: number, to: number) => { - if (from === to) return - const next = [...lines] - const [line] = next.splice(from, 1) - next.splice(to, 0, line) - onChange(next) + const removeLine = (key: string) => { + remove(key) + onChange(rows.filter((row) => row.key !== key).map((row) => row.value)) + } + + const applyPreset = (preset: BumperLineDto[]) => { + reset(preset) + onChange(preset) + } + + const move = (fromKey: string, toKey: string) => { + if (fromKey === toKey) return + const next = [...rows] + const from = next.findIndex((row) => row.key === fromKey) + const to = next.findIndex((row) => row.key === toKey) + if (from < 0 || to < 0) return + + const [row] = next.splice(from, 1) + next.splice(to, 0, row) + const values = next.map((r) => r.value) + reset(values) + onChange(values) } /** Вставка плейсхолдера в позицию курсора — иначе его пришлось бы допечатывать руками. */ const insert = (token: string) => { - const index = focused.current ?? lines.length - 1 - if (index < 0) return - const input = inputs.current[index] - const text = lines[index].text - const at = input?.selectionStart ?? text.length - patch(index, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) + const key = focused.current ?? rows.at(-1)?.key + const row = rows.find((r) => r.key === key) + if (!key || !row) return + + const input = inputs.current.get(key) + const at = input?.selectionStart ?? row.value.text.length + const text = row.value.text + setLine(key, { text: `${text.slice(0, at)}{${token}}${text.slice(at)}` }) requestAnimationFrame(() => { input?.focus() const caret = at + token.length + 2 @@ -101,25 +128,25 @@ export function BumperLinesEditor({ key={preset.key} size="sm" variant="outline" - onClick={() => onChange(preset.lines)} + onClick={() => applyPreset(preset.lines)} > {t(`admin.bumpers.preset_${preset.key}`)} ))} - {lines.map((line, index) => { + {rows.map(({ key, value: line }) => { const unknown = unknownTokens(line.text) return (
{ - dragged.current = index + dragged.current = key }} onDragOver={(e) => e.preventDefault()} onDrop={() => { - if (dragged.current !== null) move(dragged.current, index) + if (dragged.current !== null) move(dragged.current, key) dragged.current = null }} className="flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/40 p-2" @@ -128,7 +155,7 @@ export function BumperLinesEditor({ patch(index, { color: e.target.value as BumperLineColor })} + onChange={(e) => setLine(key, { color: e.target.value as BumperLineColor })} > {COLORS.map((color) => (