Compare commits

...
3 Commits
Author SHA1 Message Date
Leonid PershinandClaude Opus 5 088af2318e Add the Prettier run to the blame ignore list
ci / build-backend (push) Successful in 2m1s
ci / build-frontend (push) Successful in 54s
ci / tests (push) Successful in 2m31s
ci / sonar (push) Successful in 5m16s
Первый прогон форматтера переписал 50 файлов; без этой записи `git blame` показывал бы его
вместо автора реальной правки. Включается локально: git config blame.ignoreRevsFile .git-blame-ignore-revs

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:20 +03:00
Leonid PershinandClaude Opus 5 0606ea3e6e Add Prettier to the frontend and gate formatting in CI
Форматтера у фронтенда не было: стиль держался вручную и успел разъехаться в 50 файлах.
Ставим Prettier с настройками под уже сложившийся стиль (без точек с запятой, одинарные кавычки,
ширина 100 — подобрана замером: при 100 расходится меньше файлов, чем при 96 или 110) и
прогоняем его по коду. `src/routeTree.gen.ts` исключён — его переписывает плагин роутера.

Чтобы форматирование больше не расходилось незаметно, добавлены проверки в CI: `csharpier check`
для бэкенда (его отсутствие и позволило накопиться 79 неотформатированным файлам) и
`prettier --check` для фронтенда. Версии форматтеров прибиты точно, без кареток: минорка меняет
вывод и красит CI на файлах, которых никто не трогал.

`.editorconfig` задаёт редакторам те же отступы и LF ещё до форматтера; значения совпадают с
настройками csharpier и Prettier намеренно — оба его читают.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:38:12 +03:00
Leonid PershinandClaude Opus 5 0442056367 Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка
работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево,
чтобы форматтеры не переписывали файлы целиком на каждом прогоне.

Коммит чисто механический: изменений содержимого нет, только концы строк.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 01:37:33 +03:00
167 changed files with 18630 additions and 18362 deletions
+22
View File
@@ -0,0 +1,22 @@
# Базовые правила для редакторов — чтобы IDE не ставила свои отступы и концы строк до того, как
# файл дойдёт до форматтера. Итоговый стиль всё равно задают csharpier (.config/dotnet-tools.json)
# и Prettier (frontend/.prettierrc.json); значения здесь совпадают с их настройками намеренно —
# оба читают .editorconfig, и расхождение развернуло бы форматирование в другую сторону.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
max_line_length = 100
[*.cs]
indent_size = 4
[*.md]
# В markdown два пробела в конце строки — это перенос, обрезать их нельзя.
trim_trailing_whitespace = false
+15
View File
@@ -0,0 +1,15 @@
# Коммиты, которые правили только оформление: перевод концов строк в LF и первые прогоны
# csharpier/Prettier. Без этого списка `git blame` на половине файлов показывал бы их вместо
# автора реальной правки.
#
# Включить локально (в .git/config, а не в репозитории — потому команду надо выполнить у себя):
# git config blame.ignoreRevsFile .git-blame-ignore-revs
#
# Добавлять сюда только чисто механические коммиты, полным SHA, по одному на строку:
# git rev-parse HEAD >> .git-blame-ignore-revs
# Перевод дерева в LF.
0442056367cb6cd3b38964dacf75f44f123e11ac
# Первый прогон Prettier по фронтенду.
0606ea3e6ee4a4fd7faf8d1b054a316633442e98
+10
View File
@@ -0,0 +1,10 @@
# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier
# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него
# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит.
* text=auto eol=lf
# Бинарники не трогаем.
*.png binary
*.jpg binary
*.ico binary
*.woff2 binary
+11
View File
@@ -21,6 +21,14 @@ jobs:
- name: Restore
working-directory: backend
run: dotnet restore TeleWave.slnx
# Форматирование проверяем до сборки: падает за секунду и сразу показывает, что чинить.
# Версия csharpier прибита в .config/dotnet-tools.json — глобальная копия разработчика на CI
# не влияет, иначе её минорка переформатировала бы репозиторий в свой стиль.
- name: Format check
working-directory: backend
run: |
dotnet tool restore
dotnet csharpier check .
# Строгая сборка: TreatWarningsAsErrors=true из Directory.Build.props не отключаем.
- name: Build (Release)
working-directory: backend
@@ -38,6 +46,9 @@ jobs:
- name: Install
working-directory: frontend
run: pnpm install --frozen-lockfile
- name: Format check
working-directory: frontend
run: pnpm format:check
- name: Lint
working-directory: frontend
run: pnpm lint
@@ -1,247 +1,247 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
public static partial class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app;
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Broadcast.UpdateChannelTime;
using TeleWave.Application.Broadcast.UpdateViewerSettings;
using TeleWave.Application.Programming.Planning.Trace;
using TeleWave.Domain.Broadcast;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
/// (<c>TemplateEndpoints</c>).
/// </summary>
public static partial class ChannelEndpoints
{
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/channels")
.WithTags("Admin.Channels")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
admin
.MapPut("/{id:guid}/settings", UpdateSettings)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
SetTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/background",
ClearTemplateBackground
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
.Produces(StatusCodes.Status204NoContent);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
PreviewPlaylist
);
admin.MapGet(
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
PreviewSegment
);
admin
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
UpdateBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete(
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
RemoveBumperVariant
)
.Produces(StatusCodes.Status204NoContent);
admin
.MapGet("/{id:guid}/schedule", GetSchedule)
.Produces<IReadOnlyList<ScheduleEntryDto>>();
admin
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
.Produces(StatusCodes.Status204NoContent);
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
return app;
}
private static async Task<IResult> UpdateViewerSettings(
Guid id,
UpdateViewerSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateViewerSettingsCommand(
id,
body.LogoImageId,
body.LogoCorner,
body.LogoOpacity,
body.ShowClock,
body.AnalogFilterStrength
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetEntryTrace(
Guid entryId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateChannel(
CreateChannelCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> GetChannel(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTime(
Guid id,
UpdateChannelTimeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateSettings(
Guid id,
UpdateChannelSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateChannelSettingsCommand(
id,
body.Name,
body.IsEnabled,
body.BumpersEnabled,
body.Bumper,
body.FillerAssetId
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> GetSchedule(
Guid id,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddDays(1);
var result = await sender.Send(
new GetChannelScheduleQuery(id, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
}
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
public sealed record UpdateChannelTimeBody(
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime
);
public sealed record UpdateChannelSettingsBody(
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
);
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
public sealed record UpdateViewerSettingsBody(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,216 +1,216 @@
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Status ?? [],
filter.Search,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } 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 (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Options;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Media;
using TeleWave.Application.Media.Delete;
using TeleWave.Application.Media.ListMedia;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Application.Media.Register;
using TeleWave.Application.Media.Stats;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
namespace TeleWave.Api.Endpoints;
public static class MediaEndpoints
{
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/media")
.WithTags("Admin.Media")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
return app;
}
/// <summary>
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
/// </summary>
private static async Task<IResult> Upload(
string fileName,
HttpRequest request,
IMediaStorage storage,
IMediaProcessingQueue queue,
ISender sender,
UploadLimits limits,
CancellationToken cancellationToken
)
{
if (string.IsNullOrWhiteSpace(fileName))
return Results.Problem(
title: MediaErrors.EmptyFileName.Code,
detail: MediaErrors.EmptyFileName.Message,
statusCode: StatusCodes.Status400BadRequest
);
if (!MediaFormats.IsAllowed(fileName))
return Results.Problem(
title: MediaErrors.UnsupportedFormat.Code,
detail: MediaErrors.UnsupportedFormat.Message,
statusCode: StatusCodes.Status400BadRequest
);
var contentLength = request.ContentLength ?? 0;
if (contentLength > limits.MaxUploadBytes)
return Results.Problem(
title: MediaErrors.FileTooLarge.Code,
detail: MediaErrors.FileTooLarge.Message,
statusCode: StatusCodes.Status400BadRequest
);
var free = storage.GetAvailableFreeSpaceBytes();
if (free - contentLength < limits.MinFreeSpaceBytes)
return Results.Problem(
title: MediaErrors.InsufficientStorage.Code,
detail: MediaErrors.InsufficientStorage.Message,
statusCode: StatusCodes.Status409Conflict
);
var extension = Path.GetExtension(fileName);
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
var result = await sender.Send(
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
cancellationToken
);
if (!result.IsSuccess)
{
await storage.DeleteUploadAsync(token, cancellationToken);
return result.ToHttpResult();
}
queue.Enqueue(result.Value);
return Results.Created(
$"/api/admin/media/{result.Value}",
new UploadMediaResponse(result.Value)
);
}
private static async Task<IResult> List(
[AsParameters] ListMediaFilter filter,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ListMediaAssetsQuery(
filter.Page is > 0 ? filter.Page.Value : 1,
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
filter.Status ?? [],
filter.Search,
filter.Sort,
filter.Desc ?? false
),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> Delete(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> ListManual(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ImportManual(
ImportManualInboxBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ImportManualInboxCommand(body.Items, body.ShowId),
cancellationToken
);
return result.ToHttpResult();
}
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
{
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } 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 (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
return Results.NotFound();
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
}
/// <summary>
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
/// </summary>
public sealed record ListMediaFilter(
int? Page,
int? PageSize,
MediaAssetStatus[]? Status,
string? Search,
string? Sort,
bool? Desc
);
public sealed record UploadMediaResponse(Guid Id);
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
@@ -1,55 +1,55 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Settings;
using TeleWave.Application.Settings.GetSiteSettings;
using TeleWave.Application.Settings.UpdateSiteSettings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class SettingsEndpoints
{
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/settings")
.WithTags("Admin.Settings")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetSettings(
ISender sender,
CancellationToken cancellationToken
)
{
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
return Results.Ok(settings);
}
private static async Task<IResult> UpdateSettings(
UpdateSiteSettingsBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateSiteSettingsCommand(
body.RegistrationEnabled,
body.PreferredAudioLanguages ?? "",
body.ChannelNumbersEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
}
public sealed record UpdateSiteSettingsBody(
bool RegistrationEnabled,
string? PreferredAudioLanguages,
bool ChannelNumbersEnabled
);
@@ -1,186 +1,186 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Library;
using TeleWave.Application.Library.AddEpisode;
using TeleWave.Application.Library.CreateShow;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Library.ListShows;
using TeleWave.Application.Library.RemoveEpisode;
using TeleWave.Application.Library.RenameShow;
using TeleWave.Application.Library.SetShowAudience;
using TeleWave.Application.Library.SetShowGenres;
using TeleWave.Application.Library.SetShowOriginalName;
using TeleWave.Domain.Library;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
public static class ShowEndpoints
{
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin/shows")
.WithTags("Admin.Shows")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
admin
.MapPut("/{id:guid}/original-name", SetOriginalName)
.Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/{id:guid}/episodes", AddEpisode)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> CreateShow(
CreateShowCommand command,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(command, cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/shows/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ListShows(
ISender sender,
CancellationToken cancellationToken,
Guid? genreId = null,
bool interstitials = false
)
{
var result = await sender.Send(
new ListShowsQuery(genreId, interstitials),
cancellationToken
);
return Results.Ok(result);
}
private static async Task<IResult> GetShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> Rename(
Guid id,
RenameShowBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> SetOriginalName(
Guid id,
SetShowOriginalNameBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowOriginalNameCommand(id, body.OriginalName),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetAudience(
Guid id,
SetShowAudienceBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowAudienceCommand(id, body.Audience),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> SetGenres(
Guid id,
SetShowGenresBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteShow(
Guid id,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> AddEpisode(
Guid id,
AddEpisodeBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new AddEpisodeCommand(id, body.MediaAssetId),
cancellationToken
);
return result.IsSuccess
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
: result.ToHttpResult();
}
private static async Task<IResult> RemoveEpisode(
Guid id,
Guid episodeId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record AddEpisodeBody(Guid MediaAssetId);
public sealed record RenameShowBody(string Name);
public sealed record SetShowOriginalNameBody(string? OriginalName);
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
public sealed record SetShowAudienceBody(ShowAudience? Audience);
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
@@ -1,191 +1,191 @@
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
using System.Globalization;
using System.Text;
using LiteCqrs;
using Microsoft.Extensions.Hosting;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static async Task<IResult> ViewerFeatures(
ISiteSettings siteSettings,
CancellationToken cancellationToken
) =>
Results.Ok(
new ViewerFeaturesDto(
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
)
);
/// <summary>
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
/// </summary>
private static IResult Watch(
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response,
IHostEnvironment env
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
Secure = !env.IsDevelopment() || request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
IIdentityService identity,
ISender sender,
CancellationToken cancellationToken
)
{
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
return Results.Unauthorized();
var profile = await identity.GetProfileAsync(userId, cancellationToken);
if (profile is null || profile.IsBlocked)
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
return Results.Unauthorized();
if (!SegmentFiles.IsSegmentName(file))
return Results.NotFound();
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
);
sb.Append(
CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
);
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
@@ -1,296 +1,296 @@
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Programming.Planning.ApplyTemplate;
using TeleWave.Application.Programming.Planning.Diff;
using TeleWave.Application.Programming.Planning.Preview;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Programming.Templates.CopyTemplate;
using TeleWave.Application.Programming.Templates.CreateSlot;
using TeleWave.Application.Programming.Templates.CreateTemplate;
using TeleWave.Application.Programming.Templates.DeleteSlot;
using TeleWave.Application.Programming.Templates.GetTemplate;
using TeleWave.Application.Programming.Templates.Layers;
using TeleWave.Application.Programming.Templates.UpdateSlot;
using TeleWave.Application.Programming.Templates.Validate;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Api.Endpoints;
/// <summary>
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
/// изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var admin = app.MapGroup("/api/admin")
.WithTags("Admin.Templates")
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
admin
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
.Produces<ScheduleTemplateDto>();
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
admin
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
.Produces(StatusCodes.Status204NoContent);
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
admin
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
.Produces<ApplyResultDto>();
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
admin
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
.Produces<SchedulePreviewDto>();
// Проверки по правилам — только по шаблону, без прогона генератора.
admin
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
.Produces<IReadOnlyList<TemplateIssueDto>>();
// Что изменится в эфире, если применить прямо сейчас.
admin
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
.Produces<ScheduleDiffDto>();
admin
.MapPost(
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
CopyTemplate
)
.Produces<CopyTemplateResultDto>();
admin
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin
.MapPut("/layers/{layerId:guid}", UpdateLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
.Produces(StatusCodes.Status204NoContent);
admin
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
admin
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
.Produces(StatusCodes.Status204NoContent);
return app;
}
private static async Task<IResult> GetTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateChannelTemplateCommand(channelId),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/channels/{channelId}/template",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> ApplyTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new ApplyChannelTemplateCommand(channelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> 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<IResult> ValidateTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DiffTemplate(
Guid channelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CopyTemplate(
Guid channelId,
Guid targetChannelId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CopyTemplateCommand(channelId, targetChannelId),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> UpdateTemplate(
Guid templateId,
UpdateTemplateBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateTemplateCommand(
templateId,
body.Name,
body.FallbackGroupId,
body.DefaultJunctionId,
body.Rules
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> CreateLayer(
Guid templateId,
CreateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new CreateLayerCommand(templateId, body.Name, body.Priority),
cancellationToken
);
return result.IsSuccess
? Results.Created(
$"/api/admin/layers/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateLayer(
Guid layerId,
UpdateLayerBody body,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(
new UpdateLayerCommand(
layerId,
body.Name,
body.Priority,
body.Applicability,
body.IsEnabled
),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteLayer(
Guid layerId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> CreateSlot(
Guid layerId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
return result.IsSuccess
? Results.Created(
$"/api/admin/slots/{result.Value}",
new CreatedIdResponse(result.Value)
)
: result.ToHttpResult();
}
private static async Task<IResult> UpdateSlot(
Guid slotId,
SlotInput input,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> DeleteSlot(
Guid slotId,
ISender sender,
CancellationToken cancellationToken
)
{
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
return result.ToHttpResult();
}
}
public sealed record UpdateTemplateBody(
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
);
public sealed record CreateLayerBody(string Name, int Priority);
public sealed record UpdateLayerBody(
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
);
+148 -148
View File
@@ -1,148 +1,148 @@
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
using System.Net;
using System.Text.Json.Serialization;
using System.Threading.RateLimiting;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Scalar.AspNetCore;
using Serilog;
using TeleWave.Api.Common;
using TeleWave.Api.Endpoints;
using TeleWave.Application;
using TeleWave.Infrastructure;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Persistence;
var builder = WebApplication.CreateBuilder(args);
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
builder.WebHost.ConfigureKestrel(options =>
options.Limits.MaxRequestBodySize =
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
);
// Структурное логирование (Serilog), конфигурация из appsettings/env.
builder.Services.AddSerilog(
(services, configuration) =>
configuration
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
);
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
foreach (
var proxy in builder
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
.Get<string[]>()
?? []
)
options.KnownProxies.Add(IPAddress.Parse(proxy));
foreach (
var network in builder
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
.Get<string[]>()
?? []
)
{
var parts = network.Split('/');
options.KnownIPNetworks.Add(
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
);
}
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<UploadLimits>();
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
builder.Services.AddRateLimiter(options =>
{
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
options.AddPolicy(
RateLimiting.AuthPolicy,
httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = authPermitLimit,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}
)
);
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
);
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
var app = builder.Build();
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
await app.Services.ApplyMigrationsAsync();
await app.Services.SeedDataAsync();
app.UseForwardedHeaders();
app.UseSerilogRequestLogging();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
// не раскрывать полную карту эндпоинтов без необходимости.
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
{
app.MapOpenApi();
app.MapScalarApiReference();
}
app.MapHealthChecks("/health");
app.MapAuthEndpoints();
app.MapRoleEndpoints();
app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapGenreEndpoints();
app.MapInterstitialEndpoints();
app.MapCollectionEndpoints();
app.MapGroupEndpoints();
app.MapTemplateEndpoints();
app.MapJunctionEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
app.MapMaintenanceEndpoints();
app.MapSettingsEndpoints();
app.MapMetadataEndpoints();
app.MapImageEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
await app.RunAsync();
@@ -1,126 +1,126 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewCommand query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0
? d
: DefaultBumperDurationSeconds;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var spec = BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
inputs
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
private async Task<string?> ResolveBackgroundPathAsync(
BumperTemplate template,
CancellationToken cancellationToken
)
{
if (template.BackgroundImageId is not { } imageId)
return null;
var extension = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var names = await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
select show.Name
)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return (
names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу"
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Broadcast.Bumpers;
public sealed class RenderBumperPreviewCommandHandler(
IAppDbContext dbContext,
IBumperRenderer renderer,
IBumperTemplateStorage storage,
IImageStore imageStore,
IOptions<BumperOptions> bumperOptions,
IOptions<StreamingOptions> streamingOptions
) : ICommandHandler<RenderBumperPreviewCommand, Result>
{
private readonly BumperOptions _bumper = bumperOptions.Value;
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
private const int DefaultBumperDurationSeconds = 8;
public async Task<Result> Handle(
RenderBumperPreviewCommand query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
if (template is null)
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
var seconds = template.AudioDurationSeconds is { } d and > 0
? d
: DefaultBumperDurationSeconds;
var aligned = (int)(
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
);
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
var inputs = new BumperSpecInputs(
fromName,
toName,
audioPath,
PosterAbsolutePath: null,
backgroundPath
);
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
foreach (var variant in template.Variants.OrderBy(v => v.Position))
{
var spec = BumperSpecFactory.Build(
_bumper,
channel.BumperFont,
template,
variant,
aligned,
inputs
);
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
}
return Result.Success();
}
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
private async Task<string?> ResolveBackgroundPathAsync(
BumperTemplate template,
CancellationToken cancellationToken
)
{
if (template.BackgroundImageId is not { } imageId)
return null;
var extension = await dbContext
.Images.AsNoTracking()
.Where(i => i.Id == imageId)
.Select(i => i.FileExtension)
.FirstOrDefaultAsync(cancellationToken);
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
}
/// <summary>
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
/// </summary>
private async Task<(string From, string To)> SampleNamesAsync(
Channel channel,
CancellationToken cancellationToken
)
{
var names = await (
from slot in dbContext.Slots.AsNoTracking()
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
join item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
where
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
select show.Name
)
.Distinct()
.Take(2)
.ToListAsync(cancellationToken);
return (
names.ElementAtOrDefault(0) ?? "Первое шоу",
names.ElementAtOrDefault(1) ?? "Второе шоу"
);
}
}
@@ -1,64 +1,64 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
int Position,
bool IsDefault,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
IReadOnlyList<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime,
Guid? TemplateId,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast;
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
public sealed record BumperTextVariantDto(
Guid Id,
int Position,
string Name,
BumperTextKind Kind,
string NowLabel,
string NextLabel,
string Line1,
string Line2,
BumperTrigger Trigger,
int Weight
);
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
public sealed record BumperTemplateDto(
Guid Id,
int Position,
bool IsDefault,
string Name,
string BackgroundColor,
string BackgroundColor2,
string AccentColor,
string TextColor,
Guid? BackgroundImageId,
bool HasAudio,
double? AudioDurationSeconds,
IReadOnlyList<BumperTextVariantDto> Variants
);
public sealed record ChannelDto(
Guid Id,
string Name,
string Slug,
bool IsEnabled,
int? Number,
int UtcOffsetMinutes,
TimeOnly DayStartTime,
Guid? TemplateId,
bool BumpersEnabled,
BumperSettingsDto Bumper,
IReadOnlyList<BumperTemplateDto> BumperTemplates,
Guid? FillerAssetId,
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
ViewerSettingsDto Viewer
);
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
public sealed record ViewerSettingsDto(
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
@@ -1,53 +1,53 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast;
public static class ChannelErrors
{
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error NumberTaken = Error.Conflict(
"Channels.NumberTaken",
"Канал с таким номером уже есть."
);
public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.TemplateNotFound",
"У канала нет шаблона сетки."
);
public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate",
"Дефолтный блок заставки удалить нельзя."
);
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.BumperTextVariantNotFound",
"Подблок заставки не найден."
);
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.CannotRemoveLastBumperTextVariant",
"Нельзя удалить последний подблок — нужен хотя бы один."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound",
"Медиа-ассет не найден."
);
public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.InvalidBumperFile",
"Недопустимый файл заставки (формат или размер)."
);
}
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast;
public static class ChannelErrors
{
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
public static readonly Error NumberTaken = Error.Conflict(
"Channels.NumberTaken",
"Канал с таким номером уже есть."
);
public static readonly Error TemplateNotFound = Error.NotFound(
"Channels.TemplateNotFound",
"У канала нет шаблона сетки."
);
public static readonly Error DuplicateSlug = Error.Conflict(
"Channels.DuplicateSlug",
"Канал с таким slug уже существует."
);
public static readonly Error BumperTemplateNotFound = Error.NotFound(
"Channels.BumperTemplateNotFound",
"Блок заставки не найден."
);
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
"Channels.CannotRemoveDefaultBumperTemplate",
"Дефолтный блок заставки удалить нельзя."
);
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
"Channels.BumperTextVariantNotFound",
"Подблок заставки не найден."
);
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
"Channels.CannotRemoveLastBumperTextVariant",
"Нельзя удалить последний подблок — нужен хотя бы один."
);
public static readonly Error AssetNotFound = Error.NotFound(
"Channels.AssetNotFound",
"Медиа-ассет не найден."
);
public static readonly Error InvalidBumperFile = Error.Validation(
"Channels.InvalidBumperFile",
"Недопустимый файл заставки (формат или размер)."
);
}
@@ -1,82 +1,82 @@
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<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(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,
new ViewerSettingsDto(
channel.LogoImageId,
channel.LogoCorner,
channel.LogoOpacity,
channel.ShowClock,
channel.AnalogFilterStrength
)
)
);
}
}
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<GetChannelQuery, Result<ChannelDto>>
{
public async Task<Result<ChannelDto>> Handle(
GetChannelQuery query,
CancellationToken cancellationToken
)
{
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
var channel = await dbContext
.Channels.AsNoTracking()
.Include(c => c.BumperTemplates)
.ThenInclude(t => t.Variants)
.AsSplitQuery()
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
if (channel is null)
return Result.Failure<ChannelDto>(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,
new ViewerSettingsDto(
channel.LogoImageId,
channel.LogoCorner,
channel.LogoOpacity,
channel.ShowClock,
channel.AnalogFilterStrength
)
)
);
}
}
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
namespace TeleWave.Application.Broadcast.Scheduling;
public sealed class SchedulerOptions
{
public const string SectionName = "Scheduler";
/// <summary>
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
/// </summary>
public int HorizonDays { get; init; } = 7;
/// <summary>
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
/// </summary>
public int RetentionDays { get; init; } = 90;
/// <summary>Период тика фонового планировщика, минуты.</summary>
public int TickMinutes { get; init; } = 30;
}
@@ -1,18 +1,18 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed record UpdateChannelSettingsCommand(
Guid ChannelId,
string Name,
bool IsEnabled,
bool BumpersEnabled,
BumperSettingsInput Bumper,
Guid? FillerAssetId
) : ICommand<Result>;
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
/// показа сюда не входят — они задаются на элементе стыка.</summary>
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
@@ -1,42 +1,42 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateChannelSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateChannelSettingsCommand command,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.FirstOrDefaultAsync(
c => c.Id == command.ChannelId,
cancellationToken
);
if (channel is null)
return Result.Failure(ChannelErrors.NotFound);
if (command.FillerAssetId is { } fillerId)
{
var exists = await dbContext.MediaAssets.AnyAsync(
a => a.Id == fillerId,
cancellationToken
);
if (!exists)
return Result.Failure(ChannelErrors.AssetNotFound);
}
channel.UpdateSettings(
command.Name,
command.IsEnabled,
command.BumpersEnabled,
command.FillerAssetId
);
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
return Result.Success();
}
}
@@ -1,12 +1,12 @@
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
}
}
using FluentValidation;
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
public sealed class UpdateChannelSettingsCommandValidator
: AbstractValidator<UpdateChannelSettingsCommand>
{
public UpdateChannelSettingsCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
}
}
@@ -1,48 +1,48 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
namespace TeleWave.Application.Common.Interfaces;
public interface IAppDbContext
{
DbSet<RefreshToken> RefreshTokens { get; }
DbSet<MediaAsset> MediaAssets { get; }
DbSet<Show> Shows { get; }
DbSet<Genre> Genres { get; }
DbSet<GenreAlias> GenreAliases { get; }
DbSet<ShowGenre> ShowGenres { get; }
DbSet<Collection> Collections { get; }
DbSet<CollectionItem> CollectionItems { get; }
DbSet<Group> Groups { get; }
DbSet<GroupItem> GroupItems { get; }
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
DbSet<GridLayer> GridLayers { get; }
DbSet<Slot> Slots { get; }
DbSet<SlotState> SlotStates { get; }
DbSet<JunctionTemplate> JunctionTemplates { get; }
DbSet<JunctionElement> JunctionElements { get; }
DbSet<Channel> Channels { get; }
DbSet<ScheduleEntry> ScheduleEntries { get; }
DbSet<BumperTextVariant> BumperTextVariants { get; }
DbSet<BumperAsset> BumperAssets { get; }
DbSet<AppSetting> AppSettings { get; }
DbSet<Image> Images { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
/// Вызывать внутри открытой транзакции.</summary>
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
}
@@ -1,63 +1,63 @@
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим.
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит.
/// </summary>
Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
);
}
using TeleWave.Domain.Media;
namespace TeleWave.Application.Common.Interfaces;
/// <summary>
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
/// </summary>
public interface IMediaStorage
{
/// <summary>Свободное место на томе хранилища, байт.</summary>
long GetAvailableFreeSpaceBytes();
/// <summary>
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
/// </summary>
Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
/// <summary>Файл ручного inbox.</summary>
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
/// <summary>
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
/// каталог наполняет человек, и он может оказаться большим.
/// </summary>
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
/// <summary>
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
/// не трогает — рядом может лежать следующая серия.
/// </summary>
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
/// <summary>
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
/// переносит — файл из каталога-источника уходит.
/// </summary>
Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
);
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
);
}
@@ -1,21 +1,21 @@
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
namespace TeleWave.Application.Common.Interfaces;
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
public interface ISiteSettings
{
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
}
@@ -1,89 +1,89 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var source = dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
.AsSplitQuery()
.Where(s =>
query.Interstitials
? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial
);
var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один.
var primaryIds = shows
.Select(s => s.PrimaryGenreId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt,
s.PrimaryGenreId is { } primaryId
&& genreNames.TryGetValue(primaryId, out var g)
? g
: null
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Library.ListShows;
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
{
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
ListShowsQuery query,
CancellationToken cancellationToken
)
{
var source = dbContext
.Shows.AsNoTracking()
.Include(s => s.Episodes)
.Include(s => s.Genres)
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
.AsSplitQuery()
.Where(s =>
query.Interstitials
? s.Kind == ShowKind.Interstitial
: s.Kind != ShowKind.Interstitial
);
var filtered = query.GenreId is { } genreId
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
: source;
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
// Названия только для основных жанров — в списке показывается один.
var primaryIds = shows
.Select(s => s.PrimaryGenreId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var genreNames = await dbContext
.Genres.AsNoTracking()
.Where(g => primaryIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
var assetIds = shows
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
.Distinct()
.ToList();
var names = await dbContext
.MediaAssets.AsNoTracking()
.Where(a => assetIds.Contains(a.Id))
.Select(a => new { a.Id, a.OriginalFileName })
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
return shows
.Select(s =>
{
var seasons = s
.Episodes.Select(e =>
names.TryGetValue(e.MediaAssetId, out var n)
? EpisodeName.ParseSeason(n)
: null
)
.Where(season => season is not null)
.Distinct()
.Count();
return new ShowSummaryDto(
s.Id,
s.Name,
s.OriginalName,
s.Kind,
s.Audience,
s.Episodes.Count,
seasons,
s.Year,
s.PosterImageId is not null,
s.CreatedAt,
s.PrimaryGenreId is { } primaryId
&& genreNames.TryGetValue(primaryId, out var g)
? g
: null
);
})
.ToList();
}
}
File diff suppressed because it is too large Load Diff
@@ -1,237 +1,237 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning;
/// <summary>
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
/// фильм — в одну единицу.
///
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
/// обрабатывается, значит получить дыру в раздаче.
/// </summary>
public sealed class GroupExpander(IAppDbContext dbContext)
{
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
/// null — история не нужна и не загружается.</param>
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
IReadOnlyCollection<Guid> groupIds,
Guid channelId,
CancellationToken cancellationToken,
DateTimeOffset? historyFrom = null
)
{
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
if (groupIds.Count == 0)
return result;
var groups = await dbContext
.Groups.AsNoTracking()
.Include(g => g.Items)
.Where(g => groupIds.Contains(g.Id))
.ToListAsync(cancellationToken);
var collectionIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.Distinct()
.ToList();
var collectionParts = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.OrderBy(i => i.Position)
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
var showIds = groups
.SelectMany(g => g.Items)
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.Concat(collectionParts.Select(p => p.ShowId))
.Distinct()
.ToList();
var loaded = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Audience,
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
})
.ToListAsync(cancellationToken);
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
var assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
var durations = await dbContext
.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
)
.Select(a => new { a.Id, a.Duration })
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
var recentPlays = await LoadRecentPlaysAsync(
channelId,
showIds,
historyFrom,
cancellationToken
);
var data = new ExpansionData(
shows,
durations,
lastPlayed,
recentPlays,
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
);
foreach (var group in groups)
result[group.Id] = group
.Items.OrderBy(i => i.Position)
.Select(item =>
item.ElementKind == GroupElementKind.Show
? data.ShowElement(item)
: data.CollectionElement(item)
)
.ToList();
return result;
}
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
/// <summary>
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
/// В БД отсюда не ходят — всё уже загружено.
/// </summary>
private sealed record ExpansionData(
IReadOnlyDictionary<Guid, ShowRow> Shows,
IReadOnlyDictionary<Guid, TimeSpan> Durations,
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
ILookup<Guid, Guid> CollectionParts
)
{
public PlanningElement ShowElement(GroupItem item) =>
new(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
UnitsOf(item.ElementId),
LastPlayedOf(item.ElementId),
AudienceOf(item.ElementId),
RecentPlaysOf(item.ElementId)
);
/// <summary>
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
/// </summary>
public PlanningElement CollectionElement(GroupItem item)
{
var parts = CollectionParts[item.ElementId].ToList();
return new PlanningElement(
item.ElementKind,
item.ElementId,
item.Weight,
item.Position,
parts.SelectMany(UnitsOf).ToList(),
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
parts.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
);
}
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
private List<PlanningUnit> UnitsOf(Guid showId) =>
Shows.TryGetValue(showId, out var show)
? show
.AssetIds.Where(Durations.ContainsKey)
.Select(
(assetId, index) =>
new PlanningUnit(assetId, Durations[assetId], showId, index)
)
.ToList()
: [];
private DateTimeOffset? LastPlayedOf(Guid showId) =>
LastPlayed.TryGetValue(showId, out var at) ? at : null;
private ShowAudience? AudienceOf(Guid showId) =>
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
}
/// <summary>
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
/// остывание среди правил.
/// </summary>
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
CancellationToken cancellationToken
) =>
await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
)
.GroupBy(e => e.ShowId!.Value)
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
/// <summary>
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
/// это лишние сотни строк на каждый прогон.
/// </summary>
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
Guid channelId,
IReadOnlyCollection<Guid> showIds,
DateTimeOffset? from,
CancellationToken cancellationToken
)
{
if (from is not { } since || showIds.Count == 0)
return [];
var plays = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.Kind == ScheduleEntryKind.Program
&& e.ShowId != null
&& showIds.Contains(e.ShowId.Value)
&& e.StartsAtUtc >= since
)
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
.ToListAsync(cancellationToken);
return plays
.GroupBy(p => p.ShowId)
.ToDictionary(
g => g.Key,
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
);
}
}
@@ -1,39 +1,39 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
/// <summary>
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
/// </summary>
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
public sealed record EntryTraceDto(
Guid EntryId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
string? ShowName,
int? EpisodeIndex,
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
string? LayerName,
int? LayerPriority,
string? SlotTitle,
SlotKind? SlotKind,
int? SlotWeekday,
TimeOnly? SlotTargetStart,
int? SlotDurationMinutes,
string? GroupName,
int? GroupItemCount,
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
string? CollectionName,
SlotStrategyKind? Strategy,
int? CooldownDays,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
int? CandidatesAfterCooldown,
int DriftMinutes,
bool Snapped,
string? JunctionName
);
@@ -1,152 +1,152 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
using System.Text.Json;
using System.Text.Json.Serialization;
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Programming.Planning;
namespace TeleWave.Application.Programming.Planning.Trace;
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
{
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
private static readonly JsonSerializerOptions TraceJsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() },
};
public async Task<Result<EntryTraceDto>> Handle(
GetEntryTraceQuery query,
CancellationToken cancellationToken
)
{
var entry = await dbContext
.ScheduleEntries.AsNoTracking()
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
if (entry is null)
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
var showName = entry.ShowId is { } showId
? await dbContext
.Shows.AsNoTracking()
.Where(s => s.Id == showId)
.Select(s => s.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var collectionName = entry.CollectionId is { } collectionId
? await dbContext
.Collections.AsNoTracking()
.Where(c => c.Id == collectionId)
.Select(c => c.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
var trace = Parse(entry.TraceJson);
if (trace is null)
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
null,
null,
null,
null,
null,
null,
null,
null,
null,
collectionName,
null,
null,
null,
0,
false,
null
)
);
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
// часть подписей окажется пустой.
var slot = trace.SlotId is { } slotId
? await dbContext
.Slots.AsNoTracking()
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
: null;
var layer = slot is null
? null
: await dbContext
.GridLayers.AsNoTracking()
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
var group = slot?.GroupId is { } groupId
? await dbContext
.Groups.AsNoTracking()
.Where(g => g.Id == groupId)
.Select(g => new { g.Name, g.ItemCount })
.FirstOrDefaultAsync(cancellationToken)
: null;
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
var junctionName = junctionId is { } id
? await dbContext
.JunctionTemplates.AsNoTracking()
.Where(j => j.Id == id)
.Select(j => j.Name)
.FirstOrDefaultAsync(cancellationToken)
: null;
return Result.Success(
new EntryTraceDto(
entry.Id,
entry.StartsAtUtc,
entry.EndsAtUtc,
showName,
entry.EpisodeIndex,
layer?.Name,
layer?.Priority,
slot?.Title,
trace.SlotKind,
slot?.Weekday,
slot?.TargetStart,
slot?.TargetDurationMinutes,
group?.Name,
group?.ItemCount,
collectionName,
trace.Strategy,
strategy?.CooldownDays,
trace.CandidatesAfterCooldown,
trace.DriftMinutes,
trace.Snapped,
junctionName
)
);
}
private static PlanTrace? Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
}
catch (JsonException)
{
return null;
}
}
}
@@ -1,231 +1,231 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
{
public async Task<Result<CopyTemplateResultDto>> Handle(
CopyTemplateCommand command,
CancellationToken cancellationToken
)
{
var target = await dbContext
.Channels.Include(c => c.BumperTemplates)
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
if (target is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
var source = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
if (source is null)
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
var sourceJunctions = await dbContext
.JunctionTemplates.AsNoTracking()
.Include(j => j.Elements)
.Where(j => j.ChannelId == command.SourceChannelId)
.ToListAsync(cancellationToken);
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
var bumperByName = target
.BumperTemplates.GroupBy(t => t.Name)
.ToDictionary(g => g.Key, g => g.First().Id);
var sourceBumperNames = await dbContext
.Channels.AsNoTracking()
.Where(c => c.Id == command.SourceChannelId)
.SelectMany(c => c.BumperTemplates)
.Select(t => new { t.Id, t.Name })
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
var (junctionMap, droppedBumperRefs) = CopyJunctions(
sourceJunctions,
target.Id,
sourceBumperNames,
bumperByName
);
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
var existing = await dbContext
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
.ToListAsync(cancellationToken);
dbContext.ScheduleTemplates.RemoveRange(existing);
await dbContext
.JunctionTemplates.Where(j =>
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
)
.ExecuteDeleteAsync(cancellationToken);
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
copyTemplate.SetRules(source.RulesJson);
if (
source.DefaultJunctionId is { } defaultJunction
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
)
copyTemplate.SetDefaultJunction(mappedDefault);
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
dbContext.ScheduleTemplates.Add(copyTemplate);
target.SetTemplate(copyTemplate.Id);
return Result.Success(
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
);
}
/// <summary>
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
/// </summary>
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
IReadOnlyList<JunctionTemplate> sourceJunctions,
Guid targetChannelId,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName
)
{
var map = new Dictionary<Guid, Guid>();
var dropped = 0;
foreach (var junction in sourceJunctions)
{
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
map[junction.Id] = copy.Id;
foreach (var element in junction.Elements.OrderBy(e => e.Position))
{
var bumperTemplateId = MapBumper(
element,
sourceBumperNames,
targetBumperByName,
ref dropped
);
copy.AddElement(element.Kind)
.Update(
element.Kind,
element.GroupId,
bumperTemplateId,
element.AmountMode,
element.AmountValue,
element.IsRequired,
element.ConditionsJson
);
}
dbContext.JunctionTemplates.Add(copy);
}
return (map, dropped);
}
/// <summary>
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
/// и это попадает в отчёт.
/// </summary>
private static Guid? MapBumper(
JunctionElement element,
IReadOnlyDictionary<Guid, string> sourceBumperNames,
IReadOnlyDictionary<string, Guid> targetBumperByName,
ref int dropped
)
{
if (element.Kind != JunctionElementKind.Bumper)
return null;
if (
element.BumperTemplateId is { } sourceId
&& sourceBumperNames.TryGetValue(sourceId, out var name)
&& targetBumperByName.TryGetValue(name, out var mapped)
)
return mapped;
dropped++;
return null;
}
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
private static (int Layers, int Slots) CopyGrid(
ScheduleTemplate source,
ScheduleTemplate copyTemplate,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var layers = 0;
var slots = 0;
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
{
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
var copyLayer = layer.IsBackground
? copyTemplate.Background!
: copyTemplate.AddLayer(layer.Name, layer.Priority);
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
if (!layer.IsBackground)
layers++;
foreach (var slot in layer.Slots)
{
CopySlot(slot, copyLayer, junctionMap);
slots++;
}
}
return (layers, slots);
}
private static void CopySlot(
Slot slot,
GridLayer copyLayer,
IReadOnlyDictionary<Guid, Guid> junctionMap
)
{
var copySlot = copyLayer.AddSlot(
slot.Title,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.SlotKind,
slot.Weekday
);
copySlot.UpdateTiming(
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Daypart,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes
);
copySlot.UpdateContent(
new SlotContent(
slot.Title,
slot.SlotKind,
slot.GroupId,
slot.StrategyJson,
slot.RepeatSourceJson,
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
Map(slot.JunctionBetweenId, junctionMap),
Map(slot.JunctionAfterId, junctionMap)
)
);
}
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
}
@@ -1,102 +1,102 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.GetTemplate;
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
{
public async Task<Result<ScheduleTemplateDto>> Handle(
GetChannelTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
var groupIds = template
.Layers.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groupNames = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
var layers = template
.Layers.OrderByDescending(l => l.Priority)
.Select(layer => new GridLayerDto(
layer.Id,
layer.Name,
layer.Priority,
layer.IsEnabled,
layer.IsBackground,
LayerApplicability.FromJson(layer.ApplicabilityJson),
layer
.Slots.OrderBy(s => s.Weekday ?? -1)
.ThenBy(s => s.TargetStart)
.Select(slot => new SlotDto(
slot.Id,
slot.LayerId,
slot.Weekday,
slot.TargetStart,
slot.TargetDurationMinutes,
slot.Title,
slot.Daypart,
slot.SlotKind,
slot.GroupId,
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
? gname
: null,
SlotStrategy.FromJson(slot.StrategyJson),
RepeatSource.FromJson(slot.RepeatSourceJson),
slot.BlockMode,
slot.BlockValue,
slot.OverflowPolicy,
slot.IsAnchor,
slot.MaxDriftMinutes,
slot.SnapToMinutes,
slot.JunctionBetweenId,
slot.JunctionAfterId
))
.ToList()
))
.ToList();
return Result.Success(
new ScheduleTemplateDto(
template.Id,
template.ChannelId,
template.Name,
template.FallbackGroupId,
template.DefaultJunctionId,
PlanningRules.FromJson(template.RulesJson),
template.Revision,
template.AppliedRevision,
template.HasPendingChanges,
channel.UtcOffsetMinutes,
channel.DayStartTime,
layers
)
);
}
}
@@ -1,139 +1,139 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<CreateLayerCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
CreateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByTemplateAsync(
dbContext,
command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure<Guid>(TemplateErrors.NotFound);
var layer = template.AddLayer(command.Name, command.Priority);
template.MarkChanged();
return Result.Success(layer.Id);
}
}
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateLayerCommand, Result>
{
public async Task<Result> Handle(
UpdateLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
layer.Update(
command.Name,
command.Priority,
command.Applicability?.ToJson(),
command.IsEnabled
);
template.MarkChanged();
return Result.Success();
}
}
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
: ICommandHandler<DeleteLayerCommand, Result>
{
public async Task<Result> Handle(
DeleteLayerCommand command,
CancellationToken cancellationToken
)
{
var template = await LayerLoader.ByLayerAsync(
dbContext,
command.LayerId,
cancellationToken
);
var layer = template?.FindLayer(command.LayerId);
if (template is null || layer is null)
return Result.Failure(TemplateErrors.LayerNotFound);
if (layer.IsBackground)
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
template.RemoveLayer(command.LayerId);
template.MarkChanged();
return Result.Success();
}
}
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
: ICommandHandler<UpdateTemplateCommand, Result>
{
public async Task<Result> Handle(
UpdateTemplateCommand command,
CancellationToken cancellationToken
)
{
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
t => t.Id == command.TemplateId,
cancellationToken
);
if (template is null)
return Result.Failure(TemplateErrors.NotFound);
if (
command.FallbackGroupId is { } groupId
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
)
return Result.Failure(TemplateErrors.GroupNotFound);
template.Rename(command.Name);
template.SetFallbackGroup(command.FallbackGroupId);
template.SetDefaultJunction(command.DefaultJunctionId);
template.SetRules(command.Rules?.ToJson());
template.MarkChanged();
return Result.Success();
}
}
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
internal static class LayerLoader
{
public static Task<ScheduleTemplate?> ByTemplateAsync(
IAppDbContext dbContext,
Guid templateId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
public static Task<ScheduleTemplate?> ByLayerAsync(
IAppDbContext dbContext,
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
}
@@ -1,67 +1,67 @@
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
using FluentValidation;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates.Layers;
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
: ICommand<Result<Guid>>;
public sealed record UpdateLayerCommand(
Guid LayerId,
string Name,
int Priority,
LayerApplicability? Applicability,
bool IsEnabled
) : ICommand<Result>;
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
public sealed record UpdateTemplateCommand(
Guid TemplateId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
PlanningRules? Rules
) : ICommand<Result>;
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
{
public CreateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
{
public UpdateLayerCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
}
}
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
{
public UpdateTemplateCommandValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
// всё равно ограничена глубиной хранения ленты.
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
.InclusiveBetween(1, 365)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
.InclusiveBetween(1, 1000)
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
RuleForEach(x => x.Rules!.MaxAudienceByTime)
.Must(window => window.From != window.To)
.WithMessage("Окно нулевой длины ничего не ограничивает.")
.When(x => x.Rules?.MaxAudienceByTime is not null);
}
}
@@ -1,75 +1,75 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
using System.Text.Json;
using System.Text.Json.Serialization;
using TeleWave.Domain.Library;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
/// </summary>
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
{
public bool Contains(TimeOnly moment) =>
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
}
/// <summary>
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
///
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
/// </summary>
public sealed record PlanningRules(
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
RepeatLimitRule? MaxRepeatsInWindow = null,
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
int? MaxBreakMinutesPerHour = null,
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
int? MaxGenreSharePercent = null,
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
int? MaxFallbackSharePercent = null
)
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
public ShowAudience? AudienceAt(TimeOnly moment)
{
if (MaxAudienceByTime is not { Count: > 0 } windows)
return null;
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
// отменяться более широким окном, случайно наложенным сверху.
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
return applicable.Count > 0 ? applicable.Min() : null;
}
public string ToJson() => JsonSerializer.Serialize(this, Options);
public static PlanningRules? FromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json))
return null;
try
{
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
}
catch (JsonException)
{
return null;
}
}
}
public sealed record RepeatLimitRule(int WindowDays, int Max);
@@ -1,59 +1,59 @@
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
using FluentValidation;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
public sealed record SlotInput(
string Title,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
/// <summary>Стык между единицами внутри блока.</summary>
Guid? JunctionBetweenId = null,
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
Guid? JunctionAfterId = null
);
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
{
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
public SlotInputValidator()
{
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
// Сутки — верхняя граница осмысленного слота: длиннее он всё равно перекроет сам себя.
RuleFor(x => x.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
RuleFor(x => x.BlockValue).GreaterThan(0);
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
RuleFor(x => x.SnapToMinutes)
.Must(value => value is null || AllowedSnap.Contains(value.Value))
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
RuleFor(x => x.Strategy!.CooldownDays)
.InclusiveBetween(0, 3650)
.When(x => x.Strategy is not null);
RuleFor(x => x.RepeatSource!.DaysAgo)
.InclusiveBetween(1, 365)
.When(x => x.RepeatSource is not null);
RuleFor(x => x.RepeatSource!.DurationMinutes)
.InclusiveBetween(1, 24 * 60)
.When(x => x.RepeatSource is not null);
}
}
@@ -1,110 +1,110 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
/// <summary>
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
/// им нужны соседние слоты и справочник групп.
/// </summary>
public sealed class SlotWriter(IAppDbContext dbContext)
{
/// <summary>
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
/// созданием.
/// </summary>
public async Task<Result> ApplyAsync(
GridLayer layer,
Slot? slot,
SlotInput input,
CancellationToken cancellationToken
)
{
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
if (
layer.HasOverlap(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
slot?.Id
)
)
return Result.Failure(TemplateErrors.SlotsOverlap);
if (input.SlotKind == SlotKind.Content)
{
if (input.GroupId is not { } groupId)
return Result.Failure(TemplateErrors.GroupRequired);
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
return Result.Failure(TemplateErrors.GroupNotFound);
}
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
return Result.Failure(TemplateErrors.RepeatSourceRequired);
var target =
slot
?? layer.AddSlot(
input.Title,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.SlotKind,
input.Weekday
);
target.UpdateTiming(
input.Weekday,
input.TargetStart,
input.TargetDurationMinutes,
input.Daypart,
input.IsAnchor,
input.MaxDriftMinutes,
input.SnapToMinutes
);
target.UpdateContent(
new SlotContent(
input.Title,
input.SlotKind,
input.GroupId,
input.Strategy?.ToJson(),
input.RepeatSource?.ToJson(),
input.BlockMode,
input.BlockValue,
input.OverflowPolicy,
input.JunctionBetweenId,
input.JunctionAfterId
)
);
return Result.Success();
}
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
Guid layerId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
/// <summary>Загружает шаблон по слоту.</summary>
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
Guid slotId,
CancellationToken cancellationToken
) =>
dbContext
.ScheduleTemplates.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
cancellationToken
);
}
@@ -1,54 +1,54 @@
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates;
public sealed record SlotDto(
Guid Id,
Guid LayerId,
int? Weekday,
TimeOnly TargetStart,
int TargetDurationMinutes,
string Title,
Daypart Daypart,
SlotKind SlotKind,
Guid? GroupId,
string? GroupName,
SlotStrategy? Strategy,
RepeatSource? RepeatSource,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
Guid? JunctionBetweenId,
Guid? JunctionAfterId
);
public sealed record GridLayerDto(
Guid Id,
string Name,
int Priority,
bool IsEnabled,
bool IsBackground,
LayerApplicability? Applicability,
IReadOnlyList<SlotDto> Slots
);
public sealed record ScheduleTemplateDto(
Guid Id,
Guid ChannelId,
string Name,
Guid? FallbackGroupId,
Guid? DefaultJunctionId,
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
PlanningRules? Rules,
int Revision,
int AppliedRevision,
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
bool HasPendingChanges,
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
int UtcOffsetMinutes,
TimeOnly DayStartTime,
IReadOnlyList<GridLayerDto> Layers
);
@@ -1,66 +1,66 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Programming.Templates;
public static class TemplateErrors
{
public static readonly Error NotFound = Error.NotFound(
"Templates.NotFound",
"Шаблон сетки не найден."
);
public static readonly Error LayerNotFound = Error.NotFound(
"Templates.LayerNotFound",
"Слой сетки не найден."
);
public static readonly Error SlotNotFound = Error.NotFound(
"Templates.SlotNotFound",
"Слот не найден."
);
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
"Templates.BackgroundLayerCannotBeDeleted",
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
);
public static readonly Error SlotsOverlap = Error.Conflict(
"Templates.SlotsOverlap",
"Слоты одного слоя пересекаются по времени."
);
public static readonly Error GroupRequired = Error.Validation(
"Templates.GroupRequired",
"Слоту с контентом нужна группа."
);
public static readonly Error GroupNotFound = Error.NotFound(
"Templates.GroupNotFound",
"Группа не найдена."
);
public static readonly Error JunctionNotFound = Error.NotFound(
"Templates.JunctionNotFound",
"Шаблон стыка не найден."
);
public static readonly Error JunctionElementNotFound = Error.NotFound(
"Templates.JunctionElementNotFound",
"Врезка не найдена."
);
public static readonly Error JunctionInUse = Error.Conflict(
"Templates.JunctionInUse",
"Стык используется слотами — сначала отвяжите его."
);
public static readonly Error JunctionGroupRequired = Error.Validation(
"Templates.JunctionGroupRequired",
"Врезке нужна группа, откуда брать ролики."
);
public static readonly Error RepeatSourceRequired = Error.Validation(
"Templates.RepeatSourceRequired",
"Слоту-повтору нужно указать, что повторять."
);
}
@@ -1,328 +1,328 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Library;
using TeleWave.Domain.Programming;
namespace TeleWave.Application.Programming.Templates.Validate;
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
{
private const int MinutesInDay = 24 * 60;
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
ValidateTemplateQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext
.Channels.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
if (channel is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
var template = await dbContext
.ScheduleTemplates.AsNoTracking()
.Include(t => t.Layers)
.ThenInclude(l => l.Slots)
.AsSplitQuery()
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
if (template is null)
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
var groupIds = layers
.SelectMany(l => l.Slots)
.Select(s => s.GroupId)
.Where(id => id is not null)
.Select(id => id!.Value)
.Distinct()
.ToList();
var groups = await dbContext
.Groups.AsNoTracking()
.Where(g => groupIds.Contains(g.Id))
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
.ToDictionaryAsync(g => g.Id, cancellationToken);
var rules = PlanningRules.FromJson(template.RulesJson);
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
var dayStart = channel.DayStartTime;
var issues = new List<TemplateIssueDto>();
foreach (var layer in layers)
{
issues.AddRange(FindOverlaps(layer, dayStart));
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
}
issues.AddRange(FindGaps(layers, dayStart));
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
}
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
private static IEnumerable<TemplateIssueDto> CheckSlot(
GridLayer layer,
Slot slot,
IReadOnlyDictionary<Guid, GroupStats> groups,
IReadOnlyDictionary<Guid, ShowAudience> strictest,
PlanningRules? rules
)
{
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupMissing,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
);
yield break;
}
if (group.ItemCount == 0)
{
yield return new TemplateIssueDto(
TemplateIssueKind.GroupEmpty,
TemplateIssueSeverity.Error,
layer.Id,
slot.Id,
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
);
yield break;
}
var perWeek = OccurrencesPerWeek(slot);
if (group.ItemCount < perWeek)
yield return new TemplateIssueDto(
TemplateIssueKind.GroupTooSmall,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
+ "повторы пойдут чаще, чем раз в неделю."
);
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
// и если это больше состава группы, отсекать будет некого.
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
if (
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
)
yield return new TemplateIssueDto(
TemplateIssueKind.CooldownUnreachable,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
+ $"в группе «{group.Name}»."
);
if (
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
&& strictest.TryGetValue(groupId, out var groupAudience)
&& groupAudience > maxAudience
)
yield return new TemplateIssueDto(
TemplateIssueKind.AudienceConflict,
TemplateIssueSeverity.Warning,
layer.Id,
slot.Id,
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
);
}
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
/// <summary>
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
{
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
// и «телом» внешнего цикла его читает только компилятор.
for (var i = 0; i < slots.Count; i++)
{
for (var j = i + 1; j < slots.Count; j++)
{
var a = slots[i];
var b = slots[j];
if (!SameDays(a, b))
continue;
var aFrom = OffsetInDay(a.TargetStart, dayStart);
var bFrom = OffsetInDay(b.TargetStart, dayStart);
if (
aFrom < bFrom + b.TargetDurationMinutes
&& bFrom < aFrom + a.TargetDurationMinutes
)
yield return new TemplateIssueDto(
TemplateIssueKind.SlotOverlap,
TemplateIssueSeverity.Warning,
layer.Id,
b.Id,
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
);
}
}
}
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
private static bool SameDays(Slot a, Slot b) =>
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
/// <summary>
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
/// </summary>
private static IEnumerable<TemplateIssueDto> FindGaps(
IReadOnlyList<GridLayer> layers,
TimeOnly dayStart
)
{
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
{
var intervals = layers
.SelectMany(l => l.Slots)
.Where(s => s.Weekday is null || s.Weekday == weekday)
.Select(s =>
{
var from = OffsetInDay(s.TargetStart, dayStart);
return (From: from, To: from + s.TargetDurationMinutes);
})
.OrderBy(i => i.From)
.ToList();
var cursor = 0;
foreach (var interval in intervals)
{
if (interval.From > cursor)
yield return Gap(weekday, cursor, interval.From, dayStart);
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
}
if (cursor < MinutesInDay)
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
}
}
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
{
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
var interval =
to - from >= MinutesInDay
? "весь день"
: $"{Clock(from, dayStart)}{Clock(to, dayStart)}";
return new TemplateIssueDto(
TemplateIssueKind.GridGap,
TemplateIssueSeverity.Error,
null,
null,
$"Не покрыто: {day} {interval}."
);
}
private static string Clock(int offsetMinutes, TimeOnly dayStart)
{
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
return $"{minutes / 60:00}:{minutes % 60:00}";
}
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
{
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
return diff >= 0 ? diff : diff + MinutesInDay;
}
/// <summary>
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
/// </summary>
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
IReadOnlyCollection<Guid> groupIds,
CancellationToken cancellationToken
)
{
if (groupIds.Count == 0)
return [];
var items = await dbContext
.GroupItems.AsNoTracking()
.Where(i => groupIds.Contains(i.GroupId))
.Select(i => new
{
i.GroupId,
i.ElementKind,
i.ElementId,
})
.ToListAsync(cancellationToken);
var showIds = items
.Where(i => i.ElementKind == GroupElementKind.Show)
.Select(i => i.ElementId)
.ToList();
var collectionIds = items
.Where(i => i.ElementKind == GroupElementKind.Collection)
.Select(i => i.ElementId)
.ToList();
var partsByCollection = await dbContext
.CollectionItems.AsNoTracking()
.Where(i => collectionIds.Contains(i.CollectionId))
.Select(i => new { i.CollectionId, i.ShowId })
.ToListAsync(cancellationToken);
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
// выражений заставляет EF пересобирать её на каждый вызов.
var neededShowIds = showIds
.Concat(partsByCollection.Select(p => p.ShowId))
.Distinct()
.ToList();
var audiences = await dbContext
.Shows.AsNoTracking()
.Where(s => neededShowIds.Contains(s.Id))
.Select(s => new { s.Id, s.Audience })
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
var result = new Dictionary<Guid, ShowAudience>();
foreach (var item in items)
{
var candidates =
item.ElementKind == GroupElementKind.Show
? [item.ElementId]
: partsByCollection
.Where(p => p.CollectionId == item.ElementId)
.Select(p => p.ShowId)
.ToList();
foreach (var showId in candidates)
{
if (audiences.GetValueOrDefault(showId) is not { } audience)
continue;
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
result[item.GroupId] = audience;
}
}
return result;
}
}
@@ -1,19 +1,19 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Settings.GetSiteSettings;
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
{
public async Task<SiteSettingsDto> Handle(
GetSiteSettingsQuery query,
CancellationToken cancellationToken
)
{
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
}
}
@@ -1,16 +1,16 @@
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
}
namespace TeleWave.Application.Settings;
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
public static class SettingKeys
{
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
public const string RegistrationEnabled = "registration.enabled";
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
}
@@ -1,9 +1,9 @@
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(
bool RegistrationEnabled,
string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled
);
namespace TeleWave.Application.Settings;
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
public sealed record SiteSettingsDto(
bool RegistrationEnabled,
string PreferredAudioLanguages,
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
bool ChannelNumbersEnabled
);
@@ -1,10 +1,10 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages,
bool ChannelNumbersEnabled
) : ICommand<Result>;
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed record UpdateSiteSettingsCommand(
bool RegistrationEnabled,
string PreferredAudioLanguages,
bool ChannelNumbersEnabled
) : ICommand<Result>;
@@ -1,34 +1,34 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled,
cancellationToken
);
return Result.Success();
}
}
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Settings.UpdateSiteSettings;
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
: ICommandHandler<UpdateSiteSettingsCommand, Result>
{
public async Task<Result> Handle(
UpdateSiteSettingsCommand command,
CancellationToken cancellationToken
)
{
// Сохранение выполняет UnitOfWorkBehavior команды.
await siteSettings.SetRegistrationEnabledAsync(
command.RegistrationEnabled,
cancellationToken
);
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
var languages = string.Join(
',',
(command.PreferredAudioLanguages ?? string.Empty)
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
);
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
await siteSettings.SetChannelNumbersEnabledAsync(
command.ChannelNumbersEnabled,
cancellationToken
);
return Result.Success();
}
}
@@ -1,93 +1,93 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null)
.ThenBy(c => c.Number)
.ThenBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
c.Number,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
c.Number,
show is null ? null : showId,
show?.Name,
show?.PosterImageId,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength
);
})
.ToList();
}
}
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
var channels = await dbContext
.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
// разрешает ничью между ненумерованными.
.OrderBy(c => c.Number == null)
.ThenBy(c => c.Number)
.ThenBy(c => c.Name)
.Select(c => new
{
c.Id,
c.Slug,
c.Name,
c.Number,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength,
})
.ToListAsync(cancellationToken);
if (channels.Count == 0)
return [];
var now = DateTimeOffset.UtcNow;
var channelIds = channels.Select(c => c.Id).ToList();
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
var currentByChannel = await dbContext
.ScheduleEntries.AsNoTracking()
.Where(e =>
channelIds.Contains(e.ChannelId)
&& e.Kind == ScheduleEntryKind.Program
&& e.StartsAtUtc <= now
&& e.EndsAtUtc > now
&& e.ShowId != null
)
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
.ToListAsync(cancellationToken);
var currentShowByChannel = currentByChannel
.GroupBy(x => x.ChannelId)
.ToDictionary(g => g.Key, g => g.First().ShowId);
var showIds = currentShowByChannel.Values.Distinct().ToList();
var shows = await dbContext
.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new
{
s.Id,
s.Name,
s.PosterImageId,
})
.ToDictionaryAsync(s => s.Id, cancellationToken);
return channels
.Select(c =>
{
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
return new PublicChannelDto(
c.Id,
c.Slug,
c.Name,
c.Number,
show is null ? null : showId,
show?.Name,
show?.PosterImageId,
c.LogoImageId,
c.LogoCorner,
c.LogoOpacity,
c.ShowClock,
c.AnalogFilterStrength
);
})
.ToList();
}
}
@@ -1,42 +1,42 @@
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
using TeleWave.Domain.Broadcast;
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(
Guid Id,
string Slug,
string Name,
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
int? Number,
Guid? CurrentShowId,
string? CurrentShowName,
Guid? CurrentShowPosterImageId,
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
Guid? LogoImageId,
LogoCorner LogoCorner,
double LogoOpacity,
bool ShowClock,
double AnalogFilterStrength
);
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
public sealed record PublicEpgEntryDto(
ScheduleEntryKind Kind,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
string? ShowName,
Guid? ShowPosterImageId,
Guid? EpisodeId,
string? EpisodeTitle,
string? EpisodeOverview,
Guid? EpisodeStillImageId
);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
+179 -179
View File
@@ -1,179 +1,179 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = [];
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; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
/// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary>
public int? Number { get; private set; }
/// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary>
public int UtcOffsetMinutes { get; private set; }
/// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary>
public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> 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,
LogoOpacity = 0.8,
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;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{
BumperFont = font;
BumperSelection = selection;
}
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings(
Guid? logoImageId,
LogoCorner logoCorner,
double logoOpacity,
bool showClock,
double analogFilterStrength
)
{
LogoImageId = logoImageId;
LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
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);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
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;
}
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
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;
/// <summary>
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
/// филлер и общие настройки заставок.
/// </summary>
public class Channel
{
private readonly List<BumperTemplate> _bumperTemplates = [];
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; }
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
public DateTimeOffset EpochUtc { get; private set; }
/// <summary>
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
/// включается глобальным флагом настроек сайта; null — номер не задан.
/// </summary>
public int? Number { get; private set; }
/// <summary>
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
/// </summary>
public int UtcOffsetMinutes { get; private set; }
/// <summary>
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
/// </summary>
public TimeOnly DayStartTime { get; private set; }
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
public Guid? TemplateId { get; private set; }
public const int DefaultUtcOffsetMinutes = 180;
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
public bool BumpersEnabled { get; private set; }
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
public BumperSelection BumperSelection { get; private set; }
public BumperFont BumperFont { get; private set; }
private const string DefaultTemplateName = "Заставка 1";
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
public Guid? FillerAssetId { get; private set; }
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
public Guid? LogoImageId { get; private set; }
public LogoCorner LogoCorner { get; private set; }
/// <summary>Прозрачность логотипа, 0..1.</summary>
public double LogoOpacity { get; private set; } = 0.8;
/// <summary>Показывать ли часы поверх картинки.</summary>
public bool ShowClock { get; private set; }
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
public double AnalogFilterStrength { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
public IReadOnlyList<BumperTemplate> 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,
LogoOpacity = 0.8,
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;
}
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
{
BumperFont = font;
BumperSelection = selection;
}
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
public void UpdateViewerSettings(
Guid? logoImageId,
LogoCorner logoCorner,
double logoOpacity,
bool showClock,
double analogFilterStrength
)
{
LogoImageId = logoImageId;
LogoCorner = logoCorner;
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
ShowClock = showClock;
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
}
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
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);
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
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;
}
/// <summary>Привязать активный шаблон сетки.</summary>
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
/// <summary>
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
/// </summary>
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;
}
}
@@ -1,123 +1,123 @@
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
ScheduleEntryOrigin origin
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId,
TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
namespace TeleWave.Domain.Broadcast;
/// <summary>
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
/// </summary>
public class ScheduleEntry
{
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public Guid MediaAssetId { get; private set; }
public ScheduleEntryKind Kind { get; private set; }
public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; }
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
public int? EpisodeIndex { get; private set; }
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
public Guid? BumperVariantId { get; private set; }
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
public Guid? SlotId { get; private set; }
/// <summary>
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
/// </summary>
public Guid? CollectionId { get; private set; }
/// <summary>
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
/// в угадывание.
/// </summary>
public string? TraceJson { get; private set; }
private ScheduleEntry() { }
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
public static ScheduleEntry FromSlot(
Guid channelId,
Guid mediaAssetId,
ScheduleEntryKind kind,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
ScheduleEntryOrigin origin
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = kind,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = origin.ShowId,
EpisodeIndex = origin.EpisodeIndex,
SlotId = origin.SlotId,
TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId,
};
public static ScheduleEntry Program(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid showId,
int episodeIndex
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Program,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
EpisodeIndex = episodeIndex,
};
public static ScheduleEntry Ad(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Ad,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
};
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
public static ScheduleEntry Bumper(
Guid channelId,
Guid mediaAssetId,
DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc,
Guid? showId,
Guid? bumperVariantId
) =>
new()
{
Id = Guid.NewGuid(),
ChannelId = channelId,
MediaAssetId = mediaAssetId,
Kind = ScheduleEntryKind.Bumper,
StartsAtUtc = startsAtUtc,
EndsAtUtc = endsAtUtc,
ShowId = showId,
BumperVariantId = bumperVariantId,
};
}
+176 -176
View File
@@ -1,176 +1,176 @@
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = [];
private readonly List<ShowGenre> _genres = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience? audience = null
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
public void SetAudience(ShowAudience? audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary =
primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
namespace TeleWave.Domain.Library;
/// <summary>
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
/// </summary>
public class Show
{
private readonly List<ShowEpisode> _episodes = [];
private readonly List<ShowGenre> _genres = [];
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
public string? OriginalName { get; private set; }
public string? Description { get; private set; }
public ShowKind Kind { get; private set; }
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
public ShowAudience? Audience { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
// ── Метаданные (TMDb/OMDb/вручную) ──
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
public string? MetadataProvider { get; private set; }
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
public string? MetadataExternalId { get; private set; }
public int? Year { get; private set; }
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
public Guid? PosterImageId { get; private set; }
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
public IReadOnlyList<ShowGenre> Genres => _genres;
private Show() { }
public static Show Create(
string name,
ShowKind kind,
string? description = null,
string? originalName = null,
ShowAudience? audience = null
) =>
new()
{
Id = Guid.NewGuid(),
Name = name,
OriginalName = Normalize(originalName),
Kind = kind,
Description = description,
Audience = audience,
CreatedAt = DateTimeOffset.UtcNow,
};
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
public void SetAudience(ShowAudience? audience) => Audience = audience;
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
/// <summary>
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
/// так шоу с жанрами никогда не остаётся без основного.
/// </summary>
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
{
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
_genres.Clear();
if (ids.Count == 0)
return;
var primary =
primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
foreach (var id in ids)
_genres.Add(ShowGenre.Create(Id, id, id == primary));
}
public void Rename(string name, string? description)
{
Name = name;
Description = description;
}
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
public void SetName(string name) => Name = name;
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
private static string? Normalize(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
public ShowEpisode AddEpisode(Guid mediaAssetId)
{
if (!CanAddEpisode)
throw new InvalidOperationException(
"Только сериал может содержать больше одной серии."
);
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
_episodes.Add(episode);
return episode;
}
public bool RemoveEpisode(Guid episodeId)
{
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode is null)
return false;
_episodes.Remove(episode);
return true;
}
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
public void ApplyMetadata(
string provider,
string externalId,
string? description,
int? year,
Guid? posterImageId
)
{
MetadataProvider = provider;
MetadataExternalId = externalId;
if (!string.IsNullOrWhiteSpace(description))
Description = description;
Year = year;
if (posterImageId is not null)
PosterImageId = posterImageId;
}
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
public void UpdateMetadataManual(string? description, int? year)
{
MetadataProvider = "manual";
MetadataExternalId = null;
Description = description;
Year = year;
}
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
public void ClearMetadata()
{
MetadataProvider = null;
MetadataExternalId = null;
Year = null;
PosterImageId = null;
Description = null;
}
}
@@ -1,21 +1,21 @@
namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource
{
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox,
/// <summary>
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
/// </summary>
ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated,
}
namespace TeleWave.Domain.Media;
/// <summary>Откуда файл попал в хранилище.</summary>
public enum MediaSource
{
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
Upload,
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
Inbox,
/// <summary>
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
/// </summary>
ManualInbox = 3,
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
Generated,
}
@@ -1,221 +1,221 @@
using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
/// </summary>
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
/// <summary>
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
/// в последний раз выходил в этом канале; по нему работает остывание.
/// </summary>
public sealed record PlanningElement(
GroupElementKind Kind,
Guid ElementId,
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null,
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
bool RestartOnEnd = true,
int CooldownDays = 0,
bool IgnoreCooldownWhenExhausted = false,
Guid? FixedElementId = null
);
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
public enum SlotStrategyKind
{
Sequential = 0,
RandomWithCooldown = 1,
Fixed = 2,
}
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
public sealed record PlanningCursor(
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
/// </summary>
public sealed record PlanningSlot(
Guid SlotId,
DateTimeOffset TargetStartUtc,
int TargetDurationMinutes,
SlotKind SlotKind,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
/// <summary>
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
/// </summary>
public sealed record PlanningJunctionElement(
JunctionElementKind Kind,
IReadOnlyList<PlanningUnit> Units,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0,
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
Guid? BumperTemplateId = null,
/// <summary>Длительность резерва под заставку.</summary>
TimeSpan BumperDuration = default
);
/// <summary>Стык: последовательность врезок между программами.</summary>
public sealed record PlanningJunction(
Guid JunctionId,
IReadOnlyList<PlanningJunctionElement> Elements
);
/// <summary>Полный вход одного прогона генератора.</summary>
public sealed record PlanningInput(
Guid ChannelId,
DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc,
IReadOnlyList<PlanningSlot> Slots,
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
IReadOnlyList<PlanningUnit> FallbackUnits,
int SegmentSeconds
);
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem(
Guid MediaAssetId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? UnitIndex,
Guid? SlotId,
PlannedItemKind Kind,
PlanTrace? Trace = null,
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null
);
public enum PlannedItemKind
{
Program = 0,
Fallback = 1,
SignOff = 2,
Ad = 3,
Promo = 4,
/// <summary>
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
/// собрана, — планировщик лишь резервирует под неё длительность.
/// </summary>
Bumper = 5,
}
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
public sealed record PlanTrace(
Guid? SlotId,
SlotKind SlotKind,
GroupElementKind? ElementKind,
Guid? ElementId,
SlotStrategyKind? Strategy,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
int? CandidatesAfterCooldown,
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
int DriftMinutes,
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
bool Snapped
);
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
public sealed record PlanningCursorUpdate(
Guid SlotId,
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
public sealed record PlanningResult(
IReadOnlyList<PlannedItem> Items,
IReadOnlyList<PlanningCursorUpdate> Cursors,
IReadOnlyList<PlanningWarning> Warnings
);
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
public enum PlanningWarningKind
{
/// <summary>Слот не дал контента — место закрыл фон.</summary>
SlotEmpty = 0,
/// <summary>Фактический старт ушёл дальше допуска.</summary>
DriftExceeded = 1,
/// <summary>Остывание отсекло всех кандидатов.</summary>
CooldownExhausted = 2,
/// <summary>Не нашлось, что повторить.</summary>
RepeatSourceEmpty = 3,
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
}
using TeleWave.Domain.Library;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
/// </summary>
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
/// <summary>
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
/// в последний раз выходил в этом канале; по нему работает остывание.
/// </summary>
public sealed record PlanningElement(
GroupElementKind Kind,
Guid ElementId,
int Weight,
int Position,
IReadOnlyList<PlanningUnit> Units,
DateTimeOffset? LastPlayedUtc = null,
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
ShowAudience? Audience = null,
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
);
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
public sealed record RepeatLimit(int WindowDays, int Max);
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
public sealed record PlanningStrategy(
SlotStrategyKind Kind,
bool RestartOnEnd = true,
int CooldownDays = 0,
bool IgnoreCooldownWhenExhausted = false,
Guid? FixedElementId = null
);
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
public enum SlotStrategyKind
{
Sequential = 0,
RandomWithCooldown = 1,
Fixed = 2,
}
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
public sealed record PlanningCursor(
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
/// </summary>
public sealed record PlanningSlot(
Guid SlotId,
DateTimeOffset TargetStartUtc,
int TargetDurationMinutes,
SlotKind SlotKind,
bool IsAnchor,
int MaxDriftMinutes,
int? SnapToMinutes,
SlotBlockMode BlockMode,
int BlockValue,
OverflowPolicy OverflowPolicy,
PlanningStrategy Strategy,
IReadOnlyList<PlanningElement> Elements,
PlanningCursor? Cursor,
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
/// <summary>Врезки между единицами внутри блока.</summary>
PlanningJunction? JunctionBetween = null,
/// <summary>Врезки в конце блока.</summary>
PlanningJunction? JunctionAfter = null,
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
ShowAudience? MaxAudience = null,
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
RepeatLimit? RepeatLimit = null
)
{
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
}
/// <summary>
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
/// решить, сколько их поставить и влезают ли они.
/// </summary>
public sealed record PlanningJunctionElement(
JunctionElementKind Kind,
IReadOnlyList<PlanningUnit> Units,
JunctionAmountMode AmountMode,
int AmountValue,
bool IsRequired,
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
bool OnlyOnElementChange = false,
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
int MinMinutesBetween = 0,
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
Guid? BumperTemplateId = null,
/// <summary>Длительность резерва под заставку.</summary>
TimeSpan BumperDuration = default
);
/// <summary>Стык: последовательность врезок между программами.</summary>
public sealed record PlanningJunction(
Guid JunctionId,
IReadOnlyList<PlanningJunctionElement> Elements
);
/// <summary>Полный вход одного прогона генератора.</summary>
public sealed record PlanningInput(
Guid ChannelId,
DateTimeOffset StartUtc,
DateTimeOffset HorizonEndUtc,
IReadOnlyList<PlanningSlot> Slots,
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
IReadOnlyList<PlanningUnit> FallbackUnits,
int SegmentSeconds
);
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
public sealed record PlannedItem(
Guid MediaAssetId,
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid? ShowId,
int? UnitIndex,
Guid? SlotId,
PlannedItemKind Kind,
PlanTrace? Trace = null,
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
Guid? BumperTemplateId = null,
Guid? FromShowId = null,
Guid? ToShowId = null,
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
Guid? CollectionId = null
);
public enum PlannedItemKind
{
Program = 0,
Fallback = 1,
SignOff = 2,
Ad = 3,
Promo = 4,
/// <summary>
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
/// собрана, — планировщик лишь резервирует под неё длительность.
/// </summary>
Bumper = 5,
}
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
public sealed record PlanTrace(
Guid? SlotId,
SlotKind SlotKind,
GroupElementKind? ElementKind,
Guid? ElementId,
SlotStrategyKind? Strategy,
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
int? CandidatesAfterCooldown,
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
int DriftMinutes,
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
bool Snapped
);
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
public sealed record PlanningCursorUpdate(
Guid SlotId,
GroupElementKind? ElementKind,
Guid? ElementId,
int NextUnitIndex
);
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
public sealed record PlanningResult(
IReadOnlyList<PlannedItem> Items,
IReadOnlyList<PlanningCursorUpdate> Cursors,
IReadOnlyList<PlanningWarning> Warnings
);
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
public enum PlanningWarningKind
{
/// <summary>Слот не дал контента — место закрыл фон.</summary>
SlotEmpty = 0,
/// <summary>Фактический старт ушёл дальше допуска.</summary>
DriftExceeded = 1,
/// <summary>Остывание отсекло всех кандидатов.</summary>
CooldownExhausted = 2,
/// <summary>Не нашлось, что повторить.</summary>
RepeatSourceEmpty = 3,
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
FallbackEmpty = 4,
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
CandidatesFiltered = 5,
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
/// <summary>Врезок в часе больше заданного потолка.</summary>
BreakLimitExceeded = 6,
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
GenreShareExceeded = 7,
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
FallbackShareExceeded = 8,
}
@@ -1,465 +1,465 @@
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
///
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
/// </summary>
public static class SchedulePlanner
{
private const int IterationBackstop = 100_000;
/// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота.
/// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{
var run = new PlanningRun(input, random);
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc;
var iterations = 0;
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
{
if (iterations++ > IterationBackstop)
break;
var slot = slots[i];
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
if (slot.TargetEndUtc <= cursor)
continue;
var nextAnchor = FindNextAnchor(slots, i + 1);
cursor = OpenSlot(slot, cursor, run, out var trace);
if (cursor >= input.HorizonEndUtc)
break;
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
}
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.FallbackEmpty,
null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
)
);
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
}
/// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary>
private static DateTimeOffset OpenSlot(
PlanningSlot slot,
DateTimeOffset cursor,
PlanningRun run,
out PlanTrace trace
)
{
var snapped = false;
if (cursor < slot.TargetStartUtc)
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
}
else if (slot.SnapToMinutes is { } snap && snap > 0)
{
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
// начать в 19:47, чем девять минут крутить фон.
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
}
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.DriftExceeded,
slot.SlotId,
$"Фактический старт разошёлся с целевым на {drift} мин."
)
);
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
return cursor;
}
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
private static DateTimeOffset FillSlot(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset? nextAnchor,
PlanTrace trace,
PlanningRun run
)
{
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind)
{
case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
run,
slot.SlotId,
PlannedItemKind.SignOff,
trace
);
case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, trace, run);
default:
return FillContent(slot, cursor, limit, trace, run);
}
}
private static DateTimeOffset FillRepeat(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var units = slot.RepeatUnits ?? [];
if (units.Count == 0)
{
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном."
)
);
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units)
{
if (cursor + unit.Duration > slotEnd)
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration;
}
return cursor;
}
private static DateTimeOffset FillContent(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null)
{
run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
if (pick.CooldownExhausted)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.CooldownExhausted,
slot.SlotId,
"Остывание отсекло всех кандидатов — взят самый давний."
)
);
var element = pick.Element;
var slotTrace = trace with
{
ElementKind = element.Kind,
ElementId = element.ElementId,
Strategy = slot.Strategy.Kind,
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
};
var unitIndex = pick.StartUnitIndex;
var budgetEnd = Min(slot.TargetEndUtc, limit);
var placed = 0;
var accumulated = TimeSpan.Zero;
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
if (
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
)
{
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
{
var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionBetween,
cursor,
limit,
new JunctionPlacement(
slot.SlotId,
run.PreviousShowId,
unit.ShowId,
ElementChanged: run.PreviousShowId != unit.ShowId
),
run.Junctions,
run.Items,
slotTrace
);
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit)
break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
placed++;
run.PreviousShowId = unit.ShowId;
}
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionAfter,
cursor,
limit,
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
run.Junctions,
run.Items,
slotTrace
);
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor;
}
/// <summary>
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
/// группы.
/// </summary>
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
{
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
var hasAllowed = slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
return hasPlayable && !hasAllowed
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
);
}
/// <summary>
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
/// </summary>
private static bool WithinBudget(
PlanningSlot slot,
int placed,
TimeSpan accumulated,
DateTimeOffset cursor,
PlanningUnit unit,
DateTimeOffset budgetEnd
)
{
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
return true;
return slot.BlockMode switch
{
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
// Последняя единица входит целиком: обрезать видеофайл нельзя.
SlotBlockMode.Duration => accumulated
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
_ => cursor + unit.Duration <= budgetEnd,
};
}
private static bool FitsEntirely(
PlanningElement element,
int fromUnitIndex,
DateTimeOffset cursor,
DateTimeOffset budgetEnd
)
{
var total = element
.Units.Skip(fromUnitIndex)
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
return cursor + total <= budgetEnd;
}
/// <summary>
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
/// </summary>
private static DateTimeOffset FillWithFallback(
DateTimeOffset from,
DateTimeOffset until,
PlanningRun run,
Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null
)
{
var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from)
return from;
var cursor = from;
var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
var unit = fallback[index % fallback.Count];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break;
run.Items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
}
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace,
CollectionId: collectionId
);
private static PlanningCursorUpdate CursorUpdate(
PlanningSlot slot,
PlanningElement element,
int nextUnitIndex
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
{
for (var i = fromIndex; i < slots.Count; i++)
if (slots[i].IsAnchor)
return slots[i].TargetStartUtc;
return null;
}
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
other is { } o && o < value ? o : value;
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
{
if (step <= TimeSpan.Zero)
return moment;
var ticks = step.Ticks;
var remainder = moment.UtcTicks % ticks;
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
}
}
using TeleWave.Domain.Broadcast.Scheduling;
namespace TeleWave.Domain.Programming.Planning;
/// <summary>
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
///
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
/// </summary>
public static class SchedulePlanner
{
private const int IterationBackstop = 100_000;
/// <summary>
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
///
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
/// границу слота.
/// </summary>
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
{
public PlanningInput Input { get; } = input;
public IRandomSource Random { get; } = random;
public List<PlannedItem> Items { get; } = [];
public List<PlanningCursorUpdate> Cursors { get; } = [];
public List<PlanningWarning> Warnings { get; } = [];
public JunctionHistory Junctions { get; } = new();
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
public Guid? PreviousShowId { get; set; }
}
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
{
var run = new PlanningRun(input, random);
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
var cursor = input.StartUtc;
var iterations = 0;
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
{
if (iterations++ > IterationBackstop)
break;
var slot = slots[i];
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
if (slot.TargetEndUtc <= cursor)
continue;
var nextAnchor = FindNextAnchor(slots, i + 1);
cursor = OpenSlot(slot, cursor, run, out var trace);
if (cursor >= input.HorizonEndUtc)
break;
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
}
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
// упрётся в дыру.
if (cursor < input.HorizonEndUtc)
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.FallbackEmpty,
null,
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
)
);
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
}
/// <summary>
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
/// фактический старт и заполняет трейс сведениями о дрейфе.
/// </summary>
private static DateTimeOffset OpenSlot(
PlanningSlot slot,
DateTimeOffset cursor,
PlanningRun run,
out PlanTrace trace
)
{
var snapped = false;
if (cursor < slot.TargetStartUtc)
{
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
}
else if (slot.SnapToMinutes is { } snap && snap > 0)
{
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
// начать в 19:47, чем девять минут крутить фон.
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
{
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
snapped = afterFill > cursor;
cursor = afterFill;
}
}
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
if (Math.Abs(drift) > slot.MaxDriftMinutes)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.DriftExceeded,
slot.SlotId,
$"Фактический старт разошёлся с целевым на {drift} мин."
)
);
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
return cursor;
}
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
private static DateTimeOffset FillSlot(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset? nextAnchor,
PlanTrace trace,
PlanningRun run
)
{
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
switch (slot.SlotKind)
{
case SlotKind.SignOff:
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
return FillWithFallback(
cursor,
Min(slot.TargetEndUtc, limit),
run,
slot.SlotId,
PlannedItemKind.SignOff,
trace
);
case SlotKind.Repeat:
return FillRepeat(slot, cursor, limit, trace, run);
default:
return FillContent(slot, cursor, limit, trace, run);
}
}
private static DateTimeOffset FillRepeat(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var units = slot.RepeatUnits ?? [];
if (units.Count == 0)
{
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.RepeatSourceEmpty,
slot.SlotId,
"В источнике повтора ничего не нашлось — слот закрыт фоном."
)
);
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
var slotEnd = Min(slot.TargetEndUtc, limit);
foreach (var unit in units)
{
if (cursor + unit.Duration > slotEnd)
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
cursor += unit.Duration;
}
return cursor;
}
private static DateTimeOffset FillContent(
PlanningSlot slot,
DateTimeOffset cursor,
DateTimeOffset limit,
PlanTrace trace,
PlanningRun run
)
{
var pick = ElementSelector.Select(slot, cursor, run.Random);
if (pick is null)
{
run.Warnings.Add(NoCandidatesWarning(slot));
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
}
if (pick.CooldownExhausted)
run.Warnings.Add(
new PlanningWarning(
PlanningWarningKind.CooldownExhausted,
slot.SlotId,
"Остывание отсекло всех кандидатов — взят самый давний."
)
);
var element = pick.Element;
var slotTrace = trace with
{
ElementKind = element.Kind,
ElementId = element.ElementId,
Strategy = slot.Strategy.Kind,
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
};
var unitIndex = pick.StartUnitIndex;
var budgetEnd = Min(slot.TargetEndUtc, limit);
var placed = 0;
var accumulated = TimeSpan.Zero;
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
if (
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
)
{
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
}
while (unitIndex < element.Units.Count && cursor < limit)
{
var unit = element.Units[unitIndex];
// Врезки между единицами: перед каждой, кроме первой в блоке.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionBetween,
cursor,
limit,
new JunctionPlacement(
slot.SlotId,
run.PreviousShowId,
unit.ShowId,
ElementChanged: run.PreviousShowId != unit.ShowId
),
run.Junctions,
run.Items,
slotTrace
);
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
if (cursor + unit.Duration > limit)
break;
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
break;
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
cursor += unit.Duration;
accumulated += unit.Duration;
unitIndex++;
placed++;
run.PreviousShowId = unit.ShowId;
}
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
if (placed > 0)
cursor = JunctionFiller.Fill(
slot.JunctionAfter,
cursor,
limit,
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
run.Junctions,
run.Items,
slotTrace
);
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
return cursor;
}
/// <summary>
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
/// группы.
/// </summary>
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
{
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
var hasAllowed = slot.Elements.Any(e =>
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
);
return hasPlayable && !hasAllowed
? new PlanningWarning(
PlanningWarningKind.CandidatesFiltered,
slot.SlotId,
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
)
: new PlanningWarning(
PlanningWarningKind.SlotEmpty,
slot.SlotId,
"Слот не дал контента — место закрыл фон."
);
}
/// <summary>
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
/// </summary>
private static bool WithinBudget(
PlanningSlot slot,
int placed,
TimeSpan accumulated,
DateTimeOffset cursor,
PlanningUnit unit,
DateTimeOffset budgetEnd
)
{
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
return true;
return slot.BlockMode switch
{
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
// Последняя единица входит целиком: обрезать видеофайл нельзя.
SlotBlockMode.Duration => accumulated
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
_ => cursor + unit.Duration <= budgetEnd,
};
}
private static bool FitsEntirely(
PlanningElement element,
int fromUnitIndex,
DateTimeOffset cursor,
DateTimeOffset budgetEnd
)
{
var total = element
.Units.Skip(fromUnitIndex)
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
return cursor + total <= budgetEnd;
}
/// <summary>
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
/// </summary>
private static DateTimeOffset FillWithFallback(
DateTimeOffset from,
DateTimeOffset until,
PlanningRun run,
Guid? slotId,
PlannedItemKind kind = PlannedItemKind.Fallback,
PlanTrace? trace = null
)
{
var fallback = run.Input.FallbackUnits;
if (fallback.Count == 0 || until <= from)
return from;
var cursor = from;
var index = 0;
var guard = 0;
while (cursor < until && guard++ < IterationBackstop)
{
var unit = fallback[index % fallback.Count];
index++;
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
break;
run.Items.Add(
new PlannedItem(
unit.MediaAssetId,
cursor,
cursor + unit.Duration,
null,
null,
slotId,
kind,
trace
)
);
cursor += unit.Duration;
}
return cursor;
}
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
private static Guid? CollectionOf(PlanningElement element) =>
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
private static PlannedItem Program(
PlanningUnit unit,
DateTimeOffset start,
Guid slotId,
PlanTrace trace,
Guid? collectionId = null
) =>
new(
unit.MediaAssetId,
start,
start + unit.Duration,
unit.ShowId,
unit.UnitIndex,
slotId,
PlannedItemKind.Program,
trace,
CollectionId: collectionId
);
private static PlanningCursorUpdate CursorUpdate(
PlanningSlot slot,
PlanningElement element,
int nextUnitIndex
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
{
for (var i = fromIndex; i < slots.Count; i++)
if (slots[i].IsAnchor)
return slots[i].TargetStartUtc;
return null;
}
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
other is { } o && o < value ? o : value;
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
{
if (step <= TimeSpan.Zero)
return moment;
var ticks = step.Ticks;
var remainder = moment.UtcTicks % ticks;
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
}
}
@@ -1,110 +1,110 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией.
///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public class ScheduleTemplate
{
private readonly List<GridLayer> _layers = [];
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
public Guid? FallbackGroupId { get; private set; }
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; }
/// <summary>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; }
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
public int AppliedRevision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<GridLayer> Layers => _layers;
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
public bool HasPendingChanges => Revision != AppliedRevision;
private const string BackgroundLayerName = "Фон";
private ScheduleTemplate() { }
public static ScheduleTemplate Create(Guid channelId, string name)
{
var template = new ScheduleTemplate
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Name = name.Trim(),
Revision = 0,
AppliedRevision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
template._layers.Add(
GridLayer.Create(
template.Id,
BackgroundLayerName,
GridLayer.BackgroundPriority,
isBackground: true
)
);
return template;
}
public void Rename(string name) => Name = name.Trim();
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
public void MarkApplied() => AppliedRevision = Revision;
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
public GridLayer AddLayer(string name, int priority)
{
var layer = GridLayer.Create(Id, name, priority);
_layers.Add(layer);
return layer;
}
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
public bool RemoveLayer(Guid layerId)
{
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
if (layer is null || layer.IsBackground)
return false;
_layers.Remove(layer);
return true;
}
/// <summary>Слой, содержащий слот, или null.</summary>
public GridLayer? FindLayerOfSlot(Guid slotId) =>
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
}
namespace TeleWave.Domain.Programming;
/// <summary>
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
/// для одного и того же. На другой канал переносится глубокой копией.
///
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
/// </summary>
public class ScheduleTemplate
{
private readonly List<GridLayer> _layers = [];
public Guid Id { get; private set; }
public Guid ChannelId { get; private set; }
public string Name { get; private set; } = string.Empty;
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
public Guid? FallbackGroupId { get; private set; }
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
public Guid? DefaultJunctionId { get; private set; }
/// <summary>
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
/// </summary>
public string? RulesJson { get; private set; }
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
public int Revision { get; private set; }
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
public int AppliedRevision { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public IReadOnlyList<GridLayer> Layers => _layers;
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
public bool HasPendingChanges => Revision != AppliedRevision;
private const string BackgroundLayerName = "Фон";
private ScheduleTemplate() { }
public static ScheduleTemplate Create(Guid channelId, string name)
{
var template = new ScheduleTemplate
{
Id = Guid.NewGuid(),
ChannelId = channelId,
Name = name.Trim(),
Revision = 0,
AppliedRevision = 0,
CreatedAt = DateTimeOffset.UtcNow,
};
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
template._layers.Add(
GridLayer.Create(
template.Id,
BackgroundLayerName,
GridLayer.BackgroundPriority,
isBackground: true
)
);
return template;
}
public void Rename(string name) => Name = name.Trim();
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
public void SetRules(string? rulesJson) =>
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
public void MarkChanged() => Revision++;
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
public void MarkApplied() => AppliedRevision = Revision;
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
public GridLayer AddLayer(string name, int priority)
{
var layer = GridLayer.Create(Id, name, priority);
_layers.Add(layer);
return layer;
}
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
public bool RemoveLayer(Guid layerId)
{
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
if (layer is null || layer.IsBackground)
return false;
_layers.Remove(layer);
return true;
}
/// <summary>Слой, содержащий слот, или null.</summary>
public GridLayer? FindLayerOfSlot(Guid slotId) =>
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
}
+131 -131
View File
@@ -1,131 +1,131 @@
namespace TeleWave.Domain.Programming;
/// <summary>
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
/// </summary>
public class Slot
{
public Guid Id { get; private set; }
public Guid LayerId { get; private set; }
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
public int? Weekday { get; private set; }
/// <summary>Целевое время старта в сутках канала.</summary>
public TimeOnly TargetStart { get; private set; }
/// <summary>Бюджет слота в минутах.</summary>
public int TargetDurationMinutes { get; private set; }
public string Title { get; private set; } = string.Empty;
public Daypart Daypart { get; private set; }
public SlotKind SlotKind { get; private set; }
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
public Guid? GroupId { get; private set; }
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
public string? StrategyJson { get; private set; }
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
public string? RepeatSourceJson { get; private set; }
public SlotBlockMode BlockMode { get; private set; }
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public OverflowPolicy OverflowPolicy { get; private set; }
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
public bool IsAnchor { get; private set; }
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
public int MaxDriftMinutes { get; private set; }
/// <summary>
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
/// </summary>
public int? SnapToMinutes { get; private set; }
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
public Guid? JunctionBetweenId { get; private set; }
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
public Guid? JunctionAfterId { get; private set; }
public const int DefaultMaxDriftMinutes = 5;
private Slot() { }
public static Slot Create(
Guid layerId,
string title,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart = Daypart.Day,
SlotKind slotKind = SlotKind.Content,
int? weekday = null
) =>
new()
{
Id = Guid.NewGuid(),
LayerId = layerId,
Title = title.Trim(),
TargetStart = targetStart,
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
Daypart = daypart,
SlotKind = slotKind,
Weekday = weekday,
BlockMode = SlotBlockMode.FillSlot,
BlockValue = 1,
OverflowPolicy = OverflowPolicy.ContinueNext,
IsAnchor = false,
MaxDriftMinutes = DefaultMaxDriftMinutes,
};
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
public void UpdateTiming(
int? weekday,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart,
bool isAnchor,
int maxDriftMinutes,
int? snapToMinutes
)
{
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
TargetStart = targetStart;
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
Daypart = daypart;
IsAnchor = isAnchor;
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
}
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
public void UpdateContent(SlotContent content)
{
JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim();
SlotKind = content.SlotKind;
BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
}
namespace TeleWave.Domain.Programming;
/// <summary>
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
/// </summary>
public class Slot
{
public Guid Id { get; private set; }
public Guid LayerId { get; private set; }
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
public int? Weekday { get; private set; }
/// <summary>Целевое время старта в сутках канала.</summary>
public TimeOnly TargetStart { get; private set; }
/// <summary>Бюджет слота в минутах.</summary>
public int TargetDurationMinutes { get; private set; }
public string Title { get; private set; } = string.Empty;
public Daypart Daypart { get; private set; }
public SlotKind SlotKind { get; private set; }
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
public Guid? GroupId { get; private set; }
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
public string? StrategyJson { get; private set; }
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
public string? RepeatSourceJson { get; private set; }
public SlotBlockMode BlockMode { get; private set; }
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
public int BlockValue { get; private set; }
public OverflowPolicy OverflowPolicy { get; private set; }
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
public bool IsAnchor { get; private set; }
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
public int MaxDriftMinutes { get; private set; }
/// <summary>
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
/// </summary>
public int? SnapToMinutes { get; private set; }
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
public Guid? JunctionBetweenId { get; private set; }
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
public Guid? JunctionAfterId { get; private set; }
public const int DefaultMaxDriftMinutes = 5;
private Slot() { }
public static Slot Create(
Guid layerId,
string title,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart = Daypart.Day,
SlotKind slotKind = SlotKind.Content,
int? weekday = null
) =>
new()
{
Id = Guid.NewGuid(),
LayerId = layerId,
Title = title.Trim(),
TargetStart = targetStart,
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
Daypart = daypart,
SlotKind = slotKind,
Weekday = weekday,
BlockMode = SlotBlockMode.FillSlot,
BlockValue = 1,
OverflowPolicy = OverflowPolicy.ContinueNext,
IsAnchor = false,
MaxDriftMinutes = DefaultMaxDriftMinutes,
};
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
public void UpdateTiming(
int? weekday,
TimeOnly targetStart,
int targetDurationMinutes,
Daypart daypart,
bool isAnchor,
int maxDriftMinutes,
int? snapToMinutes
)
{
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
TargetStart = targetStart;
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
Daypart = daypart;
IsAnchor = isAnchor;
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
}
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
public void UpdateContent(SlotContent content)
{
JunctionBetweenId = content.JunctionBetweenId;
JunctionAfterId = content.JunctionAfterId;
Title = content.Title.Trim();
SlotKind = content.SlotKind;
BlockMode = content.BlockMode;
BlockValue = Math.Max(1, content.BlockValue);
OverflowPolicy = content.OverflowPolicy;
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
}
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
public TimeSpan TargetEndOffset =>
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
}
@@ -1,96 +1,96 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
var channelIds = await db
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var report = await generator.GenerateAsync(
channelId,
now,
rebuildFuture: false,
cancellationToken
);
if (report.Added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
report.Added
);
foreach (var warning in report.Warnings)
logger.LogWarning(
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
channelId,
warning.SlotId,
warning.Kind,
warning.Details
);
}
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
namespace TeleWave.Infrastructure.Broadcast;
/// <summary>
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
/// без удаления существующих записей, поэтому эфир не «дёргается».
/// </summary>
public sealed class SchedulingBackgroundService(
IServiceScopeFactory scopeFactory,
IOptions<SchedulerOptions> options,
ILogger<SchedulingBackgroundService> logger
) : BackgroundService
{
private readonly SchedulerOptions _options = options.Value;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
try
{
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
}
catch (OperationCanceledException)
{
return;
}
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
);
do
{
try
{
await TickAsync(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "Ошибка тика планировщика");
}
} while (await timer.WaitForNextTickAsync(stoppingToken));
}
private async Task TickAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
var channelIds = await db
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
.Select(c => c.Id)
.ToListAsync(cancellationToken);
var now = DateTimeOffset.UtcNow;
foreach (var channelId in channelIds)
{
var report = await generator.GenerateAsync(
channelId,
now,
rebuildFuture: false,
cancellationToken
);
if (report.Added > 0)
logger.LogInformation(
"Канал {ChannelId}: добавлено {Count} записей расписания",
channelId,
report.Added
);
foreach (var warning in report.Warnings)
logger.LogWarning(
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
channelId,
warning.SlotId,
warning.Kind,
warning.Details
);
}
}
}
@@ -1,168 +1,168 @@
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Library;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure;
/// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
);
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
services.AddScoped<GenreSeeder>();
AddMedia(services, configuration);
AddBroadcast(services, configuration);
AddMetadata(services, configuration);
return services;
}
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
{
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(
configuration.GetSection(SchedulerOptions.SectionName)
);
services.Configure<StreamingOptions>(
configuration.GetSection(StreamingOptions.SectionName)
);
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>();
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
services.AddHostedService<SchedulingBackgroundService>();
services.AddHostedService<MaintenanceBackgroundService>();
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>();
}
}
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Library;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Metadata;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Settings;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure;
/// <summary>
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
/// </summary>
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration
)
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
configuration["ConnectionStrings:Default"]
?? throw new InvalidOperationException(
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
)
)
);
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services
.AddIdentityCore<AppUser>(options =>
{
options.User.RequireUniqueEmail = false;
options.Password.RequiredLength = 8;
options.Password.RequireDigit = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = false;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
})
.AddRoles<AppRole>()
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
services.Configure<AdminSeedOptions>(
configuration.GetSection(AdminSeedOptions.SectionName)
);
var jwtOptions =
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
throw new InvalidOperationException(
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
);
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException(
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
);
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtOptions.Issuer,
ValidateAudience = true,
ValidAudience = jwtOptions.Audience,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
),
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
};
});
services.AddAuthorization();
services.AddScoped<IIdentityService, IdentityService>();
services.AddScoped<IJwtTokenService, JwtTokenService>();
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
services.AddScoped<IRoleService, RoleService>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddScoped<ISiteSettings, SiteSettings>();
services.AddScoped<DbInitializer>();
services.AddScoped<GenreSeeder>();
AddMedia(services, configuration);
AddBroadcast(services, configuration);
AddMetadata(services, configuration);
return services;
}
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
{
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
}
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(
configuration.GetSection(SchedulerOptions.SectionName)
);
services.Configure<StreamingOptions>(
configuration.GetSection(StreamingOptions.SectionName)
);
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>();
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
services.AddHostedService<SchedulingBackgroundService>();
services.AddHostedService<MaintenanceBackgroundService>();
}
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
{
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
services.AddSingleton<MediaPathResolver>();
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
services.AddSingleton<IImageStore, ImageStore>();
services.AddSingleton<IImageDownloader, ImageDownloader>();
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
services.AddHostedService<MediaProcessingBackgroundService>();
services.AddHostedService<InboxScannerBackgroundService>();
services.AddHostedService<BumperRenderBackgroundService>();
}
}
@@ -1,171 +1,171 @@
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{
private const int CopyBufferSize = 1024 * 1024;
public long GetAvailableFreeSpaceBytes()
{
try
{
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
}
catch (Exception ex) when (ex is ArgumentException or IOException)
{
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
return long.MaxValue;
}
}
public async Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
)
{
Directory.CreateDirectory(paths.UploadsDir);
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
var path = paths.UploadPath(token);
await using var file = new FileStream(
path,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
CopyBufferSize,
useAsync: true
);
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
return token;
}
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
{
var path = paths.UploadPath(uploadToken);
if (File.Exists(path))
File.Delete(path);
return Task.CompletedTask;
}
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
{
if (!Directory.Exists(paths.ManualDir))
return [];
return Directory
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
.Take(Math.Max(1, max))
.Select(path => new FileInfo(path))
.Where(file => file.Exists)
.Select(file => new IMediaStorage.ManualInboxFile(
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
file.Name,
file.Length
))
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Task CleanupManualLeftoversAsync(
string relativePath,
CancellationToken cancellationToken
)
{
var path = paths.ManualPath(relativePath);
var directory = Path.GetDirectoryName(path);
if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал.
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
foreach (var sibling in Directory.EnumerateFiles(directory))
{
var name = Path.GetFileName(sibling);
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
if (MediaFormats.IsAllowed(name))
continue;
try
{
File.Delete(sibling);
}
catch (IOException)
{
// Файл занят или уже удалён — не повод валить импорт целиком.
}
}
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
if (
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
&& !Directory.EnumerateFileSystemEntries(directory).Any()
)
{
try
{
Directory.Delete(directory);
}
catch (IOException)
{
// Каталог занят — оставим как есть.
}
}
return Task.CompletedTask;
}
public Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
)
{
var sourcePath = source switch
{
MediaSource.Inbox => paths.InboxPath(sourceToken),
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
_ => paths.UploadPath(sourceToken),
};
if (!File.Exists(sourcePath))
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
Directory.CreateDirectory(paths.OriginalsDir);
var destination = paths.OriginalPath(assetId, extension);
File.Move(sourcePath, destination, overwrite: true);
return Task.CompletedTask;
}
public Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
) =>
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
Task.Run(
() =>
{
var original = paths.OriginalPath(assetId, extension);
if (File.Exists(original))
File.Delete(original);
var assetDir = paths.AssetDir(assetId);
if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true);
},
cancellationToken
);
}
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media;
using TeleWave.Domain.Media;
namespace TeleWave.Infrastructure.Media;
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
{
private const int CopyBufferSize = 1024 * 1024;
public long GetAvailableFreeSpaceBytes()
{
try
{
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
}
catch (Exception ex) when (ex is ArgumentException or IOException)
{
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
return long.MaxValue;
}
}
public async Task<string> SaveUploadAsync(
Stream content,
string extension,
CancellationToken cancellationToken
)
{
Directory.CreateDirectory(paths.UploadsDir);
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
var path = paths.UploadPath(token);
await using var file = new FileStream(
path,
FileMode.CreateNew,
FileAccess.Write,
FileShare.None,
CopyBufferSize,
useAsync: true
);
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
return token;
}
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
{
var path = paths.UploadPath(uploadToken);
if (File.Exists(path))
File.Delete(path);
return Task.CompletedTask;
}
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
{
if (!Directory.Exists(paths.ManualDir))
return [];
return Directory
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
.Take(Math.Max(1, max))
.Select(path => new FileInfo(path))
.Where(file => file.Exists)
.Select(file => new IMediaStorage.ManualInboxFile(
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
file.Name,
file.Length
))
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Task CleanupManualLeftoversAsync(
string relativePath,
CancellationToken cancellationToken
)
{
var path = paths.ManualPath(relativePath);
var directory = Path.GetDirectoryName(path);
if (directory is null || !Directory.Exists(directory))
return Task.CompletedTask;
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
// «Серия.Extended.mkv» это не мусор, а другой материал.
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
foreach (var sibling in Directory.EnumerateFiles(directory))
{
var name = Path.GetFileName(sibling);
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
continue;
if (MediaFormats.IsAllowed(name))
continue;
try
{
File.Delete(sibling);
}
catch (IOException)
{
// Файл занят или уже удалён — не повод валить импорт целиком.
}
}
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
if (
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
&& !Directory.EnumerateFileSystemEntries(directory).Any()
)
{
try
{
Directory.Delete(directory);
}
catch (IOException)
{
// Каталог занят — оставим как есть.
}
}
return Task.CompletedTask;
}
public Task PromoteToOriginalAsync(
MediaSource source,
string sourceToken,
Guid assetId,
string extension,
CancellationToken cancellationToken
)
{
var sourcePath = source switch
{
MediaSource.Inbox => paths.InboxPath(sourceToken),
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
_ => paths.UploadPath(sourceToken),
};
if (!File.Exists(sourcePath))
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
Directory.CreateDirectory(paths.OriginalsDir);
var destination = paths.OriginalPath(assetId, extension);
File.Move(sourcePath, destination, overwrite: true);
return Task.CompletedTask;
}
public Task DeleteAssetArtifactsAsync(
Guid assetId,
string extension,
CancellationToken cancellationToken
) =>
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
Task.Run(
() =>
{
var original = paths.OriginalPath(assetId, extension);
if (File.Exists(original))
File.Delete(original);
var assetDir = paths.AssetDir(assetId);
if (Directory.Exists(assetDir))
Directory.Delete(assetDir, recursive: true);
},
cancellationToken
);
}
@@ -1,111 +1,111 @@
using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
/// </summary>
public sealed class MediaPathResolver
{
private readonly string _root;
public MediaPathResolver(IOptions<StorageOptions> options)
{
_root = Path.GetFullPath(options.Value.RootPath);
InboxDir = Path.Combine(_root, "inbox");
ManualDir = Path.Combine(_root, "manual");
UploadsDir = Path.Combine(_root, "uploads");
OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers");
ImagesDir = Path.Combine(_root, "images");
}
public string InboxDir { get; }
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
public string ManualDir { get; }
public string UploadsDir { get; }
public string OriginalsDir { get; }
public string AssetsDir { get; }
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; }
public void EnsureDirectories()
{
Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(ManualDir);
Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(ImagesDir);
}
public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) =>
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
public string AssetDir(Guid assetId) =>
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
public string SegmentPath(Guid assetId, string fileName)
{
var assetDir = AssetDir(assetId);
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
}
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
public string InboxPath(string fileName) =>
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
/// <summary>
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
/// обязательна: строка приходит из запроса.
/// </summary>
public string ManualPath(string relativePath) =>
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
private static string EnsureWithin(string baseDir, string candidate)
{
var full = Path.GetFullPath(candidate);
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
? baseDir
: baseDir + Path.DirectorySeparatorChar;
if (
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
)
throw new UnauthorizedAccessException(
$"Путь '{candidate}' выходит за пределы каталога хранилища."
);
return full;
}
}
using Microsoft.Extensions.Options;
namespace TeleWave.Infrastructure.Media;
/// <summary>
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
/// </summary>
public sealed class MediaPathResolver
{
private readonly string _root;
public MediaPathResolver(IOptions<StorageOptions> options)
{
_root = Path.GetFullPath(options.Value.RootPath);
InboxDir = Path.Combine(_root, "inbox");
ManualDir = Path.Combine(_root, "manual");
UploadsDir = Path.Combine(_root, "uploads");
OriginalsDir = Path.Combine(_root, "originals");
AssetsDir = Path.Combine(_root, "assets");
BumpersDir = Path.Combine(_root, "bumpers");
ImagesDir = Path.Combine(_root, "images");
}
public string InboxDir { get; }
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
public string ManualDir { get; }
public string UploadsDir { get; }
public string OriginalsDir { get; }
public string AssetsDir { get; }
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
public string BumpersDir { get; }
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
public string ImagesDir { get; }
public void EnsureDirectories()
{
Directory.CreateDirectory(InboxDir);
Directory.CreateDirectory(ManualDir);
Directory.CreateDirectory(UploadsDir);
Directory.CreateDirectory(OriginalsDir);
Directory.CreateDirectory(AssetsDir);
Directory.CreateDirectory(BumpersDir);
Directory.CreateDirectory(ImagesDir);
}
public string BumperTemplateDir(Guid templateId) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
public string ImagePath(Guid imageId, string extension) =>
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
public string OriginalPath(Guid assetId, string extension) =>
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
public string AssetDir(Guid assetId) =>
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
public string SegmentPath(Guid assetId, string fileName)
{
var assetDir = AssetDir(assetId);
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
}
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
public string InboxPath(string fileName) =>
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
/// <summary>
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
/// обязательна: строка приходит из запроса.
/// </summary>
public string ManualPath(string relativePath) =>
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
private static string EnsureWithin(string baseDir, string candidate)
{
var full = Path.GetFullPath(candidate);
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
? baseDir
: baseDir + Path.DirectorySeparatorChar;
if (
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
)
throw new UnauthorizedAccessException(
$"Путь '{candidate}' выходит за пределы каталога хранилища."
);
return full;
}
}
@@ -1,330 +1,330 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedUserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
Email = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedEmail = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
RevokedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey(
"PK_AspNetUserLogins",
x => new { x.LoginProvider, x.ProviderKey }
);
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey(
"PK_AspNetUserTokens",
x => new
{
x.UserId,
x.LoginProvider,
x.Name,
}
);
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail"
);
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AspNetRoleClaims");
migrationBuilder.DropTable(name: "AspNetUserClaims");
migrationBuilder.DropTable(name: "AspNetUserLogins");
migrationBuilder.DropTable(name: "AspNetUserRoles");
migrationBuilder.DropTable(name: "AspNetUserTokens");
migrationBuilder.DropTable(name: "RefreshTokens");
migrationBuilder.DropTable(name: "AspNetRoles");
migrationBuilder.DropTable(name: "AspNetUsers");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedUserName = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
Email = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
NormalizedEmail = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
PasswordHash = table.Column<string>(type: "text", nullable: true),
SecurityStamp = table.Column<string>(type: "text", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
PhoneNumber = table.Column<string>(type: "text", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "RefreshTokens",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
TokenHash = table.Column<string>(type: "text", nullable: false),
ExpiresAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
RevokedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ClaimType = table.Column<string>(type: "text", nullable: true),
ClaimValue = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "text", nullable: false),
ProviderKey = table.Column<string>(type: "text", nullable: false),
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey(
"PK_AspNetUserLogins",
x => new { x.LoginProvider, x.ProviderKey }
);
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uuid", nullable: false),
LoginProvider = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Value = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey(
"PK_AspNetUserTokens",
x => new
{
x.UserId,
x.LoginProvider,
x.Name,
}
);
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId"
);
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId"
);
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail"
);
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_TokenHash",
table: "RefreshTokens",
column: "TokenHash",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_RefreshTokens_UserId",
table: "RefreshTokens",
column: "UserId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AspNetRoleClaims");
migrationBuilder.DropTable(name: "AspNetUserClaims");
migrationBuilder.DropTable(name: "AspNetUserLogins");
migrationBuilder.DropTable(name: "AspNetUserRoles");
migrationBuilder.DropTable(name: "AspNetUserTokens");
migrationBuilder.DropTable(name: "RefreshTokens");
migrationBuilder.DropTable(name: "AspNetRoles");
migrationBuilder.DropTable(name: "AspNetUsers");
}
}
}
@@ -1,90 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMediaAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaAssets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalFileName = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: false
),
OriginalExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: false
),
Source = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
SegmentCount = table.Column<int>(type: "integer", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true),
VideoCodec = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
AudioCodec = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
RelativePath = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ErrorMessage = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UpdatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_MediaAssets", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CreatedAt",
table: "MediaAssets",
column: "CreatedAt"
);
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_Status",
table: "MediaAssets",
column: "Status"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "MediaAssets");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddMediaAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "MediaAssets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OriginalFileName = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: false
),
OriginalExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: false
),
Source = table.Column<int>(type: "integer", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
SegmentCount = table.Column<int>(type: "integer", nullable: true),
Width = table.Column<int>(type: "integer", nullable: true),
Height = table.Column<int>(type: "integer", nullable: true),
VideoCodec = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
AudioCodec = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: true
),
RelativePath = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: true
),
ErrorMessage = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
UpdatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_MediaAssets", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_CreatedAt",
table: "MediaAssets",
column: "CreatedAt"
);
migrationBuilder.CreateIndex(
name: "IX_MediaAssets_Status",
table: "MediaAssets",
column: "Status"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "MediaAssets");
}
}
}
@@ -1,308 +1,308 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBroadcastAndLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Slug = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
EpochUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
AdInsertion = table.Column<int>(type: "integer", nullable: false),
AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
NextAdIndex = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "ScheduleEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
ShowId = table.Column<Guid>(type: "uuid", nullable: true),
EpisodeIndex = table.Column<int>(type: "integer", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "Shows",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
Kind = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Shows", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Mode = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ShowEpisode",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ShowEpisode", x => x.Id);
table.ForeignKey(
name: "FK_ShowEpisode_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_Channels_Slug",
table: "Channels",
column: "Slug",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId"
);
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "EndsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "StartsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_MediaAssetId",
table: "ShowEpisode",
column: "MediaAssetId"
);
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_ShowId_Position",
table: "ShowEpisode",
columns: new[] { "ShowId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ScheduleEntries");
migrationBuilder.DropTable(name: "ShowEpisode");
migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropTable(name: "Shows");
migrationBuilder.DropTable(name: "Channels");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBroadcastAndLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Channels",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Slug = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
EpochUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
AdInsertion = table.Column<int>(type: "integer", nullable: false),
AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
NextAdIndex = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Channels", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "ScheduleEntries",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
ShowId = table.Column<Guid>(type: "uuid", nullable: true),
EpisodeIndex = table.Column<int>(type: "integer", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "Shows",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
Kind = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Shows", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Mode = table.Column<int>(type: "integer", nullable: false),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ShowEpisode",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ShowEpisode", x => x.Id);
table.ForeignKey(
name: "FK_ShowEpisode_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_Channels_Slug",
table: "Channels",
column: "Slug",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId"
);
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "EndsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "StartsAtUtc" }
);
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_MediaAssetId",
table: "ShowEpisode",
column: "MediaAssetId"
);
migrationBuilder.CreateIndex(
name: "IX_ShowEpisode_ShowId_Position",
table: "ShowEpisode",
columns: new[] { "ShowId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ScheduleEntries");
migrationBuilder.DropTable(name: "ShowEpisode");
migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropTable(name: "Shows");
migrationBuilder.DropTable(name: "Channels");
}
}
}
@@ -1,61 +1,61 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBumpers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BumpersEnabled",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
migrationBuilder.CreateTable(
name: "BumperAssets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
Signature = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperAssets", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
table: "BumperAssets",
columns: new[] { "FromShowId", "ToShowId", "Signature" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperAssets");
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddBumpers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "BumpersEnabled",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
migrationBuilder.CreateTable(
name: "BumperAssets",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
Signature = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperAssets", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
table: "BumperAssets",
columns: new[] { "FromShowId", "ToShowId", "Signature" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperAssets");
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
}
}
}
@@ -1,118 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelBumperSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BumperAccentColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x38bdf8"
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x0b1020"
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x1e293b"
);
migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 8
);
migrationBuilder.AddColumn<int>(
name: "BumperFont",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperNextLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "ДАЛЕЕ"
);
migrationBuilder.AddColumn<string>(
name: "BumperNowLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "СЕЙЧАС"
);
migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: true
);
migrationBuilder.AddColumn<string>(
name: "BumperTextColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "white"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelBumperSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BumperAccentColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x38bdf8"
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x0b1020"
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "0x1e293b"
);
migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 8
);
migrationBuilder.AddColumn<int>(
name: "BumperFont",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperNextLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "ДАЛЕЕ"
);
migrationBuilder.AddColumn<string>(
name: "BumperNowLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "СЕЙЧАС"
);
migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: true
);
migrationBuilder.AddColumn<string>(
name: "BumperTextColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: "white"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
}
}
}
@@ -1,97 +1,97 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperFilesAndJingles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperMode",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperRevision",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "NextJingleIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ChannelJingle",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperFilesAndJingles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperMode",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperRevision",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "NextJingleIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ChannelJingle",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels");
}
}
}
@@ -1,41 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Value = table.Column<string>(
type: "character varying(1024)",
maxLength: 1024,
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AppSettings");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AppSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppSettings",
columns: table => new
{
Key = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Value = table.Column<string>(
type: "character varying(1024)",
maxLength: 1024,
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_AppSettings", x => x.Key);
}
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "AppSettings");
}
}
}
@@ -1,57 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MetadataExternalId",
table: "Shows",
type: "character varying(64)",
maxLength: 64,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "MetadataProvider",
table: "Shows",
type: "character varying(16)",
maxLength: 16,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "PosterPath",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Year",
table: "Shows",
type: "integer",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows");
migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows");
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
migrationBuilder.DropColumn(name: "Year", table: "Shows");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MetadataExternalId",
table: "Shows",
type: "character varying(64)",
maxLength: 64,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "MetadataProvider",
table: "Shows",
type: "character varying(16)",
maxLength: 16,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "PosterPath",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Year",
table: "Shows",
type: "integer",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows");
migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows");
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
migrationBuilder.DropColumn(name: "Year", table: "Shows");
}
}
}
@@ -1,76 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "AirDate",
table: "ShowEpisode",
type: "date",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Episode",
table: "ShowEpisode",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "Overview",
table: "ShowEpisode",
type: "character varying(4096)",
maxLength: 4096,
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Season",
table: "ShowEpisode",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "Title",
table: "ShowEpisode",
type: "character varying(512)",
maxLength: 512,
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateOnly>(
name: "AirDate",
table: "ShowEpisode",
type: "date",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Episode",
table: "ShowEpisode",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "Overview",
table: "ShowEpisode",
type: "character varying(4096)",
maxLength: 4096,
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Season",
table: "ShowEpisode",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "Title",
table: "ShowEpisode",
type: "character varying(512)",
maxLength: 512,
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode");
}
}
}
@@ -1,246 +1,246 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
migrationBuilder.RenameColumn(
name: "NextJingleIndex",
table: "Channels",
newName: "NextBumperIndex"
);
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
// умолчанию Rotation (0).
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperSelection",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "BumperTemplate",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
BackgroundColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
BackgroundColor2 = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
AccentColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
TextColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
BackgroundImageExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: true
),
AudioExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: true
),
AudioDurationSeconds = table.Column<double>(
type: "double precision",
nullable: true
),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
table.ForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate",
columns: new[] { "ChannelId", "Position" }
);
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
migrationBuilder.Sql(
"""
INSERT INTO "BumperTemplate"
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
"AccentColor", "TextColor", "Revision", "CreatedAt")
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
'0x38bdf8', 'white', 0, now()
FROM "Channels" c;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperTemplate");
migrationBuilder.RenameColumn(
name: "NextBumperIndex",
table: "Channels",
newName: "NextJingleIndex"
);
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperRevision",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperAccentColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "BumperMode",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "BumperTextColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.CreateTable(
name: "ChannelJingle",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" }
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelJingle");
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
migrationBuilder.RenameColumn(
name: "NextJingleIndex",
table: "Channels",
newName: "NextBumperIndex"
);
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
// умолчанию Rotation (0).
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperSelection",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "BumperTemplate",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
BackgroundColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
BackgroundColor2 = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
AccentColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
TextColor = table.Column<string>(
type: "character varying(32)",
maxLength: 32,
nullable: false
),
BackgroundImageExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: true
),
AudioExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: true
),
AudioDurationSeconds = table.Column<double>(
type: "double precision",
nullable: true
),
Revision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
table.ForeignKey(
name: "FK_BumperTemplate_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperTemplate_ChannelId_Position",
table: "BumperTemplate",
columns: new[] { "ChannelId", "Position" }
);
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
migrationBuilder.Sql(
"""
INSERT INTO "BumperTemplate"
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
"AccentColor", "TextColor", "Revision", "CreatedAt")
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
'0x38bdf8', 'white', 0, now()
FROM "Channels" c;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperTemplate");
migrationBuilder.RenameColumn(
name: "NextBumperIndex",
table: "Channels",
newName: "NextJingleIndex"
);
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
migrationBuilder.AddColumn<int>(
name: "BumperRevision",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperAccentColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundColor2",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperBackgroundExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "BumperDurationSeconds",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "BumperMode",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<string>(
name: "BumperMusicExtension",
table: "Channels",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "BumperTextColor",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.CreateTable(
name: "ChannelJingle",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
table.ForeignKey(
name: "FK_ChannelJingle_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelJingle_ChannelId_Position",
table: "ChannelJingle",
columns: new[] { "ChannelId", "Position" }
);
}
}
}
@@ -1,27 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowOriginalName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "OriginalName", table: "Shows");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowOriginalName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "OriginalName", table: "Shows");
}
}
}
@@ -1,54 +1,54 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ImageRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Images",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Category = table.Column<int>(type: "integer", nullable: false),
FileExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: false
),
OriginalFileName = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_Images_Category_CreatedAt",
table: "Images",
columns: new[] { "Category", "CreatedAt" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Images");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ImageRegistry : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Images",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Category = table.Column<int>(type: "integer", nullable: false),
FileExtension = table.Column<string>(
type: "character varying(16)",
maxLength: 16,
nullable: false
),
OriginalFileName = table.Column<string>(
type: "character varying(512)",
maxLength: 512,
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Images", x => x.Id);
}
);
migrationBuilder.CreateIndex(
name: "IX_Images_Category_CreatedAt",
table: "Images",
columns: new[] { "Category", "CreatedAt" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Images");
}
}
}
@@ -1,78 +1,78 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowPosterImage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "PosterImageId",
table: "Shows",
type: "uuid",
nullable: true
);
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows");
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true
);
migrationBuilder.AddColumn<string>(
name: "PosterPath",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowPosterImage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "PosterImageId",
table: "Shows",
type: "uuid",
nullable: true
);
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows");
migrationBuilder.AlterColumn<string>(
name: "OriginalName",
table: "Shows",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(256)",
oldMaxLength: 256,
oldNullable: true
);
migrationBuilder.AddColumn<string>(
name: "PosterPath",
table: "Shows",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
}
}
}
@@ -1,89 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeStillAndBumperBgImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StillImageId",
table: "ShowEpisode",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "BackgroundImageId",
table: "BumperTemplate",
type: "uuid",
nullable: true
);
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
// Фоны блоков заставок → реестр (Category=3 BumperBackground).
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate");
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "BackgroundImageExtension",
table: "BumperTemplate",
type: "character varying(16)",
maxLength: 16,
nullable: true
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class EpisodeStillAndBumperBgImages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StillImageId",
table: "ShowEpisode",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "BackgroundImageId",
table: "BumperTemplate",
type: "uuid",
nullable: true
);
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
// Фоны блоков заставок → реестр (Category=3 BumperBackground).
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD; img uuid;
BEGIN
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
img := gen_random_uuid();
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode");
migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate");
migrationBuilder.AddColumn<string>(
name: "StillPath",
table: "ShowEpisode",
type: "character varying(256)",
maxLength: 256,
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "BackgroundImageExtension",
table: "BumperTemplate",
type: "character varying(16)",
maxLength: 16,
nullable: true
);
}
}
}
@@ -1,131 +1,131 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTextVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BumperTextVariant",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
Kind = table.Column<int>(type: "integer", nullable: false),
NowLabel = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
NextLabel = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
Line1 = table.Column<string>(
type: "character varying(120)",
maxLength: 120,
nullable: false
),
Line2 = table.Column<string>(
type: "character varying(120)",
maxLength: 120,
nullable: false
),
Trigger = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperTextVariant", x => x.Id);
table.ForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
column: x => x.BumperTemplateId,
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariant",
columns: new[] { "BumperTemplateId", "Position" }
);
// Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило
// показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2).
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl,
c."BumperOnlyBetweenDifferentShows" AS only_diff
FROM "BumperTemplate" t
JOIN "Channels" c ON c."Id" = t."ChannelId"
LOOP
INSERT INTO "BumperTextVariant"
("Id", "BumperTemplateId", "Position", "Name", "Kind",
"NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt")
VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0,
COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'),
'', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now());
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperTextVariant");
migrationBuilder.AddColumn<string>(
name: "BumperNextLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperNowLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperTextVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "BumperTextVariant",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Name = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
Kind = table.Column<int>(type: "integer", nullable: false),
NowLabel = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
NextLabel = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
Line1 = table.Column<string>(
type: "character varying(120)",
maxLength: 120,
nullable: false
),
Line2 = table.Column<string>(
type: "character varying(120)",
maxLength: 120,
nullable: false
),
Trigger = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_BumperTextVariant", x => x.Id);
table.ForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
column: x => x.BumperTemplateId,
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariant",
columns: new[] { "BumperTemplateId", "Position" }
);
// Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило
// показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2).
migrationBuilder.Sql(
"""
DO $$
DECLARE r RECORD;
BEGIN
FOR r IN
SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl,
c."BumperOnlyBetweenDifferentShows" AS only_diff
FROM "BumperTemplate" t
JOIN "Channels" c ON c."Id" = t."ChannelId"
LOOP
INSERT INTO "BumperTextVariant"
("Id", "BumperTemplateId", "Position", "Name", "Kind",
"NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt")
VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0,
COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'),
'', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now());
END LOOP;
END $$;
"""
);
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "BumperTextVariant");
migrationBuilder.AddColumn<string>(
name: "BumperNextLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<string>(
name: "BumperNowLabel",
table: "Channels",
type: "text",
nullable: false,
defaultValue: ""
);
migrationBuilder.AddColumn<bool>(
name: "BumperOnlyBetweenDifferentShows",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
}
}
}
@@ -1,125 +1,125 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperChancesWeightsAndScheduleVariant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant"
);
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant"
);
migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariants",
newName: "IX_BumperTextVariants_BumperTemplateId_Position"
);
migrationBuilder.AddColumn<Guid>(
name: "BumperVariantId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0
);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0
);
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "BumperTextVariants",
type: "integer",
nullable: false,
defaultValue: 1
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants",
column: "Id"
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants"
);
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants"
);
migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants");
migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariants_BumperTemplateId_Position",
table: "BumperTextVariant",
newName: "IX_BumperTextVariant_BumperTemplateId_Position"
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant",
column: "Id"
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperChancesWeightsAndScheduleVariant : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant"
);
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant"
);
migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariant_BumperTemplateId_Position",
table: "BumperTextVariants",
newName: "IX_BumperTextVariants_BumperTemplateId_Position"
);
migrationBuilder.AddColumn<Guid>(
name: "BumperVariantId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0
);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 1.0
);
migrationBuilder.AddColumn<int>(
name: "Weight",
table: "BumperTextVariants",
type: "integer",
nullable: false,
defaultValue: 1
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants",
column: "Id"
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
table: "BumperTextVariants"
);
migrationBuilder.DropPrimaryKey(
name: "PK_BumperTextVariants",
table: "BumperTextVariants"
);
migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants");
migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant");
migrationBuilder.RenameIndex(
name: "IX_BumperTextVariants_BumperTemplateId_Position",
table: "BumperTextVariant",
newName: "IX_BumperTextVariant_BumperTemplateId_Position"
);
migrationBuilder.AddPrimaryKey(
name: "PK_BumperTextVariant",
table: "BumperTextVariant",
column: "Id"
);
migrationBuilder.AddForeignKey(
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
table: "BumperTextVariant",
column: "BumperTemplateId",
principalTable: "BumperTemplate",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
}
}
@@ -1,59 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelShowPreferredHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PreferredWeightMultiplier",
table: "ChannelShow",
type: "integer",
nullable: false,
defaultValue: 3
);
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelShowPreferredHours : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PreferredWeightMultiplier",
table: "ChannelShow",
type: "integer",
nullable: false,
defaultValue: 3
);
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow");
}
}
}
@@ -1,102 +1,102 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class WeeklyProgrammingOverrides : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone"
);
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone"
);
migrationBuilder.AddColumn<int>(
name: "DayOfWeek",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "EndMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Recurrence",
table: "ProgrammingOverride",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "StartMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride");
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0)
),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0)
),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class WeeklyProgrammingOverrides : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone"
);
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: true,
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone"
);
migrationBuilder.AddColumn<int>(
name: "DayOfWeek",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "EndMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "Recurrence",
table: "ProgrammingOverride",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "StartMinute",
table: "ProgrammingOverride",
type: "integer",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride");
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "StartsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0)
),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true
);
migrationBuilder.AlterColumn<DateTimeOffset>(
name: "EndsAtUtc",
table: "ProgrammingOverride",
type: "timestamp with time zone",
nullable: false,
defaultValue: new DateTimeOffset(
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
new TimeSpan(0, 0, 0, 0, 0)
),
oldClrType: typeof(DateTimeOffset),
oldType: "timestamp with time zone",
oldNullable: true
);
}
}
}
@@ -1,49 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperAssetRenderContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "VariantId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class BumperAssetRenderContext : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ChannelId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
migrationBuilder.AddColumn<Guid>(
name: "VariantId",
table: "BumperAssets",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
}
}
}
@@ -1,37 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MediaProcessingTiming : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "ProcessingDuration",
table: "MediaAssets",
type: "interval",
nullable: true
);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt",
table: "MediaAssets",
type: "timestamp with time zone",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MediaProcessingTiming : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeSpan>(
name: "ProcessingDuration",
table: "MediaAssets",
type: "interval",
nullable: true
);
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ProcessingStartedAt",
table: "MediaAssets",
type: "timestamp with time zone",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
}
}
}
@@ -1,28 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudience : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Audience", table: "Shows");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudience : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Audience", table: "Shows");
}
}
}
@@ -1,132 +1,132 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGenres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Genres",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Slug = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Genres", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GenreAliases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
Value = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_GenreAliases", x => x.Id);
table.ForeignKey(
name: "FK_GenreAliases_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ShowGenres",
columns: table => new
{
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
table.ForeignKey(
name: "FK_ShowGenres_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
table.ForeignKey(
name: "FK_ShowGenres_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_GenreId",
table: "GenreAliases",
column: "GenreId"
);
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_Value",
table: "GenreAliases",
column: "Value",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_Genres_Slug",
table: "Genres",
column: "Slug",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_ShowGenres_GenreId",
table: "ShowGenres",
column: "GenreId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "GenreAliases");
migrationBuilder.DropTable(name: "ShowGenres");
migrationBuilder.DropTable(name: "Genres");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGenres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Genres",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Slug = table.Column<string>(
type: "character varying(64)",
maxLength: 64,
nullable: false
),
SortOrder = table.Column<int>(type: "integer", nullable: false),
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Genres", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GenreAliases",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
Value = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_GenreAliases", x => x.Id);
table.ForeignKey(
name: "FK_GenreAliases_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ShowGenres",
columns: table => new
{
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
table.ForeignKey(
name: "FK_ShowGenres_Genres_GenreId",
column: x => x.GenreId,
principalTable: "Genres",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
table.ForeignKey(
name: "FK_ShowGenres_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_GenreId",
table: "GenreAliases",
column: "GenreId"
);
migrationBuilder.CreateIndex(
name: "IX_GenreAliases_Value",
table: "GenreAliases",
column: "Value",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_Genres_Slug",
table: "Genres",
column: "Slug",
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_ShowGenres_GenreId",
table: "ShowGenres",
column: "GenreId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "GenreAliases");
migrationBuilder.DropTable(name: "ShowGenres");
migrationBuilder.DropTable(name: "Genres");
}
}
}
@@ -1,98 +1,98 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Collections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Collections", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "CollectionItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CollectionItems", x => x.Id);
table.ForeignKey(
name: "FK_CollectionItems_Collections_CollectionId",
column: x => x.CollectionId,
principalTable: "Collections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_CollectionItems_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_Position",
table: "CollectionItems",
columns: new[] { "CollectionId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_ShowId",
table: "CollectionItems",
columns: new[] { "CollectionId", "ShowId" },
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_ShowId",
table: "CollectionItems",
column: "ShowId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "CollectionItems");
migrationBuilder.DropTable(name: "Collections");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCollections : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Collections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Collections", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "CollectionItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_CollectionItems", x => x.Id);
table.ForeignKey(
name: "FK_CollectionItems_Collections_CollectionId",
column: x => x.CollectionId,
principalTable: "Collections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_CollectionItems_Shows_ShowId",
column: x => x.ShowId,
principalTable: "Shows",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_Position",
table: "CollectionItems",
columns: new[] { "CollectionId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_CollectionId_ShowId",
table: "CollectionItems",
columns: new[] { "CollectionId", "ShowId" },
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_CollectionItems_ShowId",
table: "CollectionItems",
column: "ShowId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "CollectionItems");
migrationBuilder.DropTable(name: "Collections");
}
}
}
@@ -1,100 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Groups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
FilterJson = table.Column<string>(type: "jsonb", nullable: true),
ItemCount = table.Column<int>(type: "integer", nullable: false),
UnitCount = table.Column<int>(type: "integer", nullable: false),
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
StatsComputedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Groups", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GroupItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
ElementKind = table.Column<int>(type: "integer", nullable: false),
ElementId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_GroupItems", x => x.Id);
table.ForeignKey(
name: "FK_GroupItems_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "ElementKind", "ElementId" }
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "GroupId", "ElementKind", "ElementId" },
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_Position",
table: "GroupItems",
columns: new[] { "GroupId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "GroupItems");
migrationBuilder.DropTable(name: "Groups");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGroups : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Groups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Description = table.Column<string>(
type: "character varying(2048)",
maxLength: 2048,
nullable: true
),
FilterJson = table.Column<string>(type: "jsonb", nullable: true),
ItemCount = table.Column<int>(type: "integer", nullable: false),
UnitCount = table.Column<int>(type: "integer", nullable: false),
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
StatsComputedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_Groups", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GroupItems",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
ElementKind = table.Column<int>(type: "integer", nullable: false),
ElementId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_GroupItems", x => x.Id);
table.ForeignKey(
name: "FK_GroupItems_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "ElementKind", "ElementId" }
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_ElementKind_ElementId",
table: "GroupItems",
columns: new[] { "GroupId", "ElementKind", "ElementId" },
unique: true
);
migrationBuilder.CreateIndex(
name: "IX_GroupItems_GroupId_Position",
table: "GroupItems",
columns: new[] { "GroupId", "Position" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "GroupItems");
migrationBuilder.DropTable(name: "Groups");
}
}
}
@@ -1,224 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddScheduleTemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeOnly>(
name: "DayStartTime",
table: "Channels",
type: "time without time zone",
nullable: false,
defaultValue: new TimeOnly(0, 0, 0)
);
migrationBuilder.AddColumn<int>(
name: "Number",
table: "Channels",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "Channels",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "UtcOffsetMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ScheduleTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
AppliedRevision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GridLayers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Priority = table.Column<int>(type: "integer", nullable: false),
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
IsBackground = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_GridLayers", x => x.Id);
table.ForeignKey(
name: "FK_GridLayers_ScheduleTemplates_TemplateId",
column: x => x.TemplateId,
principalTable: "ScheduleTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "Slots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LayerId = table.Column<Guid>(type: "uuid", nullable: false),
Weekday = table.Column<int>(type: "integer", nullable: true),
TargetStart = table.Column<TimeOnly>(
type: "time without time zone",
nullable: false
),
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
Title = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Daypart = table.Column<int>(type: "integer", nullable: false),
SlotKind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
SnapToMinutes = table.Column<int>(type: "integer", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_Slots", x => x.Id);
table.ForeignKey(
name: "FK_Slots_GridLayers_LayerId",
column: x => x.LayerId,
principalTable: "GridLayers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_Slots_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
}
);
migrationBuilder.CreateTable(
name: "SlotStates",
columns: table => new
{
SlotId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
NextUnitIndex = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_SlotStates", x => x.SlotId);
table.ForeignKey(
name: "FK_SlotStates_Slots_SlotId",
column: x => x.SlotId,
principalTable: "Slots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_Channels_Number",
table: "Channels",
column: "Number",
unique: true,
filter: "\"Number\" IS NOT NULL"
);
migrationBuilder.CreateIndex(
name: "IX_GridLayers_TemplateId_Priority",
table: "GridLayers",
columns: new[] { "TemplateId", "Priority" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleTemplates_ChannelId",
table: "ScheduleTemplates",
column: "ChannelId"
);
migrationBuilder.CreateIndex(
name: "IX_Slots_GroupId",
table: "Slots",
column: "GroupId"
);
migrationBuilder.CreateIndex(
name: "IX_Slots_LayerId_TargetStart",
table: "Slots",
columns: new[] { "LayerId", "TargetStart" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "SlotStates");
migrationBuilder.DropTable(name: "Slots");
migrationBuilder.DropTable(name: "GridLayers");
migrationBuilder.DropTable(name: "ScheduleTemplates");
migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels");
migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels");
migrationBuilder.DropColumn(name: "Number", table: "Channels");
migrationBuilder.DropColumn(name: "TemplateId", table: "Channels");
migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddScheduleTemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<TimeOnly>(
name: "DayStartTime",
table: "Channels",
type: "time without time zone",
nullable: false,
defaultValue: new TimeOnly(0, 0, 0)
);
migrationBuilder.AddColumn<int>(
name: "Number",
table: "Channels",
type: "integer",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "TemplateId",
table: "Channels",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<int>(
name: "UtcOffsetMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ScheduleTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
Revision = table.Column<int>(type: "integer", nullable: false),
AppliedRevision = table.Column<int>(type: "integer", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "GridLayers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
Priority = table.Column<int>(type: "integer", nullable: false),
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
IsBackground = table.Column<bool>(type: "boolean", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_GridLayers", x => x.Id);
table.ForeignKey(
name: "FK_GridLayers_ScheduleTemplates_TemplateId",
column: x => x.TemplateId,
principalTable: "ScheduleTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "Slots",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
LayerId = table.Column<Guid>(type: "uuid", nullable: false),
Weekday = table.Column<int>(type: "integer", nullable: true),
TargetStart = table.Column<TimeOnly>(
type: "time without time zone",
nullable: false
),
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
Title = table.Column<string>(
type: "character varying(256)",
maxLength: 256,
nullable: false
),
Daypart = table.Column<int>(type: "integer", nullable: false),
SlotKind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
SnapToMinutes = table.Column<int>(type: "integer", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_Slots", x => x.Id);
table.ForeignKey(
name: "FK_Slots_GridLayers_LayerId",
column: x => x.LayerId,
principalTable: "GridLayers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "FK_Slots_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
}
);
migrationBuilder.CreateTable(
name: "SlotStates",
columns: table => new
{
SlotId = table.Column<Guid>(type: "uuid", nullable: false),
CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
NextUnitIndex = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_SlotStates", x => x.SlotId);
table.ForeignKey(
name: "FK_SlotStates_Slots_SlotId",
column: x => x.SlotId,
principalTable: "Slots",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_Channels_Number",
table: "Channels",
column: "Number",
unique: true,
filter: "\"Number\" IS NOT NULL"
);
migrationBuilder.CreateIndex(
name: "IX_GridLayers_TemplateId_Priority",
table: "GridLayers",
columns: new[] { "TemplateId", "Priority" }
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleTemplates_ChannelId",
table: "ScheduleTemplates",
column: "ChannelId"
);
migrationBuilder.CreateIndex(
name: "IX_Slots_GroupId",
table: "Slots",
column: "GroupId"
);
migrationBuilder.CreateIndex(
name: "IX_Slots_LayerId_TargetStart",
table: "Slots",
columns: new[] { "LayerId", "TargetStart" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "SlotStates");
migrationBuilder.DropTable(name: "Slots");
migrationBuilder.DropTable(name: "GridLayers");
migrationBuilder.DropTable(name: "ScheduleTemplates");
migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels");
migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels");
migrationBuilder.DropColumn(name: "Number", table: "Channels");
migrationBuilder.DropColumn(name: "TemplateId", table: "Channels");
migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels");
}
}
}
@@ -1,59 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryTrace : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries"
);
migrationBuilder.AddColumn<Guid>(
name: "SlotId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "TraceJson",
table: "ScheduleEntries",
type: "jsonb",
nullable: true
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries"
);
migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" }
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryTrace : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries"
);
migrationBuilder.AddColumn<Guid>(
name: "SlotId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<string>(
name: "TraceJson",
table: "ScheduleEntries",
type: "jsonb",
nullable: true
);
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
table: "ScheduleEntries"
);
migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries");
migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries");
migrationBuilder.CreateIndex(
name: "IX_ScheduleEntries_ChannelId_ShowId",
table: "ScheduleEntries",
columns: new[] { "ChannelId", "ShowId" }
);
}
}
}
@@ -1,219 +1,219 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropLegacyRotation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels");
migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels");
migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AdInsertion",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "AdsPerBreak",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "NextAdIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
PreferredWeightMultiplier = table.Column<int>(
type: "integer",
nullable: false,
defaultValue: 3
),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: true),
EndMinute = table.Column<int>(type: "integer", nullable: true),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
Mode = table.Column<int>(type: "integer", nullable: false),
Recurrence = table.Column<int>(type: "integer", nullable: false),
StartMinute = table.Column<int>(type: "integer", nullable: true),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId"
);
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId"
);
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
);
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropLegacyRotation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "ChannelAd");
migrationBuilder.DropTable(name: "ChannelShowHour");
migrationBuilder.DropTable(name: "OverrideShow");
migrationBuilder.DropTable(name: "ChannelShow");
migrationBuilder.DropTable(name: "ProgrammingOverride");
migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels");
migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels");
migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AdInsertion",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "AdsPerBreak",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<int>(
name: "NextAdIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.CreateTable(
name: "ChannelAd",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelAd", x => x.Id);
table.ForeignKey(
name: "FK_ChannelAd_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
BlockMode = table.Column<int>(type: "integer", nullable: false),
BlockValue = table.Column<int>(type: "integer", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
PreferredWeightMultiplier = table.Column<int>(
type: "integer",
nullable: false,
defaultValue: 3
),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShow", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShow_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ProgrammingOverride",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
DayOfWeek = table.Column<int>(type: "integer", nullable: true),
EndMinute = table.Column<int>(type: "integer", nullable: true),
EndsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
Mode = table.Column<int>(type: "integer", nullable: false),
Recurrence = table.Column<int>(type: "integer", nullable: false),
StartMinute = table.Column<int>(type: "integer", nullable: true),
StartsAtUtc = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
table.ForeignKey(
name: "FK_ProgrammingOverride_Channels_ChannelId",
column: x => x.ChannelId,
principalTable: "Channels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "ChannelShowHour",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
EndHour = table.Column<int>(type: "integer", nullable: false),
StartHour = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
table.ForeignKey(
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
column: x => x.ChannelShowId,
principalTable: "ChannelShow",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "OverrideShow",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
Weight = table.Column<int>(type: "integer", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("PK_OverrideShow", x => x.Id);
table.ForeignKey(
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
column: x => x.ProgrammingOverrideId,
principalTable: "ProgrammingOverride",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_ChannelAd_ChannelId_Position",
table: "ChannelAd",
columns: new[] { "ChannelId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShow_ChannelId_ShowId",
table: "ChannelShow",
columns: new[] { "ChannelId", "ShowId" }
);
migrationBuilder.CreateIndex(
name: "IX_ChannelShowHour_ChannelShowId",
table: "ChannelShowHour",
column: "ChannelShowId"
);
migrationBuilder.CreateIndex(
name: "IX_OverrideShow_ProgrammingOverrideId",
table: "OverrideShow",
column: "ProgrammingOverrideId"
);
migrationBuilder.CreateIndex(
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
table: "ProgrammingOverride",
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
);
}
}
}
@@ -1,125 +1,125 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJunctionTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "JunctionAfterId",
table: "Slots",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "JunctionBetweenId",
table: "Slots",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "DefaultJunctionId",
table: "ScheduleTemplates",
type: "uuid",
nullable: true
);
migrationBuilder.CreateTable(
name: "JunctionTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "JunctionElements",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
AmountMode = table.Column<int>(type: "integer", nullable: false),
AmountValue = table.Column<int>(type: "integer", nullable: false),
IsRequired = table.Column<bool>(type: "boolean", nullable: false),
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionElements", x => x.Id);
table.ForeignKey(
name: "FK_JunctionElements_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
table.ForeignKey(
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
column: x => x.JunctionTemplateId,
principalTable: "JunctionTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_GroupId",
table: "JunctionElements",
column: "GroupId"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_JunctionTemplateId_Position",
table: "JunctionElements",
columns: new[] { "JunctionTemplateId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates",
column: "ChannelId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "JunctionElements");
migrationBuilder.DropTable(name: "JunctionTemplates");
migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots");
migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots");
migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJunctionTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "JunctionAfterId",
table: "Slots",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "JunctionBetweenId",
table: "Slots",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<Guid>(
name: "DefaultJunctionId",
table: "ScheduleTemplates",
type: "uuid",
nullable: true
);
migrationBuilder.CreateTable(
name: "JunctionTemplates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(
type: "character varying(128)",
maxLength: 128,
nullable: false
),
CreatedAt = table.Column<DateTimeOffset>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
}
);
migrationBuilder.CreateTable(
name: "JunctionElements",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
Position = table.Column<int>(type: "integer", nullable: false),
Kind = table.Column<int>(type: "integer", nullable: false),
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
AmountMode = table.Column<int>(type: "integer", nullable: false),
AmountValue = table.Column<int>(type: "integer", nullable: false),
IsRequired = table.Column<bool>(type: "boolean", nullable: false),
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("PK_JunctionElements", x => x.Id);
table.ForeignKey(
name: "FK_JunctionElements_Groups_GroupId",
column: x => x.GroupId,
principalTable: "Groups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict
);
table.ForeignKey(
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
column: x => x.JunctionTemplateId,
principalTable: "JunctionTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_GroupId",
table: "JunctionElements",
column: "GroupId"
);
migrationBuilder.CreateIndex(
name: "IX_JunctionElements_JunctionTemplateId_Position",
table: "JunctionElements",
columns: new[] { "JunctionTemplateId", "Position" }
);
migrationBuilder.CreateIndex(
name: "IX_JunctionTemplates_ChannelId",
table: "JunctionTemplates",
column: "ChannelId"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "JunctionElements");
migrationBuilder.DropTable(name: "JunctionTemplates");
migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots");
migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots");
migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates");
}
}
}
@@ -1,64 +1,64 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropDeadBumperSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
migrationBuilder.Sql(
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
);
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "NextBumperIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class DropDeadBumperSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
migrationBuilder.Sql(
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
);
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "BumperEpisodeChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "BumperMinIntervalMinutes",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<double>(
name: "BumperShowChangeChance",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "NextBumperIndex",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
}
}
}
@@ -1,27 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TemplatePlanningRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RulesJson",
table: "ScheduleTemplates",
type: "jsonb",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates");
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class TemplatePlanningRules : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "RulesJson",
table: "ScheduleTemplates",
type: "jsonb",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates");
}
}
}
@@ -1,68 +1,68 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelViewerSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "AnalogFilterStrength",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "LogoCorner",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<Guid>(
name: "LogoImageId",
table: "Channels",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<double>(
name: "LogoOpacity",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<bool>(
name: "ShowClock",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels");
migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels");
migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels");
migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels");
migrationBuilder.DropColumn(name: "ShowClock", table: "Channels");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ChannelViewerSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<double>(
name: "AnalogFilterStrength",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<int>(
name: "LogoCorner",
table: "Channels",
type: "integer",
nullable: false,
defaultValue: 0
);
migrationBuilder.AddColumn<Guid>(
name: "LogoImageId",
table: "Channels",
type: "uuid",
nullable: true
);
migrationBuilder.AddColumn<double>(
name: "LogoOpacity",
table: "Channels",
type: "double precision",
nullable: false,
defaultValue: 0.0
);
migrationBuilder.AddColumn<bool>(
name: "ShowClock",
table: "Channels",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels");
migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels");
migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels");
migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels");
migrationBuilder.DropColumn(name: "ShowClock", table: "Channels");
}
}
}
@@ -1,28 +1,28 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CollectionId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries");
}
}
}
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryCollection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CollectionId",
table: "ScheduleEntries",
type: "uuid",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries");
}
}
}
@@ -1,38 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudienceMpaa : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true
);
}
}
}
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ShowAudienceMpaa : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: true,
oldClrType: typeof(int),
oldType: "integer"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "Audience",
table: "Shows",
type: "integer",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "integer",
oldNullable: true
);
}
}
}
@@ -1,83 +1,83 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
public DbSet<Collection> Collections => Set<Collection>();
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupItem> GroupItems => Set<GroupItem>();
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
public DbSet<GridLayer> GridLayers => Set<GridLayer>();
public DbSet<Slot> Slots => Set<Slot>();
public DbSet<SlotState> SlotStates => Set<SlotState>();
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken);
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
{
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (
entityType.ClrType.Namespace?.StartsWith(
"TeleWave.Domain",
StringComparison.Ordinal
) != true
)
continue;
var idProperty = entityType.FindProperty("Id");
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
idProperty.ValueGenerated = ValueGenerated.Never;
}
}
}
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Domain.Auth;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Images;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Domain.Settings;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Persistence;
/// <summary>
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
/// (добавляются по мере реализации фич).
/// </summary>
public class AppDbContext(DbContextOptions<AppDbContext> options)
: IdentityDbContext<AppUser, AppRole, Guid>(options),
IAppDbContext
{
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
public DbSet<Show> Shows => Set<Show>();
public DbSet<Genre> Genres => Set<Genre>();
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
public DbSet<Collection> Collections => Set<Collection>();
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
public DbSet<Group> Groups => Set<Group>();
public DbSet<GroupItem> GroupItems => Set<GroupItem>();
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
public DbSet<GridLayer> GridLayers => Set<GridLayer>();
public DbSet<Slot> Slots => Set<Slot>();
public DbSet<SlotState> SlotStates => Set<SlotState>();
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
public DbSet<Image> Images => Set<Image>();
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
Database.BeginTransactionAsync(cancellationToken);
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
{
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
// корректности не нарушают.
var key = BitConverter.ToInt64(channelId.ToByteArray());
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
}
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (
entityType.ClrType.Namespace?.StartsWith(
"TeleWave.Domain",
StringComparison.Ordinal
) != true
)
continue;
var idProperty = entityType.FindProperty("Id");
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
idProperty.ValueGenerated = ValueGenerated.Never;
}
}
}
@@ -1,61 +1,61 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{
public void Configure(EntityTypeBuilder<Channel> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique();
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
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);
}
}
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
builder
.HasMany(x => x.Variants)
.WithOne()
.HasForeignKey(v => v.BumperTemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
{
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
{
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);
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using TeleWave.Domain.Broadcast;
namespace TeleWave.Infrastructure.Persistence.Configurations;
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
{
public void Configure(EntityTypeBuilder<Channel> builder)
{
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
builder.HasIndex(x => x.Slug).IsUnique();
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
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);
}
}
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
{
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
{
builder.HasIndex(x => new { x.ChannelId, x.Position });
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
builder.Property(x => x.AudioExtension).HasMaxLength(16);
builder
.HasMany(x => x.Variants)
.WithOne()
.HasForeignKey(v => v.BumperTemplateId)
.OnDelete(DeleteBehavior.Cascade);
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
}
}
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
{
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
{
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);
}
}
@@ -1,53 +1,53 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings;
using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{
await UpsertAsync(
SettingKeys.RegistrationEnabled,
enabled ? "true" : "false",
cancellationToken
);
}
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(
string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
UpsertAsync(
SettingKeys.ChannelNumbersEnabled,
enabled ? "true" : "false",
cancellationToken
);
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
s => s.Key == key,
cancellationToken
);
if (existing is null)
dbContext.AppSettings.Add(AppSetting.Create(key, value));
else
existing.SetValue(value);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Settings;
using TeleWave.Domain.Settings;
namespace TeleWave.Infrastructure.Settings;
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
{
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
{
await UpsertAsync(
SettingKeys.RegistrationEnabled,
enabled ? "true" : "false",
cancellationToken
);
}
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
public Task SetPreferredAudioLanguagesAsync(
string value,
CancellationToken cancellationToken
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
UpsertAsync(
SettingKeys.ChannelNumbersEnabled,
enabled ? "true" : "false",
cancellationToken
);
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
{
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
s => s.Key == key,
cancellationToken
);
if (existing is null)
dbContext.AppSettings.Add(AppSetting.Create(key, value));
else
existing.SetValue(value);
}
}
@@ -1,69 +1,69 @@
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Broadcast;
public class ChannelHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
{
var fixture = new TestDb();
await using var db = fixture.New();
var ok = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("News", "news"),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
var dup = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("Other", "news"),
CancellationToken.None
);
Assert.False(dup.IsSuccess);
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
}
[Fact]
public async Task UpdateChannelSettings_UpdatesBumperChances()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
new UpdateChannelSettingsCommand(
channel.Id,
"c",
true,
true,
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
null
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var stored = await verify.Channels.FindAsync(channel.Id);
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
}
}
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.CreateChannel;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Broadcast;
public class ChannelHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
{
var fixture = new TestDb();
await using var db = fixture.New();
var ok = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("News", "news"),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
var dup = await new CreateChannelCommandHandler(db).Handle(
new CreateChannelCommand("Other", "news"),
CancellationToken.None
);
Assert.False(dup.IsSuccess);
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
}
[Fact]
public async Task UpdateChannelSettings_UpdatesBumperChances()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
new UpdateChannelSettingsCommand(
channel.Id,
"c",
true,
true,
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
null
),
CancellationToken.None
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync(CancellationToken.None);
await using var verify = fixture.New();
var stored = await verify.Channels.FindAsync(channel.Id);
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
}
}
@@ -1,201 +1,201 @@
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Broadcast;
public class QueryHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
[Fact]
public async Task GetChannel_UnknownId_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new GetChannelQueryHandler(db).Handle(
new GetChannelQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task ListChannels_ReturnsSummaries()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.Channels.Add(Channel.Create("A", "a", T0));
seed.Channels.Add(Channel.Create("B", "b", T0));
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new ListChannelsQueryHandler(db).Handle(
new ListChannelsQuery(),
CancellationToken.None
);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
var show = Show.Create("Show A", ShowKind.Series);
var variant = channel.BumperTemplates[0].Variants[0];
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
var program = ScheduleEntry.Program(
channel.Id,
asset.Id,
T0,
T0.AddMinutes(20),
show.Id,
0
);
var bumper = ScheduleEntry.Bumper(
channel.Id,
Guid.NewGuid(),
T0.AddMinutes(20),
T0.AddMinutes(20).AddSeconds(8),
show.Id,
variant.Id
);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Channels.Add(channel);
seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(program);
seed.ScheduleEntries.Add(bumper);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetChannelScheduleQueryHandler(db).Handle(
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
Assert.NotNull(bumperDto.BumperName);
}
[Fact]
public async Task GetShow_ReturnsDtoOrNotFound()
{
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var ok = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(show.Id),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
var missing = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(missing.IsSuccess);
}
[Fact]
public async Task DeleteShow_RemovesOrNotFound()
{
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(missing.IsSuccess);
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(show.Id),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
}
[Fact]
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
Guid templateId;
await using (var db = fixture.New())
{
var added = await new AddBumperTemplateCommandHandler(db).Handle(
new AddBumperTemplateCommand(channel.Id, ""),
CancellationToken.None
);
Assert.True(added.IsSuccess);
templateId = added.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
var storage = Substitute.For<IBumperTemplateStorage>();
await using (var db = fixture.New())
{
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, templateId),
CancellationToken.None
);
Assert.True(removed.IsSuccess);
}
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
// дефолтный блок удалить нельзя
await using (var db = fixture.New())
{
var def = await db
.Channels.Include(c => c.BumperTemplates)
.FirstAsync(c => c.Id == channel.Id);
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
}
}
}
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.GetChannel;
using TeleWave.Application.Broadcast.GetSchedule;
using TeleWave.Application.Broadcast.ListChannels;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Library.DeleteShow;
using TeleWave.Application.Library.GetShow;
using TeleWave.Application.Programming.Groups;
using TeleWave.Application.Tests.Support;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using Xunit;
namespace TeleWave.Application.Tests.Broadcast;
public class QueryHandlersTests
{
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
[Fact]
public async Task GetChannel_UnknownId_ReturnsNotFound()
{
var fixture = new TestDb();
await using var db = fixture.New();
var result = await new GetChannelQueryHandler(db).Handle(
new GetChannelQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(result.IsSuccess);
}
[Fact]
public async Task ListChannels_ReturnsSummaries()
{
var fixture = new TestDb();
await using (var seed = fixture.New())
{
seed.Channels.Add(Channel.Create("A", "a", T0));
seed.Channels.Add(Channel.Create("B", "b", T0));
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new ListChannelsQueryHandler(db).Handle(
new ListChannelsQuery(),
CancellationToken.None
);
Assert.Equal(2, result.Count);
}
[Fact]
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
var show = Show.Create("Show A", ShowKind.Series);
var variant = channel.BumperTemplates[0].Variants[0];
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
var program = ScheduleEntry.Program(
channel.Id,
asset.Id,
T0,
T0.AddMinutes(20),
show.Id,
0
);
var bumper = ScheduleEntry.Bumper(
channel.Id,
Guid.NewGuid(),
T0.AddMinutes(20),
T0.AddMinutes(20).AddSeconds(8),
show.Id,
variant.Id
);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
seed.Channels.Add(channel);
seed.MediaAssets.Add(asset);
seed.ScheduleEntries.Add(program);
seed.ScheduleEntries.Add(bumper);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var result = await new GetChannelScheduleQueryHandler(db).Handle(
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
CancellationToken.None
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Count);
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
Assert.NotNull(bumperDto.BumperName);
}
[Fact]
public async Task GetShow_ReturnsDtoOrNotFound()
{
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var ok = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(show.Id),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
var missing = await new GetShowQueryHandler(db).Handle(
new GetShowQuery(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(missing.IsSuccess);
}
[Fact]
public async Task DeleteShow_RemovesOrNotFound()
{
var fixture = new TestDb();
var show = Show.Create("A", ShowKind.Series);
await using (var seed = fixture.New())
{
seed.Shows.Add(show);
await seed.SaveChangesAsync(CancellationToken.None);
}
await using var db = fixture.New();
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(Guid.NewGuid()),
CancellationToken.None
);
Assert.False(missing.IsSuccess);
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
new DeleteShowCommand(show.Id),
CancellationToken.None
);
Assert.True(ok.IsSuccess);
}
[Fact]
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
{
var fixture = new TestDb();
var channel = Channel.Create("c", "c", T0);
await using (var seed = fixture.New())
{
seed.Channels.Add(channel);
await seed.SaveChangesAsync(CancellationToken.None);
}
Guid templateId;
await using (var db = fixture.New())
{
var added = await new AddBumperTemplateCommandHandler(db).Handle(
new AddBumperTemplateCommand(channel.Id, ""),
CancellationToken.None
);
Assert.True(added.IsSuccess);
templateId = added.Value;
await db.SaveChangesAsync(CancellationToken.None);
}
var storage = Substitute.For<IBumperTemplateStorage>();
await using (var db = fixture.New())
{
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, templateId),
CancellationToken.None
);
Assert.True(removed.IsSuccess);
}
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
// дефолтный блок удалить нельзя
await using (var db = fixture.New())
{
var def = await db
.Channels.Include(c => c.BumperTemplates)
.FirstAsync(c => c.Id == channel.Id);
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
CancellationToken.None
);
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
}
}
}
@@ -1,150 +1,150 @@
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
/// </summary>
public class LayerApplicabilityTests
{
private static readonly TimeOnly DayStart = new(6, 0);
[Fact]
public void Empty_CoversEveryDate()
{
var applicability = new LayerApplicability();
Assert.True(applicability.IsEmpty);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
}
[Theory]
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
[InlineData(2026, 12, 20, true)] // ровно начало
[InlineData(2026, 1, 8, true)] // ровно конец
[InlineData(2026, 12, 19, false)] // за день до начала
[InlineData(2026, 1, 9, false)] // на следующий день после конца
[InlineData(2026, 6, 15, false)] // середина года
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
int year,
int month,
int day,
bool expected
)
{
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
// по паре (месяц, день), а не по датам.
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
}
[Fact]
public void AnnualRange_WithinOneYear_DoesNotWrap()
{
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
}
[Fact]
public void Sections_AreCombinedWithOr()
{
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
var applicability = new LayerApplicability(
Weekdays: [1],
SpecificDates: [new DateOnly(2026, 3, 21)]
);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
}
[Fact]
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
{
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
// половину настройки — своей группы и стратегии у половинки нет.
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var top = template.AddLayer("Прайм", 100);
var bottom = template.AddLayer("Обычный", 50);
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
Assert.Contains("Кино", titles);
Assert.Contains("Ночь", titles);
Assert.DoesNotContain("Сериал", titles);
}
[Fact]
public void Build_DropsLayersThatDoNotApplyOnTheDate()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var newYear = template.AddLayer("Новогодний", 100);
newYear.Update(
newYear.Name,
newYear.Priority,
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
isEnabled: true
);
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
var usual = template.AddLayer("Обычный", 50);
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
Assert.Contains("Вечерний сериал", march);
Assert.DoesNotContain("Ирония судьбы", march);
Assert.Contains("Ирония судьбы", december);
Assert.DoesNotContain("Вечерний сериал", december);
}
[Fact]
public void Build_DisabledLayerIsIgnored()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var layer = template.AddLayer("Выключённый", 100);
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
Assert.Empty(Build(template));
}
private static void AddSlot(
GridLayer layer,
string title,
TimeOnly start,
int durationMinutes
) => layer.AddSlot(title, start, durationMinutes);
private static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template,
DateTimeOffset? from = null
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build(
template,
utcOffsetMinutes: 180,
DayStart,
start,
start.AddHours(18)
);
}
}
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Domain.Programming;
using Xunit;
namespace TeleWave.Application.Tests.Programming;
/// <summary>
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
/// </summary>
public class LayerApplicabilityTests
{
private static readonly TimeOnly DayStart = new(6, 0);
[Fact]
public void Empty_CoversEveryDate()
{
var applicability = new LayerApplicability();
Assert.True(applicability.IsEmpty);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
}
[Theory]
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
[InlineData(2026, 12, 20, true)] // ровно начало
[InlineData(2026, 1, 8, true)] // ровно конец
[InlineData(2026, 12, 19, false)] // за день до начала
[InlineData(2026, 1, 9, false)] // на следующий день после конца
[InlineData(2026, 6, 15, false)] // середина года
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
int year,
int month,
int day,
bool expected
)
{
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
// по паре (месяц, день), а не по датам.
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
}
[Fact]
public void AnnualRange_WithinOneYear_DoesNotWrap()
{
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
}
[Fact]
public void Sections_AreCombinedWithOr()
{
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
var applicability = new LayerApplicability(
Weekdays: [1],
SpecificDates: [new DateOnly(2026, 3, 21)]
);
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
}
[Fact]
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
{
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
// половину настройки — своей группы и стратегии у половинки нет.
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var top = template.AddLayer("Прайм", 100);
var bottom = template.AddLayer("Обычный", 50);
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
Assert.Contains("Кино", titles);
Assert.Contains("Ночь", titles);
Assert.DoesNotContain("Сериал", titles);
}
[Fact]
public void Build_DropsLayersThatDoNotApplyOnTheDate()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var newYear = template.AddLayer("Новогодний", 100);
newYear.Update(
newYear.Name,
newYear.Priority,
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
isEnabled: true
);
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
var usual = template.AddLayer("Обычный", 50);
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
.Select(s => s.Slot.Title)
.ToList();
Assert.Contains("Вечерний сериал", march);
Assert.DoesNotContain("Ирония судьбы", march);
Assert.Contains("Ирония судьбы", december);
Assert.DoesNotContain("Вечерний сериал", december);
}
[Fact]
public void Build_DisabledLayerIsIgnored()
{
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
var layer = template.AddLayer("Выключённый", 100);
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
Assert.Empty(Build(template));
}
private static void AddSlot(
GridLayer layer,
string title,
TimeOnly start,
int durationMinutes
) => layer.AddSlot(title, start, durationMinutes);
private static IReadOnlyList<ScheduledSlot> Build(
ScheduleTemplate template,
DateTimeOffset? from = null
)
{
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
return EffectiveGridBuilder.Build(
template,
utcOffsetMinutes: 180,
DayStart,
start,
start.AddHours(18)
);
}
}
@@ -1,77 +1,77 @@
using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Application.Tests.Validators;
public class ValidatorTests
{
[Fact]
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
{
var v = new UpdateBumperTextVariantCommandValidator();
var good = new UpdateBumperTextVariantCommand(
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
"Name",
BumperTextKind.NowNext,
"NOW",
"NEXT",
"l1",
"l2",
BumperTrigger.Both,
3
);
Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid);
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
}
[Fact]
public void UpdateChannelSettings_ChecksRanges()
{
var v = new UpdateChannelSettingsCommandValidator();
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
var good = new UpdateChannelSettingsCommand(
Guid.NewGuid(),
"Name",
true,
true,
bumper,
null
);
Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid);
}
[Fact]
public void ResetUserPassword_RequiresMinLength()
{
var v = new ResetUserPasswordCommandValidator();
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
}
[Fact]
public void CreateUser_ChecksNameAndPassword()
{
var v = new CreateUserCommandValidator();
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
}
}
using TeleWave.Application.Admin.Users.CreateUser;
using TeleWave.Application.Admin.Users.ResetPassword;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Broadcast.Bumpers;
using TeleWave.Application.Broadcast.UpdateChannelSettings;
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Application.Tests.Validators;
public class ValidatorTests
{
[Fact]
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
{
var v = new UpdateBumperTextVariantCommandValidator();
var good = new UpdateBumperTextVariantCommand(
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
"Name",
BumperTextKind.NowNext,
"NOW",
"NEXT",
"l1",
"l2",
BumperTrigger.Both,
3
);
Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid);
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
}
[Fact]
public void UpdateChannelSettings_ChecksRanges()
{
var v = new UpdateChannelSettingsCommandValidator();
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
var good = new UpdateChannelSettingsCommand(
Guid.NewGuid(),
"Name",
true,
true,
bumper,
null
);
Assert.True(v.Validate(good).IsValid);
Assert.False(v.Validate(good with { Name = "" }).IsValid);
}
[Fact]
public void ResetUserPassword_RequiresMinLength()
{
var v = new ResetUserPasswordCommandValidator();
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "short")).IsValid);
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
}
[Fact]
public void CreateUser_ChecksNameAndPassword()
{
var v = new CreateUserCommandValidator();
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("ab", "Password1", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "short", Guid.NewGuid())).IsValid);
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
}
}
@@ -1,145 +1,145 @@
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
[Fact]
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
{
var channel = NewChannel();
Assert.NotEqual(Guid.Empty, channel.Id);
Assert.Equal("News", channel.Name);
Assert.Equal("news", channel.Slug);
Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc);
Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
var template = Assert.Single(channel.BumperTemplates);
Assert.True(template.IsDefault);
Assert.Equal(0, template.Position);
Assert.Single(template.Variants); // дефолтный подблок
}
[Fact]
public void UpdateSettings_ChangesFields()
{
var channel = NewChannel();
var filler = Guid.NewGuid();
channel.UpdateSettings("N2", false, true, filler);
Assert.Equal("N2", channel.Name);
Assert.False(channel.IsEnabled);
Assert.True(channel.BumpersEnabled);
Assert.Equal(filler, channel.FillerAssetId);
}
[Fact]
public void UpdateBumperSettings_ChangesFontAndSelection()
{
var channel = NewChannel();
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
}
[Theory]
[InlineData(-1.0, 2.0, 0.0, 1.0)]
[InlineData(0.4, 0.25, 0.4, 0.25)]
public void UpdateViewerSettings_ClampsStrengths(
double opacity,
double filter,
double expectedOpacity,
double expectedFilter
)
{
var channel = NewChannel();
var logo = Guid.NewGuid();
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
Assert.Equal(logo, channel.LogoImageId);
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
Assert.Equal(expectedOpacity, channel.LogoOpacity);
Assert.True(channel.ShowClock);
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
}
[Fact]
public void NewChannel_HasViewerOverlaysOff()
{
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
var channel = NewChannel();
Assert.Null(channel.LogoImageId);
Assert.False(channel.ShowClock);
Assert.Equal(0.0, channel.AnalogFilterStrength);
}
[Fact]
public void AddBumperTemplate_AppendsWithNextPosition()
{
var channel = NewChannel();
var t1 = channel.AddBumperTemplate("Block 2");
var t2 = channel.AddBumperTemplate("Block 3");
Assert.Equal(1, t1.Position);
Assert.Equal(2, t2.Position);
Assert.False(t1.IsDefault);
Assert.Equal(3, channel.BumperTemplates.Count);
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
}
[Fact]
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
{
var channel = NewChannel();
var def = channel.BumperTemplates[0];
var extra = channel.AddBumperTemplate("Block 2");
Assert.False(channel.RemoveBumperTemplate(def.Id));
Assert.True(channel.RemoveBumperTemplate(extra.Id));
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
Assert.Single(channel.BumperTemplates);
}
[Fact]
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
{
var channel = NewChannel();
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
Assert.Null(channel.Number);
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
}
[Fact]
public void SetTemplate_LinksAndUnlinks()
{
var channel = NewChannel();
var templateId = Guid.NewGuid();
channel.SetTemplate(templateId);
Assert.Equal(templateId, channel.TemplateId);
channel.SetTemplate(null);
Assert.Null(channel.TemplateId);
}
}
using TeleWave.Domain.Broadcast;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class ChannelTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
[Fact]
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
{
var channel = NewChannel();
Assert.NotEqual(Guid.Empty, channel.Id);
Assert.Equal("News", channel.Name);
Assert.Equal("news", channel.Slug);
Assert.True(channel.IsEnabled);
Assert.Equal(Epoch, channel.EpochUtc);
Assert.False(channel.BumpersEnabled);
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
var template = Assert.Single(channel.BumperTemplates);
Assert.True(template.IsDefault);
Assert.Equal(0, template.Position);
Assert.Single(template.Variants); // дефолтный подблок
}
[Fact]
public void UpdateSettings_ChangesFields()
{
var channel = NewChannel();
var filler = Guid.NewGuid();
channel.UpdateSettings("N2", false, true, filler);
Assert.Equal("N2", channel.Name);
Assert.False(channel.IsEnabled);
Assert.True(channel.BumpersEnabled);
Assert.Equal(filler, channel.FillerAssetId);
}
[Fact]
public void UpdateBumperSettings_ChangesFontAndSelection()
{
var channel = NewChannel();
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
Assert.Equal(BumperFont.Serif, channel.BumperFont);
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
}
[Theory]
[InlineData(-1.0, 2.0, 0.0, 1.0)]
[InlineData(0.4, 0.25, 0.4, 0.25)]
public void UpdateViewerSettings_ClampsStrengths(
double opacity,
double filter,
double expectedOpacity,
double expectedFilter
)
{
var channel = NewChannel();
var logo = Guid.NewGuid();
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
Assert.Equal(logo, channel.LogoImageId);
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
Assert.Equal(expectedOpacity, channel.LogoOpacity);
Assert.True(channel.ShowClock);
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
}
[Fact]
public void NewChannel_HasViewerOverlaysOff()
{
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
var channel = NewChannel();
Assert.Null(channel.LogoImageId);
Assert.False(channel.ShowClock);
Assert.Equal(0.0, channel.AnalogFilterStrength);
}
[Fact]
public void AddBumperTemplate_AppendsWithNextPosition()
{
var channel = NewChannel();
var t1 = channel.AddBumperTemplate("Block 2");
var t2 = channel.AddBumperTemplate("Block 3");
Assert.Equal(1, t1.Position);
Assert.Equal(2, t2.Position);
Assert.False(t1.IsDefault);
Assert.Equal(3, channel.BumperTemplates.Count);
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
}
[Fact]
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
{
var channel = NewChannel();
var def = channel.BumperTemplates[0];
var extra = channel.AddBumperTemplate("Block 2");
Assert.False(channel.RemoveBumperTemplate(def.Id));
Assert.True(channel.RemoveBumperTemplate(extra.Id));
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
Assert.Single(channel.BumperTemplates);
}
[Fact]
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
{
var channel = NewChannel();
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
Assert.Null(channel.Number);
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
}
[Fact]
public void SetTemplate_LinksAndUnlinks()
{
var channel = NewChannel();
var templateId = Guid.NewGuid();
channel.SetTemplate(templateId);
Assert.Equal(templateId, channel.TemplateId);
channel.SetTemplate(null);
Assert.Null(channel.TemplateId);
}
}
@@ -1,41 +1,41 @@
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Settings;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
public class DomainRecordFactoryTests
{
[Fact]
public void BumperAsset_Create_StoresPair()
{
var channel = Guid.NewGuid();
var template = Guid.NewGuid();
var variant = Guid.NewGuid();
var from = Guid.NewGuid();
var to = Guid.NewGuid();
var asset = Guid.NewGuid();
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature);
Assert.Equal(asset, b.MediaAssetId);
}
[Fact]
public void AppSetting_CreateAndSetValue()
{
var s = AppSetting.Create("site.title", "TeleWave");
Assert.Equal("site.title", s.Key);
Assert.Equal("TeleWave", s.Value);
s.SetValue("New");
Assert.Equal("New", s.Value);
}
}
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Settings;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
public class DomainRecordFactoryTests
{
[Fact]
public void BumperAsset_Create_StoresPair()
{
var channel = Guid.NewGuid();
var template = Guid.NewGuid();
var variant = Guid.NewGuid();
var from = Guid.NewGuid();
var to = Guid.NewGuid();
var asset = Guid.NewGuid();
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
Assert.Equal(channel, b.ChannelId);
Assert.Equal(template, b.TemplateId);
Assert.Equal(variant, b.VariantId);
Assert.Equal(from, b.FromShowId);
Assert.Equal(to, b.ToShowId);
Assert.Equal("sig", b.Signature);
Assert.Equal(asset, b.MediaAssetId);
}
[Fact]
public void AppSetting_CreateAndSetValue()
{
var s = AppSetting.Create("site.title", "TeleWave");
Assert.Equal("site.title", s.Key);
Assert.Equal("TeleWave", s.Value);
s.SetValue("New");
Assert.Equal("New", s.Value);
}
}
@@ -1,298 +1,298 @@
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}"
)
);
db.MediaAssets.Add(asset);
return asset;
}
}
using Microsoft.Extensions.Options;
using NSubstitute;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Programming.Planning;
using TeleWave.Application.Programming.Templates;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Domain.Programming;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
/// </summary>
[Collection("postgres")]
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
{
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
[SkippableFact]
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
Assert.False(report.ChannelSkipped);
Assert.True(report.Added > 0);
await using var verify = fixture.CreateContext();
var entries = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
.OrderBy(e => e.StartsAtUtc)
.ToList();
Assert.NotEmpty(entries);
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
for (var i = 1; i < entries.Count; i++)
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
Assert.NotEmpty(programs);
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
Assert.Equal(world.ShowId, state.CurrentElementId);
Assert.True(state.NextUnitIndex > 0);
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
Assert.False(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_IsIdempotentWithinHorizon()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
await using var second = fixture.CreateContext();
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
Assert.Equal(0, again.Added);
}
[SkippableFact]
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var first = fixture.CreateContext();
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
await using var beforeDb = fixture.CreateContext();
var past = beforeDb
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
.Select(e => e.Id)
.ToHashSet();
Assert.NotEmpty(past);
await using var rebuild = fixture.CreateContext();
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
await using var verify = fixture.CreateContext();
var survived = verify
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
.Select(e => e.Id)
.ToList();
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
Assert.Equal(past.Count, survived.Count);
}
[SkippableFact]
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
await using var db = fixture.CreateContext();
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
await using var verify = fixture.CreateContext();
var programs = verify
.ScheduleEntries.Where(e =>
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
)
.ToList();
Assert.NotEmpty(programs);
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
}
[SkippableFact]
public async Task Preview_WritesNothing()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 3);
await using var db = fixture.CreateContext();
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
Assert.NotNull(result);
Assert.NotEmpty(result.Items);
await using var verify = fixture.CreateContext();
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
Assert.True(
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
);
}
[SkippableFact]
public async Task Generate_EmptyGroup_FallsBackAndWarns()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var world = await SeedAsync(seedDb, episodes: 0);
await using var db = fixture.CreateContext();
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
Assert.Contains(
report.Warnings,
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
);
await using var verify = fixture.CreateContext();
Assert.All(
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
);
}
// ── Обвязка ─────────────────────────────────────────────────────────────
private static GridScheduleGenerator Generator(AppDbContext db)
{
var random = new SequenceRandom(0);
return new GridScheduleGenerator(
db,
new GroupExpander(db),
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
new PostCheckRunner(db),
random,
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
Options.Create(new StreamingOptions())
);
}
private sealed record World(
Guid ChannelId,
Guid TemplateId,
Guid SlotId,
Guid ShowId,
Guid? CollectionId
);
/// <summary>
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
/// </summary>
private static async Task<World> SeedAsync(
AppDbContext db,
int episodes,
bool asCollection = false
)
{
var suffix = Guid.NewGuid().ToString("N")[..8];
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
for (var i = 0; i < episodes; i++)
{
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
show.AddEpisode(asset.Id);
}
db.Shows.Add(show);
Collection? collection = null;
if (asCollection)
{
collection = Collection.Create($"Франшиза {suffix}");
collection.AddShow(show.Id);
db.Collections.Add(collection);
}
var group = Group.Create($"Группа {suffix}");
group.AddElement(
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
asCollection ? collection!.Id : show.Id
);
db.Groups.Add(group);
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
var layer = template.AddLayer("Базовый", 10);
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
slot.UpdateContent(
new SlotContent(
slot.Title,
SlotKind.Content,
group.Id,
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
null,
SlotBlockMode.FillSlot,
1,
OverflowPolicy.ContinueNext
)
);
channel.SetTemplate(template.Id);
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
template.MarkChanged();
db.Channels.Add(channel);
db.ScheduleTemplates.Add(template);
await db.SaveChangesAsync();
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
}
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
{
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
asset.MarkProcessing();
asset.MarkReady(
new MediaReadyInfo(
duration,
SegmentSeconds: 2,
SegmentCount: (int)(duration.TotalSeconds / 2),
Width: 1920,
Height: 1080,
VideoCodec: "h264",
AudioCodec: "aac",
RelativePath: $"segments/{asset.Id}"
)
);
db.MediaAssets.Add(asset);
return asset;
}
}
@@ -1,176 +1,176 @@
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary>
[Collection("postgres")]
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var first = $"{tag}-s01e01.mkv";
var second = $"{tag}-s01e02.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Imported);
Assert.Empty(result.Value.Failed);
await db.SaveChangesAsync();
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
await storage
.Received(2)
.PromoteToOriginalAsync(
MediaSource.ManualInbox,
Arg.Any<string>(),
Arg.Any<Guid>(),
".mkv",
Arg.Any<CancellationToken>()
);
await using var verify = fixture.CreateContext();
var episodes = verify
.Shows.Where(s => s.Id == showId)
.SelectMany(s => s.Episodes)
.OrderBy(e => e.Position)
.ToList();
Assert.Equal(2, episodes.Count);
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
Assert.Equal(1, episodes[0].Season);
Assert.Equal(1, episodes[0].Episode);
Assert.Equal(2, episodes[1].Episode);
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
// заводит записи из ручного inbox.
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
Assert.Equal(2, assets.Count);
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
}
[SkippableFact]
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var good = $"{tag}-ok.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile(good, good, 1000),
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Imported);
Assert.Equal(2, result.Value.Failed.Count);
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
}
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show);
await db.SaveChangesAsync();
return show.Id;
}
}
using NSubstitute;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Media.ManualInbox;
using TeleWave.Domain.Library;
using TeleWave.Domain.Media;
using TeleWave.Infrastructure.Persistence;
using Xunit;
namespace TeleWave.Integration.Tests;
/// <summary>
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
/// </summary>
[Collection("postgres")]
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
{
[SkippableFact]
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var first = $"{tag}-s01e01.mkv";
var second = $"{tag}-s01e02.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
showId
),
default
);
Assert.True(result.IsSuccess);
Assert.Equal(2, result.Value.Imported);
Assert.Empty(result.Value.Failed);
await db.SaveChangesAsync();
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
await storage
.Received(2)
.PromoteToOriginalAsync(
MediaSource.ManualInbox,
Arg.Any<string>(),
Arg.Any<Guid>(),
".mkv",
Arg.Any<CancellationToken>()
);
await using var verify = fixture.CreateContext();
var episodes = verify
.Shows.Where(s => s.Id == showId)
.SelectMany(s => s.Episodes)
.OrderBy(e => e.Position)
.ToList();
Assert.Equal(2, episodes.Count);
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
Assert.Equal(1, episodes[0].Season);
Assert.Equal(1, episodes[0].Episode);
Assert.Equal(2, episodes[1].Episode);
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
// заводит записи из ручного inbox.
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
Assert.Equal(2, assets.Count);
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
}
[SkippableFact]
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var tag = Guid.NewGuid().ToString("N")[..8];
var good = $"{tag}-ok.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([
new IMediaStorage.ManualInboxFile(good, good, 1000),
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand(
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
showId
),
default
);
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
Assert.True(result.IsSuccess);
Assert.Equal(1, result.Value.Imported);
Assert.Equal(2, result.Value.Failed.Count);
Assert.Contains(result.Value.Failed, f => f.RelativePath == "readme.txt");
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
}
[SkippableFact]
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
{
Skip.IfNot(fixture.Available, "Docker недоступен");
await using var seedDb = fixture.CreateContext();
var showId = await SeedShowAsync(seedDb);
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
var storage = Substitute.For<IMediaStorage>();
storage
.ListManualInbox(Arg.Any<int>())
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
await using var db = fixture.CreateContext();
var result = await new ImportManualInboxCommandHandler(
db,
storage,
Substitute.For<IMediaProcessingQueue>()
).Handle(
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
default
);
Assert.True(result.IsSuccess);
await db.SaveChangesAsync();
await using var verify = fixture.CreateContext();
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
Assert.Equal(4, episode.Season);
Assert.Equal(12, episode.Episode);
// И спутники рядом с забранным файлом убираются.
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
}
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
private static async Task<Guid> SeedShowAsync(AppDbContext db)
{
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
db.Shows.Add(show);
await db.SaveChangesAsync();
return show.Id;
}
}

Some files were not shown because too many files have changed in this diff Show More