Implement BumperEndpoints and remove deprecated bumper-related functionality
ci / build-backend (push) Successful in 1m39s
ci / build-frontend (push) Failing after 26s
ci / tests (push) Skipped
ci / sonar (push) Skipped

Added new BumperEndpoints to the API for managing bumper templates and variants, enhancing the channel management capabilities. Removed outdated bumper-related commands and handlers from the application, streamlining the codebase and improving maintainability. Updated ChannelEndpoints to reflect these changes and ensure proper routing for the new endpoints.
This commit is contained in:
Leonid Pershin
2026-07-27 22:01:56 +03:00
parent ee0b4d2d01
commit ba3721eb92
140 changed files with 7326 additions and 3609 deletions
@@ -2,14 +2,15 @@ using System.Globalization;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using static TeleWave.Infrastructure.Media.FfmpegText;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Синтезирует ТВ-заставку перехода полностью на ffmpeg (без исходного файла) по
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + текст «Сейчас/Далее» + короткий
/// джингл, и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
/// <see cref="BumperRenderSpec"/>: анимированный градиентный фон + строки текста + короткий джингл,
/// и режет результат на те же HLS-сегменты, что и обычный ассет. Длительность фиксированная и
/// кратная сегменту, поэтому эфирная математика не отличает заставку от программы.
/// </summary>
public sealed class FfmpegBumperRenderer(
@@ -22,21 +23,19 @@ public sealed class FfmpegBumperRenderer(
private readonly MediaOptions _media = mediaOptions.Value;
/// <summary>
/// Файлы с динамическим текстом заставки. Всё пользователь-редактируемое (названия шоу, подписи,
/// свободные строки) ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая,
/// <c>;</c>, <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку
/// <c>-filter_complex</c>.
/// Одна строка, разложенная под drawtext: файл с текстом, размер, цвет и вертикальная позиция.
/// Текст ffmpeg читает через <c>textfile=</c> с <c>expansion=none</c>: иначе запятая, <c>;</c>,
/// <c>[</c> или <c>]</c> в тексте ломают (или инъектируют звенья в) цепочку <c>-filter_complex</c>.
/// </summary>
private sealed record TextFiles(string Now, string Next, string NowLabel, string NextLabel)
{
public static TextFiles In(string assetDir) =>
new(
Path.Combine(assetDir, "now.txt"),
Path.Combine(assetDir, "next.txt"),
Path.Combine(assetDir, "nowlabel.txt"),
Path.Combine(assetDir, "nextlabel.txt")
);
}
private sealed record LayoutLine(
string File,
string Text,
int FontSize,
string Color,
int Y,
bool Shadow,
double FadeStart
);
public async Task<BumperRenderResult> RenderAsync(
Guid assetId,
@@ -53,33 +52,18 @@ public sealed class FfmpegBumperRenderer(
Directory.Delete(assetDir, recursive: true);
Directory.CreateDirectory(assetDir);
// Названия шоу / свободные строки.
var text = TextFiles.In(assetDir);
var line1 = spec.FreeText ? spec.FreeLine1 : spec.NowTitle;
var line2 = spec.FreeText ? spec.FreeLine2 : spec.NextTitle;
await File.WriteAllTextAsync(text.Now, line1, new UTF8Encoding(false), cancellationToken);
await File.WriteAllTextAsync(text.Next, line2, new UTF8Encoding(false), cancellationToken);
// Подписи «Сейчас/Далее» нужны лишь в одноимённом режиме — в FreeText их не рисуют.
if (!spec.FreeText)
{
var layout = Layout(assetDir, spec);
foreach (var line in layout)
await File.WriteAllTextAsync(
text.NowLabel,
spec.NowLabel,
line.File,
line.Text,
new UTF8Encoding(false),
cancellationToken
);
await File.WriteAllTextAsync(
text.NextLabel,
spec.NextLabel,
new UTF8Encoding(false),
cancellationToken
);
}
try
{
var args = BuildArgs(assetDir, seg, target, text, spec);
var args = BuildArgs(assetDir, seg, target, layout, spec);
var result = await ProcessRunner.RunAsync(
_media.FfmpegPath,
args,
@@ -115,36 +99,83 @@ public sealed class FfmpegBumperRenderer(
}
finally
{
TryDelete(text.Now);
TryDelete(text.Next);
TryDelete(text.NowLabel);
TryDelete(text.NextLabel);
foreach (var line in layout)
TryDelete(line.File);
}
}
/// <summary>
/// Раскладывает строки по кадру: блок центрируется целиком по вертикали, размер зависит от роли
/// и ужимается под ширину, строки проявляются по очереди. Фиксированных мест у ролей нет —
/// иначе набор из двух строк висел бы в верхней трети кадра, как это было у «Сейчас/Далее».
/// </summary>
private static List<LayoutLine> Layout(string assetDir, BumperRenderSpec spec)
{
var h = spec.Height;
var titleSize = Math.Max(24, h / 10);
var labelSize = Math.Max(14, h / 22);
var captionSize = Math.Max(12, h / 28);
var textWidth = (int)(spec.Width * 0.92); // Поля по 4% с каждой стороны.
var sizes = spec
.Lines.Select(line =>
{
var baseSize = line.Style switch
{
BumperLineStyle.Title => titleSize,
BumperLineStyle.Caption => captionSize,
_ => labelSize,
};
return FitSize(line.Text, baseSize, textWidth);
})
.ToList();
// Между подписью и её названием зазор меньше, чем между смысловыми блоками.
var gaps = new List<int>();
for (var i = 1; i < spec.Lines.Count; i++)
gaps.Add(
spec.Lines[i - 1].Style == BumperLineStyle.Label
? (int)(sizes[i] * 0.25)
: (int)(sizes[i] * 0.8)
);
var totalHeight = sizes.Sum(s => (int)(s * 1.2)) + gaps.Sum();
var y = (h - totalHeight) / 2;
var layout = new List<LayoutLine>(spec.Lines.Count);
for (var i = 0; i < spec.Lines.Count; i++)
{
if (i > 0)
y += (int)(sizes[i - 1] * 1.2) + gaps[i - 1];
var line = spec.Lines[i];
layout.Add(
new LayoutLine(
Path.Combine(assetDir, $"line{i}.txt"),
line.Text,
sizes[i],
line.Color == BumperLineColor.Accent ? spec.AccentColor : spec.TextColor,
y,
line.Style == BumperLineStyle.Title,
0.2 + i * 0.3
)
);
}
return layout;
}
private List<string> BuildArgs(
string assetDir,
int seg,
int target,
TextFiles text,
IReadOnlyList<LayoutLine> layout,
BumperRenderSpec spec
)
{
var w = spec.Width;
var h = spec.Height;
var titleSize = Math.Max(24, h / 10);
var labelSize = Math.Max(14, h / 22);
var gap = (int)(labelSize * 1.4);
// Доступная ширина под текст (поля по 4% с каждой стороны) — под неё ужимаем длинные строки.
var textWidth = (int)(w * 0.92);
var nowLabelY = (int)(h * 0.22);
var nowTitleY = nowLabelY + gap;
var nextLabelY = (int)(h * 0.60);
var nextTitleY = nextLabelY + gap;
var font = EscapePath(spec.FontFile);
var outStart = Math.Max(0, target - 1);
@@ -200,41 +231,8 @@ public sealed class FfmpegBumperRenderer(
}
var vchain = new StringBuilder(videoPrefix);
if (spec.FreeText)
{
// Свободный текст: две центрированные строки (акцентная + основная), ужатые под ширину кадра.
var line1Size = FitSize(spec.FreeLine1, labelSize + 4, textWidth);
var line2Size = FitSize(spec.FreeLine2, titleSize, textWidth);
var line1Y = (int)(h * 0.40);
var line2Y = line1Y + (int)(line2Size * 1.2);
vchain
.Append(',')
.Append(DrawTitle(font, text.Now, spec.AccentColor, line1Size, line1Y, 0.2));
vchain
.Append(',')
.Append(DrawTitle(font, text.Next, spec.TextColor, line2Size, line2Y, 0.5));
}
else
{
var nowSize = FitSize(spec.NowTitle, titleSize, textWidth);
var nextSize = FitSize(spec.NextTitle, titleSize, textWidth);
vchain
.Append(',')
.Append(
DrawLabel(font, text.NowLabel, spec.AccentColor, labelSize, nowLabelY, 0.2)
);
vchain
.Append(',')
.Append(DrawTitle(font, text.Now, spec.TextColor, nowSize, nowTitleY, 0.3));
vchain
.Append(',')
.Append(
DrawLabel(font, text.NextLabel, spec.AccentColor, labelSize, nextLabelY, 1.0)
);
vchain
.Append(',')
.Append(DrawTitle(font, text.Next, spec.TextColor, nextSize, nextTitleY, 1.1));
}
foreach (var line in layout)
vchain.Append(',').Append(DrawLine(font, line));
vchain.Append("[v]");
var filterComplex = $"{vchain};{audioChain}";
@@ -304,33 +302,20 @@ public sealed class FfmpegBumperRenderer(
private static bool IsImage(string path) =>
ImageExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
private static string DrawTitle(
string font,
string textFile,
string color,
int size,
int y,
double fadeStart
) =>
$"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none"
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
+ ":shadowcolor=black@0.6:shadowx=2:shadowy=2"
+ $":alpha='{FadeExpr(fadeStart)}'";
private static string DrawLabel(
string font,
string textFile,
string color,
int size,
int y,
double fadeStart
) =>
// Подпись читается из файла (textfile=) с expansion=none — произвольные символы подписи
// не могут сломать/инъектировать цепочку filter_complex (см. запись файлов в RenderAsync).
$"drawtext=fontfile={font}:textfile={EscapePath(textFile)}:expansion=none"
+ $":fontcolor={color}:fontsize={size}:x=(w-text_w)/2:y={y}"
+ ":shadowcolor=black@0.6:shadowx=1:shadowy=1"
+ $":alpha='{FadeExpr(fadeStart)}'";
/// <summary>
/// Звено drawtext для одной строки. Текст читается из файла (textfile=) с expansion=none —
/// произвольные символы не могут сломать/инъектировать цепочку filter_complex.
/// </summary>
private static string DrawLine(string font, LayoutLine line)
{
var shadow = line.Shadow
? ":shadowcolor=black@0.6:shadowx=2:shadowy=2"
: ":shadowcolor=black@0.6:shadowx=1:shadowy=1";
return $"drawtext=fontfile={font}:textfile={EscapePath(line.File)}:expansion=none"
+ $":fontcolor={line.Color}:fontsize={line.FontSize}:x=(w-text_w)/2:y={line.Y}"
+ shadow
+ $":alpha='{FadeExpr(line.FadeStart)}'";
}
private static string FadeExpr(double start) =>
$"if(lt(t,{Fmt(start)}),0,min(1,(t-{Fmt(start)})/0.5))";
@@ -0,0 +1,465 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class SharedJunctionsAndBumperLines : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Перенос данных идёт до всех схемных операций: старые колонки ещё на месте.
// Текст подблоков превращается в строки, «Сейчас/Далее» — в четыре строки с
// плейсхолдерами, свободный текст — в две.
migrationBuilder.Sql("""ALTER TABLE "BumperTextVariants" ADD COLUMN "Lines" jsonb;""");
migrationBuilder.Sql(
"""
UPDATE "BumperTextVariants" SET "Lines" = CASE WHEN "Kind" = 1 THEN
jsonb_build_array(
jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("Line1", '')),
jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', COALESCE("Line2", ''))
)
ELSE
jsonb_build_array(
jsonb_build_object('Position', 0, 'Style', 0, 'Color', 0, 'Text', COALESCE("NowLabel", '')),
jsonb_build_object('Position', 1, 'Style', 1, 'Color', 1, 'Text', '{now.title}'),
jsonb_build_object('Position', 2, 'Style', 0, 'Color', 0, 'Text', COALESCE("NextLabel", '')),
jsonb_build_object('Position', 3, 'Style', 1, 'Color', 1, 'Text', '{next.title}')
)
END;
"""
);
// Пустые строки выбрасываем и перенумеровываем — иначе в кадре останутся дыры.
migrationBuilder.Sql(
"""
UPDATE "BumperTextVariants" v SET "Lines" = f.lines
FROM (
SELECT id, COALESCE(jsonb_agg(jsonb_set(elem, '{Position}', to_jsonb(rn - 1)) ORDER BY rn), '[]'::jsonb) AS lines
FROM (
SELECT t."Id" AS id, e.elem AS elem,
row_number() OVER (PARTITION BY t."Id" ORDER BY e.ord) AS rn
FROM "BumperTextVariants" t,
LATERAL jsonb_array_elements(t."Lines") WITH ORDINALITY AS e(elem, ord)
WHERE COALESCE(e.elem->>'Text', '') <> ''
) x GROUP BY id
) f WHERE v."Id" = f.id;
"""
);
// Фон: «Сейчас/Далее» показывал постер следующего шоу, свободный текст — фон блока.
migrationBuilder.Sql(
"""ALTER TABLE "BumperTextVariants" ADD COLUMN "Background" integer NOT NULL DEFAULT 0;"""
);
migrationBuilder.Sql(
"""UPDATE "BumperTextVariants" SET "Background" = CASE WHEN "Kind" = 1 THEN 0 ELSE 1 END;"""
);
// Шрифт переезжает с канала на блок — у блока он был канальным.
migrationBuilder.Sql(
"""ALTER TABLE "BumperTemplate" ADD COLUMN "Font" integer NOT NULL DEFAULT 0;"""
);
migrationBuilder.Sql(
"""UPDATE "BumperTemplate" t SET "Font" = c."BumperFont" FROM "Channels" c WHERE c."Id" = t."ChannelId";"""
);
// Кэш заставок: сигнатура теперь считается по содержимому, а подставленного текста
// у старых записей нет. Это кэш — он пересоберётся при ближайшей генерации.
migrationBuilder.Sql("""DELETE FROM "BumperAssets";""");
migrationBuilder.DropForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId",
table: "BumperTemplate"
);
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants"
);
migrationBuilder.DropIndex(
name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates"
);
migrationBuilder.DropIndex(
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
table: "BumperAssets"
);
migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplate", table: "BumperTemplate");
migrationBuilder.DropIndex(
name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate"
);
migrationBuilder.DropColumn(name: "ChannelId", table: "JunctionTemplates");
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
migrationBuilder.DropColumn(name: "Line1", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "Line2", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "NextLabel", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "NowLabel", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "FromShowId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "ToShowId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperTemplate");
migrationBuilder.RenameTable(name: "BumperTemplate", newName: "BumperTemplates");
migrationBuilder.DropColumn(name: "Kind", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "Position", table: "BumperTemplates");
migrationBuilder.AddColumn<int>(
name: "MaxTotalSeconds",
table: "JunctionTemplates",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "BumperVariantId",
table: "JunctionElements",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "ChoiceKey",
table: "JunctionElements",
type: "character varying(64)",
maxLength: 64,
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "ChoiceWeight",
table: "JunctionElements",
type: "integer",
nullable: false,
defaultValue: 1
);
migrationBuilder.AddColumn<Guid>(
name: "PosterShowId",
table: "BumperAssets",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "RenderedLinesJson",
table: "BumperAssets",
type: "jsonb",
nullable: false,
defaultValue: "[]"
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTemplates",
table: "BumperTemplates",
column: "Id"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_Name",
table: "JunctionTemplates",
column: "Name"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_BumperTemplateId",
table: "JunctionElements",
column: "BumperTemplateId"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_BumperVariantId",
table: "JunctionElements",
column: "BumperVariantId"
);
migrationBuilder.CreateIndex(
name: "IX_BumperAssets_MediaAssetId",
table: "BumperAssets",
column: "MediaAssetId"
);
migrationBuilder.CreateIndex(
name: "IX_BumperAssets_Signature",
table: "BumperAssets",
column: "Signature",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_BumperTemplates_Name",
table: "BumperTemplates",
column: "Name"
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId",
table: "BumperTextVariants",
column: "BumperTemplateId",
principalTable: "BumperTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
migrationBuilder.AddForeignKey(
name: "FK_JunctionElements_BumperTemplates_BumperTemplateId",
table: "JunctionElements",
column: "BumperTemplateId",
principalTable: "BumperTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
migrationBuilder.AddForeignKey(
name: "FK_JunctionElements_BumperTextVariants_BumperVariantId",
table: "JunctionElements",
column: "BumperVariantId",
principalTable: "BumperTextVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplates_BumperTemplateId",
table: "BumperTextVariants"
);
migrationBuilder.DropForeignKey(
name: "FK_JunctionElements_BumperTemplates_BumperTemplateId",
table: "JunctionElements"
);
migrationBuilder.DropForeignKey(
name: "FK_JunctionElements_BumperTextVariants_BumperVariantId",
table: "JunctionElements"
);
migrationBuilder.DropIndex(
name: "IX_JunctionTemplates_Name",
table: "JunctionTemplates"
);
migrationBuilder.DropIndex(
name: "IX_JunctionElements_BumperTemplateId",
table: "JunctionElements"
);
migrationBuilder.DropIndex(
name: "IX_JunctionElements_BumperVariantId",
table: "JunctionElements"
);
migrationBuilder.DropIndex(name: "IX_BumperAssets_MediaAssetId", table: "BumperAssets");
migrationBuilder.DropIndex(name: "IX_BumperAssets_Signature", table: "BumperAssets");
migrationBuilder.DropPrimaryKey(name: "PK_BumperTemplates", table: "BumperTemplates");
migrationBuilder.DropIndex(name: "IX_BumperTemplates_Name", table: "BumperTemplates");
migrationBuilder.DropColumn(name: "MaxTotalSeconds", table: "JunctionTemplates");
migrationBuilder.DropColumn(name: "BumperVariantId", table: "JunctionElements");
migrationBuilder.DropColumn(name: "ChoiceKey", table: "JunctionElements");
migrationBuilder.DropColumn(name: "ChoiceWeight", table: "JunctionElements");
migrationBuilder.DropColumn(name: "Lines", table: "BumperTextVariants");
migrationBuilder.DropColumn(name: "PosterShowId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "RenderedLinesJson", table: "BumperAssets");
migrationBuilder.RenameTable(name: "BumperTemplates", newName: "BumperTemplate");
migrationBuilder.DropColumn(name: "Background", table: "BumperTextVariants");
migrationBuilder.AddColumn<int>(
name: "Kind",
table: "BumperTextVariants",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.DropColumn(name: "Font", table: "BumperTemplate");
migrationBuilder.AddColumn<int>(
name: "Position",
table: "BumperTemplate",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "JunctionTemplates",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<int>(
name: "BumperFont",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "BumperSelection",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<bool>(
name: "BumpersEnabled",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
migrationBuilder.AddColumn<string>(
name: "Line1",
table: "BumperTextVariants",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "Line2",
table: "BumperTextVariants",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "NextLabel",
table: "BumperTextVariants",
type: "character varying(64)",
maxLength: 64,
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "NowLabel",
table: "BumperTextVariants",
type: "character varying(64)",
maxLength: 64,
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "FromShowId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "ToShowId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "BumperTemplate",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTemplate",
table: "BumperTemplate",
column: "Id"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates",
column: "ChannelId"
);
migrationBuilder.CreateIndex(
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
table: "BumperAssets",
columns: new[] { "FromShowId", "ToShowId", "Signature" }
);
migrationBuilder.CreateIndex(
name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate",
columns: new[] { "ChannelId", "Position" }
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId",
table: "BumperTemplate",
column: "ChannelId",
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
}
}
@@ -164,18 +164,19 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FromShowId")
.HasColumnType("uuid");
b.Property<Guid>("MediaAssetId")
.HasColumnType("uuid");
b.Property<Guid?>("PosterShowId")
.HasColumnType("uuid");
b.Property<string>("RenderedLinesJson")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Signature")
.IsRequired()
.HasMaxLength(128)
@@ -184,15 +185,15 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<Guid>("ToShowId")
.HasColumnType("uuid");
b.Property<Guid>("VariantId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("FromShowId", "ToShowId", "Signature");
b.HasIndex("MediaAssetId");
b.HasIndex("Signature")
.IsUnique();
b.ToTable("BumperAssets");
});
@@ -227,20 +228,17 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid?>("BackgroundImageId")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Font")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
b.Property<int>("Revision")
.HasColumnType("integer");
@@ -251,9 +249,9 @@ namespace TeleWave.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ChannelId", "Position");
b.HasIndex("Name");
b.ToTable("BumperTemplate");
b.ToTable("BumperTemplates");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
@@ -261,40 +259,20 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<int>("Background")
.HasColumnType("integer");
b.Property<Guid>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Kind")
.HasColumnType("integer");
b.Property<string>("Line1")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Line2")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NextLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<string>("NowLabel")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("Position")
.HasColumnType("integer");
@@ -321,15 +299,6 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<double>("AnalogFilterStrength")
.HasColumnType("double precision");
b.Property<int>("BumperFont")
.HasColumnType("integer");
b.Property<int>("BumperSelection")
.HasColumnType("integer");
b.Property<bool>("BumpersEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -886,6 +855,18 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid?>("BumperTemplateId")
.HasColumnType("uuid");
b.Property<Guid?>("BumperVariantId")
.HasColumnType("uuid");
b.Property<string>("ChoiceKey")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<int>("ChoiceWeight")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(1);
b.Property<string>("ConditionsJson")
.HasColumnType("jsonb");
@@ -906,6 +887,10 @@ namespace TeleWave.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("BumperTemplateId");
b.HasIndex("BumperVariantId");
b.HasIndex("GroupId");
b.HasIndex("JunctionTemplateId", "Position");
@@ -918,12 +903,12 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid>("Id")
.HasColumnType("uuid");
b.Property<Guid>("ChannelId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("MaxTotalSeconds")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
@@ -931,7 +916,7 @@ namespace TeleWave.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("Name");
b.ToTable("JunctionTemplates");
});
@@ -1234,15 +1219,6 @@ namespace TeleWave.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.Channel", null)
.WithMany("BumperTemplates")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null)
@@ -1250,6 +1226,37 @@ namespace TeleWave.Infrastructure.Migrations
.HasForeignKey("BumperTemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 =>
{
b1.Property<Guid>("BumperTextVariantId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<int>("Color");
b1.Property<int>("Position");
b1.Property<int>("Style");
b1.Property<string>("Text")
.IsRequired()
.HasMaxLength(120);
b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal");
b1.ToTable("BumperTextVariants");
b1
.ToJson("Lines")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("BumperTextVariantId");
});
b.Navigation("Lines");
});
modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
@@ -1320,6 +1327,16 @@ namespace TeleWave.Infrastructure.Migrations
modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
{
b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null)
.WithMany()
.HasForeignKey("BumperTemplateId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null)
.WithMany()
.HasForeignKey("BumperVariantId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("TeleWave.Domain.Programming.Group", null)
.WithMany()
.HasForeignKey("GroupId")
@@ -1360,11 +1377,6 @@ namespace TeleWave.Infrastructure.Migrations
b.Navigation("Variants");
});
modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
{
b.Navigation("BumperTemplates");
});
modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
{
b.Navigation("Items");
@@ -40,6 +40,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTemplate> BumperTemplates => Set<BumperTemplate>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
@@ -9,13 +9,10 @@ public class BumperAssetConfiguration : IEntityTypeConfiguration<BumperAsset>
public void Configure(EntityTypeBuilder<BumperAsset> builder)
{
builder.Property(x => x.Signature).IsRequired().HasMaxLength(128);
builder.Property(x => x.RenderedLinesJson).HasColumnType("jsonb");
// Кэш-ключ заставки: одна отрендеренная пара «из→в» при данной сигнатуре оформления.
builder.HasIndex(x => new
{
x.FromShowId,
x.ToShowId,
x.Signature,
});
// Кэш-ключ — сигнатура содержимого: одинаковая заставка на трёх каналах рендерится один раз.
builder.HasIndex(x => x.Signature).IsUnique();
builder.HasIndex(x => x.MediaAssetId);
}
}
@@ -15,13 +15,6 @@ public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
builder
.HasMany(x => x.BumperTemplates)
.WithOne()
.HasForeignKey(t => t.ChannelId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
@@ -29,7 +22,7 @@ public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTempla
{
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.HasIndex(x => x.Name);
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
@@ -52,10 +45,18 @@ public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTex
{
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
// Строки — одной jsonb-колонкой: они всегда читаются и пишутся вместе с подблоком,
// отдельная таблица дала бы join и порядковые правки там, где список заменяется целиком.
builder.OwnsMany(
x => x.Lines,
lines =>
{
lines.ToJson();
lines.Property(l => l.Text).HasMaxLength(120);
}
);
builder.Navigation(x => x.Lines).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Infrastructure.Persistence.Configurations;
@@ -10,7 +11,7 @@ public class JunctionTemplateConfiguration : IEntityTypeConfiguration<JunctionTe
public void Configure(EntityTypeBuilder<JunctionTemplate> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.ChannelId);
builder.HasIndex(x => x.Name);
builder
.HasMany(x => x.Elements)
@@ -27,6 +28,8 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration<JunctionEle
{
builder.HasIndex(x => new { x.JunctionTemplateId, x.Position });
builder.Property(x => x.ConditionsJson).HasColumnType("jsonb");
builder.Property(x => x.ChoiceKey).HasMaxLength(64);
builder.Property(x => x.ChoiceWeight).HasDefaultValue(JunctionElement.DefaultChoiceWeight);
// Группа не удаляется, пока на неё ссылается врезка: иначе стык молча перестал бы работать.
builder
@@ -34,5 +37,19 @@ public class JunctionElementConfiguration : IEntityTypeConfiguration<JunctionEle
.WithMany()
.HasForeignKey(x => x.GroupId)
.OnDelete(DeleteBehavior.Restrict);
// То же и с заставкой: блок общий, и удаление используемого выключило бы заставки в чужих
// каналах. Проверку дублирует хендлер — ради внятной ошибки вместо нарушения ссылки.
builder
.HasOne<BumperTemplate>()
.WithMany()
.HasForeignKey(x => x.BumperTemplateId)
.OnDelete(DeleteBehavior.Restrict);
builder
.HasOne<BumperTextVariant>()
.WithMany()
.HasForeignKey(x => x.BumperVariantId)
.OnDelete(DeleteBehavior.Restrict);
}
}