diff --git a/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs
new file mode 100644
index 0000000..638a7a4
--- /dev/null
+++ b/backend/src/TeleWave.Api/Endpoints/InterstitialEndpoints.cs
@@ -0,0 +1,64 @@
+using LiteCqrs;
+using TeleWave.Api.Common;
+using TeleWave.Application.Library.Interstitials;
+using TeleWave.Application.Library.Interstitials.ImportInterstitials;
+using TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
+using TeleWave.Application.Library.Interstitials.ListInterstitials;
+using TeleWave.Infrastructure.Identity;
+
+namespace TeleWave.Api.Endpoints;
+
+///
+/// Ролики-врезки: тот же Show(Kind = Interstitial), но со своим экраном (см. 6.7). Правка
+/// названия и удаление идут через обычные эндпоинты шоу — здесь только то, чего у библиотеки нет:
+/// список с длительностями, блоки и импорт загруженных файлов.
+///
+public static class InterstitialEndpoints
+{
+ public static IEndpointRouteBuilder MapInterstitialEndpoints(this IEndpointRouteBuilder app)
+ {
+ var admin = app.MapGroup("/api/admin/interstitials")
+ .WithTags("Admin.Interstitials")
+ .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
+
+ admin.MapGet("", List).Produces>();
+ admin.MapGet("/blocks", ListBlocks).Produces>();
+ admin.MapPost("/import", Import).Produces();
+
+ return app;
+ }
+
+ private static async Task List(ISender sender, CancellationToken cancellationToken)
+ {
+ var result = await sender.Send(new ListInterstitialsQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task ListBlocks(
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(new ListInterstitialBlocksQuery(), cancellationToken);
+ return Results.Ok(result);
+ }
+
+ private static async Task Import(
+ ImportInterstitialsBody body,
+ ISender sender,
+ CancellationToken cancellationToken
+ )
+ {
+ var result = await sender.Send(
+ new ImportInterstitialsCommand(body.MediaAssetIds),
+ cancellationToken
+ );
+ return result.IsSuccess
+ ? Results.Ok(new ImportInterstitialsResponse(result.Value))
+ : result.ToHttpResult();
+ }
+}
+
+public sealed record ImportInterstitialsBody(IReadOnlyList MediaAssetIds);
+
+public sealed record ImportInterstitialsResponse(int Imported);
diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
index 2277204..f65363f 100644
--- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs
@@ -1,3 +1,5 @@
+using System.Text;
+using System.Text.RegularExpressions;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
@@ -16,6 +18,8 @@ namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
+ private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
+
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
@@ -27,6 +31,11 @@ public static class MediaEndpoints
admin.MapGet("/stats", Stats).Produces();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
+ // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
+ // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
+ admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
+ admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
+
return app;
}
@@ -135,6 +144,56 @@ public static class MediaEndpoints
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
+
+ /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.
+ private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
+ {
+ string indexPath;
+ try
+ {
+ indexPath = paths.SegmentPath(id, "index.m3u8");
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Results.NotFound();
+ }
+ if (!File.Exists(indexPath))
+ return Results.NotFound();
+
+ var baseUrl = $"/api/admin/media/{id}/preview/";
+ var sb = new StringBuilder();
+ foreach (var line in File.ReadLines(indexPath))
+ {
+ var trimmed = line.Trim();
+ if (trimmed.Length == 0)
+ continue;
+ // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
+ sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
+ .Append('\n');
+ }
+
+ return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
+ }
+
+ private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
+ {
+ if (!SegmentFileName.IsMatch(file))
+ return Results.NotFound();
+
+ string path;
+ try
+ {
+ path = paths.SegmentPath(id, file);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Results.NotFound();
+ }
+ if (!File.Exists(path))
+ return Results.NotFound();
+
+ return Results.File(path, "video/mp2t", enableRangeProcessing: true);
+ }
}
public sealed record UploadMediaResponse(Guid Id);
diff --git a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
index 8acc6cc..524f324 100644
--- a/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
+++ b/backend/src/TeleWave.Api/Endpoints/TemplateEndpoints.cs
@@ -1,6 +1,7 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
+using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.DeleteSlot;
@@ -34,6 +35,10 @@ public static class TemplateEndpoints
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces();
+ // Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
+ admin
+ .MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
+ .Produces();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
@@ -75,6 +80,20 @@ public static class TemplateEndpoints
return result.ToHttpResult();
}
+ private static async Task PreviewTemplate(
+ Guid channelId,
+ ISender sender,
+ CancellationToken cancellationToken,
+ int days = 1
+ )
+ {
+ var result = await sender.Send(
+ new PreviewScheduleQuery(channelId, days),
+ cancellationToken
+ );
+ return result.ToHttpResult();
+ }
+
private static async Task UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
diff --git a/backend/src/TeleWave.Api/Program.cs b/backend/src/TeleWave.Api/Program.cs
index 1d5f07a..7b7de2c 100644
--- a/backend/src/TeleWave.Api/Program.cs
+++ b/backend/src/TeleWave.Api/Program.cs
@@ -125,6 +125,7 @@ app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
+app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
index 4583658..e8773ff 100644
--- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
+++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs
@@ -5,13 +5,7 @@ namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).
-public sealed record BumperSettingsDto(
- BumperFont Font,
- int MinIntervalMinutes,
- BumperSelection Selection,
- double ShowChangeChance,
- double EpisodeChangeChance
-);
+public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.
public sealed record BumperTextVariantDto(
diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
index 27aa129..30e2740 100644
--- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs
@@ -1,80 +1,74 @@
-using LiteCqrs;
-using Microsoft.EntityFrameworkCore;
-using TeleWave.Application.Common.Interfaces;
-using TeleWave.Application.Common.Models;
-
-namespace TeleWave.Application.Broadcast.GetChannel;
-
-public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
- : IQueryHandler>
-{
- public async Task> Handle(
- GetChannelQuery query,
- CancellationToken cancellationToken
- )
- {
- // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
- // (GetChannelTemplateQuery) — здесь только собственные свойства канала.
- var channel = await dbContext
- .Channels.AsNoTracking()
- .Include(c => c.BumperTemplates)
- .ThenInclude(t => t.Variants)
- .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
- if (channel is null)
- return Result.Failure(ChannelErrors.NotFound);
-
- var bumperTemplates = channel
- .BumperTemplates.OrderBy(t => t.Position)
- .Select(t => new BumperTemplateDto(
- t.Id,
- t.Position,
- t.IsDefault,
- t.Name,
- t.BackgroundColor,
- t.BackgroundColor2,
- t.AccentColor,
- t.TextColor,
- t.BackgroundImageId,
- t.AudioExtension is not null,
- t.AudioDurationSeconds,
- t.Variants.OrderBy(v => v.Position)
- .Select(v => new BumperTextVariantDto(
- v.Id,
- v.Position,
- v.Name,
- v.Kind,
- v.NowLabel,
- v.NextLabel,
- v.Line1,
- v.Line2,
- v.Trigger,
- v.Weight
- ))
- .ToList()
- ))
- .ToList();
-
- return Result.Success(
- new ChannelDto(
- channel.Id,
- channel.Name,
- channel.Slug,
- channel.IsEnabled,
- channel.Number,
- channel.UtcOffsetMinutes,
- channel.DayStartTime,
- channel.TemplateId,
- channel.BumpersEnabled,
- new BumperSettingsDto(
- channel.BumperFont,
- channel.BumperMinIntervalMinutes,
- channel.BumperSelection,
- channel.BumperShowChangeChance,
- channel.BumperEpisodeChangeChance
- ),
- bumperTemplates,
- channel.FillerAssetId
- )
- );
- }
-}
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Broadcast.GetChannel;
+
+public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
+ : IQueryHandler>
+{
+ public async Task> Handle(
+ GetChannelQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ // Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
+ // (GetChannelTemplateQuery) — здесь только собственные свойства канала.
+ var channel = await dbContext
+ .Channels.AsNoTracking()
+ .Include(c => c.BumperTemplates)
+ .ThenInclude(t => t.Variants)
+ .FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
+ if (channel is null)
+ return Result.Failure(ChannelErrors.NotFound);
+
+ var bumperTemplates = channel
+ .BumperTemplates.OrderBy(t => t.Position)
+ .Select(t => new BumperTemplateDto(
+ t.Id,
+ t.Position,
+ t.IsDefault,
+ t.Name,
+ t.BackgroundColor,
+ t.BackgroundColor2,
+ t.AccentColor,
+ t.TextColor,
+ t.BackgroundImageId,
+ t.AudioExtension is not null,
+ t.AudioDurationSeconds,
+ t.Variants.OrderBy(v => v.Position)
+ .Select(v => new BumperTextVariantDto(
+ v.Id,
+ v.Position,
+ v.Name,
+ v.Kind,
+ v.NowLabel,
+ v.NextLabel,
+ v.Line1,
+ v.Line2,
+ v.Trigger,
+ v.Weight
+ ))
+ .ToList()
+ ))
+ .ToList();
+
+ return Result.Success(
+ new ChannelDto(
+ channel.Id,
+ channel.Name,
+ channel.Slug,
+ channel.IsEnabled,
+ channel.Number,
+ channel.UtcOffsetMinutes,
+ channel.DayStartTime,
+ channel.TemplateId,
+ channel.BumpersEnabled,
+ new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
+ bumperTemplates,
+ channel.FillerAssetId
+ )
+ );
+ }
+}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
index b923620..e5c4f62 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs
@@ -13,11 +13,6 @@ public sealed record UpdateChannelSettingsCommand(
Guid? FillerAssetId
) : ICommand;
-/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings).
-public sealed record BumperSettingsInput(
- BumperFont Font,
- int MinIntervalMinutes,
- BumperSelection Selection,
- double ShowChangeChance,
- double EpisodeChangeChance
-);
+/// Общие настройки ТВ-заставок канала (см. Channel.UpdateBumperSettings). Условия
+/// показа сюда не входят — они задаются на элементе стыка.
+public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
index 1df2f03..4b78edd 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs
@@ -36,13 +36,7 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
command.BumpersEnabled,
command.FillerAssetId
);
- channel.UpdateBumperSettings(
- command.Bumper.Font,
- command.Bumper.MinIntervalMinutes,
- command.Bumper.Selection,
- command.Bumper.ShowChangeChance,
- command.Bumper.EpisodeChangeChance
- );
+ channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
index 15941fb..9089ce6 100644
--- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
+++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs
@@ -8,9 +8,5 @@ public sealed class UpdateChannelSettingsCommandValidator
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
-
- RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440);
- RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0);
- RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0);
}
}
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs
new file mode 100644
index 0000000..a2bbdc3
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommand.cs
@@ -0,0 +1,12 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+
+namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
+
+///
+/// Превращает готовые медиа-ассеты в ролики: по одному Show(Kind = Interstitial) на файл,
+/// имя — имя файла без расширения. Ассеты, уже привязанные к какому-нибудь шоу, пропускаются —
+/// так повторный импорт того же выделения не плодит дубли.
+///
+public sealed record ImportInterstitialsCommand(IReadOnlyList MediaAssetIds)
+ : ICommand>;
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs
new file mode 100644
index 0000000..755ecac
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandHandler.cs
@@ -0,0 +1,52 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Domain.Library;
+
+namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
+
+public sealed class ImportInterstitialsCommandHandler(IAppDbContext dbContext)
+ : ICommandHandler>
+{
+ public async Task> Handle(
+ ImportInterstitialsCommand command,
+ CancellationToken cancellationToken
+ )
+ {
+ var ids = command.MediaAssetIds.Distinct().ToList();
+
+ var assets = await dbContext
+ .MediaAssets.AsNoTracking()
+ .Where(a => ids.Contains(a.Id))
+ .Select(a => new { a.Id, a.OriginalFileName })
+ .ToListAsync(cancellationToken);
+ if (assets.Count == 0)
+ return Result.Failure(ShowErrors.AssetNotFound);
+
+ var alreadyUsed = await dbContext
+ .Shows.SelectMany(s => s.Episodes)
+ .Where(e => ids.Contains(e.MediaAssetId))
+ .Select(e => e.MediaAssetId)
+ .ToListAsync(cancellationToken);
+ var used = alreadyUsed.ToHashSet();
+
+ var created = 0;
+ foreach (var asset in assets.Where(a => !used.Contains(a.Id)))
+ {
+ var show = Show.Create(ClipName(asset.OriginalFileName), ShowKind.Interstitial);
+ show.AddEpisode(asset.Id);
+ dbContext.Shows.Add(show);
+ created++;
+ }
+
+ return Result.Success(created);
+ }
+
+ /// Имя ролика — имя файла без расширения; пустое (файл вида «.mp4») заменяем самим файлом.
+ private static string ClipName(string fileName)
+ {
+ var name = Path.GetFileNameWithoutExtension(fileName);
+ return string.IsNullOrWhiteSpace(name) ? fileName : name;
+ }
+}
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs
new file mode 100644
index 0000000..8e87ce9
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ImportInterstitials/ImportInterstitialsCommandValidator.cs
@@ -0,0 +1,10 @@
+using FluentValidation;
+
+namespace TeleWave.Application.Library.Interstitials.ImportInterstitials;
+
+public sealed class ImportInterstitialsCommandValidator
+ : AbstractValidator
+{
+ public ImportInterstitialsCommandValidator() =>
+ RuleFor(x => x.MediaAssetIds).NotEmpty().Must(ids => ids.Count <= 500);
+}
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs b/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs
new file mode 100644
index 0000000..38b191a
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/InterstitialDtos.cs
@@ -0,0 +1,28 @@
+using TeleWave.Domain.Media;
+
+namespace TeleWave.Application.Library.Interstitials;
+
+///
+/// Ролик на экране «Ролики». Это то же Show(Kind = Interstitial), но показывается по-другому:
+/// у ролика нет ни года, ни постера, ни серий — важна длительность, поэтому она приходит сразу,
+/// а не вторым запросом за медиа-ассетом.
+///
+public sealed record InterstitialDto(
+ Guid Id,
+ string Name,
+ Guid? MediaAssetId,
+ MediaAssetStatus? AssetStatus,
+ double? DurationSeconds,
+ DateTimeOffset CreatedAt
+);
+
+///
+/// Рекламный блок — коллекция, целиком собранная из роликов. Отдельного типа под блок нет
+/// (см. 3.7): блок и есть коллекция, здесь она показывается со своей суммарной длительностью.
+///
+public sealed record InterstitialBlockDto(
+ Guid Id,
+ string Name,
+ int ItemCount,
+ double DurationSeconds
+);
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs
new file mode 100644
index 0000000..51b80b0
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQuery.cs
@@ -0,0 +1,6 @@
+using LiteCqrs;
+
+namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
+
+/// Коллекции, целиком собранные из роликов, — рекламные блоки экрана «Ролики».
+public sealed record ListInterstitialBlocksQuery : IQuery>;
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs
new file mode 100644
index 0000000..002d35b
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitialBlocks/ListInterstitialBlocksQueryHandler.cs
@@ -0,0 +1,55 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Library;
+
+namespace TeleWave.Application.Library.Interstitials.ListInterstitialBlocks;
+
+public sealed class ListInterstitialBlocksQueryHandler(IAppDbContext dbContext)
+ : IQueryHandler>
+{
+ public async Task> Handle(
+ ListInterstitialBlocksQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ var collections = await dbContext
+ .Collections.AsNoTracking()
+ .Include(c => c.Items)
+ .OrderBy(c => c.Name)
+ .ToListAsync(cancellationToken);
+
+ // «Блок» — не отдельная сущность, а признак состава: коллекция целиком из роликов. Смешанные
+ // коллекции (франшизы) остаются на своём экране и сюда не попадают.
+ var showIds = collections.SelectMany(c => c.Items.Select(i => i.ShowId)).Distinct().ToList();
+ var clips = await dbContext
+ .Shows.AsNoTracking()
+ .Where(s => showIds.Contains(s.Id) && s.Kind == ShowKind.Interstitial)
+ .Select(s => new
+ {
+ s.Id,
+ AssetIds = s.Episodes.Select(e => e.MediaAssetId).ToList(),
+ })
+ .ToDictionaryAsync(s => s.Id, s => s.AssetIds, cancellationToken);
+
+ var assetIds = clips.Values.SelectMany(ids => ids).Distinct().ToList();
+ var durations = await dbContext
+ .MediaAssets.AsNoTracking()
+ .Where(a => assetIds.Contains(a.Id) && a.Duration != null)
+ .Select(a => new { a.Id, a.Duration })
+ .ToDictionaryAsync(a => a.Id, a => a.Duration!.Value.TotalSeconds, cancellationToken);
+
+ return collections
+ .Where(c => c.Items.Count > 0 && c.Items.All(i => clips.ContainsKey(i.ShowId)))
+ .Select(c => new InterstitialBlockDto(
+ c.Id,
+ c.Name,
+ c.Items.Count,
+ c.Items.Sum(i =>
+ clips[i.ShowId]
+ .Sum(assetId => durations.TryGetValue(assetId, out var d) ? d : 0)
+ )
+ ))
+ .ToList();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs
new file mode 100644
index 0000000..3b55afe
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQuery.cs
@@ -0,0 +1,5 @@
+using LiteCqrs;
+
+namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
+
+public sealed record ListInterstitialsQuery : IQuery>;
diff --git a/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs
new file mode 100644
index 0000000..b420a52
--- /dev/null
+++ b/backend/src/TeleWave.Application/Library/Interstitials/ListInterstitials/ListInterstitialsQueryHandler.cs
@@ -0,0 +1,60 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Domain.Library;
+
+namespace TeleWave.Application.Library.Interstitials.ListInterstitials;
+
+public sealed class ListInterstitialsQueryHandler(IAppDbContext dbContext)
+ : IQueryHandler>
+{
+ public async Task> Handle(
+ ListInterstitialsQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ var shows = await dbContext
+ .Shows.AsNoTracking()
+ .Include(s => s.Episodes)
+ .Where(s => s.Kind == ShowKind.Interstitial)
+ .OrderBy(s => s.Name)
+ .ToListAsync(cancellationToken);
+
+ // У ролика ровно одна «серия» — берём её ассет, чтобы показать длительность и статус обработки.
+ var assetIds = shows
+ .Select(s => s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).FirstOrDefault())
+ .Where(id => id != Guid.Empty)
+ .Distinct()
+ .ToList();
+ var assets = await dbContext
+ .MediaAssets.AsNoTracking()
+ .Where(a => assetIds.Contains(a.Id))
+ .Select(a => new
+ {
+ a.Id,
+ a.Status,
+ a.Duration,
+ })
+ .ToDictionaryAsync(a => a.Id, cancellationToken);
+
+ return shows
+ .Select(s =>
+ {
+ var assetId = s
+ .Episodes.OrderBy(e => e.Position)
+ .Select(e => (Guid?)e.MediaAssetId)
+ .FirstOrDefault();
+ var asset =
+ assetId is { } id && assets.TryGetValue(id, out var a) ? a : null;
+ return new InterstitialDto(
+ s.Id,
+ s.Name,
+ assetId,
+ asset?.Status,
+ asset?.Duration?.TotalSeconds,
+ s.CreatedAt
+ );
+ })
+ .ToList();
+ }
+}
diff --git a/backend/src/TeleWave.Application/Library/ShowErrors.cs b/backend/src/TeleWave.Application/Library/ShowErrors.cs
index 95d592f..e9f25df 100644
--- a/backend/src/TeleWave.Application/Library/ShowErrors.cs
+++ b/backend/src/TeleWave.Application/Library/ShowErrors.cs
@@ -13,7 +13,7 @@ public static class ShowErrors
public static readonly Error SingleAlreadyHasEpisode = Error.Conflict(
"Shows.SingleAlreadyHasEpisode",
- "Полнометражка/разовый выпуск может содержать только одну серию."
+ "Больше одной серии бывает только у сериала."
);
public static readonly Error AssetNotFound = Error.NotFound(
diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
index 3c5812b..beb8d8d 100644
--- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
+++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs
@@ -146,6 +146,39 @@ public sealed class GridScheduleGenerator(
return new GenerationReport(added, result.Warnings);
}
+ ///
+ /// Сухой прогон: считает, каким получился бы эфир по текущим правилам, но ничего не пишет —
+ /// ни ленты, ни курсоров слотов, ни отметки о применении. Заставки остаются резервом известной
+ /// длины: рендер долгий и может упасть, поэтому он делается только при реальном применении.
+ ///
+ public async Task PreviewAsync(
+ Guid channelId,
+ DateTimeOffset from,
+ int days,
+ CancellationToken cancellationToken
+ )
+ {
+ var channel = await dbContext
+ .Channels.AsNoTracking()
+ .Include(c => c.BumperTemplates)
+ .ThenInclude(t => t.Variants)
+ .FirstOrDefaultAsync(c => c.Id == channelId, cancellationToken);
+ if (channel is null || channel.TemplateId is null)
+ return null;
+
+ var template = await dbContext
+ .ScheduleTemplates.AsNoTracking()
+ .Include(t => t.Layers)
+ .ThenInclude(l => l.Slots)
+ .FirstOrDefaultAsync(t => t.Id == channel.TemplateId, cancellationToken);
+ if (template is null)
+ return null;
+
+ var horizonEnd = from.AddDays(Math.Clamp(days, 1, Math.Max(1, _options.HorizonDays)));
+ var input = await BuildInputAsync(channel, template, from, horizonEnd, cancellationToken);
+ return Domain.Programming.Planning.SchedulePlanner.Plan(input, random);
+ }
+
///
/// Чистит прошлое сверх окна хранения. Окно должно покрывать самое долгое остывание среди правил —
/// история показов берётся из самой ленты, отдельного журнала нет.
diff --git a/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs
new file mode 100644
index 0000000..97ade00
--- /dev/null
+++ b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQuery.cs
@@ -0,0 +1,33 @@
+using LiteCqrs;
+using TeleWave.Application.Common.Models;
+using TeleWave.Application.Programming.Planning.ApplyTemplate;
+using TeleWave.Domain.Programming.Planning;
+
+namespace TeleWave.Application.Programming.Planning.Preview;
+
+///
+/// Прогон генератора по текущим правилам без записи: что было бы в эфире, если применить сейчас.
+/// Курсоры слотов не двигаются, поэтому предпросмотр можно жать сколько угодно раз.
+///
+public sealed record PreviewScheduleQuery(Guid ChannelId, int Days = 1)
+ : IQuery>;
+
+/// Запись предполагаемой ленты. Заставка приходит без ассета — он рендерится при применении.
+public sealed record PreviewItemDto(
+ PlannedItemKind Kind,
+ DateTimeOffset StartsAtUtc,
+ DateTimeOffset EndsAtUtc,
+ Guid? ShowId,
+ string? Title,
+ Guid? SlotId,
+ string? SlotTitle
+);
+
+public sealed record SchedulePreviewDto(
+ DateTimeOffset FromUtc,
+ DateTimeOffset ToUtc,
+ /// Время канала: лента отдаётся в UTC, а показывается в нём.
+ int UtcOffsetMinutes,
+ IReadOnlyList Items,
+ IReadOnlyList Warnings
+);
diff --git a/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs
new file mode 100644
index 0000000..a4834e1
--- /dev/null
+++ b/backend/src/TeleWave.Application/Programming/Planning/Preview/PreviewScheduleQueryHandler.cs
@@ -0,0 +1,112 @@
+using LiteCqrs;
+using Microsoft.EntityFrameworkCore;
+using TeleWave.Application.Broadcast;
+using TeleWave.Application.Common.Interfaces;
+using TeleWave.Application.Common.Models;
+using TeleWave.Application.Programming.Planning.ApplyTemplate;
+using TeleWave.Domain.Programming.Planning;
+
+namespace TeleWave.Application.Programming.Planning.Preview;
+
+public sealed class PreviewScheduleQueryHandler(
+ IAppDbContext dbContext,
+ GridScheduleGenerator generator
+) : IQueryHandler>
+{
+ public async Task> Handle(
+ PreviewScheduleQuery query,
+ CancellationToken cancellationToken
+ )
+ {
+ var channel = await dbContext
+ .Channels.AsNoTracking()
+ .Where(c => c.Id == query.ChannelId)
+ .Select(c => new { c.UtcOffsetMinutes })
+ .FirstOrDefaultAsync(cancellationToken);
+ if (channel is null)
+ return Result.Failure(ChannelErrors.NotFound);
+
+ var from = DateTimeOffset.UtcNow;
+ var result = await generator.PreviewAsync(
+ query.ChannelId,
+ from,
+ query.Days,
+ cancellationToken
+ );
+ if (result is null)
+ return Result.Failure(ChannelErrors.TemplateNotFound);
+
+ var showNames = await LoadShowNamesAsync(result.Items, cancellationToken);
+ var slotTitles = await LoadSlotTitlesAsync(result.Items, cancellationToken);
+
+ var items = result
+ .Items.Select(item => new PreviewItemDto(
+ item.Kind,
+ item.StartsAtUtc,
+ item.EndsAtUtc,
+ item.ShowId,
+ item.ShowId is { } showId && showNames.TryGetValue(showId, out var name)
+ ? name
+ : null,
+ item.SlotId,
+ item.SlotId is { } slotId && slotTitles.TryGetValue(slotId, out var title)
+ ? title
+ : null
+ ))
+ .ToList();
+
+ var to = items.Count == 0 ? from : items[^1].EndsAtUtc;
+
+ return Result.Success(
+ new SchedulePreviewDto(
+ from,
+ to,
+ channel.UtcOffsetMinutes,
+ items,
+ result
+ .Warnings.Select(w => new PlanningWarningDto(w.Kind, w.SlotId, w.Details))
+ .ToList()
+ )
+ );
+ }
+
+ private async Task> LoadShowNamesAsync(
+ IReadOnlyList items,
+ CancellationToken cancellationToken
+ )
+ {
+ var ids = items
+ .Select(i => i.ShowId)
+ .Where(id => id is not null && id != Guid.Empty)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+ if (ids.Count == 0)
+ return [];
+
+ return await dbContext
+ .Shows.AsNoTracking()
+ .Where(s => ids.Contains(s.Id))
+ .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
+ }
+
+ private async Task> LoadSlotTitlesAsync(
+ IReadOnlyList items,
+ CancellationToken cancellationToken
+ )
+ {
+ var ids = items
+ .Select(i => i.SlotId)
+ .Where(id => id is not null)
+ .Select(id => id!.Value)
+ .Distinct()
+ .ToList();
+ if (ids.Count == 0)
+ return [];
+
+ return await dbContext
+ .Slots.AsNoTracking()
+ .Where(s => ids.Contains(s.Id))
+ .ToDictionaryAsync(s => s.Id, s => s.Title, cancellationToken);
+ }
+}
diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs
index f2496a5..525f182 100644
--- a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs
@@ -1,17 +1,17 @@
namespace TeleWave.Domain.Broadcast;
-/// Как выбирать блок заставки на каждом переходе между шоу.
+///
+/// Как выбирать подблок заставки на переходе. Значения заданы явно: прежний вариант «по кругу»
+/// (0) убран — курсора ротации в новом пайплайне нет, и он молча вырождался в случайный выбор.
+///
public enum BumperSelection
{
- /// По кругу в порядке блоков (курсор ).
- Rotation,
+ /// Случайный подблок на каждом переходе (равновероятно).
+ Random = 1,
- /// Случайный блок на каждом переходе (равновероятно).
- Random,
+ /// Всегда первый (дефолтный) подблок.
+ AlwaysFirst = 2,
- /// Всегда первый (дефолтный) блок.
- AlwaysFirst,
-
- /// Случайный блок с учётом веса подблока ().
- WeightedRandom,
+ /// Случайный подблок с учётом веса ().
+ WeightedRandom = 3,
}
diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs
index 518d399..600757a 100644
--- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs
+++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs
@@ -1,176 +1,145 @@
-namespace TeleWave.Domain.Broadcast;
-
-///
-/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (,
-/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный
-/// филлер и общие настройки заставок.
-///
-public class Channel
-{
- private readonly List _bumperTemplates = new();
-
- public Guid Id { get; private set; }
- public string Name { get; private set; } = string.Empty;
- public string Slug { get; private set; } = string.Empty;
- public bool IsEnabled { get; private set; }
-
- /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.
- public DateTimeOffset EpochUtc { get; private set; }
-
- ///
- /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
- /// включается глобальным флагом настроек сайта; null — номер не задан.
- ///
- public int? Number { get; private set; }
-
- ///
- /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
- /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
- /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
- ///
- public int UtcOffsetMinutes { get; private set; }
-
- ///
- /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
- /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
- ///
- public TimeOnly DayStartTime { get; private set; }
-
- /// Активный шаблон сетки канала (один на канал).
- public Guid? TemplateId { get; private set; }
-
- public const int DefaultUtcOffsetMinutes = 180;
- public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
-
- // ── Настройки ТВ-заставок. В срезе 2 условия показа переезжают в элементы стыка,
- // здесь останется только общий для канала шрифт. ──
-
- /// Вставлять ли ТВ-заставки на переходах между разными шоу.
- public bool BumpersEnabled { get; private set; }
-
- /// Как выбирать блок заставки на каждом переходе (по кругу/случайно/всегда первый).
- public BumperSelection BumperSelection { get; private set; }
-
- /// Курсор ротации блоков заставок.
- public int NextBumperIndex { get; private set; }
-
- public BumperFont BumperFont { get; private set; }
-
- /// Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе).
- public int BumperMinIntervalMinutes { get; private set; }
-
- /// Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда).
- public double BumperShowChangeChance { get; private set; } = 1.0;
-
- /// Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев).
- public double BumperEpisodeChangeChance { get; private set; } = 1.0;
-
- private const string DefaultTemplateName = "Заставка 1";
-
- /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка).
- public Guid? FillerAssetId { get; private set; }
-
- public DateTimeOffset CreatedAt { get; private set; }
-
- /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.
- public IReadOnlyList BumperTemplates => _bumperTemplates;
-
- private Channel() { }
-
- public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
- {
- var channel = new Channel
- {
- Id = Guid.NewGuid(),
- Name = name,
- Slug = slug,
- IsEnabled = true,
- EpochUtc = epochUtc,
- BumpersEnabled = false,
- BumperSelection = BumperSelection.Rotation,
- NextBumperIndex = 0,
- BumperFont = BumperFont.Sans,
- BumperMinIntervalMinutes = 0,
- BumperShowChangeChance = 1.0,
- BumperEpisodeChangeChance = 1.0,
- UtcOffsetMinutes = DefaultUtcOffsetMinutes,
- DayStartTime = DefaultDayStartTime,
- CreatedAt = DateTimeOffset.UtcNow,
- };
- // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
- channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
- return channel;
- }
-
- public void UpdateSettings(
- string name,
- bool isEnabled,
- bool bumpersEnabled,
- Guid? fillerAssetId
- )
- {
- Name = name;
- IsEnabled = isEnabled;
- BumpersEnabled = bumpersEnabled;
- FillerAssetId = fillerAssetId;
- }
-
- ///
- /// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и
- /// вероятности появления на смене шоу / между блоками одного шоу (0..1).
- ///
- public void UpdateBumperSettings(
- BumperFont font,
- int minIntervalMinutes,
- BumperSelection selection,
- double showChangeChance,
- double episodeChangeChance
- )
- {
- BumperFont = font;
- BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes);
- BumperSelection = selection;
- BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0);
- BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0);
- }
-
- /// Добавить блок заставки в конец списка. Возвращает созданный блок.
- public BumperTemplate AddBumperTemplate(string name)
- {
- var nextPosition =
- _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
- var template = BumperTemplate.Create(Id, nextPosition, name);
- _bumperTemplates.Add(template);
- return template;
- }
-
- public BumperTemplate? FindBumperTemplate(Guid templateId) =>
- _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
-
- /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.
- public bool RemoveBumperTemplate(Guid templateId)
- {
- var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
- if (template is null || template.IsDefault)
- return false;
- _bumperTemplates.Remove(template);
- return true;
- }
-
- /// Планировщик двигает курсор ротации блоков заставок по мере вставки.
- public void SetNextBumperIndex(int index) => NextBumperIndex = index;
-
- /// Привязать активный шаблон сетки.
- public void SetTemplate(Guid? templateId) => TemplateId = templateId;
-
- ///
- /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
- /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
- ///
- public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
- {
- Number = number is > 0 ? number : null;
- UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
- DayStartTime = dayStartTime;
- }
-}
+namespace TeleWave.Domain.Broadcast;
+
+///
+/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (,
+/// см. Domain/Programming); канал хранит только собственные свойства: время, номер, аварийный
+/// филлер и общие настройки заставок.
+///
+public class Channel
+{
+ private readonly List _bumperTemplates = new();
+
+ public Guid Id { get; private set; }
+ public string Name { get; private set; } = string.Empty;
+ public string Slug { get; private set; } = string.Empty;
+ public bool IsEnabled { get; private set; }
+
+ /// Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.
+ public DateTimeOffset EpochUtc { get; private set; }
+
+ ///
+ /// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
+ /// включается глобальным флагом настроек сайта; null — номер не задан.
+ ///
+ public int? Number { get; private set; }
+
+ ///
+ /// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
+ /// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
+ /// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
+ ///
+ public int UtcOffsetMinutes { get; private set; }
+
+ ///
+ /// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
+ /// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
+ ///
+ public TimeOnly DayStartTime { get; private set; }
+
+ /// Активный шаблон сетки канала (один на канал).
+ public Guid? TemplateId { get; private set; }
+
+ public const int DefaultUtcOffsetMinutes = 180;
+ public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
+
+ // ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
+ // живут в элементах стыка; на канале осталось только общее для всех заставок. ──
+
+ /// Вставлять ли ТВ-заставки вообще: общий выключатель канала.
+ public bool BumpersEnabled { get; private set; }
+
+ /// Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).
+ public BumperSelection BumperSelection { get; private set; }
+
+ public BumperFont BumperFont { get; private set; }
+
+ private const string DefaultTemplateName = "Заставка 1";
+
+ /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка).
+ public Guid? FillerAssetId { get; private set; }
+
+ public DateTimeOffset CreatedAt { get; private set; }
+
+ /// Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.
+ public IReadOnlyList BumperTemplates => _bumperTemplates;
+
+ private Channel() { }
+
+ public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
+ {
+ var channel = new Channel
+ {
+ Id = Guid.NewGuid(),
+ Name = name,
+ Slug = slug,
+ IsEnabled = true,
+ EpochUtc = epochUtc,
+ BumpersEnabled = false,
+ BumperSelection = BumperSelection.WeightedRandom,
+ BumperFont = BumperFont.Sans,
+ UtcOffsetMinutes = DefaultUtcOffsetMinutes,
+ DayStartTime = DefaultDayStartTime,
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+ // На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
+ channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
+ return channel;
+ }
+
+ public void UpdateSettings(
+ string name,
+ bool isEnabled,
+ bool bumpersEnabled,
+ Guid? fillerAssetId
+ )
+ {
+ Name = name;
+ IsEnabled = isEnabled;
+ BumpersEnabled = bumpersEnabled;
+ FillerAssetId = fillerAssetId;
+ }
+
+ /// Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.
+ public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
+ {
+ BumperFont = font;
+ BumperSelection = selection;
+ }
+
+ /// Добавить блок заставки в конец списка. Возвращает созданный блок.
+ public BumperTemplate AddBumperTemplate(string name)
+ {
+ var nextPosition =
+ _bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
+ var template = BumperTemplate.Create(Id, nextPosition, name);
+ _bumperTemplates.Add(template);
+ return template;
+ }
+
+ public BumperTemplate? FindBumperTemplate(Guid templateId) =>
+ _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
+
+ /// Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.
+ public bool RemoveBumperTemplate(Guid templateId)
+ {
+ var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
+ if (template is null || template.IsDefault)
+ return false;
+ _bumperTemplates.Remove(template);
+ return true;
+ }
+
+ /// Привязать активный шаблон сетки.
+ public void SetTemplate(Guid? templateId) => TemplateId = templateId;
+
+ ///
+ /// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
+ /// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
+ ///
+ public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
+ {
+ Number = number is > 0 ? number : null;
+ UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
+ DayStartTime = dayStartTime;
+ }
+}
diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs
index 23dfc5a..9d99c57 100644
--- a/backend/src/TeleWave.Domain/Library/Show.cs
+++ b/backend/src/TeleWave.Domain/Library/Show.cs
@@ -109,7 +109,7 @@ public class Show
{
if (!CanAddEpisode)
throw new InvalidOperationException(
- "Полнометражка (ShowKind.Single) может содержать только одну серию."
+ "Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
@@ -127,7 +127,9 @@ public class Show
return true;
}
- public bool CanAddEpisode => Kind != ShowKind.Single || _episodes.Count == 0;
+ /// Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
+ /// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.
+ public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.
public void ApplyMetadata(
diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs
new file mode 100644
index 0000000..9f00ba9
--- /dev/null
+++ b/backend/src/TeleWave.Infrastructure/Migrations/20260726105315_DropDeadBumperSettings.Designer.cs
@@ -0,0 +1,1368 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using TeleWave.Infrastructure.Persistence;
+
+#nullable disable
+
+namespace TeleWave.Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260726105315_DropDeadBumperSettings")]
+ partial class DropDeadBumperSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetRoleClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ClaimType")
+ .HasColumnType("text");
+
+ b.Property("ClaimValue")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserClaims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b =>
+ {
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("ProviderKey")
+ .HasColumnType("text");
+
+ b.Property("ProviderDisplayName")
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("LoginProvider", "ProviderKey");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AspNetUserLogins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("RoleId")
+ .HasColumnType("uuid");
+
+ b.HasKey("UserId", "RoleId");
+
+ b.HasIndex("RoleId");
+
+ b.ToTable("AspNetUserRoles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b =>
+ {
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.Property("LoginProvider")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("UserId", "LoginProvider", "Name");
+
+ b.ToTable("AspNetUserTokens", (string)null);
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ReplacedByTokenHash")
+ .HasColumnType("text");
+
+ b.Property("RevokedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UserId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenHash")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("RefreshTokens");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FromShowId")
+ .HasColumnType("uuid");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("Signature")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("ToShowId")
+ .HasColumnType("uuid");
+
+ b.Property("VariantId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FromShowId", "ToShowId", "Signature");
+
+ b.ToTable("BumperAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AccentColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("AudioDurationSeconds")
+ .HasColumnType("double precision");
+
+ b.Property("AudioExtension")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("BackgroundColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundColor2")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("BackgroundImageId")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.Property("TextColor")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "Position");
+
+ b.ToTable("BumperTemplate");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("Line1")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Line2")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("character varying(120)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("NextLabel")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("NowLabel")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Trigger")
+ .HasColumnType("integer");
+
+ b.Property("Weight")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(1);
+
+ b.HasKey("Id");
+
+ b.HasIndex("BumperTemplateId", "Position");
+
+ b.ToTable("BumperTextVariants");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperFont")
+ .HasColumnType("integer");
+
+ b.Property("BumperSelection")
+ .HasColumnType("integer");
+
+ b.Property("BumpersEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DayStartTime")
+ .HasColumnType("time without time zone");
+
+ b.Property("EpochUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FillerAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Number")
+ .HasColumnType("integer");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("UtcOffsetMinutes")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Number")
+ .IsUnique()
+ .HasFilter("\"Number\" IS NOT NULL");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Channels");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BumperVariantId")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("EndsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EpisodeIndex")
+ .HasColumnType("integer");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("SlotId")
+ .HasColumnType("uuid");
+
+ b.Property("StartsAtUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TraceJson")
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId", "EndsAtUtc");
+
+ b.HasIndex("ChannelId", "StartsAtUtc");
+
+ b.HasIndex("ChannelId", "ShowId", "StartsAtUtc");
+
+ b.ToTable("ScheduleEntries");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Images.Image", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Category")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Category", "CreatedAt");
+
+ b.ToTable("Images");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Collection", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.ToTable("Collections");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CollectionId")
+ .HasColumnType("uuid");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ShowId");
+
+ b.HasIndex("CollectionId", "Position");
+
+ b.HasIndex("CollectionId", "ShowId")
+ .IsUnique();
+
+ b.ToTable("CollectionItems");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("IsSystem")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Slug")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GenreId");
+
+ b.HasIndex("Value")
+ .IsUnique();
+
+ b.ToTable("GenreAliases");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.Show", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("Audience")
+ .HasColumnType("integer");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("MetadataExternalId")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)");
+
+ b.Property("MetadataProvider")
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("OriginalName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("PosterImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Year")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Shows");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AirDate")
+ .HasColumnType("date");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Episode")
+ .HasColumnType("integer");
+
+ b.Property("MediaAssetId")
+ .HasColumnType("uuid");
+
+ b.Property("Overview")
+ .HasMaxLength(4096)
+ .HasColumnType("character varying(4096)");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Season")
+ .HasColumnType("integer");
+
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("StillImageId")
+ .HasColumnType("uuid");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaAssetId");
+
+ b.HasIndex("ShowId", "Position");
+
+ b.ToTable("ShowEpisode");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Library.ShowGenre", b =>
+ {
+ b.Property("ShowId")
+ .HasColumnType("uuid");
+
+ b.Property("GenreId")
+ .HasColumnType("uuid");
+
+ b.Property("IsPrimary")
+ .HasColumnType("boolean");
+
+ b.HasKey("ShowId", "GenreId");
+
+ b.HasIndex("GenreId");
+
+ b.ToTable("ShowGenres");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AudioCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Duration")
+ .HasColumnType("interval");
+
+ b.Property("ErrorMessage")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("Height")
+ .HasColumnType("integer");
+
+ b.Property("OriginalExtension")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("character varying(16)");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)");
+
+ b.Property("ProcessingDuration")
+ .HasColumnType("interval");
+
+ b.Property("ProcessingStartedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("RelativePath")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("SegmentCount")
+ .HasColumnType("integer");
+
+ b.Property("SegmentSeconds")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .HasColumnType("integer");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("VideoCodec")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)");
+
+ b.Property("Width")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CreatedAt");
+
+ b.HasIndex("Status");
+
+ b.ToTable("MediaAssets");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ApplicabilityJson")
+ .HasColumnType("jsonb");
+
+ b.Property("IsBackground")
+ .HasColumnType("boolean");
+
+ b.Property("IsEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.Property("Priority")
+ .HasColumnType("integer");
+
+ b.Property("TemplateId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TemplateId", "Priority");
+
+ b.ToTable("GridLayers");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.Group", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)");
+
+ b.Property("FilterJson")
+ .HasColumnType("jsonb");
+
+ b.Property("ItemCount")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("StatsComputedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TotalDuration")
+ .HasColumnType("interval");
+
+ b.Property("UnitCount")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Groups");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ElementId")
+ .HasColumnType("uuid");
+
+ b.Property("ElementKind")
+ .HasColumnType("integer");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Weight")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ElementKind", "ElementId");
+
+ b.HasIndex("GroupId", "Position");
+
+ b.HasIndex("GroupId", "ElementKind", "ElementId")
+ .IsUnique();
+
+ b.ToTable("GroupItems");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AmountMode")
+ .HasColumnType("integer");
+
+ b.Property("AmountValue")
+ .HasColumnType("integer");
+
+ b.Property("BumperTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("ConditionsJson")
+ .HasColumnType("jsonb");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property("IsRequired")
+ .HasColumnType("boolean");
+
+ b.Property("JunctionTemplateId")
+ .HasColumnType("uuid");
+
+ b.Property("Kind")
+ .HasColumnType("integer");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroupId");
+
+ b.HasIndex("JunctionTemplateId", "Position");
+
+ b.ToTable("JunctionElements");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.ToTable("JunctionTemplates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("AppliedRevision")
+ .HasColumnType("integer");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DefaultJunctionId")
+ .HasColumnType("uuid");
+
+ b.Property("FallbackGroupId")
+ .HasColumnType("uuid");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)");
+
+ b.Property("Revision")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChannelId");
+
+ b.ToTable("ScheduleTemplates");
+ });
+
+ modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid");
+
+ b.Property("BlockMode")
+ .HasColumnType("integer");
+
+ b.Property("BlockValue")
+ .HasColumnType("integer");
+
+ b.Property("Daypart")
+ .HasColumnType("integer");
+
+ b.Property("GroupId")
+ .HasColumnType("uuid");
+
+ b.Property