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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
@@ -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
|
||||||
@@ -1,247 +1,247 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.CreateChannel;
|
using TeleWave.Application.Broadcast.CreateChannel;
|
||||||
using TeleWave.Application.Broadcast.GetChannel;
|
using TeleWave.Application.Broadcast.GetChannel;
|
||||||
using TeleWave.Application.Broadcast.GetSchedule;
|
using TeleWave.Application.Broadcast.GetSchedule;
|
||||||
using TeleWave.Application.Broadcast.ListChannels;
|
using TeleWave.Application.Broadcast.ListChannels;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
using TeleWave.Application.Broadcast.UpdateChannelTime;
|
||||||
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
using TeleWave.Application.Broadcast.UpdateViewerSettings;
|
||||||
using TeleWave.Application.Programming.Planning.Trace;
|
using TeleWave.Application.Programming.Planning.Trace;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
|
/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки —
|
||||||
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
|
/// в <c>ChannelEndpoints.Bumpers.cs</c>. Что и когда идёт в эфире, задаёт шаблон сетки
|
||||||
/// (<c>TemplateEndpoints</c>).
|
/// (<c>TemplateEndpoints</c>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static partial class ChannelEndpoints
|
public static partial class ChannelEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/channels")
|
var admin = app.MapGroup("/api/admin/channels")
|
||||||
.WithTags("Admin.Channels")
|
.WithTags("Admin.Channels")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", CreateChannel).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
|
admin.MapGet("", ListChannels).Produces<IReadOnlyList<ChannelSummaryDto>>();
|
||||||
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
|
admin.MapGet("/{id:guid}", GetChannel).Produces<ChannelDto>();
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/settings", UpdateSettings)
|
.MapPut("/{id:guid}/settings", UpdateSettings)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
.MapPost("/{id:guid}/bumper/templates", AddBumperTemplate)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
|
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
|
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
|
.MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
|
.MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapPut(
|
.MapPut(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
||||||
SetTemplateBackground
|
SetTemplateBackground
|
||||||
)
|
)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete(
|
.MapDelete(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
"/{id:guid}/bumper/templates/{templateId:guid}/background",
|
||||||
ClearTemplateBackground
|
ClearTemplateBackground
|
||||||
)
|
)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapGet(
|
admin.MapGet(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8",
|
||||||
PreviewPlaylist
|
PreviewPlaylist
|
||||||
);
|
);
|
||||||
admin.MapGet(
|
admin.MapGet(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
"/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}",
|
||||||
PreviewSegment
|
PreviewSegment
|
||||||
);
|
);
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
|
.MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut(
|
.MapPut(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
||||||
UpdateBumperVariant
|
UpdateBumperVariant
|
||||||
)
|
)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete(
|
.MapDelete(
|
||||||
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
"/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}",
|
||||||
RemoveBumperVariant
|
RemoveBumperVariant
|
||||||
)
|
)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapGet("/{id:guid}/schedule", GetSchedule)
|
.MapGet("/{id:guid}/schedule", GetSchedule)
|
||||||
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
.Produces<IReadOnlyList<ScheduleEntryDto>>();
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
.MapPut("/{id:guid}/viewer", UpdateViewerSettings)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
// «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации.
|
||||||
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces<EntryTraceDto>();
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateViewerSettings(
|
private static async Task<IResult> UpdateViewerSettings(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateViewerSettingsBody body,
|
UpdateViewerSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateViewerSettingsCommand(
|
new UpdateViewerSettingsCommand(
|
||||||
id,
|
id,
|
||||||
body.LogoImageId,
|
body.LogoImageId,
|
||||||
body.LogoCorner,
|
body.LogoCorner,
|
||||||
body.LogoOpacity,
|
body.LogoOpacity,
|
||||||
body.ShowClock,
|
body.ShowClock,
|
||||||
body.AnalogFilterStrength
|
body.AnalogFilterStrength
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetEntryTrace(
|
private static async Task<IResult> GetEntryTrace(
|
||||||
Guid entryId,
|
Guid entryId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
|
var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateChannel(
|
private static async Task<IResult> CreateChannel(
|
||||||
CreateChannelCommand command,
|
CreateChannelCommand command,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/channels/{result.Value}",
|
$"/api/admin/channels/{result.Value}",
|
||||||
new CreatedIdResponse(result.Value)
|
new CreatedIdResponse(result.Value)
|
||||||
)
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListChannels(
|
private static async Task<IResult> ListChannels(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
|
var result = await sender.Send(new ListChannelsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetChannel(
|
private static async Task<IResult> GetChannel(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
|
var result = await sender.Send(new GetChannelQuery(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateTime(
|
private static async Task<IResult> UpdateTime(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateChannelTimeBody body,
|
UpdateChannelTimeBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
|
new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
private static async Task<IResult> UpdateSettings(
|
||||||
Guid id,
|
Guid id,
|
||||||
UpdateChannelSettingsBody body,
|
UpdateChannelSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateChannelSettingsCommand(
|
new UpdateChannelSettingsCommand(
|
||||||
id,
|
id,
|
||||||
body.Name,
|
body.Name,
|
||||||
body.IsEnabled,
|
body.IsEnabled,
|
||||||
body.BumpersEnabled,
|
body.BumpersEnabled,
|
||||||
body.Bumper,
|
body.Bumper,
|
||||||
body.FillerAssetId
|
body.FillerAssetId
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSchedule(
|
private static async Task<IResult> GetSchedule(
|
||||||
Guid id,
|
Guid id,
|
||||||
DateTimeOffset? from,
|
DateTimeOffset? from,
|
||||||
DateTimeOffset? to,
|
DateTimeOffset? to,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
||||||
var toUtc = to ?? fromUtc.AddDays(1);
|
var toUtc = to ?? fromUtc.AddDays(1);
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new GetChannelScheduleQuery(id, fromUtc, toUtc),
|
new GetChannelScheduleQuery(id, fromUtc, toUtc),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
|
/// <summary>Номер канала и его время: смещение от UTC и начало вещательных суток.</summary>
|
||||||
public sealed record UpdateChannelTimeBody(
|
public sealed record UpdateChannelTimeBody(
|
||||||
int? Number,
|
int? Number,
|
||||||
int UtcOffsetMinutes,
|
int UtcOffsetMinutes,
|
||||||
TimeOnly DayStartTime
|
TimeOnly DayStartTime
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UpdateChannelSettingsBody(
|
public sealed record UpdateChannelSettingsBody(
|
||||||
string Name,
|
string Name,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool BumpersEnabled,
|
bool BumpersEnabled,
|
||||||
BumperSettingsInput Bumper,
|
BumperSettingsInput Bumper,
|
||||||
Guid? FillerAssetId
|
Guid? FillerAssetId
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
/// <summary>Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8).</summary>
|
||||||
public sealed record UpdateViewerSettingsBody(
|
public sealed record UpdateViewerSettingsBody(
|
||||||
Guid? LogoImageId,
|
Guid? LogoImageId,
|
||||||
LogoCorner LogoCorner,
|
LogoCorner LogoCorner,
|
||||||
double LogoOpacity,
|
double LogoOpacity,
|
||||||
bool ShowClock,
|
bool ShowClock,
|
||||||
double AnalogFilterStrength
|
double AnalogFilterStrength
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,216 +1,216 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Media;
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Application.Media.Delete;
|
using TeleWave.Application.Media.Delete;
|
||||||
using TeleWave.Application.Media.ListMedia;
|
using TeleWave.Application.Media.ListMedia;
|
||||||
using TeleWave.Application.Media.ManualInbox;
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Application.Media.Register;
|
using TeleWave.Application.Media.Register;
|
||||||
using TeleWave.Application.Media.Stats;
|
using TeleWave.Application.Media.Stats;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class MediaEndpoints
|
public static class MediaEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/media")
|
var admin = app.MapGroup("/api/admin/media")
|
||||||
.WithTags("Admin.Media")
|
.WithTags("Admin.Media")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", Upload).Produces<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||||
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||||
|
|
||||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||||
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<IResult> Upload(
|
private static async Task<IResult> Upload(
|
||||||
string fileName,
|
string fileName,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
IMediaStorage storage,
|
IMediaStorage storage,
|
||||||
IMediaProcessingQueue queue,
|
IMediaProcessingQueue queue,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
UploadLimits limits,
|
UploadLimits limits,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
title: MediaErrors.EmptyFileName.Code,
|
title: MediaErrors.EmptyFileName.Code,
|
||||||
detail: MediaErrors.EmptyFileName.Message,
|
detail: MediaErrors.EmptyFileName.Message,
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!MediaFormats.IsAllowed(fileName))
|
if (!MediaFormats.IsAllowed(fileName))
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
title: MediaErrors.UnsupportedFormat.Code,
|
title: MediaErrors.UnsupportedFormat.Code,
|
||||||
detail: MediaErrors.UnsupportedFormat.Message,
|
detail: MediaErrors.UnsupportedFormat.Message,
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
var contentLength = request.ContentLength ?? 0;
|
var contentLength = request.ContentLength ?? 0;
|
||||||
if (contentLength > limits.MaxUploadBytes)
|
if (contentLength > limits.MaxUploadBytes)
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
title: MediaErrors.FileTooLarge.Code,
|
title: MediaErrors.FileTooLarge.Code,
|
||||||
detail: MediaErrors.FileTooLarge.Message,
|
detail: MediaErrors.FileTooLarge.Message,
|
||||||
statusCode: StatusCodes.Status400BadRequest
|
statusCode: StatusCodes.Status400BadRequest
|
||||||
);
|
);
|
||||||
|
|
||||||
var free = storage.GetAvailableFreeSpaceBytes();
|
var free = storage.GetAvailableFreeSpaceBytes();
|
||||||
if (free - contentLength < limits.MinFreeSpaceBytes)
|
if (free - contentLength < limits.MinFreeSpaceBytes)
|
||||||
return Results.Problem(
|
return Results.Problem(
|
||||||
title: MediaErrors.InsufficientStorage.Code,
|
title: MediaErrors.InsufficientStorage.Code,
|
||||||
detail: MediaErrors.InsufficientStorage.Message,
|
detail: MediaErrors.InsufficientStorage.Message,
|
||||||
statusCode: StatusCodes.Status409Conflict
|
statusCode: StatusCodes.Status409Conflict
|
||||||
);
|
);
|
||||||
|
|
||||||
var extension = Path.GetExtension(fileName);
|
var extension = Path.GetExtension(fileName);
|
||||||
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken);
|
||||||
|
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
await storage.DeleteUploadAsync(token, cancellationToken);
|
await storage.DeleteUploadAsync(token, cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
queue.Enqueue(result.Value);
|
queue.Enqueue(result.Value);
|
||||||
return Results.Created(
|
return Results.Created(
|
||||||
$"/api/admin/media/{result.Value}",
|
$"/api/admin/media/{result.Value}",
|
||||||
new UploadMediaResponse(result.Value)
|
new UploadMediaResponse(result.Value)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> List(
|
private static async Task<IResult> List(
|
||||||
[AsParameters] ListMediaFilter filter,
|
[AsParameters] ListMediaFilter filter,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ListMediaAssetsQuery(
|
new ListMediaAssetsQuery(
|
||||||
filter.Page is > 0 ? filter.Page.Value : 1,
|
filter.Page is > 0 ? filter.Page.Value : 1,
|
||||||
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
filter.PageSize is > 0 ? filter.PageSize.Value : 20,
|
||||||
filter.Status ?? [],
|
filter.Status ?? [],
|
||||||
filter.Search,
|
filter.Search,
|
||||||
filter.Sort,
|
filter.Sort,
|
||||||
filter.Desc ?? false
|
filter.Desc ?? false
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Delete(
|
private static async Task<IResult> Delete(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListManual(
|
private static async Task<IResult> ListManual(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ImportManual(
|
private static async Task<IResult> ImportManual(
|
||||||
ImportManualInboxBody body,
|
ImportManualInboxBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ImportManualInboxCommand(body.Items, body.ShowId),
|
new ImportManualInboxCommand(body.Items, body.ShowId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
var baseUrl = $"/api/admin/media/{id}/preview/";
|
var baseUrl = $"/api/admin/media/{id}/preview/";
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
foreach (var line in File.ReadLines(indexPath))
|
foreach (var line in File.ReadLines(indexPath))
|
||||||
{
|
{
|
||||||
var trimmed = line.Trim();
|
var trimmed = line.Trim();
|
||||||
if (trimmed.Length == 0)
|
if (trimmed.Length == 0)
|
||||||
continue;
|
continue;
|
||||||
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
// Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL.
|
||||||
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed))
|
||||||
.Append('\n');
|
.Append('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
|
private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths)
|
||||||
{
|
{
|
||||||
if (!SegmentFiles.IsSegmentName(file))
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query
|
||||||
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые
|
||||||
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при
|
||||||
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
/// <c>AsParameters</c> обязательность определяется nullable-типом, а не значением по умолчанию.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ListMediaFilter(
|
public sealed record ListMediaFilter(
|
||||||
int? Page,
|
int? Page,
|
||||||
int? PageSize,
|
int? PageSize,
|
||||||
MediaAssetStatus[]? Status,
|
MediaAssetStatus[]? Status,
|
||||||
string? Search,
|
string? Search,
|
||||||
string? Sort,
|
string? Sort,
|
||||||
bool? Desc
|
bool? Desc
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record UploadMediaResponse(Guid Id);
|
public sealed record UploadMediaResponse(Guid Id);
|
||||||
|
|
||||||
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
public sealed record ImportManualInboxBody(IReadOnlyList<ManualImportItem> Items, Guid ShowId);
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Settings;
|
using TeleWave.Application.Settings;
|
||||||
using TeleWave.Application.Settings.GetSiteSettings;
|
using TeleWave.Application.Settings.GetSiteSettings;
|
||||||
using TeleWave.Application.Settings.UpdateSiteSettings;
|
using TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class SettingsEndpoints
|
public static class SettingsEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/settings")
|
var admin = app.MapGroup("/api/admin/settings")
|
||||||
.WithTags("Admin.Settings")
|
.WithTags("Admin.Settings")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
admin.MapGet("", GetSettings).Produces<SiteSettingsDto>();
|
||||||
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSettings(
|
private static async Task<IResult> GetSettings(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken);
|
||||||
return Results.Ok(settings);
|
return Results.Ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSettings(
|
private static async Task<IResult> UpdateSettings(
|
||||||
UpdateSiteSettingsBody body,
|
UpdateSiteSettingsBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateSiteSettingsCommand(
|
new UpdateSiteSettingsCommand(
|
||||||
body.RegistrationEnabled,
|
body.RegistrationEnabled,
|
||||||
body.PreferredAudioLanguages ?? "",
|
body.PreferredAudioLanguages ?? "",
|
||||||
body.ChannelNumbersEnabled
|
body.ChannelNumbersEnabled
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsBody(
|
public sealed record UpdateSiteSettingsBody(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string? PreferredAudioLanguages,
|
string? PreferredAudioLanguages,
|
||||||
bool ChannelNumbersEnabled
|
bool ChannelNumbersEnabled
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,186 +1,186 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Library;
|
using TeleWave.Application.Library;
|
||||||
using TeleWave.Application.Library.AddEpisode;
|
using TeleWave.Application.Library.AddEpisode;
|
||||||
using TeleWave.Application.Library.CreateShow;
|
using TeleWave.Application.Library.CreateShow;
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.GetShow;
|
||||||
using TeleWave.Application.Library.ListShows;
|
using TeleWave.Application.Library.ListShows;
|
||||||
using TeleWave.Application.Library.RemoveEpisode;
|
using TeleWave.Application.Library.RemoveEpisode;
|
||||||
using TeleWave.Application.Library.RenameShow;
|
using TeleWave.Application.Library.RenameShow;
|
||||||
using TeleWave.Application.Library.SetShowAudience;
|
using TeleWave.Application.Library.SetShowAudience;
|
||||||
using TeleWave.Application.Library.SetShowGenres;
|
using TeleWave.Application.Library.SetShowGenres;
|
||||||
using TeleWave.Application.Library.SetShowOriginalName;
|
using TeleWave.Application.Library.SetShowOriginalName;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class ShowEndpoints
|
public static class ShowEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin/shows")
|
var admin = app.MapGroup("/api/admin/shows")
|
||||||
.WithTags("Admin.Shows")
|
.WithTags("Admin.Shows")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
admin.MapPost("", CreateShow).Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
admin.MapGet("", ListShows).Produces<IReadOnlyList<ShowSummaryDto>>();
|
||||||
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
admin.MapGet("/{id:guid}", GetShow).Produces<ShowDto>();
|
||||||
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
.MapPut("/{id:guid}/original-name", SetOriginalName)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent);
|
||||||
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapPost("/{id:guid}/episodes", AddEpisode)
|
.MapPost("/{id:guid}/episodes", AddEpisode)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
|
.MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateShow(
|
private static async Task<IResult> CreateShow(
|
||||||
CreateShowCommand command,
|
CreateShowCommand command,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(command, cancellationToken);
|
var result = await sender.Send(command, cancellationToken);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/shows/{result.Value}",
|
$"/api/admin/shows/{result.Value}",
|
||||||
new CreatedIdResponse(result.Value)
|
new CreatedIdResponse(result.Value)
|
||||||
)
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListShows(
|
private static async Task<IResult> ListShows(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
Guid? genreId = null,
|
Guid? genreId = null,
|
||||||
bool interstitials = false
|
bool interstitials = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ListShowsQuery(genreId, interstitials),
|
new ListShowsQuery(genreId, interstitials),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetShow(
|
private static async Task<IResult> GetShow(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
|
var result = await sender.Send(new GetShowQuery(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Rename(
|
private static async Task<IResult> Rename(
|
||||||
Guid id,
|
Guid id,
|
||||||
RenameShowBody body,
|
RenameShowBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetOriginalName(
|
private static async Task<IResult> SetOriginalName(
|
||||||
Guid id,
|
Guid id,
|
||||||
SetShowOriginalNameBody body,
|
SetShowOriginalNameBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetShowOriginalNameCommand(id, body.OriginalName),
|
new SetShowOriginalNameCommand(id, body.OriginalName),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetAudience(
|
private static async Task<IResult> SetAudience(
|
||||||
Guid id,
|
Guid id,
|
||||||
SetShowAudienceBody body,
|
SetShowAudienceBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetShowAudienceCommand(id, body.Audience),
|
new SetShowAudienceCommand(id, body.Audience),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SetGenres(
|
private static async Task<IResult> SetGenres(
|
||||||
Guid id,
|
Guid id,
|
||||||
SetShowGenresBody body,
|
SetShowGenresBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
|
new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteShow(
|
private static async Task<IResult> DeleteShow(
|
||||||
Guid id,
|
Guid id,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
|
var result = await sender.Send(new DeleteShowCommand(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> AddEpisode(
|
private static async Task<IResult> AddEpisode(
|
||||||
Guid id,
|
Guid id,
|
||||||
AddEpisodeBody body,
|
AddEpisodeBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new AddEpisodeCommand(id, body.MediaAssetId),
|
new AddEpisodeCommand(id, body.MediaAssetId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
|
? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value))
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> RemoveEpisode(
|
private static async Task<IResult> RemoveEpisode(
|
||||||
Guid id,
|
Guid id,
|
||||||
Guid episodeId,
|
Guid episodeId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
public sealed record AddEpisodeBody(Guid MediaAssetId);
|
||||||
|
|
||||||
public sealed record RenameShowBody(string Name);
|
public sealed record RenameShowBody(string Name);
|
||||||
|
|
||||||
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
public sealed record SetShowOriginalNameBody(string? OriginalName);
|
||||||
|
|
||||||
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
|
/// <summary>Рейтинг шоу; null — снять проставленный.</summary>
|
||||||
public sealed record SetShowAudienceBody(ShowAudience? Audience);
|
public sealed record SetShowAudienceBody(ShowAudience? Audience);
|
||||||
|
|
||||||
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
|
public sealed record SetShowGenresBody(IReadOnlyList<Guid> GenreIds, Guid? PrimaryGenreId);
|
||||||
|
|||||||
@@ -1,191 +1,191 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Application.Streaming.GetLivePlaylist;
|
using TeleWave.Application.Streaming.GetLivePlaylist;
|
||||||
using TeleWave.Application.Streaming.GetPublicEpg;
|
using TeleWave.Application.Streaming.GetPublicEpg;
|
||||||
using TeleWave.Application.Streaming.ListPublicChannels;
|
using TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
using TeleWave.Infrastructure.Streaming;
|
using TeleWave.Infrastructure.Streaming;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
public static class StreamingEndpoints
|
public static class StreamingEndpoints
|
||||||
{
|
{
|
||||||
private const string StreamCookieName = "tw_stream";
|
private const string StreamCookieName = "tw_stream";
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
|
||||||
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
|
||||||
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
|
||||||
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
||||||
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
||||||
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
|
||||||
channels.MapGet("/{slug}/epg", Epg);
|
channels.MapGet("/{slug}/epg", Epg);
|
||||||
|
|
||||||
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
|
||||||
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
|
||||||
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ListChannels(
|
private static async Task<IResult> ListChannels(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
|
||||||
return Results.Ok(result);
|
return Results.Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ViewerFeatures(
|
private static async Task<IResult> ViewerFeatures(
|
||||||
ISiteSettings siteSettings,
|
ISiteSettings siteSettings,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
Results.Ok(
|
Results.Ok(
|
||||||
new ViewerFeaturesDto(
|
new ViewerFeaturesDto(
|
||||||
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
||||||
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
||||||
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IResult Watch(
|
private static IResult Watch(
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
StreamTokenService tokens,
|
StreamTokenService tokens,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
IHostEnvironment env
|
IHostEnvironment env
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (currentUser.UserId is not { } userId)
|
if (currentUser.UserId is not { } userId)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var (token, expiresAt) = tokens.Issue(userId);
|
var (token, expiresAt) = tokens.Issue(userId);
|
||||||
response.Cookies.Append(
|
response.Cookies.Append(
|
||||||
StreamCookieName,
|
StreamCookieName,
|
||||||
token,
|
token,
|
||||||
new CookieOptions
|
new CookieOptions
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
||||||
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
||||||
Secure = !env.IsDevelopment() || request.IsHttps,
|
Secure = !env.IsDevelopment() || request.IsHttps,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
Path = "/api",
|
Path = "/api",
|
||||||
Expires = expiresAt,
|
Expires = expiresAt,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> Epg(
|
private static async Task<IResult> Epg(
|
||||||
string slug,
|
string slug,
|
||||||
DateTimeOffset? from,
|
DateTimeOffset? from,
|
||||||
DateTimeOffset? to,
|
DateTimeOffset? to,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
var fromUtc = from ?? DateTimeOffset.UtcNow;
|
||||||
var toUtc = to ?? fromUtc.AddHours(12);
|
var toUtc = to ?? fromUtc.AddHours(12);
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
new GetPublicEpgQuery(slug, fromUtc, toUtc),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> LivePlaylist(
|
private static async Task<IResult> LivePlaylist(
|
||||||
string slug,
|
string slug,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
StreamTokenService tokens,
|
StreamTokenService tokens,
|
||||||
IIdentityService identity,
|
IIdentityService identity,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
||||||
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
||||||
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
if (tokens.Validate(request.Cookies[StreamCookieName]) is not { } userId)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
||||||
if (profile is null || profile.IsBlocked)
|
if (profile is null || profile.IsBlocked)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
|
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
if (result.Value.Segments.Count == 0)
|
if (result.Value.Segments.Count == 0)
|
||||||
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||||
|
|
||||||
response.Headers.CacheControl = "no-cache";
|
response.Headers.CacheControl = "no-cache";
|
||||||
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IResult Segment(
|
private static IResult Segment(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string file,
|
string file,
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
HttpResponse response,
|
HttpResponse response,
|
||||||
StreamTokenService tokens,
|
StreamTokenService tokens,
|
||||||
MediaPathResolver paths
|
MediaPathResolver paths
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
if (tokens.Validate(request.Cookies[StreamCookieName]) is null)
|
||||||
return Results.Unauthorized();
|
return Results.Unauthorized();
|
||||||
if (!SegmentFiles.IsSegmentName(file))
|
if (!SegmentFiles.IsSegmentName(file))
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Render(LivePlaylistDto playlist)
|
private static string Render(LivePlaylistDto playlist)
|
||||||
{
|
{
|
||||||
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.Append("#EXTM3U\n");
|
sb.Append("#EXTM3U\n");
|
||||||
sb.Append("#EXT-X-VERSION:3\n");
|
sb.Append("#EXT-X-VERSION:3\n");
|
||||||
sb.Append(
|
sb.Append(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
$"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n"
|
||||||
);
|
);
|
||||||
sb.Append(
|
sb.Append(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n"
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach (var segment in playlist.Segments)
|
foreach (var segment in playlist.Segments)
|
||||||
{
|
{
|
||||||
if (segment.Discontinuity)
|
if (segment.Discontinuity)
|
||||||
sb.Append("#EXT-X-DISCONTINUITY\n");
|
sb.Append("#EXT-X-DISCONTINUITY\n");
|
||||||
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
|
||||||
sb.Append(
|
sb.Append(
|
||||||
CultureInfo.InvariantCulture,
|
CultureInfo.InvariantCulture,
|
||||||
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
|
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
||||||
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|
||||||
|
|||||||
@@ -1,296 +1,296 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
using TeleWave.Application.Programming.Planning.ApplyTemplate;
|
||||||
using TeleWave.Application.Programming.Planning.Diff;
|
using TeleWave.Application.Programming.Planning.Diff;
|
||||||
using TeleWave.Application.Programming.Planning.Preview;
|
using TeleWave.Application.Programming.Planning.Preview;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.CreateSlot;
|
using TeleWave.Application.Programming.Templates.CreateSlot;
|
||||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
using TeleWave.Application.Programming.Templates.DeleteSlot;
|
||||||
using TeleWave.Application.Programming.Templates.GetTemplate;
|
using TeleWave.Application.Programming.Templates.GetTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.Layers;
|
using TeleWave.Application.Programming.Templates.Layers;
|
||||||
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
using TeleWave.Application.Programming.Templates.UpdateSlot;
|
||||||
using TeleWave.Application.Programming.Templates.Validate;
|
using TeleWave.Application.Programming.Templates.Validate;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Api.Endpoints;
|
namespace TeleWave.Api.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
|
/// Сетка канала: шаблон, слои, слоты. Правка ничего не двигает в эфире — она помечает шаблон
|
||||||
/// изменённым, а хвост пересобирается отдельной командой применения.
|
/// изменённым, а хвост пересобирается отдельной командой применения.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class TemplateEndpoints
|
public static class TemplateEndpoints
|
||||||
{
|
{
|
||||||
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var admin = app.MapGroup("/api/admin")
|
var admin = app.MapGroup("/api/admin")
|
||||||
.WithTags("Admin.Templates")
|
.WithTags("Admin.Templates")
|
||||||
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
.RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin));
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
|
.MapGet("/channels/{channelId:guid}/template", GetTemplate)
|
||||||
.Produces<ScheduleTemplateDto>();
|
.Produces<ScheduleTemplateDto>();
|
||||||
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
|
// Завести сетку каналу, у которого её нет (напр. пережившему снос старой ротации).
|
||||||
admin
|
admin
|
||||||
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
|
.MapPost("/channels/{channelId:guid}/template", CreateTemplate)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
|
.MapPut("/templates/{templateId:guid}", UpdateTemplate)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
|
// Применение правил к эфиру — отдельным действием: правка слотов эфир не двигает.
|
||||||
admin
|
admin
|
||||||
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
.MapPost("/channels/{channelId:guid}/template/apply", ApplyTemplate)
|
||||||
.Produces<ApplyResultDto>();
|
.Produces<ApplyResultDto>();
|
||||||
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
// Предпросмотр — тот же генератор, но без записи и без продвижения курсоров.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
.MapGet("/channels/{channelId:guid}/template/preview", PreviewTemplate)
|
||||||
.Produces<SchedulePreviewDto>();
|
.Produces<SchedulePreviewDto>();
|
||||||
// Проверки по правилам — только по шаблону, без прогона генератора.
|
// Проверки по правилам — только по шаблону, без прогона генератора.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
|
.MapGet("/channels/{channelId:guid}/template/issues", ValidateTemplate)
|
||||||
.Produces<IReadOnlyList<TemplateIssueDto>>();
|
.Produces<IReadOnlyList<TemplateIssueDto>>();
|
||||||
// Что изменится в эфире, если применить прямо сейчас.
|
// Что изменится в эфире, если применить прямо сейчас.
|
||||||
admin
|
admin
|
||||||
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
|
.MapGet("/channels/{channelId:guid}/template/diff", DiffTemplate)
|
||||||
.Produces<ScheduleDiffDto>();
|
.Produces<ScheduleDiffDto>();
|
||||||
admin
|
admin
|
||||||
.MapPost(
|
.MapPost(
|
||||||
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
|
"/channels/{channelId:guid}/template/copy-to/{targetChannelId:guid}",
|
||||||
CopyTemplate
|
CopyTemplate
|
||||||
)
|
)
|
||||||
.Produces<CopyTemplateResultDto>();
|
.Produces<CopyTemplateResultDto>();
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
.MapPost("/templates/{templateId:guid}/layers", CreateLayer)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin
|
admin
|
||||||
.MapPut("/layers/{layerId:guid}", UpdateLayer)
|
.MapPut("/layers/{layerId:guid}", UpdateLayer)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
|
.MapDelete("/layers/{layerId:guid}", DeleteLayer)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
admin
|
admin
|
||||||
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
|
.MapPost("/layers/{layerId:guid}/slots", CreateSlot)
|
||||||
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
.Produces<CreatedIdResponse>(StatusCodes.Status201Created);
|
||||||
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
|
admin.MapPut("/slots/{slotId:guid}", UpdateSlot).Produces(StatusCodes.Status204NoContent);
|
||||||
admin
|
admin
|
||||||
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
|
.MapDelete("/slots/{slotId:guid}", DeleteSlot)
|
||||||
.Produces(StatusCodes.Status204NoContent);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetTemplate(
|
private static async Task<IResult> GetTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
|
var result = await sender.Send(new GetChannelTemplateQuery(channelId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateTemplate(
|
private static async Task<IResult> CreateTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new CreateChannelTemplateCommand(channelId),
|
new CreateChannelTemplateCommand(channelId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/channels/{channelId}/template",
|
$"/api/admin/channels/{channelId}/template",
|
||||||
new CreatedIdResponse(result.Value)
|
new CreatedIdResponse(result.Value)
|
||||||
)
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ApplyTemplate(
|
private static async Task<IResult> ApplyTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new ApplyChannelTemplateCommand(channelId),
|
new ApplyChannelTemplateCommand(channelId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> PreviewTemplate(
|
private static async Task<IResult> PreviewTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int days = 1
|
int days = 1
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new PreviewScheduleQuery(channelId, days),
|
new PreviewScheduleQuery(channelId, days),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ValidateTemplate(
|
private static async Task<IResult> ValidateTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
|
var result = await sender.Send(new ValidateTemplateQuery(channelId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DiffTemplate(
|
private static async Task<IResult> DiffTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
|
var result = await sender.Send(new PreviewApplyDiffQuery(channelId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CopyTemplate(
|
private static async Task<IResult> CopyTemplate(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
Guid targetChannelId,
|
Guid targetChannelId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new CopyTemplateCommand(channelId, targetChannelId),
|
new CopyTemplateCommand(channelId, targetChannelId),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateTemplate(
|
private static async Task<IResult> UpdateTemplate(
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
UpdateTemplateBody body,
|
UpdateTemplateBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateTemplateCommand(
|
new UpdateTemplateCommand(
|
||||||
templateId,
|
templateId,
|
||||||
body.Name,
|
body.Name,
|
||||||
body.FallbackGroupId,
|
body.FallbackGroupId,
|
||||||
body.DefaultJunctionId,
|
body.DefaultJunctionId,
|
||||||
body.Rules
|
body.Rules
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateLayer(
|
private static async Task<IResult> CreateLayer(
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
CreateLayerBody body,
|
CreateLayerBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new CreateLayerCommand(templateId, body.Name, body.Priority),
|
new CreateLayerCommand(templateId, body.Name, body.Priority),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/layers/{result.Value}",
|
$"/api/admin/layers/{result.Value}",
|
||||||
new CreatedIdResponse(result.Value)
|
new CreatedIdResponse(result.Value)
|
||||||
)
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateLayer(
|
private static async Task<IResult> UpdateLayer(
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
UpdateLayerBody body,
|
UpdateLayerBody body,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(
|
var result = await sender.Send(
|
||||||
new UpdateLayerCommand(
|
new UpdateLayerCommand(
|
||||||
layerId,
|
layerId,
|
||||||
body.Name,
|
body.Name,
|
||||||
body.Priority,
|
body.Priority,
|
||||||
body.Applicability,
|
body.Applicability,
|
||||||
body.IsEnabled
|
body.IsEnabled
|
||||||
),
|
),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteLayer(
|
private static async Task<IResult> DeleteLayer(
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
|
var result = await sender.Send(new DeleteLayerCommand(layerId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> CreateSlot(
|
private static async Task<IResult> CreateSlot(
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
SlotInput input,
|
SlotInput input,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
|
var result = await sender.Send(new CreateSlotCommand(layerId, input), cancellationToken);
|
||||||
return result.IsSuccess
|
return result.IsSuccess
|
||||||
? Results.Created(
|
? Results.Created(
|
||||||
$"/api/admin/slots/{result.Value}",
|
$"/api/admin/slots/{result.Value}",
|
||||||
new CreatedIdResponse(result.Value)
|
new CreatedIdResponse(result.Value)
|
||||||
)
|
)
|
||||||
: result.ToHttpResult();
|
: result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> UpdateSlot(
|
private static async Task<IResult> UpdateSlot(
|
||||||
Guid slotId,
|
Guid slotId,
|
||||||
SlotInput input,
|
SlotInput input,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
|
var result = await sender.Send(new UpdateSlotCommand(slotId, input), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> DeleteSlot(
|
private static async Task<IResult> DeleteSlot(
|
||||||
Guid slotId,
|
Guid slotId,
|
||||||
ISender sender,
|
ISender sender,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
|
var result = await sender.Send(new DeleteSlotCommand(slotId), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record UpdateTemplateBody(
|
public sealed record UpdateTemplateBody(
|
||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId,
|
Guid? DefaultJunctionId,
|
||||||
PlanningRules? Rules
|
PlanningRules? Rules
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record CreateLayerBody(string Name, int Priority);
|
public sealed record CreateLayerBody(string Name, int Priority);
|
||||||
|
|
||||||
public sealed record UpdateLayerBody(
|
public sealed record UpdateLayerBody(
|
||||||
string Name,
|
string Name,
|
||||||
int Priority,
|
int Priority,
|
||||||
LayerApplicability? Applicability,
|
LayerApplicability? Applicability,
|
||||||
bool IsEnabled
|
bool IsEnabled
|
||||||
);
|
);
|
||||||
|
|||||||
+148
-148
@@ -1,148 +1,148 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.RateLimiting;
|
using System.Threading.RateLimiting;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using TeleWave.Api.Common;
|
using TeleWave.Api.Common;
|
||||||
using TeleWave.Api.Endpoints;
|
using TeleWave.Api.Endpoints;
|
||||||
using TeleWave.Application;
|
using TeleWave.Application;
|
||||||
using TeleWave.Infrastructure;
|
using TeleWave.Infrastructure;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
|
// Загрузка медиа стримится на диск; поднимаем лимит тела запроса Kestrel до максимума загрузки
|
||||||
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
|
// (иначе дефолтные ~30 МБ рубят большие файлы). Собственный контроль размера — в MediaEndpoints.
|
||||||
builder.WebHost.ConfigureKestrel(options =>
|
builder.WebHost.ConfigureKestrel(options =>
|
||||||
options.Limits.MaxRequestBodySize =
|
options.Limits.MaxRequestBodySize =
|
||||||
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
|
builder.Configuration.GetValue<long?>("Media:MaxUploadBytes") ?? 20L * 1024 * 1024 * 1024
|
||||||
);
|
);
|
||||||
|
|
||||||
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
// Структурное логирование (Serilog), конфигурация из appsettings/env.
|
||||||
builder.Services.AddSerilog(
|
builder.Services.AddSerilog(
|
||||||
(services, configuration) =>
|
(services, configuration) =>
|
||||||
configuration
|
configuration
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
.ReadFrom.Services(services)
|
.ReadFrom.Services(services)
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
);
|
);
|
||||||
|
|
||||||
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно
|
||||||
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя
|
||||||
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
|
// прокси. По умолчанию (без конфигурации) остаётся дефолт ASP.NET Core — доверие только loopback,
|
||||||
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
// а для прод-топологии прокси задаётся через ForwardedHeaders__KnownProxies/KnownNetworks (см. .env.example).
|
||||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||||
|
|
||||||
foreach (
|
foreach (
|
||||||
var proxy in builder
|
var proxy in builder
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
.Configuration.GetSection("ForwardedHeaders:KnownProxies")
|
||||||
.Get<string[]>()
|
.Get<string[]>()
|
||||||
?? []
|
?? []
|
||||||
)
|
)
|
||||||
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
options.KnownProxies.Add(IPAddress.Parse(proxy));
|
||||||
|
|
||||||
foreach (
|
foreach (
|
||||||
var network in builder
|
var network in builder
|
||||||
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
.Configuration.GetSection("ForwardedHeaders:KnownNetworks")
|
||||||
.Get<string[]>()
|
.Get<string[]>()
|
||||||
?? []
|
?? []
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var parts = network.Split('/');
|
var parts = network.Split('/');
|
||||||
options.KnownIPNetworks.Add(
|
options.KnownIPNetworks.Add(
|
||||||
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
builder.Services.AddSingleton<UploadLimits>();
|
builder.Services.AddSingleton<UploadLimits>();
|
||||||
builder.Services.AddApplication();
|
builder.Services.AddApplication();
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
var authPermitLimit = builder.Configuration.GetValue("RateLimiting:AuthPermitLimit", 20);
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
|
// Партиционируем по IP клиента (реальный адрес доступен после UseForwardedHeaders): единый
|
||||||
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
|
// непартиционированный лимит превращается в DoS — один клиент исчерпывает окно логина для всех.
|
||||||
options.AddPolicy(
|
options.AddPolicy(
|
||||||
RateLimiting.AuthPolicy,
|
RateLimiting.AuthPolicy,
|
||||||
httpContext =>
|
httpContext =>
|
||||||
RateLimitPartition.GetFixedWindowLimiter(
|
RateLimitPartition.GetFixedWindowLimiter(
|
||||||
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||||
_ => new FixedWindowRateLimiterOptions
|
_ => new FixedWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = authPermitLimit,
|
PermitLimit = authPermitLimit,
|
||||||
Window = TimeSpan.FromMinutes(1),
|
Window = TimeSpan.FromMinutes(1),
|
||||||
QueueLimit = 0,
|
QueueLimit = 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
|
// Энумы сериализуются строками, не числами — самодокументируемый JSON.
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())
|
||||||
);
|
);
|
||||||
|
|
||||||
builder.Services.AddProblemDetails();
|
builder.Services.AddProblemDetails();
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
builder.Services.AddHealthChecks().AddDbContextCheck<AppDbContext>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
// Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте.
|
||||||
await app.Services.ApplyMigrationsAsync();
|
await app.Services.ApplyMigrationsAsync();
|
||||||
await app.Services.SeedDataAsync();
|
await app.Services.SeedDataAsync();
|
||||||
|
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseExceptionHandler();
|
app.UseExceptionHandler();
|
||||||
|
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
|
// Схему/UI API публикуем не в проде (или явным флагом Api:EnableOpenApi=true) — чтобы в продакшене
|
||||||
// не раскрывать полную карту эндпоинтов без необходимости.
|
// не раскрывать полную карту эндпоинтов без необходимости.
|
||||||
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
|
if (app.Environment.IsDevelopment() || app.Configuration.GetValue("Api:EnableOpenApi", false))
|
||||||
{
|
{
|
||||||
app.MapOpenApi();
|
app.MapOpenApi();
|
||||||
app.MapScalarApiReference();
|
app.MapScalarApiReference();
|
||||||
}
|
}
|
||||||
|
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
app.MapAuthEndpoints();
|
app.MapAuthEndpoints();
|
||||||
app.MapRoleEndpoints();
|
app.MapRoleEndpoints();
|
||||||
app.MapAdminUserEndpoints();
|
app.MapAdminUserEndpoints();
|
||||||
app.MapMediaEndpoints();
|
app.MapMediaEndpoints();
|
||||||
app.MapShowEndpoints();
|
app.MapShowEndpoints();
|
||||||
app.MapGenreEndpoints();
|
app.MapGenreEndpoints();
|
||||||
app.MapInterstitialEndpoints();
|
app.MapInterstitialEndpoints();
|
||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
app.MapGroupEndpoints();
|
app.MapGroupEndpoints();
|
||||||
app.MapTemplateEndpoints();
|
app.MapTemplateEndpoints();
|
||||||
app.MapJunctionEndpoints();
|
app.MapJunctionEndpoints();
|
||||||
app.MapChannelEndpoints();
|
app.MapChannelEndpoints();
|
||||||
app.MapStreamingEndpoints();
|
app.MapStreamingEndpoints();
|
||||||
app.MapMaintenanceEndpoints();
|
app.MapMaintenanceEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapMetadataEndpoints();
|
app.MapMetadataEndpoints();
|
||||||
app.MapImageEndpoints();
|
app.MapImageEndpoints();
|
||||||
|
|
||||||
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
|
||||||
app.UseDefaultFiles();
|
app.UseDefaultFiles();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.MapFallbackToFile("index.html");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
// Раньше здесь объявлялся публичный partial-класс Program, чтобы WebApplicationTestFactory видела
|
||||||
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
// сгенерированный класс. В ASP.NET Core 10 он и так публичный (ASP0027), объявление стало лишним.
|
||||||
await app.RunAsync();
|
await app.RunAsync();
|
||||||
|
|||||||
+126
-126
@@ -1,126 +1,126 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.Bumpers;
|
namespace TeleWave.Application.Broadcast.Bumpers;
|
||||||
|
|
||||||
public sealed class RenderBumperPreviewCommandHandler(
|
public sealed class RenderBumperPreviewCommandHandler(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
IBumperRenderer renderer,
|
IBumperRenderer renderer,
|
||||||
IBumperTemplateStorage storage,
|
IBumperTemplateStorage storage,
|
||||||
IImageStore imageStore,
|
IImageStore imageStore,
|
||||||
IOptions<BumperOptions> bumperOptions,
|
IOptions<BumperOptions> bumperOptions,
|
||||||
IOptions<StreamingOptions> streamingOptions
|
IOptions<StreamingOptions> streamingOptions
|
||||||
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
) : ICommandHandler<RenderBumperPreviewCommand, Result>
|
||||||
{
|
{
|
||||||
private readonly BumperOptions _bumper = bumperOptions.Value;
|
private readonly BumperOptions _bumper = bumperOptions.Value;
|
||||||
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
private readonly int _segmentSeconds = Math.Max(1, streamingOptions.Value.SegmentSeconds);
|
||||||
|
|
||||||
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
/// <summary>Длительность заставки без загруженного звука (сек) — как в генераторе.</summary>
|
||||||
private const int DefaultBumperDurationSeconds = 8;
|
private const int DefaultBumperDurationSeconds = 8;
|
||||||
|
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
RenderBumperPreviewCommand query,
|
RenderBumperPreviewCommand query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.Include(c => c.BumperTemplates)
|
||||||
.ThenInclude(t => t.Variants)
|
.ThenInclude(t => t.Variants)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
return Result.Failure(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
var template = channel.BumperTemplates.FirstOrDefault(t => t.Id == query.TemplateId);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
return Result.Failure(ChannelErrors.BumperTemplateNotFound);
|
||||||
|
|
||||||
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
var (fromName, toName) = await SampleNamesAsync(channel, cancellationToken);
|
||||||
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
var backgroundPath = await ResolveBackgroundPathAsync(template, cancellationToken);
|
||||||
var seconds = template.AudioDurationSeconds is { } d and > 0
|
var seconds = template.AudioDurationSeconds is { } d and > 0
|
||||||
? d
|
? d
|
||||||
: DefaultBumperDurationSeconds;
|
: DefaultBumperDurationSeconds;
|
||||||
var aligned = (int)(
|
var aligned = (int)(
|
||||||
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
Math.Ceiling(Math.Max(_segmentSeconds, seconds) / _segmentSeconds) * _segmentSeconds
|
||||||
);
|
);
|
||||||
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
var audioPath = storage.AudioPath(template.Id, template.AudioExtension);
|
||||||
|
|
||||||
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
|
// Постер зависит от конкретного «следующего» шоу — в превью его не подставляем.
|
||||||
var inputs = new BumperSpecInputs(
|
var inputs = new BumperSpecInputs(
|
||||||
fromName,
|
fromName,
|
||||||
toName,
|
toName,
|
||||||
audioPath,
|
audioPath,
|
||||||
PosterAbsolutePath: null,
|
PosterAbsolutePath: null,
|
||||||
backgroundPath
|
backgroundPath
|
||||||
);
|
);
|
||||||
|
|
||||||
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
// Рендерим каждый подблок в свой ассет-превью (id по подблоку).
|
||||||
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
foreach (var variant in template.Variants.OrderBy(v => v.Position))
|
||||||
{
|
{
|
||||||
var spec = BumperSpecFactory.Build(
|
var spec = BumperSpecFactory.Build(
|
||||||
_bumper,
|
_bumper,
|
||||||
channel.BumperFont,
|
channel.BumperFont,
|
||||||
template,
|
template,
|
||||||
variant,
|
variant,
|
||||||
aligned,
|
aligned,
|
||||||
inputs
|
inputs
|
||||||
);
|
);
|
||||||
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
await renderer.RenderAsync(BumperPreview.AssetId(variant.Id), spec, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
/// <summary>Путь к фон-картинке блока в общем реестре или null, если она не привязана.</summary>
|
||||||
private async Task<string?> ResolveBackgroundPathAsync(
|
private async Task<string?> ResolveBackgroundPathAsync(
|
||||||
BumperTemplate template,
|
BumperTemplate template,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (template.BackgroundImageId is not { } imageId)
|
if (template.BackgroundImageId is not { } imageId)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var extension = await dbContext
|
var extension = await dbContext
|
||||||
.Images.AsNoTracking()
|
.Images.AsNoTracking()
|
||||||
.Where(i => i.Id == imageId)
|
.Where(i => i.Id == imageId)
|
||||||
.Select(i => i.FileExtension)
|
.Select(i => i.FileExtension)
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
return extension is null ? null : imageStore.ResolvePath(imageId, extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
/// Примерные названия «из/в» для превью. Берём шоу из групп, на которые ссылаются слоты канала:
|
||||||
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
/// так превью показывает реальные названия этого канала, а не случайные из библиотеки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<(string From, string To)> SampleNamesAsync(
|
private async Task<(string From, string To)> SampleNamesAsync(
|
||||||
Channel channel,
|
Channel channel,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var names = await (
|
var names = await (
|
||||||
from slot in dbContext.Slots.AsNoTracking()
|
from slot in dbContext.Slots.AsNoTracking()
|
||||||
join layer in dbContext.GridLayers.AsNoTracking() on slot.LayerId equals layer.Id
|
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 item in dbContext.GroupItems.AsNoTracking() on slot.GroupId equals item.GroupId
|
||||||
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
|
join show in dbContext.Shows.AsNoTracking() on item.ElementId equals show.Id
|
||||||
where
|
where
|
||||||
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
|
layer.TemplateId == channel.TemplateId && item.ElementKind == GroupElementKind.Show
|
||||||
select show.Name
|
select show.Name
|
||||||
)
|
)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Take(2)
|
.Take(2)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
names.ElementAtOrDefault(0) ?? "Первое шоу",
|
||||||
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
names.ElementAtOrDefault(1) ?? "Второе шоу"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast;
|
namespace TeleWave.Application.Broadcast;
|
||||||
|
|
||||||
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
public sealed record ChannelSummaryDto(Guid Id, string Name, string Slug, bool IsEnabled);
|
||||||
|
|
||||||
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
/// <summary>Общие для канала настройки ТВ-заставок (стиль/звук/текст — на блоках/подблоках).</summary>
|
||||||
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
|
public sealed record BumperSettingsDto(BumperFont Font, BumperSelection Selection);
|
||||||
|
|
||||||
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
/// <summary>Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока.</summary>
|
||||||
public sealed record BumperTextVariantDto(
|
public sealed record BumperTextVariantDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
int Position,
|
int Position,
|
||||||
string Name,
|
string Name,
|
||||||
BumperTextKind Kind,
|
BumperTextKind Kind,
|
||||||
string NowLabel,
|
string NowLabel,
|
||||||
string NextLabel,
|
string NextLabel,
|
||||||
string Line1,
|
string Line1,
|
||||||
string Line2,
|
string Line2,
|
||||||
BumperTrigger Trigger,
|
BumperTrigger Trigger,
|
||||||
int Weight
|
int Weight
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
/// <summary>Блок заставки: своё оформление + звук + подблоки. <see cref="AudioDurationSeconds"/> — длина звука (сек).</summary>
|
||||||
public sealed record BumperTemplateDto(
|
public sealed record BumperTemplateDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
int Position,
|
int Position,
|
||||||
bool IsDefault,
|
bool IsDefault,
|
||||||
string Name,
|
string Name,
|
||||||
string BackgroundColor,
|
string BackgroundColor,
|
||||||
string BackgroundColor2,
|
string BackgroundColor2,
|
||||||
string AccentColor,
|
string AccentColor,
|
||||||
string TextColor,
|
string TextColor,
|
||||||
Guid? BackgroundImageId,
|
Guid? BackgroundImageId,
|
||||||
bool HasAudio,
|
bool HasAudio,
|
||||||
double? AudioDurationSeconds,
|
double? AudioDurationSeconds,
|
||||||
IReadOnlyList<BumperTextVariantDto> Variants
|
IReadOnlyList<BumperTextVariantDto> Variants
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record ChannelDto(
|
public sealed record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
string Slug,
|
string Slug,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
int? Number,
|
int? Number,
|
||||||
int UtcOffsetMinutes,
|
int UtcOffsetMinutes,
|
||||||
TimeOnly DayStartTime,
|
TimeOnly DayStartTime,
|
||||||
Guid? TemplateId,
|
Guid? TemplateId,
|
||||||
bool BumpersEnabled,
|
bool BumpersEnabled,
|
||||||
BumperSettingsDto Bumper,
|
BumperSettingsDto Bumper,
|
||||||
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
IReadOnlyList<BumperTemplateDto> BumperTemplates,
|
||||||
Guid? FillerAssetId,
|
Guid? FillerAssetId,
|
||||||
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
/// <summary>Оверлеи и фильтр зрительской части — всё опционально (см. 6.8).</summary>
|
||||||
ViewerSettingsDto Viewer
|
ViewerSettingsDto Viewer
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
|
/// <summary>Как канал выглядит у зрителя: логотип-оверлей, часы, аналоговый фильтр.</summary>
|
||||||
public sealed record ViewerSettingsDto(
|
public sealed record ViewerSettingsDto(
|
||||||
Guid? LogoImageId,
|
Guid? LogoImageId,
|
||||||
LogoCorner LogoCorner,
|
LogoCorner LogoCorner,
|
||||||
double LogoOpacity,
|
double LogoOpacity,
|
||||||
bool ShowClock,
|
bool ShowClock,
|
||||||
double AnalogFilterStrength
|
double AnalogFilterStrength
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast;
|
namespace TeleWave.Application.Broadcast;
|
||||||
|
|
||||||
public static class ChannelErrors
|
public static class ChannelErrors
|
||||||
{
|
{
|
||||||
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
public static readonly Error NotFound = Error.NotFound("Channels.NotFound", "Канал не найден.");
|
||||||
|
|
||||||
public static readonly Error NumberTaken = Error.Conflict(
|
public static readonly Error NumberTaken = Error.Conflict(
|
||||||
"Channels.NumberTaken",
|
"Channels.NumberTaken",
|
||||||
"Канал с таким номером уже есть."
|
"Канал с таким номером уже есть."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error TemplateNotFound = Error.NotFound(
|
public static readonly Error TemplateNotFound = Error.NotFound(
|
||||||
"Channels.TemplateNotFound",
|
"Channels.TemplateNotFound",
|
||||||
"У канала нет шаблона сетки."
|
"У канала нет шаблона сетки."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error DuplicateSlug = Error.Conflict(
|
public static readonly Error DuplicateSlug = Error.Conflict(
|
||||||
"Channels.DuplicateSlug",
|
"Channels.DuplicateSlug",
|
||||||
"Канал с таким slug уже существует."
|
"Канал с таким slug уже существует."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
public static readonly Error BumperTemplateNotFound = Error.NotFound(
|
||||||
"Channels.BumperTemplateNotFound",
|
"Channels.BumperTemplateNotFound",
|
||||||
"Блок заставки не найден."
|
"Блок заставки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
public static readonly Error CannotRemoveDefaultBumperTemplate = Error.Validation(
|
||||||
"Channels.CannotRemoveDefaultBumperTemplate",
|
"Channels.CannotRemoveDefaultBumperTemplate",
|
||||||
"Дефолтный блок заставки удалить нельзя."
|
"Дефолтный блок заставки удалить нельзя."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
public static readonly Error BumperTextVariantNotFound = Error.NotFound(
|
||||||
"Channels.BumperTextVariantNotFound",
|
"Channels.BumperTextVariantNotFound",
|
||||||
"Подблок заставки не найден."
|
"Подблок заставки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
public static readonly Error CannotRemoveLastBumperTextVariant = Error.Validation(
|
||||||
"Channels.CannotRemoveLastBumperTextVariant",
|
"Channels.CannotRemoveLastBumperTextVariant",
|
||||||
"Нельзя удалить последний подблок — нужен хотя бы один."
|
"Нельзя удалить последний подблок — нужен хотя бы один."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error AssetNotFound = Error.NotFound(
|
public static readonly Error AssetNotFound = Error.NotFound(
|
||||||
"Channels.AssetNotFound",
|
"Channels.AssetNotFound",
|
||||||
"Медиа-ассет не найден."
|
"Медиа-ассет не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error InvalidBumperFile = Error.Validation(
|
public static readonly Error InvalidBumperFile = Error.Validation(
|
||||||
"Channels.InvalidBumperFile",
|
"Channels.InvalidBumperFile",
|
||||||
"Недопустимый файл заставки (формат или размер)."
|
"Недопустимый файл заставки (формат или размер)."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +1,82 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.GetChannel;
|
namespace TeleWave.Application.Broadcast.GetChannel;
|
||||||
|
|
||||||
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
public sealed class GetChannelQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
: IQueryHandler<GetChannelQuery, Result<ChannelDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ChannelDto>> Handle(
|
public async Task<Result<ChannelDto>> Handle(
|
||||||
GetChannelQuery query,
|
GetChannelQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
// Что и когда идёт в эфире, лежит в шаблоне сетки и запрашивается отдельно
|
||||||
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
// (GetChannelTemplateQuery) — здесь только собственные свойства канала.
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Include(c => c.BumperTemplates)
|
.Include(c => c.BumperTemplates)
|
||||||
.ThenInclude(t => t.Variants)
|
.ThenInclude(t => t.Variants)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.Id, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
return Result.Failure<ChannelDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var bumperTemplates = channel
|
var bumperTemplates = channel
|
||||||
.BumperTemplates.OrderBy(t => t.Position)
|
.BumperTemplates.OrderBy(t => t.Position)
|
||||||
.Select(t => new BumperTemplateDto(
|
.Select(t => new BumperTemplateDto(
|
||||||
t.Id,
|
t.Id,
|
||||||
t.Position,
|
t.Position,
|
||||||
t.IsDefault,
|
t.IsDefault,
|
||||||
t.Name,
|
t.Name,
|
||||||
t.BackgroundColor,
|
t.BackgroundColor,
|
||||||
t.BackgroundColor2,
|
t.BackgroundColor2,
|
||||||
t.AccentColor,
|
t.AccentColor,
|
||||||
t.TextColor,
|
t.TextColor,
|
||||||
t.BackgroundImageId,
|
t.BackgroundImageId,
|
||||||
t.AudioExtension is not null,
|
t.AudioExtension is not null,
|
||||||
t.AudioDurationSeconds,
|
t.AudioDurationSeconds,
|
||||||
t.Variants.OrderBy(v => v.Position)
|
t.Variants.OrderBy(v => v.Position)
|
||||||
.Select(v => new BumperTextVariantDto(
|
.Select(v => new BumperTextVariantDto(
|
||||||
v.Id,
|
v.Id,
|
||||||
v.Position,
|
v.Position,
|
||||||
v.Name,
|
v.Name,
|
||||||
v.Kind,
|
v.Kind,
|
||||||
v.NowLabel,
|
v.NowLabel,
|
||||||
v.NextLabel,
|
v.NextLabel,
|
||||||
v.Line1,
|
v.Line1,
|
||||||
v.Line2,
|
v.Line2,
|
||||||
v.Trigger,
|
v.Trigger,
|
||||||
v.Weight
|
v.Weight
|
||||||
))
|
))
|
||||||
.ToList()
|
.ToList()
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new ChannelDto(
|
new ChannelDto(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
channel.Name,
|
channel.Name,
|
||||||
channel.Slug,
|
channel.Slug,
|
||||||
channel.IsEnabled,
|
channel.IsEnabled,
|
||||||
channel.Number,
|
channel.Number,
|
||||||
channel.UtcOffsetMinutes,
|
channel.UtcOffsetMinutes,
|
||||||
channel.DayStartTime,
|
channel.DayStartTime,
|
||||||
channel.TemplateId,
|
channel.TemplateId,
|
||||||
channel.BumpersEnabled,
|
channel.BumpersEnabled,
|
||||||
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
new BumperSettingsDto(channel.BumperFont, channel.BumperSelection),
|
||||||
bumperTemplates,
|
bumperTemplates,
|
||||||
channel.FillerAssetId,
|
channel.FillerAssetId,
|
||||||
new ViewerSettingsDto(
|
new ViewerSettingsDto(
|
||||||
channel.LogoImageId,
|
channel.LogoImageId,
|
||||||
channel.LogoCorner,
|
channel.LogoCorner,
|
||||||
channel.LogoOpacity,
|
channel.LogoOpacity,
|
||||||
channel.ShowClock,
|
channel.ShowClock,
|
||||||
channel.AnalogFilterStrength
|
channel.AnalogFilterStrength
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
namespace TeleWave.Application.Broadcast.Scheduling;
|
namespace TeleWave.Application.Broadcast.Scheduling;
|
||||||
|
|
||||||
public sealed class SchedulerOptions
|
public sealed class SchedulerOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Scheduler";
|
public const string SectionName = "Scheduler";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
|
/// На сколько дней вперёд держать материализованное расписание. Неделя — часть замысла:
|
||||||
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
/// «знать, что мультики будут в субботу в 9:30» работает, только если программа известна заранее.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int HorizonDays { get; init; } = 7;
|
public int HorizonDays { get; init; } = 7;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
|
/// Сколько дней прошедшего расписания хранить. Должно покрывать самое долгое остывание среди правил
|
||||||
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
|
/// канала и самый глубокий повтор: история показов берётся из самой ленты, отдельного журнала нет.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int RetentionDays { get; init; } = 90;
|
public int RetentionDays { get; init; } = 90;
|
||||||
|
|
||||||
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
/// <summary>Период тика фонового планировщика, минуты.</summary>
|
||||||
public int TickMinutes { get; init; } = 30;
|
public int TickMinutes { get; init; } = 30;
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -1,18 +1,18 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
public sealed record UpdateChannelSettingsCommand(
|
public sealed record UpdateChannelSettingsCommand(
|
||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
string Name,
|
string Name,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool BumpersEnabled,
|
bool BumpersEnabled,
|
||||||
BumperSettingsInput Bumper,
|
BumperSettingsInput Bumper,
|
||||||
Guid? FillerAssetId
|
Guid? FillerAssetId
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|
||||||
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
|
/// <summary>Общие настройки ТВ-заставок канала (см. <c>Channel.UpdateBumperSettings</c>). Условия
|
||||||
/// показа сюда не входят — они задаются на элементе стыка.</summary>
|
/// показа сюда не входят — они задаются на элементе стыка.</summary>
|
||||||
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
|
public sealed record BumperSettingsInput(BumperFont Font, BumperSelection Selection);
|
||||||
|
|||||||
+42
-42
@@ -1,42 +1,42 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
: ICommandHandler<UpdateChannelSettingsCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateChannelSettingsCommand command,
|
UpdateChannelSettingsCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
var channel = await dbContext.Channels.FirstOrDefaultAsync(
|
||||||
c => c.Id == command.ChannelId,
|
c => c.Id == command.ChannelId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure(ChannelErrors.NotFound);
|
return Result.Failure(ChannelErrors.NotFound);
|
||||||
|
|
||||||
if (command.FillerAssetId is { } fillerId)
|
if (command.FillerAssetId is { } fillerId)
|
||||||
{
|
{
|
||||||
var exists = await dbContext.MediaAssets.AnyAsync(
|
var exists = await dbContext.MediaAssets.AnyAsync(
|
||||||
a => a.Id == fillerId,
|
a => a.Id == fillerId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (!exists)
|
if (!exists)
|
||||||
return Result.Failure(ChannelErrors.AssetNotFound);
|
return Result.Failure(ChannelErrors.AssetNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
channel.UpdateSettings(
|
channel.UpdateSettings(
|
||||||
command.Name,
|
command.Name,
|
||||||
command.IsEnabled,
|
command.IsEnabled,
|
||||||
command.BumpersEnabled,
|
command.BumpersEnabled,
|
||||||
command.FillerAssetId
|
command.FillerAssetId
|
||||||
);
|
);
|
||||||
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
channel.UpdateBumperSettings(command.Bumper.Font, command.Bumper.Selection);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-12
@@ -1,12 +1,12 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
namespace TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
|
|
||||||
public sealed class UpdateChannelSettingsCommandValidator
|
public sealed class UpdateChannelSettingsCommandValidator
|
||||||
: AbstractValidator<UpdateChannelSettingsCommand>
|
: AbstractValidator<UpdateChannelSettingsCommand>
|
||||||
{
|
{
|
||||||
public UpdateChannelSettingsCommandValidator()
|
public UpdateChannelSettingsCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,48 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using TeleWave.Domain.Auth;
|
using TeleWave.Domain.Auth;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
public interface IAppDbContext
|
public interface IAppDbContext
|
||||||
{
|
{
|
||||||
DbSet<RefreshToken> RefreshTokens { get; }
|
DbSet<RefreshToken> RefreshTokens { get; }
|
||||||
DbSet<MediaAsset> MediaAssets { get; }
|
DbSet<MediaAsset> MediaAssets { get; }
|
||||||
DbSet<Show> Shows { get; }
|
DbSet<Show> Shows { get; }
|
||||||
DbSet<Genre> Genres { get; }
|
DbSet<Genre> Genres { get; }
|
||||||
DbSet<GenreAlias> GenreAliases { get; }
|
DbSet<GenreAlias> GenreAliases { get; }
|
||||||
DbSet<ShowGenre> ShowGenres { get; }
|
DbSet<ShowGenre> ShowGenres { get; }
|
||||||
DbSet<Collection> Collections { get; }
|
DbSet<Collection> Collections { get; }
|
||||||
DbSet<CollectionItem> CollectionItems { get; }
|
DbSet<CollectionItem> CollectionItems { get; }
|
||||||
DbSet<Group> Groups { get; }
|
DbSet<Group> Groups { get; }
|
||||||
DbSet<GroupItem> GroupItems { get; }
|
DbSet<GroupItem> GroupItems { get; }
|
||||||
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
|
DbSet<ScheduleTemplate> ScheduleTemplates { get; }
|
||||||
DbSet<GridLayer> GridLayers { get; }
|
DbSet<GridLayer> GridLayers { get; }
|
||||||
DbSet<Slot> Slots { get; }
|
DbSet<Slot> Slots { get; }
|
||||||
DbSet<SlotState> SlotStates { get; }
|
DbSet<SlotState> SlotStates { get; }
|
||||||
DbSet<JunctionTemplate> JunctionTemplates { get; }
|
DbSet<JunctionTemplate> JunctionTemplates { get; }
|
||||||
DbSet<JunctionElement> JunctionElements { get; }
|
DbSet<JunctionElement> JunctionElements { get; }
|
||||||
DbSet<Channel> Channels { get; }
|
DbSet<Channel> Channels { get; }
|
||||||
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
DbSet<ScheduleEntry> ScheduleEntries { get; }
|
||||||
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
DbSet<BumperTextVariant> BumperTextVariants { get; }
|
||||||
DbSet<BumperAsset> BumperAssets { get; }
|
DbSet<BumperAsset> BumperAssets { get; }
|
||||||
DbSet<AppSetting> AppSettings { get; }
|
DbSet<AppSetting> AppSettings { get; }
|
||||||
DbSet<Image> Images { get; }
|
DbSet<Image> Images { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
/// <summary>Открывает явную транзакцию БД — для команд с несколькими операциями (в т.ч.
|
||||||
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
/// <c>ExecuteDelete</c> в обход change-tracker), которые должны быть атомарны.</summary>
|
||||||
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
/// <summary>Берёт транзакционную advisory-блокировку по каналу (снимается при коммите/откате).
|
||||||
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
/// Сериализует генерацию расписания одного канала между фоновым тиком и ручной перегенерацией.
|
||||||
/// Вызывать внутри открытой транзакции.</summary>
|
/// Вызывать внутри открытой транзакции.</summary>
|
||||||
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +1,63 @@
|
|||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
||||||
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IMediaStorage
|
public interface IMediaStorage
|
||||||
{
|
{
|
||||||
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
||||||
long GetAvailableFreeSpaceBytes();
|
long GetAvailableFreeSpaceBytes();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
||||||
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string> SaveUploadAsync(
|
Task<string> SaveUploadAsync(
|
||||||
Stream content,
|
Stream content,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
||||||
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Файл ручного inbox.</summary>
|
/// <summary>Файл ручного inbox.</summary>
|
||||||
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
|
/// <param name="RelativePath">Путь относительно manual/ — может содержать подкаталоги.</param>
|
||||||
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
|
public readonly record struct ManualInboxFile(string RelativePath, string Name, long SizeBytes);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
|
/// Что лежит в <c>manual/</c>, включая подкаталоги. <paramref name="max"/> ограничивает выдачу:
|
||||||
/// каталог наполняет человек, и он может оказаться большим.
|
/// каталог наполняет человек, и он может оказаться большим.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
|
IReadOnlyList<ManualInboxFile> ListManualInbox(int max);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
|
/// Убирает то, что осталось в <c>manual/</c> рядом с забранным файлом: спутники с тем же именем
|
||||||
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
|
/// и другим расширением (субтитры, nfo, обложка) и опустевший каталог. Другие видеофайлы
|
||||||
/// не трогает — рядом может лежать следующая серия.
|
/// не трогает — рядом может лежать следующая серия.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
|
Task CleanupManualLeftoversAsync(string relativePath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
|
/// Переносит исходник в <c>originals/{assetId}{ext}</c>. Каталог-источник определяется
|
||||||
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
|
/// <paramref name="source"/>: <c>uploads/</c>, <c>inbox/</c> либо <c>manual/</c>. Именно
|
||||||
/// переносит — файл из каталога-источника уходит.
|
/// переносит — файл из каталога-источника уходит.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task PromoteToOriginalAsync(
|
Task PromoteToOriginalAsync(
|
||||||
MediaSource source,
|
MediaSource source,
|
||||||
string sourceToken,
|
string sourceToken,
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
||||||
Task DeleteAssetArtifactsAsync(
|
Task DeleteAssetArtifactsAsync(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
namespace TeleWave.Application.Common.Interfaces;
|
namespace TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
/// <summary>Доступ к глобальным настройкам сайта (key-value), скрывающий хранилище от хендлеров.</summary>
|
||||||
public interface ISiteSettings
|
public interface ISiteSettings
|
||||||
{
|
{
|
||||||
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
/// <summary>Пишет значение в контекст (сохранение — за UnitOfWorkBehavior вызывающей команды).</summary>
|
||||||
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||||
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
/// (напр. «rus,eng»); пусто — без предпочтения.</summary>
|
||||||
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
Task SetPreferredAudioLanguagesAsync(string value, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
|
/// <summary>Разрешено ли переключение каналов по номерам (см. 6.8).</summary>
|
||||||
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
|
Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,89 +1,89 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Library.ListShows;
|
namespace TeleWave.Application.Library.ListShows;
|
||||||
|
|
||||||
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
public sealed class ListShowsQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
: IQueryHandler<ListShowsQuery, IReadOnlyList<ShowSummaryDto>>
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
public async Task<IReadOnlyList<ShowSummaryDto>> Handle(
|
||||||
ListShowsQuery query,
|
ListShowsQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var source = dbContext
|
var source = dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Include(s => s.Episodes)
|
.Include(s => s.Episodes)
|
||||||
.Include(s => s.Genres)
|
.Include(s => s.Genres)
|
||||||
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
|
// Серии и жанры — сиблинги: одним запросом это честное перемножение (сериал на 200 серий
|
||||||
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
|
// с тремя жанрами даёт 600 строк вместо 203). Разделяем.
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.Where(s =>
|
.Where(s =>
|
||||||
query.Interstitials
|
query.Interstitials
|
||||||
? s.Kind == ShowKind.Interstitial
|
? s.Kind == ShowKind.Interstitial
|
||||||
: s.Kind != ShowKind.Interstitial
|
: s.Kind != ShowKind.Interstitial
|
||||||
);
|
);
|
||||||
|
|
||||||
var filtered = query.GenreId is { } genreId
|
var filtered = query.GenreId is { } genreId
|
||||||
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
|
? source.Where(s => s.Genres.Any(g => g.GenreId == genreId))
|
||||||
: source;
|
: source;
|
||||||
|
|
||||||
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
var shows = await filtered.OrderBy(s => s.Name).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
// Названия только для основных жанров — в списке показывается один.
|
// Названия только для основных жанров — в списке показывается один.
|
||||||
var primaryIds = shows
|
var primaryIds = shows
|
||||||
.Select(s => s.PrimaryGenreId)
|
.Select(s => s.PrimaryGenreId)
|
||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var genreNames = await dbContext
|
var genreNames = await dbContext
|
||||||
.Genres.AsNoTracking()
|
.Genres.AsNoTracking()
|
||||||
.Where(g => primaryIds.Contains(g.Id))
|
.Where(g => primaryIds.Contains(g.Id))
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||||
|
|
||||||
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
// Имена ассетов нужны, чтобы распознать сезоны (номера в модели не хранятся).
|
||||||
var assetIds = shows
|
var assetIds = shows
|
||||||
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
.SelectMany(s => s.Episodes.Select(e => e.MediaAssetId))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var names = await dbContext
|
var names = await dbContext
|
||||||
.MediaAssets.AsNoTracking()
|
.MediaAssets.AsNoTracking()
|
||||||
.Where(a => assetIds.Contains(a.Id))
|
.Where(a => assetIds.Contains(a.Id))
|
||||||
.Select(a => new { a.Id, a.OriginalFileName })
|
.Select(a => new { a.Id, a.OriginalFileName })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken);
|
||||||
|
|
||||||
return shows
|
return shows
|
||||||
.Select(s =>
|
.Select(s =>
|
||||||
{
|
{
|
||||||
var seasons = s
|
var seasons = s
|
||||||
.Episodes.Select(e =>
|
.Episodes.Select(e =>
|
||||||
names.TryGetValue(e.MediaAssetId, out var n)
|
names.TryGetValue(e.MediaAssetId, out var n)
|
||||||
? EpisodeName.ParseSeason(n)
|
? EpisodeName.ParseSeason(n)
|
||||||
: null
|
: null
|
||||||
)
|
)
|
||||||
.Where(season => season is not null)
|
.Where(season => season is not null)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.Count();
|
.Count();
|
||||||
return new ShowSummaryDto(
|
return new ShowSummaryDto(
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Name,
|
s.Name,
|
||||||
s.OriginalName,
|
s.OriginalName,
|
||||||
s.Kind,
|
s.Kind,
|
||||||
s.Audience,
|
s.Audience,
|
||||||
s.Episodes.Count,
|
s.Episodes.Count,
|
||||||
seasons,
|
seasons,
|
||||||
s.Year,
|
s.Year,
|
||||||
s.PosterImageId is not null,
|
s.PosterImageId is not null,
|
||||||
s.CreatedAt,
|
s.CreatedAt,
|
||||||
s.PrimaryGenreId is { } primaryId
|
s.PrimaryGenreId is { } primaryId
|
||||||
&& genreNames.TryGetValue(primaryId, out var g)
|
&& genreNames.TryGetValue(primaryId, out var g)
|
||||||
? g
|
? g
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,237 +1,237 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Programming.Planning;
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Planning;
|
namespace TeleWave.Application.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
/// Разворачивает группы в последовательности единиц воспроизведения для планировщика: сериал —
|
||||||
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
/// в свои серии, коллекция — в части по порядку (сериал внутри коллекции тоже разворачивается),
|
||||||
/// фильм — в одну единицу.
|
/// фильм — в одну единицу.
|
||||||
///
|
///
|
||||||
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
/// В эфир попадают только готовые ассеты с известной длительностью: поставить в ленту то, что ещё
|
||||||
/// обрабатывается, значит получить дыру в раздаче.
|
/// обрабатывается, значит получить дыру в раздаче.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GroupExpander(IAppDbContext dbContext)
|
public sealed class GroupExpander(IAppDbContext dbContext)
|
||||||
{
|
{
|
||||||
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
/// <param name="historyFrom">С какого момента нужна история показов для потолка повторов;
|
||||||
/// null — история не нужна и не загружается.</param>
|
/// null — история не нужна и не загружается.</param>
|
||||||
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
public async Task<IReadOnlyDictionary<Guid, IReadOnlyList<PlanningElement>>> ExpandAsync(
|
||||||
IReadOnlyCollection<Guid> groupIds,
|
IReadOnlyCollection<Guid> groupIds,
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
DateTimeOffset? historyFrom = null
|
DateTimeOffset? historyFrom = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
var result = new Dictionary<Guid, IReadOnlyList<PlanningElement>>();
|
||||||
if (groupIds.Count == 0)
|
if (groupIds.Count == 0)
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
var groups = await dbContext
|
var groups = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Include(g => g.Items)
|
.Include(g => g.Items)
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var collectionIds = groups
|
var collectionIds = groups
|
||||||
.SelectMany(g => g.Items)
|
.SelectMany(g => g.Items)
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||||
.Select(i => i.ElementId)
|
.Select(i => i.ElementId)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var collectionParts = await dbContext
|
var collectionParts = await dbContext
|
||||||
.CollectionItems.AsNoTracking()
|
.CollectionItems.AsNoTracking()
|
||||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||||
.OrderBy(i => i.Position)
|
.OrderBy(i => i.Position)
|
||||||
.Select(i => new { i.CollectionId, i.ShowId })
|
.Select(i => new { i.CollectionId, i.ShowId })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var showIds = groups
|
var showIds = groups
|
||||||
.SelectMany(g => g.Items)
|
.SelectMany(g => g.Items)
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||||
.Select(i => i.ElementId)
|
.Select(i => i.ElementId)
|
||||||
.Concat(collectionParts.Select(p => p.ShowId))
|
.Concat(collectionParts.Select(p => p.ShowId))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var loaded = await dbContext
|
var loaded = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id))
|
.Where(s => showIds.Contains(s.Id))
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
{
|
{
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Audience,
|
s.Audience,
|
||||||
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
|
AssetIds = s.Episodes.OrderBy(e => e.Position).Select(e => e.MediaAssetId).ToList(),
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var shows = loaded.ToDictionary(s => s.Id, s => new ShowRow(s.Audience, s.AssetIds));
|
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 assetIds = shows.Values.SelectMany(s => s.AssetIds).Distinct().ToList();
|
||||||
var durations = await dbContext
|
var durations = await dbContext
|
||||||
.MediaAssets.AsNoTracking()
|
.MediaAssets.AsNoTracking()
|
||||||
.Where(a =>
|
.Where(a =>
|
||||||
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
assetIds.Contains(a.Id) && a.Status == MediaAssetStatus.Ready && a.Duration != null
|
||||||
)
|
)
|
||||||
.Select(a => new { a.Id, a.Duration })
|
.Select(a => new { a.Id, a.Duration })
|
||||||
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
.ToDictionaryAsync(a => a.Id, a => a.Duration!.Value, cancellationToken);
|
||||||
|
|
||||||
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
var lastPlayed = await LoadLastPlayedAsync(channelId, showIds, cancellationToken);
|
||||||
var recentPlays = await LoadRecentPlaysAsync(
|
var recentPlays = await LoadRecentPlaysAsync(
|
||||||
channelId,
|
channelId,
|
||||||
showIds,
|
showIds,
|
||||||
historyFrom,
|
historyFrom,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
var data = new ExpansionData(
|
var data = new ExpansionData(
|
||||||
shows,
|
shows,
|
||||||
durations,
|
durations,
|
||||||
lastPlayed,
|
lastPlayed,
|
||||||
recentPlays,
|
recentPlays,
|
||||||
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
collectionParts.ToLookup(p => p.CollectionId, p => p.ShowId)
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach (var group in groups)
|
foreach (var group in groups)
|
||||||
result[group.Id] = group
|
result[group.Id] = group
|
||||||
.Items.OrderBy(i => i.Position)
|
.Items.OrderBy(i => i.Position)
|
||||||
.Select(item =>
|
.Select(item =>
|
||||||
item.ElementKind == GroupElementKind.Show
|
item.ElementKind == GroupElementKind.Show
|
||||||
? data.ShowElement(item)
|
? data.ShowElement(item)
|
||||||
: data.CollectionElement(item)
|
: data.CollectionElement(item)
|
||||||
)
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
|
/// <summary>Шоу в виде, нужном развороту: рейтинг и ассеты серий в порядке показа.</summary>
|
||||||
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
|
private sealed record ShowRow(ShowAudience? Audience, IReadOnlyList<Guid> AssetIds);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
|
/// Справочники одного прогона. Сборка элементов вынесена сюда из <see cref="ExpandAsync"/>:
|
||||||
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
|
/// там уже была цепочка запросов, и вместе с двойным циклом метод читался только целиком.
|
||||||
/// В БД отсюда не ходят — всё уже загружено.
|
/// В БД отсюда не ходят — всё уже загружено.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed record ExpansionData(
|
private sealed record ExpansionData(
|
||||||
IReadOnlyDictionary<Guid, ShowRow> Shows,
|
IReadOnlyDictionary<Guid, ShowRow> Shows,
|
||||||
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
IReadOnlyDictionary<Guid, TimeSpan> Durations,
|
||||||
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
|
IReadOnlyDictionary<Guid, DateTimeOffset> LastPlayed,
|
||||||
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
|
IReadOnlyDictionary<Guid, IReadOnlyList<DateTimeOffset>> RecentPlays,
|
||||||
ILookup<Guid, Guid> CollectionParts
|
ILookup<Guid, Guid> CollectionParts
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public PlanningElement ShowElement(GroupItem item) =>
|
public PlanningElement ShowElement(GroupItem item) =>
|
||||||
new(
|
new(
|
||||||
item.ElementKind,
|
item.ElementKind,
|
||||||
item.ElementId,
|
item.ElementId,
|
||||||
item.Weight,
|
item.Weight,
|
||||||
item.Position,
|
item.Position,
|
||||||
UnitsOf(item.ElementId),
|
UnitsOf(item.ElementId),
|
||||||
LastPlayedOf(item.ElementId),
|
LastPlayedOf(item.ElementId),
|
||||||
AudienceOf(item.ElementId),
|
AudienceOf(item.ElementId),
|
||||||
RecentPlaysOf(item.ElementId)
|
RecentPlaysOf(item.ElementId)
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
|
/// Коллекция как один элемент: единицы всех частей по порядку; остывание — по самой свежей
|
||||||
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
/// части (показ любой означает, что франшиза недавно была в эфире); рейтинг — строжайший
|
||||||
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
/// (франшиза идёт целиком, и одна взрослая часть делает взрослой всю).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public PlanningElement CollectionElement(GroupItem item)
|
public PlanningElement CollectionElement(GroupItem item)
|
||||||
{
|
{
|
||||||
var parts = CollectionParts[item.ElementId].ToList();
|
var parts = CollectionParts[item.ElementId].ToList();
|
||||||
return new PlanningElement(
|
return new PlanningElement(
|
||||||
item.ElementKind,
|
item.ElementKind,
|
||||||
item.ElementId,
|
item.ElementId,
|
||||||
item.Weight,
|
item.Weight,
|
||||||
item.Position,
|
item.Position,
|
||||||
parts.SelectMany(UnitsOf).ToList(),
|
parts.SelectMany(UnitsOf).ToList(),
|
||||||
parts.Select(LastPlayedOf).Where(p => p is not null).DefaultIfEmpty(null).Max(),
|
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.Select(AudienceOf).Where(a => a is not null).DefaultIfEmpty(null).Max(),
|
||||||
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
|
parts.SelectMany(id => RecentPlaysOf(id) ?? []).ToList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
|
/// <summary>Единицы воспроизведения шоу: только серии с готовым ассетом, в порядке показа.</summary>
|
||||||
private List<PlanningUnit> UnitsOf(Guid showId) =>
|
private List<PlanningUnit> UnitsOf(Guid showId) =>
|
||||||
Shows.TryGetValue(showId, out var show)
|
Shows.TryGetValue(showId, out var show)
|
||||||
? show
|
? show
|
||||||
.AssetIds.Where(Durations.ContainsKey)
|
.AssetIds.Where(Durations.ContainsKey)
|
||||||
.Select(
|
.Select(
|
||||||
(assetId, index) =>
|
(assetId, index) =>
|
||||||
new PlanningUnit(assetId, Durations[assetId], showId, index)
|
new PlanningUnit(assetId, Durations[assetId], showId, index)
|
||||||
)
|
)
|
||||||
.ToList()
|
.ToList()
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
private DateTimeOffset? LastPlayedOf(Guid showId) =>
|
private DateTimeOffset? LastPlayedOf(Guid showId) =>
|
||||||
LastPlayed.TryGetValue(showId, out var at) ? at : null;
|
LastPlayed.TryGetValue(showId, out var at) ? at : null;
|
||||||
|
|
||||||
private ShowAudience? AudienceOf(Guid showId) =>
|
private ShowAudience? AudienceOf(Guid showId) =>
|
||||||
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
|
Shows.TryGetValue(showId, out var show) ? show.Audience : null;
|
||||||
|
|
||||||
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
|
private IReadOnlyList<DateTimeOffset>? RecentPlaysOf(Guid showId) =>
|
||||||
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
|
RecentPlays.TryGetValue(showId, out var plays) ? plays : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
/// Когда каждое шоу в последний раз выходило в этом канале. Источник — сама лента: отдельного
|
||||||
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
/// журнала показов нет, поэтому глубина хранения расписания должна покрывать максимальное
|
||||||
/// остывание среди правил.
|
/// остывание среди правил.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
private async Task<Dictionary<Guid, DateTimeOffset>> LoadLastPlayedAsync(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
IReadOnlyCollection<Guid> showIds,
|
IReadOnlyCollection<Guid> showIds,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
await dbContext
|
await dbContext
|
||||||
.ScheduleEntries.AsNoTracking()
|
.ScheduleEntries.AsNoTracking()
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
e.ChannelId == channelId
|
e.ChannelId == channelId
|
||||||
&& e.Kind == ScheduleEntryKind.Program
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
&& e.ShowId != null
|
&& e.ShowId != null
|
||||||
&& showIds.Contains(e.ShowId.Value)
|
&& showIds.Contains(e.ShowId.Value)
|
||||||
)
|
)
|
||||||
.GroupBy(e => e.ShowId!.Value)
|
.GroupBy(e => e.ShowId!.Value)
|
||||||
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
.Select(g => new { ShowId = g.Key, LastPlayed = g.Max(e => e.StartsAtUtc) })
|
||||||
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
.ToDictionaryAsync(x => x.ShowId, x => x.LastPlayed, cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
/// Старты показов за окно потолка повторов. Загружается только когда правило задано: без него
|
||||||
/// это лишние сотни строк на каждый прогон.
|
/// это лишние сотни строк на каждый прогон.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
private async Task<Dictionary<Guid, IReadOnlyList<DateTimeOffset>>> LoadRecentPlaysAsync(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
IReadOnlyCollection<Guid> showIds,
|
IReadOnlyCollection<Guid> showIds,
|
||||||
DateTimeOffset? from,
|
DateTimeOffset? from,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (from is not { } since || showIds.Count == 0)
|
if (from is not { } since || showIds.Count == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var plays = await dbContext
|
var plays = await dbContext
|
||||||
.ScheduleEntries.AsNoTracking()
|
.ScheduleEntries.AsNoTracking()
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
e.ChannelId == channelId
|
e.ChannelId == channelId
|
||||||
&& e.Kind == ScheduleEntryKind.Program
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
&& e.ShowId != null
|
&& e.ShowId != null
|
||||||
&& showIds.Contains(e.ShowId.Value)
|
&& showIds.Contains(e.ShowId.Value)
|
||||||
&& e.StartsAtUtc >= since
|
&& e.StartsAtUtc >= since
|
||||||
)
|
)
|
||||||
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
.Select(e => new { ShowId = e.ShowId!.Value, e.StartsAtUtc })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
return plays
|
return plays
|
||||||
.GroupBy(p => p.ShowId)
|
.GroupBy(p => p.ShowId)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
g => g.Key,
|
g => g.Key,
|
||||||
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
g => (IReadOnlyList<DateTimeOffset>)g.Select(p => p.StartsAtUtc).ToList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,39 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Programming.Planning;
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
/// Цепочка происхождения записи — «почему это здесь» (см. 6.5). Трейс пишется в момент генерации
|
||||||
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
/// и хранится в самой записи: восстановить его потом невозможно, состав групп и правила меняются.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
public sealed record GetEntryTraceQuery(Guid EntryId) : IQuery<Result<EntryTraceDto>>;
|
||||||
|
|
||||||
public sealed record EntryTraceDto(
|
public sealed record EntryTraceDto(
|
||||||
Guid EntryId,
|
Guid EntryId,
|
||||||
DateTimeOffset StartsAtUtc,
|
DateTimeOffset StartsAtUtc,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset EndsAtUtc,
|
||||||
string? ShowName,
|
string? ShowName,
|
||||||
int? EpisodeIndex,
|
int? EpisodeIndex,
|
||||||
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
/// <summary>Слой и слот, из которых выросла запись; null — трейс не писался (старая запись).</summary>
|
||||||
string? LayerName,
|
string? LayerName,
|
||||||
int? LayerPriority,
|
int? LayerPriority,
|
||||||
string? SlotTitle,
|
string? SlotTitle,
|
||||||
SlotKind? SlotKind,
|
SlotKind? SlotKind,
|
||||||
int? SlotWeekday,
|
int? SlotWeekday,
|
||||||
TimeOnly? SlotTargetStart,
|
TimeOnly? SlotTargetStart,
|
||||||
int? SlotDurationMinutes,
|
int? SlotDurationMinutes,
|
||||||
string? GroupName,
|
string? GroupName,
|
||||||
int? GroupItemCount,
|
int? GroupItemCount,
|
||||||
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
|
/// <summary>Коллекция, частью которой шла запись, — если в эфир шла франшиза, а не одиночное шоу.</summary>
|
||||||
string? CollectionName,
|
string? CollectionName,
|
||||||
SlotStrategyKind? Strategy,
|
SlotStrategyKind? Strategy,
|
||||||
int? CooldownDays,
|
int? CooldownDays,
|
||||||
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
/// <summary>Сколько кандидатов осталось после остывания (null — выбор шёл без него).</summary>
|
||||||
int? CandidatesAfterCooldown,
|
int? CandidatesAfterCooldown,
|
||||||
int DriftMinutes,
|
int DriftMinutes,
|
||||||
bool Snapped,
|
bool Snapped,
|
||||||
string? JunctionName
|
string? JunctionName
|
||||||
);
|
);
|
||||||
|
|||||||
+152
-152
@@ -1,152 +1,152 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Programming.Planning;
|
using TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Planning.Trace;
|
namespace TeleWave.Application.Programming.Planning.Trace;
|
||||||
|
|
||||||
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
public sealed class GetEntryTraceQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
: IQueryHandler<GetEntryTraceQuery, Result<EntryTraceDto>>
|
||||||
{
|
{
|
||||||
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
/// <summary>Те же настройки, что при записи трейса генератором.</summary>
|
||||||
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
private static readonly JsonSerializerOptions TraceJsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
public async Task<Result<EntryTraceDto>> Handle(
|
public async Task<Result<EntryTraceDto>> Handle(
|
||||||
GetEntryTraceQuery query,
|
GetEntryTraceQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var entry = await dbContext
|
var entry = await dbContext
|
||||||
.ScheduleEntries.AsNoTracking()
|
.ScheduleEntries.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
.FirstOrDefaultAsync(e => e.Id == query.EntryId, cancellationToken);
|
||||||
if (entry is null)
|
if (entry is null)
|
||||||
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
return Result.Failure<EntryTraceDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var showName = entry.ShowId is { } showId
|
var showName = entry.ShowId is { } showId
|
||||||
? await dbContext
|
? await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => s.Id == showId)
|
.Where(s => s.Id == showId)
|
||||||
.Select(s => s.Name)
|
.Select(s => s.Name)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var collectionName = entry.CollectionId is { } collectionId
|
var collectionName = entry.CollectionId is { } collectionId
|
||||||
? await dbContext
|
? await dbContext
|
||||||
.Collections.AsNoTracking()
|
.Collections.AsNoTracking()
|
||||||
.Where(c => c.Id == collectionId)
|
.Where(c => c.Id == collectionId)
|
||||||
.Select(c => c.Name)
|
.Select(c => c.Name)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var trace = Parse(entry.TraceJson);
|
var trace = Parse(entry.TraceJson);
|
||||||
if (trace is null)
|
if (trace is null)
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new EntryTraceDto(
|
new EntryTraceDto(
|
||||||
entry.Id,
|
entry.Id,
|
||||||
entry.StartsAtUtc,
|
entry.StartsAtUtc,
|
||||||
entry.EndsAtUtc,
|
entry.EndsAtUtc,
|
||||||
showName,
|
showName,
|
||||||
entry.EpisodeIndex,
|
entry.EpisodeIndex,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
collectionName,
|
collectionName,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
0,
|
0,
|
||||||
false,
|
false,
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
// Слот мог быть удалён или изменён после генерации — трейс от этого не портится, просто
|
||||||
// часть подписей окажется пустой.
|
// часть подписей окажется пустой.
|
||||||
var slot = trace.SlotId is { } slotId
|
var slot = trace.SlotId is { } slotId
|
||||||
? await dbContext
|
? await dbContext
|
||||||
.Slots.AsNoTracking()
|
.Slots.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
.FirstOrDefaultAsync(s => s.Id == slotId, cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
var layer = slot is null
|
var layer = slot is null
|
||||||
? null
|
? null
|
||||||
: await dbContext
|
: await dbContext
|
||||||
.GridLayers.AsNoTracking()
|
.GridLayers.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
.FirstOrDefaultAsync(l => l.Id == slot.LayerId, cancellationToken);
|
||||||
|
|
||||||
var group = slot?.GroupId is { } groupId
|
var group = slot?.GroupId is { } groupId
|
||||||
? await dbContext
|
? await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Where(g => g.Id == groupId)
|
.Where(g => g.Id == groupId)
|
||||||
.Select(g => new { g.Name, g.ItemCount })
|
.Select(g => new { g.Name, g.ItemCount })
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
var strategy = SlotStrategy.FromJson(slot?.StrategyJson);
|
||||||
|
|
||||||
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
var junctionId = slot?.JunctionAfterId ?? slot?.JunctionBetweenId;
|
||||||
var junctionName = junctionId is { } id
|
var junctionName = junctionId is { } id
|
||||||
? await dbContext
|
? await dbContext
|
||||||
.JunctionTemplates.AsNoTracking()
|
.JunctionTemplates.AsNoTracking()
|
||||||
.Where(j => j.Id == id)
|
.Where(j => j.Id == id)
|
||||||
.Select(j => j.Name)
|
.Select(j => j.Name)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new EntryTraceDto(
|
new EntryTraceDto(
|
||||||
entry.Id,
|
entry.Id,
|
||||||
entry.StartsAtUtc,
|
entry.StartsAtUtc,
|
||||||
entry.EndsAtUtc,
|
entry.EndsAtUtc,
|
||||||
showName,
|
showName,
|
||||||
entry.EpisodeIndex,
|
entry.EpisodeIndex,
|
||||||
layer?.Name,
|
layer?.Name,
|
||||||
layer?.Priority,
|
layer?.Priority,
|
||||||
slot?.Title,
|
slot?.Title,
|
||||||
trace.SlotKind,
|
trace.SlotKind,
|
||||||
slot?.Weekday,
|
slot?.Weekday,
|
||||||
slot?.TargetStart,
|
slot?.TargetStart,
|
||||||
slot?.TargetDurationMinutes,
|
slot?.TargetDurationMinutes,
|
||||||
group?.Name,
|
group?.Name,
|
||||||
group?.ItemCount,
|
group?.ItemCount,
|
||||||
collectionName,
|
collectionName,
|
||||||
trace.Strategy,
|
trace.Strategy,
|
||||||
strategy?.CooldownDays,
|
strategy?.CooldownDays,
|
||||||
trace.CandidatesAfterCooldown,
|
trace.CandidatesAfterCooldown,
|
||||||
trace.DriftMinutes,
|
trace.DriftMinutes,
|
||||||
trace.Snapped,
|
trace.Snapped,
|
||||||
junctionName
|
junctionName
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PlanTrace? Parse(string? json)
|
private static PlanTrace? Parse(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
return JsonSerializer.Deserialize<PlanTrace>(json, TraceJsonOptions);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+231
-231
@@ -1,231 +1,231 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
namespace TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
|
|
||||||
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
public sealed class CopyTemplateCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
: ICommandHandler<CopyTemplateCommand, Result<CopyTemplateResultDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<CopyTemplateResultDto>> Handle(
|
public async Task<Result<CopyTemplateResultDto>> Handle(
|
||||||
CopyTemplateCommand command,
|
CopyTemplateCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var target = await dbContext
|
var target = await dbContext
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
.Channels.Include(c => c.BumperTemplates)
|
||||||
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == command.TargetChannelId, cancellationToken);
|
||||||
if (target is null)
|
if (target is null)
|
||||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var source = await dbContext
|
var source = await dbContext
|
||||||
.ScheduleTemplates.AsNoTracking()
|
.ScheduleTemplates.AsNoTracking()
|
||||||
.Include(t => t.Layers)
|
.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
.FirstOrDefaultAsync(t => t.ChannelId == command.SourceChannelId, cancellationToken);
|
||||||
if (source is null)
|
if (source is null)
|
||||||
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
return Result.Failure<CopyTemplateResultDto>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
var sourceJunctions = await dbContext
|
var sourceJunctions = await dbContext
|
||||||
.JunctionTemplates.AsNoTracking()
|
.JunctionTemplates.AsNoTracking()
|
||||||
.Include(j => j.Elements)
|
.Include(j => j.Elements)
|
||||||
.Where(j => j.ChannelId == command.SourceChannelId)
|
.Where(j => j.ChannelId == command.SourceChannelId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
// Заставки живут на канале и на диске, поэтому не копируются: врезка ищет блок с таким же
|
||||||
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
// именем у приёмника, а не найдя — остаётся без ссылки, и это возвращается в отчёте.
|
||||||
var bumperByName = target
|
var bumperByName = target
|
||||||
.BumperTemplates.GroupBy(t => t.Name)
|
.BumperTemplates.GroupBy(t => t.Name)
|
||||||
.ToDictionary(g => g.Key, g => g.First().Id);
|
.ToDictionary(g => g.Key, g => g.First().Id);
|
||||||
var sourceBumperNames = await dbContext
|
var sourceBumperNames = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Where(c => c.Id == command.SourceChannelId)
|
.Where(c => c.Id == command.SourceChannelId)
|
||||||
.SelectMany(c => c.BumperTemplates)
|
.SelectMany(c => c.BumperTemplates)
|
||||||
.Select(t => new { t.Id, t.Name })
|
.Select(t => new { t.Id, t.Name })
|
||||||
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken);
|
||||||
|
|
||||||
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
var (junctionMap, droppedBumperRefs) = CopyJunctions(
|
||||||
sourceJunctions,
|
sourceJunctions,
|
||||||
target.Id,
|
target.Id,
|
||||||
sourceBumperNames,
|
sourceBumperNames,
|
||||||
bumperByName
|
bumperByName
|
||||||
);
|
);
|
||||||
|
|
||||||
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
// Прежняя сетка приёмника заменяется целиком: слить две сетки автоматически нельзя,
|
||||||
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
// а «добавить поверх» дало бы кашу из пересекающихся слотов.
|
||||||
var existing = await dbContext
|
var existing = await dbContext
|
||||||
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
.ScheduleTemplates.Where(t => t.ChannelId == target.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
dbContext.ScheduleTemplates.RemoveRange(existing);
|
dbContext.ScheduleTemplates.RemoveRange(existing);
|
||||||
await dbContext
|
await dbContext
|
||||||
.JunctionTemplates.Where(j =>
|
.JunctionTemplates.Where(j =>
|
||||||
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
j.ChannelId == target.Id && !junctionMap.Values.Contains(j.Id)
|
||||||
)
|
)
|
||||||
.ExecuteDeleteAsync(cancellationToken);
|
.ExecuteDeleteAsync(cancellationToken);
|
||||||
|
|
||||||
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
var copyTemplate = ScheduleTemplate.Create(target.Id, source.Name);
|
||||||
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
copyTemplate.SetFallbackGroup(source.FallbackGroupId);
|
||||||
copyTemplate.SetRules(source.RulesJson);
|
copyTemplate.SetRules(source.RulesJson);
|
||||||
if (
|
if (
|
||||||
source.DefaultJunctionId is { } defaultJunction
|
source.DefaultJunctionId is { } defaultJunction
|
||||||
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
&& junctionMap.TryGetValue(defaultJunction, out var mappedDefault)
|
||||||
)
|
)
|
||||||
copyTemplate.SetDefaultJunction(mappedDefault);
|
copyTemplate.SetDefaultJunction(mappedDefault);
|
||||||
|
|
||||||
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
var (layers, slots) = CopyGrid(source, copyTemplate, junctionMap);
|
||||||
|
|
||||||
dbContext.ScheduleTemplates.Add(copyTemplate);
|
dbContext.ScheduleTemplates.Add(copyTemplate);
|
||||||
target.SetTemplate(copyTemplate.Id);
|
target.SetTemplate(copyTemplate.Id);
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
new CopyTemplateResultDto(layers, slots, junctionMap.Count, droppedBumperRefs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
/// Копирует стыки на канал-приёмник. Возвращает соответствие «стык источника → копия» (по нему
|
||||||
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
/// потом перевешиваются ссылки слотов) и число врезок, потерявших ссылку на блок заставки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
private (Dictionary<Guid, Guid> Map, int DroppedBumperRefs) CopyJunctions(
|
||||||
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
IReadOnlyList<JunctionTemplate> sourceJunctions,
|
||||||
Guid targetChannelId,
|
Guid targetChannelId,
|
||||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||||
IReadOnlyDictionary<string, Guid> targetBumperByName
|
IReadOnlyDictionary<string, Guid> targetBumperByName
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<Guid, Guid>();
|
var map = new Dictionary<Guid, Guid>();
|
||||||
var dropped = 0;
|
var dropped = 0;
|
||||||
|
|
||||||
foreach (var junction in sourceJunctions)
|
foreach (var junction in sourceJunctions)
|
||||||
{
|
{
|
||||||
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
var copy = JunctionTemplate.Create(targetChannelId, junction.Name);
|
||||||
map[junction.Id] = copy.Id;
|
map[junction.Id] = copy.Id;
|
||||||
|
|
||||||
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
foreach (var element in junction.Elements.OrderBy(e => e.Position))
|
||||||
{
|
{
|
||||||
var bumperTemplateId = MapBumper(
|
var bumperTemplateId = MapBumper(
|
||||||
element,
|
element,
|
||||||
sourceBumperNames,
|
sourceBumperNames,
|
||||||
targetBumperByName,
|
targetBumperByName,
|
||||||
ref dropped
|
ref dropped
|
||||||
);
|
);
|
||||||
copy.AddElement(element.Kind)
|
copy.AddElement(element.Kind)
|
||||||
.Update(
|
.Update(
|
||||||
element.Kind,
|
element.Kind,
|
||||||
element.GroupId,
|
element.GroupId,
|
||||||
bumperTemplateId,
|
bumperTemplateId,
|
||||||
element.AmountMode,
|
element.AmountMode,
|
||||||
element.AmountValue,
|
element.AmountValue,
|
||||||
element.IsRequired,
|
element.IsRequired,
|
||||||
element.ConditionsJson
|
element.ConditionsJson
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
dbContext.JunctionTemplates.Add(copy);
|
dbContext.JunctionTemplates.Add(copy);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (map, dropped);
|
return (map, dropped);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
/// Блок заставки у приёмника, соответствующий блоку источника по имени. Заставки живут на канале
|
||||||
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
/// и на диске, поэтому не копируются: не нашлось одноимённого — врезка остаётся без ссылки,
|
||||||
/// и это попадает в отчёт.
|
/// и это попадает в отчёт.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static Guid? MapBumper(
|
private static Guid? MapBumper(
|
||||||
JunctionElement element,
|
JunctionElement element,
|
||||||
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
IReadOnlyDictionary<Guid, string> sourceBumperNames,
|
||||||
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
IReadOnlyDictionary<string, Guid> targetBumperByName,
|
||||||
ref int dropped
|
ref int dropped
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (element.Kind != JunctionElementKind.Bumper)
|
if (element.Kind != JunctionElementKind.Bumper)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
element.BumperTemplateId is { } sourceId
|
element.BumperTemplateId is { } sourceId
|
||||||
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
&& sourceBumperNames.TryGetValue(sourceId, out var name)
|
||||||
&& targetBumperByName.TryGetValue(name, out var mapped)
|
&& targetBumperByName.TryGetValue(name, out var mapped)
|
||||||
)
|
)
|
||||||
return mapped;
|
return mapped;
|
||||||
|
|
||||||
dropped++;
|
dropped++;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
/// <summary>Переносит слои со слотами. Возвращает, сколько слоёв (кроме фонового) и слотов скопировано.</summary>
|
||||||
private static (int Layers, int Slots) CopyGrid(
|
private static (int Layers, int Slots) CopyGrid(
|
||||||
ScheduleTemplate source,
|
ScheduleTemplate source,
|
||||||
ScheduleTemplate copyTemplate,
|
ScheduleTemplate copyTemplate,
|
||||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var layers = 0;
|
var layers = 0;
|
||||||
var slots = 0;
|
var slots = 0;
|
||||||
|
|
||||||
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
foreach (var layer in source.Layers.OrderByDescending(l => l.Priority))
|
||||||
{
|
{
|
||||||
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
// Фоновый слой у нового шаблона уже есть — в него переносим слоты, а не заводим второй.
|
||||||
var copyLayer = layer.IsBackground
|
var copyLayer = layer.IsBackground
|
||||||
? copyTemplate.Background!
|
? copyTemplate.Background!
|
||||||
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
: copyTemplate.AddLayer(layer.Name, layer.Priority);
|
||||||
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
copyLayer.Update(layer.Name, layer.Priority, layer.ApplicabilityJson, layer.IsEnabled);
|
||||||
if (!layer.IsBackground)
|
if (!layer.IsBackground)
|
||||||
layers++;
|
layers++;
|
||||||
|
|
||||||
foreach (var slot in layer.Slots)
|
foreach (var slot in layer.Slots)
|
||||||
{
|
{
|
||||||
CopySlot(slot, copyLayer, junctionMap);
|
CopySlot(slot, copyLayer, junctionMap);
|
||||||
slots++;
|
slots++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (layers, slots);
|
return (layers, slots);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void CopySlot(
|
private static void CopySlot(
|
||||||
Slot slot,
|
Slot slot,
|
||||||
GridLayer copyLayer,
|
GridLayer copyLayer,
|
||||||
IReadOnlyDictionary<Guid, Guid> junctionMap
|
IReadOnlyDictionary<Guid, Guid> junctionMap
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var copySlot = copyLayer.AddSlot(
|
var copySlot = copyLayer.AddSlot(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
slot.TargetStart,
|
slot.TargetStart,
|
||||||
slot.TargetDurationMinutes,
|
slot.TargetDurationMinutes,
|
||||||
slot.Daypart,
|
slot.Daypart,
|
||||||
slot.SlotKind,
|
slot.SlotKind,
|
||||||
slot.Weekday
|
slot.Weekday
|
||||||
);
|
);
|
||||||
copySlot.UpdateTiming(
|
copySlot.UpdateTiming(
|
||||||
slot.Weekday,
|
slot.Weekday,
|
||||||
slot.TargetStart,
|
slot.TargetStart,
|
||||||
slot.TargetDurationMinutes,
|
slot.TargetDurationMinutes,
|
||||||
slot.Daypart,
|
slot.Daypart,
|
||||||
slot.IsAnchor,
|
slot.IsAnchor,
|
||||||
slot.MaxDriftMinutes,
|
slot.MaxDriftMinutes,
|
||||||
slot.SnapToMinutes
|
slot.SnapToMinutes
|
||||||
);
|
);
|
||||||
copySlot.UpdateContent(
|
copySlot.UpdateContent(
|
||||||
new SlotContent(
|
new SlotContent(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
slot.SlotKind,
|
slot.SlotKind,
|
||||||
slot.GroupId,
|
slot.GroupId,
|
||||||
slot.StrategyJson,
|
slot.StrategyJson,
|
||||||
slot.RepeatSourceJson,
|
slot.RepeatSourceJson,
|
||||||
slot.BlockMode,
|
slot.BlockMode,
|
||||||
slot.BlockValue,
|
slot.BlockValue,
|
||||||
slot.OverflowPolicy,
|
slot.OverflowPolicy,
|
||||||
Map(slot.JunctionBetweenId, junctionMap),
|
Map(slot.JunctionBetweenId, junctionMap),
|
||||||
Map(slot.JunctionAfterId, junctionMap)
|
Map(slot.JunctionAfterId, junctionMap)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
private static Guid? Map(Guid? id, IReadOnlyDictionary<Guid, Guid> map) =>
|
||||||
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
id is { } value && map.TryGetValue(value, out var mapped) ? mapped : null;
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-102
@@ -1,102 +1,102 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
namespace TeleWave.Application.Programming.Templates.GetTemplate;
|
||||||
|
|
||||||
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
public sealed class GetChannelTemplateQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
|
: IQueryHandler<GetChannelTemplateQuery, Result<ScheduleTemplateDto>>
|
||||||
{
|
{
|
||||||
public async Task<Result<ScheduleTemplateDto>> Handle(
|
public async Task<Result<ScheduleTemplateDto>> Handle(
|
||||||
GetChannelTemplateQuery query,
|
GetChannelTemplateQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
|
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var template = await dbContext
|
var template = await dbContext
|
||||||
.ScheduleTemplates.AsNoTracking()
|
.ScheduleTemplates.AsNoTracking()
|
||||||
.Include(t => t.Layers)
|
.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
|
return Result.Failure<ScheduleTemplateDto>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
|
// Имена групп резолвим одним запросом: инспектор слота показывает их сразу, без второго обхода.
|
||||||
var groupIds = template
|
var groupIds = template
|
||||||
.Layers.SelectMany(l => l.Slots)
|
.Layers.SelectMany(l => l.Slots)
|
||||||
.Select(s => s.GroupId)
|
.Select(s => s.GroupId)
|
||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var groupNames = await dbContext
|
var groupNames = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
.ToDictionaryAsync(g => g.Id, g => g.Name, cancellationToken);
|
||||||
|
|
||||||
var layers = template
|
var layers = template
|
||||||
.Layers.OrderByDescending(l => l.Priority)
|
.Layers.OrderByDescending(l => l.Priority)
|
||||||
.Select(layer => new GridLayerDto(
|
.Select(layer => new GridLayerDto(
|
||||||
layer.Id,
|
layer.Id,
|
||||||
layer.Name,
|
layer.Name,
|
||||||
layer.Priority,
|
layer.Priority,
|
||||||
layer.IsEnabled,
|
layer.IsEnabled,
|
||||||
layer.IsBackground,
|
layer.IsBackground,
|
||||||
LayerApplicability.FromJson(layer.ApplicabilityJson),
|
LayerApplicability.FromJson(layer.ApplicabilityJson),
|
||||||
layer
|
layer
|
||||||
.Slots.OrderBy(s => s.Weekday ?? -1)
|
.Slots.OrderBy(s => s.Weekday ?? -1)
|
||||||
.ThenBy(s => s.TargetStart)
|
.ThenBy(s => s.TargetStart)
|
||||||
.Select(slot => new SlotDto(
|
.Select(slot => new SlotDto(
|
||||||
slot.Id,
|
slot.Id,
|
||||||
slot.LayerId,
|
slot.LayerId,
|
||||||
slot.Weekday,
|
slot.Weekday,
|
||||||
slot.TargetStart,
|
slot.TargetStart,
|
||||||
slot.TargetDurationMinutes,
|
slot.TargetDurationMinutes,
|
||||||
slot.Title,
|
slot.Title,
|
||||||
slot.Daypart,
|
slot.Daypart,
|
||||||
slot.SlotKind,
|
slot.SlotKind,
|
||||||
slot.GroupId,
|
slot.GroupId,
|
||||||
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
slot.GroupId is { } gid && groupNames.TryGetValue(gid, out var gname)
|
||||||
? gname
|
? gname
|
||||||
: null,
|
: null,
|
||||||
SlotStrategy.FromJson(slot.StrategyJson),
|
SlotStrategy.FromJson(slot.StrategyJson),
|
||||||
RepeatSource.FromJson(slot.RepeatSourceJson),
|
RepeatSource.FromJson(slot.RepeatSourceJson),
|
||||||
slot.BlockMode,
|
slot.BlockMode,
|
||||||
slot.BlockValue,
|
slot.BlockValue,
|
||||||
slot.OverflowPolicy,
|
slot.OverflowPolicy,
|
||||||
slot.IsAnchor,
|
slot.IsAnchor,
|
||||||
slot.MaxDriftMinutes,
|
slot.MaxDriftMinutes,
|
||||||
slot.SnapToMinutes,
|
slot.SnapToMinutes,
|
||||||
slot.JunctionBetweenId,
|
slot.JunctionBetweenId,
|
||||||
slot.JunctionAfterId
|
slot.JunctionAfterId
|
||||||
))
|
))
|
||||||
.ToList()
|
.ToList()
|
||||||
))
|
))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return Result.Success(
|
return Result.Success(
|
||||||
new ScheduleTemplateDto(
|
new ScheduleTemplateDto(
|
||||||
template.Id,
|
template.Id,
|
||||||
template.ChannelId,
|
template.ChannelId,
|
||||||
template.Name,
|
template.Name,
|
||||||
template.FallbackGroupId,
|
template.FallbackGroupId,
|
||||||
template.DefaultJunctionId,
|
template.DefaultJunctionId,
|
||||||
PlanningRules.FromJson(template.RulesJson),
|
PlanningRules.FromJson(template.RulesJson),
|
||||||
template.Revision,
|
template.Revision,
|
||||||
template.AppliedRevision,
|
template.AppliedRevision,
|
||||||
template.HasPendingChanges,
|
template.HasPendingChanges,
|
||||||
channel.UtcOffsetMinutes,
|
channel.UtcOffsetMinutes,
|
||||||
channel.DayStartTime,
|
channel.DayStartTime,
|
||||||
layers
|
layers
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-139
@@ -1,139 +1,139 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||||
|
|
||||||
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
|
public sealed class CreateLayerCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<CreateLayerCommand, Result<Guid>>
|
: ICommandHandler<CreateLayerCommand, Result<Guid>>
|
||||||
{
|
{
|
||||||
public async Task<Result<Guid>> Handle(
|
public async Task<Result<Guid>> Handle(
|
||||||
CreateLayerCommand command,
|
CreateLayerCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var template = await LayerLoader.ByTemplateAsync(
|
var template = await LayerLoader.ByTemplateAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
command.TemplateId,
|
command.TemplateId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<Guid>(TemplateErrors.NotFound);
|
return Result.Failure<Guid>(TemplateErrors.NotFound);
|
||||||
|
|
||||||
var layer = template.AddLayer(command.Name, command.Priority);
|
var layer = template.AddLayer(command.Name, command.Priority);
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
return Result.Success(layer.Id);
|
return Result.Success(layer.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
|
public sealed class UpdateLayerCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<UpdateLayerCommand, Result>
|
: ICommandHandler<UpdateLayerCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateLayerCommand command,
|
UpdateLayerCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var template = await LayerLoader.ByLayerAsync(
|
var template = await LayerLoader.ByLayerAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
command.LayerId,
|
command.LayerId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
var layer = template?.FindLayer(command.LayerId);
|
var layer = template?.FindLayer(command.LayerId);
|
||||||
if (template is null || layer is null)
|
if (template is null || layer is null)
|
||||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||||
|
|
||||||
layer.Update(
|
layer.Update(
|
||||||
command.Name,
|
command.Name,
|
||||||
command.Priority,
|
command.Priority,
|
||||||
command.Applicability?.ToJson(),
|
command.Applicability?.ToJson(),
|
||||||
command.IsEnabled
|
command.IsEnabled
|
||||||
);
|
);
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
public sealed class DeleteLayerCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<DeleteLayerCommand, Result>
|
: ICommandHandler<DeleteLayerCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
DeleteLayerCommand command,
|
DeleteLayerCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var template = await LayerLoader.ByLayerAsync(
|
var template = await LayerLoader.ByLayerAsync(
|
||||||
dbContext,
|
dbContext,
|
||||||
command.LayerId,
|
command.LayerId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
var layer = template?.FindLayer(command.LayerId);
|
var layer = template?.FindLayer(command.LayerId);
|
||||||
if (template is null || layer is null)
|
if (template is null || layer is null)
|
||||||
return Result.Failure(TemplateErrors.LayerNotFound);
|
return Result.Failure(TemplateErrors.LayerNotFound);
|
||||||
|
|
||||||
if (layer.IsBackground)
|
if (layer.IsBackground)
|
||||||
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
|
return Result.Failure(TemplateErrors.BackgroundLayerCannotBeDeleted);
|
||||||
|
|
||||||
template.RemoveLayer(command.LayerId);
|
template.RemoveLayer(command.LayerId);
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
public sealed class UpdateTemplateCommandHandler(IAppDbContext dbContext)
|
||||||
: ICommandHandler<UpdateTemplateCommand, Result>
|
: ICommandHandler<UpdateTemplateCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateTemplateCommand command,
|
UpdateTemplateCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
var template = await dbContext.ScheduleTemplates.FirstOrDefaultAsync(
|
||||||
t => t.Id == command.TemplateId,
|
t => t.Id == command.TemplateId,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure(TemplateErrors.NotFound);
|
return Result.Failure(TemplateErrors.NotFound);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
command.FallbackGroupId is { } groupId
|
command.FallbackGroupId is { } groupId
|
||||||
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
&& !await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken)
|
||||||
)
|
)
|
||||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||||
|
|
||||||
template.Rename(command.Name);
|
template.Rename(command.Name);
|
||||||
template.SetFallbackGroup(command.FallbackGroupId);
|
template.SetFallbackGroup(command.FallbackGroupId);
|
||||||
template.SetDefaultJunction(command.DefaultJunctionId);
|
template.SetDefaultJunction(command.DefaultJunctionId);
|
||||||
template.SetRules(command.Rules?.ToJson());
|
template.SetRules(command.Rules?.ToJson());
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
|
/// <summary>Загрузка шаблона со слоями и слотами: любая правка слоя меняет ревизию всего шаблона.</summary>
|
||||||
internal static class LayerLoader
|
internal static class LayerLoader
|
||||||
{
|
{
|
||||||
public static Task<ScheduleTemplate?> ByTemplateAsync(
|
public static Task<ScheduleTemplate?> ByTemplateAsync(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
Guid templateId,
|
Guid templateId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
dbContext
|
dbContext
|
||||||
.ScheduleTemplates.Include(t => t.Layers)
|
.ScheduleTemplates.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
.FirstOrDefaultAsync(t => t.Id == templateId, cancellationToken);
|
||||||
|
|
||||||
public static Task<ScheduleTemplate?> ByLayerAsync(
|
public static Task<ScheduleTemplate?> ByLayerAsync(
|
||||||
IAppDbContext dbContext,
|
IAppDbContext dbContext,
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
dbContext
|
dbContext
|
||||||
.ScheduleTemplates.Include(t => t.Layers)
|
.ScheduleTemplates.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,67 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Layers;
|
namespace TeleWave.Application.Programming.Templates.Layers;
|
||||||
|
|
||||||
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
|
public sealed record CreateLayerCommand(Guid TemplateId, string Name, int Priority)
|
||||||
: ICommand<Result<Guid>>;
|
: ICommand<Result<Guid>>;
|
||||||
|
|
||||||
public sealed record UpdateLayerCommand(
|
public sealed record UpdateLayerCommand(
|
||||||
Guid LayerId,
|
Guid LayerId,
|
||||||
string Name,
|
string Name,
|
||||||
int Priority,
|
int Priority,
|
||||||
LayerApplicability? Applicability,
|
LayerApplicability? Applicability,
|
||||||
bool IsEnabled
|
bool IsEnabled
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|
||||||
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
public sealed record DeleteLayerCommand(Guid LayerId) : ICommand<Result>;
|
||||||
|
|
||||||
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
/// <summary>Имя шаблона, аварийная группа, стык по умолчанию и правила отбора кандидатов.</summary>
|
||||||
public sealed record UpdateTemplateCommand(
|
public sealed record UpdateTemplateCommand(
|
||||||
Guid TemplateId,
|
Guid TemplateId,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId,
|
Guid? DefaultJunctionId,
|
||||||
PlanningRules? Rules
|
PlanningRules? Rules
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|
||||||
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
public sealed class CreateLayerCommandValidator : AbstractValidator<CreateLayerCommand>
|
||||||
{
|
{
|
||||||
public CreateLayerCommandValidator()
|
public CreateLayerCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
|
public sealed class UpdateLayerCommandValidator : AbstractValidator<UpdateLayerCommand>
|
||||||
{
|
{
|
||||||
public UpdateLayerCommandValidator()
|
public UpdateLayerCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(128);
|
||||||
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
RuleFor(x => x.Priority).InclusiveBetween(1, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
public sealed class UpdateTemplateCommandValidator : AbstractValidator<UpdateTemplateCommand>
|
||||||
{
|
{
|
||||||
public UpdateTemplateCommandValidator()
|
public UpdateTemplateCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||||
|
|
||||||
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
// Год — верхняя граница окна повторов: за ним правило перестаёт что-либо значить, а история
|
||||||
// всё равно ограничена глубиной хранения ленты.
|
// всё равно ограничена глубиной хранения ленты.
|
||||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.WindowDays)
|
||||||
.InclusiveBetween(1, 365)
|
.InclusiveBetween(1, 365)
|
||||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||||
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
RuleFor(x => x.Rules!.MaxRepeatsInWindow!.Max)
|
||||||
.InclusiveBetween(1, 1000)
|
.InclusiveBetween(1, 1000)
|
||||||
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
.When(x => x.Rules?.MaxRepeatsInWindow is not null);
|
||||||
|
|
||||||
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
RuleForEach(x => x.Rules!.MaxAudienceByTime)
|
||||||
.Must(window => window.From != window.To)
|
.Must(window => window.From != window.To)
|
||||||
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
.WithMessage("Окно нулевой длины ничего не ограничивает.")
|
||||||
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
.When(x => x.Rules?.MaxAudienceByTime is not null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,75 +1,75 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
/// Окно детского времени: с <paramref name="From"/> до <paramref name="To"/> во времени канала
|
||||||
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
/// в эфир идёт только контент не строже <paramref name="MaxAudience"/>. Окно может переходить
|
||||||
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
/// через полночь (23:00–06:00) — тогда границы сравниваются в обратную сторону.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
public sealed record AudienceWindow(TimeOnly From, TimeOnly To, ShowAudience MaxAudience)
|
||||||
{
|
{
|
||||||
public bool Contains(TimeOnly moment) =>
|
public bool Contains(TimeOnly moment) =>
|
||||||
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
From <= To ? moment >= From && moment < To : moment >= From || moment < To;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
/// Правила отбора кандидатов на уровне канала (см. 3.8). Это жёсткие фильтры: они отсекают
|
||||||
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
/// недопустимое до жребия, поэтому не требуют пересборки и не ломают воспроизводимость.
|
||||||
///
|
///
|
||||||
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
/// Область действия — весь канал: дейпарты сюда не заведены намеренно, окна детского времени
|
||||||
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
/// и так задаются временем, а потолок повторов осмыслен только целиком по каналу.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningRules(
|
public sealed record PlanningRules(
|
||||||
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
IReadOnlyList<AudienceWindow>? MaxAudienceByTime = null,
|
||||||
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
/// <summary>Не чаще <c>Max</c> раз за <c>WindowDays</c> суток (null — без ограничения).</summary>
|
||||||
RepeatLimitRule? MaxRepeatsInWindow = null,
|
RepeatLimitRule? MaxRepeatsInWindow = null,
|
||||||
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
// ── Пост-проверки: считаются по готовой ленте, дают предупреждения и ничего не переигрывают. ──
|
||||||
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
/// <summary>Потолок врезок в часе, минуты (null — не проверять).</summary>
|
||||||
int? MaxBreakMinutesPerHour = null,
|
int? MaxBreakMinutesPerHour = null,
|
||||||
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
/// <summary>Потолок доли одного жанра за вещательные сутки, проценты (null — не проверять).</summary>
|
||||||
int? MaxGenreSharePercent = null,
|
int? MaxGenreSharePercent = null,
|
||||||
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
/// <summary>Потолок доли эфира, отданной фону, проценты (null — не проверять).</summary>
|
||||||
int? MaxFallbackSharePercent = null
|
int? MaxFallbackSharePercent = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions Options = new()
|
private static readonly JsonSerializerOptions Options = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
Converters = { new JsonStringEnumConverter() },
|
Converters = { new JsonStringEnumConverter() },
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
/// <summary>Возрастной потолок в указанный момент времени канала (null — без ограничения).</summary>
|
||||||
public ShowAudience? AudienceAt(TimeOnly moment)
|
public ShowAudience? AudienceAt(TimeOnly moment)
|
||||||
{
|
{
|
||||||
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
if (MaxAudienceByTime is not { Count: > 0 } windows)
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
// Пересекающиеся окна разрешаются в пользу строгого: детское время не должно
|
||||||
// отменяться более широким окном, случайно наложенным сверху.
|
// отменяться более широким окном, случайно наложенным сверху.
|
||||||
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
|
var applicable = windows.Where(w => w.Contains(moment)).Select(w => w.MaxAudience).ToList();
|
||||||
return applicable.Count > 0 ? applicable.Min() : null;
|
return applicable.Count > 0 ? applicable.Min() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
public string ToJson() => JsonSerializer.Serialize(this, Options);
|
||||||
|
|
||||||
public static PlanningRules? FromJson(string? json)
|
public static PlanningRules? FromJson(string? json)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(json))
|
if (string.IsNullOrWhiteSpace(json))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
return JsonSerializer.Deserialize<PlanningRules>(json, Options);
|
||||||
}
|
}
|
||||||
catch (JsonException)
|
catch (JsonException)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
public sealed record RepeatLimitRule(int WindowDays, int Max);
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
|
/// <summary>Полный набор настроек слота — общий для создания и правки.</summary>
|
||||||
public sealed record SlotInput(
|
public sealed record SlotInput(
|
||||||
string Title,
|
string Title,
|
||||||
int? Weekday,
|
int? Weekday,
|
||||||
TimeOnly TargetStart,
|
TimeOnly TargetStart,
|
||||||
int TargetDurationMinutes,
|
int TargetDurationMinutes,
|
||||||
Daypart Daypart,
|
Daypart Daypart,
|
||||||
SlotKind SlotKind,
|
SlotKind SlotKind,
|
||||||
Guid? GroupId,
|
Guid? GroupId,
|
||||||
SlotStrategy? Strategy,
|
SlotStrategy? Strategy,
|
||||||
RepeatSource? RepeatSource,
|
RepeatSource? RepeatSource,
|
||||||
SlotBlockMode BlockMode,
|
SlotBlockMode BlockMode,
|
||||||
int BlockValue,
|
int BlockValue,
|
||||||
OverflowPolicy OverflowPolicy,
|
OverflowPolicy OverflowPolicy,
|
||||||
bool IsAnchor,
|
bool IsAnchor,
|
||||||
int MaxDriftMinutes,
|
int MaxDriftMinutes,
|
||||||
int? SnapToMinutes,
|
int? SnapToMinutes,
|
||||||
/// <summary>Стык между единицами внутри блока.</summary>
|
/// <summary>Стык между единицами внутри блока.</summary>
|
||||||
Guid? JunctionBetweenId = null,
|
Guid? JunctionBetweenId = null,
|
||||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||||
Guid? JunctionAfterId = null
|
Guid? JunctionAfterId = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
|
public sealed class SlotInputValidator : AbstractValidator<SlotInput>
|
||||||
{
|
{
|
||||||
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
|
/// <summary>Округлять старт можно только до значений, которые читаются как «круглое время».</summary>
|
||||||
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
|
private static readonly int[] AllowedSnap = [5, 10, 15, 30];
|
||||||
|
|
||||||
public SlotInputValidator()
|
public SlotInputValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
RuleFor(x => x.Title).NotEmpty().MaximumLength(256);
|
||||||
RuleFor(x => x.Weekday).InclusiveBetween(0, 6).When(x => x.Weekday is not null);
|
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.TargetDurationMinutes).InclusiveBetween(1, 24 * 60);
|
||||||
RuleFor(x => x.BlockValue).GreaterThan(0);
|
RuleFor(x => x.BlockValue).GreaterThan(0);
|
||||||
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
|
RuleFor(x => x.MaxDriftMinutes).InclusiveBetween(0, 12 * 60);
|
||||||
|
|
||||||
RuleFor(x => x.SnapToMinutes)
|
RuleFor(x => x.SnapToMinutes)
|
||||||
.Must(value => value is null || AllowedSnap.Contains(value.Value))
|
.Must(value => value is null || AllowedSnap.Contains(value.Value))
|
||||||
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
|
.WithMessage("Округление старта допустимо до 5, 10, 15 или 30 минут.");
|
||||||
|
|
||||||
RuleFor(x => x.Strategy!.CooldownDays)
|
RuleFor(x => x.Strategy!.CooldownDays)
|
||||||
.InclusiveBetween(0, 3650)
|
.InclusiveBetween(0, 3650)
|
||||||
.When(x => x.Strategy is not null);
|
.When(x => x.Strategy is not null);
|
||||||
|
|
||||||
RuleFor(x => x.RepeatSource!.DaysAgo)
|
RuleFor(x => x.RepeatSource!.DaysAgo)
|
||||||
.InclusiveBetween(1, 365)
|
.InclusiveBetween(1, 365)
|
||||||
.When(x => x.RepeatSource is not null);
|
.When(x => x.RepeatSource is not null);
|
||||||
RuleFor(x => x.RepeatSource!.DurationMinutes)
|
RuleFor(x => x.RepeatSource!.DurationMinutes)
|
||||||
.InclusiveBetween(1, 24 * 60)
|
.InclusiveBetween(1, 24 * 60)
|
||||||
.When(x => x.RepeatSource is not null);
|
.When(x => x.RepeatSource is not null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,110 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
/// Общая часть создания и правки слота: проверки, которые нельзя доверить валидатору, потому что
|
||||||
/// им нужны соседние слоты и справочник групп.
|
/// им нужны соседние слоты и справочник групп.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SlotWriter(IAppDbContext dbContext)
|
public sealed class SlotWriter(IAppDbContext dbContext)
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
/// Проверяет вход и применяет его к слоту. <paramref name="slot"/> = null — проверка перед
|
||||||
/// созданием.
|
/// созданием.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<Result> ApplyAsync(
|
public async Task<Result> ApplyAsync(
|
||||||
GridLayer layer,
|
GridLayer layer,
|
||||||
Slot? slot,
|
Slot? slot,
|
||||||
SlotInput input,
|
SlotInput input,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
|
// Перекрытие внутри слоя запрещено: два слота на одну минуту сделали бы выбор неоднозначным.
|
||||||
if (
|
if (
|
||||||
layer.HasOverlap(
|
layer.HasOverlap(
|
||||||
input.Weekday,
|
input.Weekday,
|
||||||
input.TargetStart,
|
input.TargetStart,
|
||||||
input.TargetDurationMinutes,
|
input.TargetDurationMinutes,
|
||||||
slot?.Id
|
slot?.Id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return Result.Failure(TemplateErrors.SlotsOverlap);
|
return Result.Failure(TemplateErrors.SlotsOverlap);
|
||||||
|
|
||||||
if (input.SlotKind == SlotKind.Content)
|
if (input.SlotKind == SlotKind.Content)
|
||||||
{
|
{
|
||||||
if (input.GroupId is not { } groupId)
|
if (input.GroupId is not { } groupId)
|
||||||
return Result.Failure(TemplateErrors.GroupRequired);
|
return Result.Failure(TemplateErrors.GroupRequired);
|
||||||
|
|
||||||
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
if (!await dbContext.Groups.AnyAsync(g => g.Id == groupId, cancellationToken))
|
||||||
return Result.Failure(TemplateErrors.GroupNotFound);
|
return Result.Failure(TemplateErrors.GroupNotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
if (input.SlotKind == SlotKind.Repeat && input.RepeatSource is null)
|
||||||
return Result.Failure(TemplateErrors.RepeatSourceRequired);
|
return Result.Failure(TemplateErrors.RepeatSourceRequired);
|
||||||
|
|
||||||
var target =
|
var target =
|
||||||
slot
|
slot
|
||||||
?? layer.AddSlot(
|
?? layer.AddSlot(
|
||||||
input.Title,
|
input.Title,
|
||||||
input.TargetStart,
|
input.TargetStart,
|
||||||
input.TargetDurationMinutes,
|
input.TargetDurationMinutes,
|
||||||
input.Daypart,
|
input.Daypart,
|
||||||
input.SlotKind,
|
input.SlotKind,
|
||||||
input.Weekday
|
input.Weekday
|
||||||
);
|
);
|
||||||
|
|
||||||
target.UpdateTiming(
|
target.UpdateTiming(
|
||||||
input.Weekday,
|
input.Weekday,
|
||||||
input.TargetStart,
|
input.TargetStart,
|
||||||
input.TargetDurationMinutes,
|
input.TargetDurationMinutes,
|
||||||
input.Daypart,
|
input.Daypart,
|
||||||
input.IsAnchor,
|
input.IsAnchor,
|
||||||
input.MaxDriftMinutes,
|
input.MaxDriftMinutes,
|
||||||
input.SnapToMinutes
|
input.SnapToMinutes
|
||||||
);
|
);
|
||||||
target.UpdateContent(
|
target.UpdateContent(
|
||||||
new SlotContent(
|
new SlotContent(
|
||||||
input.Title,
|
input.Title,
|
||||||
input.SlotKind,
|
input.SlotKind,
|
||||||
input.GroupId,
|
input.GroupId,
|
||||||
input.Strategy?.ToJson(),
|
input.Strategy?.ToJson(),
|
||||||
input.RepeatSource?.ToJson(),
|
input.RepeatSource?.ToJson(),
|
||||||
input.BlockMode,
|
input.BlockMode,
|
||||||
input.BlockValue,
|
input.BlockValue,
|
||||||
input.OverflowPolicy,
|
input.OverflowPolicy,
|
||||||
input.JunctionBetweenId,
|
input.JunctionBetweenId,
|
||||||
input.JunctionAfterId
|
input.JunctionAfterId
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
|
/// <summary>Загружает шаблон целиком по слою — правка слота меняет ревизию всего шаблона.</summary>
|
||||||
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
|
public Task<ScheduleTemplate?> LoadTemplateByLayerAsync(
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
dbContext
|
dbContext
|
||||||
.ScheduleTemplates.Include(t => t.Layers)
|
.ScheduleTemplates.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
.FirstOrDefaultAsync(t => t.Layers.Any(l => l.Id == layerId), cancellationToken);
|
||||||
|
|
||||||
/// <summary>Загружает шаблон по слоту.</summary>
|
/// <summary>Загружает шаблон по слоту.</summary>
|
||||||
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
|
public Task<ScheduleTemplate?> LoadTemplateBySlotAsync(
|
||||||
Guid slotId,
|
Guid slotId,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
dbContext
|
dbContext
|
||||||
.ScheduleTemplates.Include(t => t.Layers)
|
.ScheduleTemplates.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(
|
.FirstOrDefaultAsync(
|
||||||
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
|
t => t.Layers.Any(l => l.Slots.Any(s => s.Id == slotId)),
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
public sealed record SlotDto(
|
public sealed record SlotDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid LayerId,
|
Guid LayerId,
|
||||||
int? Weekday,
|
int? Weekday,
|
||||||
TimeOnly TargetStart,
|
TimeOnly TargetStart,
|
||||||
int TargetDurationMinutes,
|
int TargetDurationMinutes,
|
||||||
string Title,
|
string Title,
|
||||||
Daypart Daypart,
|
Daypart Daypart,
|
||||||
SlotKind SlotKind,
|
SlotKind SlotKind,
|
||||||
Guid? GroupId,
|
Guid? GroupId,
|
||||||
string? GroupName,
|
string? GroupName,
|
||||||
SlotStrategy? Strategy,
|
SlotStrategy? Strategy,
|
||||||
RepeatSource? RepeatSource,
|
RepeatSource? RepeatSource,
|
||||||
SlotBlockMode BlockMode,
|
SlotBlockMode BlockMode,
|
||||||
int BlockValue,
|
int BlockValue,
|
||||||
OverflowPolicy OverflowPolicy,
|
OverflowPolicy OverflowPolicy,
|
||||||
bool IsAnchor,
|
bool IsAnchor,
|
||||||
int MaxDriftMinutes,
|
int MaxDriftMinutes,
|
||||||
int? SnapToMinutes,
|
int? SnapToMinutes,
|
||||||
Guid? JunctionBetweenId,
|
Guid? JunctionBetweenId,
|
||||||
Guid? JunctionAfterId
|
Guid? JunctionAfterId
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record GridLayerDto(
|
public sealed record GridLayerDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Name,
|
string Name,
|
||||||
int Priority,
|
int Priority,
|
||||||
bool IsEnabled,
|
bool IsEnabled,
|
||||||
bool IsBackground,
|
bool IsBackground,
|
||||||
LayerApplicability? Applicability,
|
LayerApplicability? Applicability,
|
||||||
IReadOnlyList<SlotDto> Slots
|
IReadOnlyList<SlotDto> Slots
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record ScheduleTemplateDto(
|
public sealed record ScheduleTemplateDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
string Name,
|
string Name,
|
||||||
Guid? FallbackGroupId,
|
Guid? FallbackGroupId,
|
||||||
Guid? DefaultJunctionId,
|
Guid? DefaultJunctionId,
|
||||||
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
/// <summary>Правила отбора кандидатов канала: детское время и потолок повторов.</summary>
|
||||||
PlanningRules? Rules,
|
PlanningRules? Rules,
|
||||||
int Revision,
|
int Revision,
|
||||||
int AppliedRevision,
|
int AppliedRevision,
|
||||||
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
/// <summary>Есть ли правки правил, ещё не применённые к эфиру.</summary>
|
||||||
bool HasPendingChanges,
|
bool HasPendingChanges,
|
||||||
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
/// <summary>Время канала — сетка задаётся в нём, а не в UTC.</summary>
|
||||||
int UtcOffsetMinutes,
|
int UtcOffsetMinutes,
|
||||||
TimeOnly DayStartTime,
|
TimeOnly DayStartTime,
|
||||||
IReadOnlyList<GridLayerDto> Layers
|
IReadOnlyList<GridLayerDto> Layers
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates;
|
namespace TeleWave.Application.Programming.Templates;
|
||||||
|
|
||||||
public static class TemplateErrors
|
public static class TemplateErrors
|
||||||
{
|
{
|
||||||
public static readonly Error NotFound = Error.NotFound(
|
public static readonly Error NotFound = Error.NotFound(
|
||||||
"Templates.NotFound",
|
"Templates.NotFound",
|
||||||
"Шаблон сетки не найден."
|
"Шаблон сетки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error LayerNotFound = Error.NotFound(
|
public static readonly Error LayerNotFound = Error.NotFound(
|
||||||
"Templates.LayerNotFound",
|
"Templates.LayerNotFound",
|
||||||
"Слой сетки не найден."
|
"Слой сетки не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error SlotNotFound = Error.NotFound(
|
public static readonly Error SlotNotFound = Error.NotFound(
|
||||||
"Templates.SlotNotFound",
|
"Templates.SlotNotFound",
|
||||||
"Слот не найден."
|
"Слот не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
|
public static readonly Error BackgroundLayerCannotBeDeleted = Error.Validation(
|
||||||
"Templates.BackgroundLayerCannotBeDeleted",
|
"Templates.BackgroundLayerCannotBeDeleted",
|
||||||
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
|
"Фоновый слой удалить нельзя — без него в сетке появятся дыры."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error SlotsOverlap = Error.Conflict(
|
public static readonly Error SlotsOverlap = Error.Conflict(
|
||||||
"Templates.SlotsOverlap",
|
"Templates.SlotsOverlap",
|
||||||
"Слоты одного слоя пересекаются по времени."
|
"Слоты одного слоя пересекаются по времени."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error GroupRequired = Error.Validation(
|
public static readonly Error GroupRequired = Error.Validation(
|
||||||
"Templates.GroupRequired",
|
"Templates.GroupRequired",
|
||||||
"Слоту с контентом нужна группа."
|
"Слоту с контентом нужна группа."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error GroupNotFound = Error.NotFound(
|
public static readonly Error GroupNotFound = Error.NotFound(
|
||||||
"Templates.GroupNotFound",
|
"Templates.GroupNotFound",
|
||||||
"Группа не найдена."
|
"Группа не найдена."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error JunctionNotFound = Error.NotFound(
|
public static readonly Error JunctionNotFound = Error.NotFound(
|
||||||
"Templates.JunctionNotFound",
|
"Templates.JunctionNotFound",
|
||||||
"Шаблон стыка не найден."
|
"Шаблон стыка не найден."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error JunctionElementNotFound = Error.NotFound(
|
public static readonly Error JunctionElementNotFound = Error.NotFound(
|
||||||
"Templates.JunctionElementNotFound",
|
"Templates.JunctionElementNotFound",
|
||||||
"Врезка не найдена."
|
"Врезка не найдена."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error JunctionInUse = Error.Conflict(
|
public static readonly Error JunctionInUse = Error.Conflict(
|
||||||
"Templates.JunctionInUse",
|
"Templates.JunctionInUse",
|
||||||
"Стык используется слотами — сначала отвяжите его."
|
"Стык используется слотами — сначала отвяжите его."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error JunctionGroupRequired = Error.Validation(
|
public static readonly Error JunctionGroupRequired = Error.Validation(
|
||||||
"Templates.JunctionGroupRequired",
|
"Templates.JunctionGroupRequired",
|
||||||
"Врезке нужна группа, откуда брать ролики."
|
"Врезке нужна группа, откуда брать ролики."
|
||||||
);
|
);
|
||||||
|
|
||||||
public static readonly Error RepeatSourceRequired = Error.Validation(
|
public static readonly Error RepeatSourceRequired = Error.Validation(
|
||||||
"Templates.RepeatSourceRequired",
|
"Templates.RepeatSourceRequired",
|
||||||
"Слоту-повтору нужно указать, что повторять."
|
"Слоту-повтору нужно указать, что повторять."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+328
-328
@@ -1,328 +1,328 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
|
|
||||||
namespace TeleWave.Application.Programming.Templates.Validate;
|
namespace TeleWave.Application.Programming.Templates.Validate;
|
||||||
|
|
||||||
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
public sealed class ValidateTemplateQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
: IQueryHandler<ValidateTemplateQuery, Result<IReadOnlyList<TemplateIssueDto>>>
|
||||||
{
|
{
|
||||||
private const int MinutesInDay = 24 * 60;
|
private const int MinutesInDay = 24 * 60;
|
||||||
|
|
||||||
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
public async Task<Result<IReadOnlyList<TemplateIssueDto>>> Handle(
|
||||||
ValidateTemplateQuery query,
|
ValidateTemplateQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = await dbContext
|
var channel = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == query.ChannelId, cancellationToken);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.NotFound);
|
||||||
|
|
||||||
var template = await dbContext
|
var template = await dbContext
|
||||||
.ScheduleTemplates.AsNoTracking()
|
.ScheduleTemplates.AsNoTracking()
|
||||||
.Include(t => t.Layers)
|
.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.AsSplitQuery()
|
.AsSplitQuery()
|
||||||
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
.FirstOrDefaultAsync(t => t.ChannelId == channel.Id, cancellationToken);
|
||||||
if (template is null)
|
if (template is null)
|
||||||
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
return Result.Failure<IReadOnlyList<TemplateIssueDto>>(ChannelErrors.TemplateNotFound);
|
||||||
|
|
||||||
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
var layers = template.Layers.Where(l => l.IsEnabled).ToList();
|
||||||
var groupIds = layers
|
var groupIds = layers
|
||||||
.SelectMany(l => l.Slots)
|
.SelectMany(l => l.Slots)
|
||||||
.Select(s => s.GroupId)
|
.Select(s => s.GroupId)
|
||||||
.Where(id => id is not null)
|
.Where(id => id is not null)
|
||||||
.Select(id => id!.Value)
|
.Select(id => id!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var groups = await dbContext
|
var groups = await dbContext
|
||||||
.Groups.AsNoTracking()
|
.Groups.AsNoTracking()
|
||||||
.Where(g => groupIds.Contains(g.Id))
|
.Where(g => groupIds.Contains(g.Id))
|
||||||
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
.Select(g => new GroupStats(g.Id, g.Name, g.ItemCount))
|
||||||
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
.ToDictionaryAsync(g => g.Id, cancellationToken);
|
||||||
|
|
||||||
var rules = PlanningRules.FromJson(template.RulesJson);
|
var rules = PlanningRules.FromJson(template.RulesJson);
|
||||||
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
var strictest = await LoadStrictestAudienceAsync(groupIds, cancellationToken);
|
||||||
var dayStart = channel.DayStartTime;
|
var dayStart = channel.DayStartTime;
|
||||||
|
|
||||||
var issues = new List<TemplateIssueDto>();
|
var issues = new List<TemplateIssueDto>();
|
||||||
|
|
||||||
foreach (var layer in layers)
|
foreach (var layer in layers)
|
||||||
{
|
{
|
||||||
issues.AddRange(FindOverlaps(layer, dayStart));
|
issues.AddRange(FindOverlaps(layer, dayStart));
|
||||||
|
|
||||||
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
foreach (var slot in layer.Slots.Where(s => s.SlotKind == SlotKind.Content))
|
||||||
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
issues.AddRange(CheckSlot(layer, slot, groups, strictest, rules));
|
||||||
}
|
}
|
||||||
|
|
||||||
issues.AddRange(FindGaps(layers, dayStart));
|
issues.AddRange(FindGaps(layers, dayStart));
|
||||||
|
|
||||||
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
return Result.Success<IReadOnlyList<TemplateIssueDto>>(issues);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
private sealed record GroupStats(Guid Id, string Name, int ItemCount);
|
||||||
|
|
||||||
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
private static IEnumerable<TemplateIssueDto> CheckSlot(
|
||||||
GridLayer layer,
|
GridLayer layer,
|
||||||
Slot slot,
|
Slot slot,
|
||||||
IReadOnlyDictionary<Guid, GroupStats> groups,
|
IReadOnlyDictionary<Guid, GroupStats> groups,
|
||||||
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
IReadOnlyDictionary<Guid, ShowAudience> strictest,
|
||||||
PlanningRules? rules
|
PlanningRules? rules
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
if (slot.GroupId is not { } groupId || !groups.TryGetValue(groupId, out var group))
|
||||||
{
|
{
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupMissing,
|
TemplateIssueKind.GroupMissing,
|
||||||
TemplateIssueSeverity.Error,
|
TemplateIssueSeverity.Error,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
$"У слота «{slot.Title}» не выбрана группа — место закроет фон."
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (group.ItemCount == 0)
|
if (group.ItemCount == 0)
|
||||||
{
|
{
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupEmpty,
|
TemplateIssueKind.GroupEmpty,
|
||||||
TemplateIssueSeverity.Error,
|
TemplateIssueSeverity.Error,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
$"Группа «{group.Name}» пуста — слот «{slot.Title}» заполнит фон."
|
||||||
);
|
);
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
var perWeek = OccurrencesPerWeek(slot);
|
var perWeek = OccurrencesPerWeek(slot);
|
||||||
if (group.ItemCount < perWeek)
|
if (group.ItemCount < perWeek)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GroupTooSmall,
|
TemplateIssueKind.GroupTooSmall,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
$"В группе «{group.Name}» {group.ItemCount} позиций при {perWeek} выходах в неделю — "
|
||||||
+ "повторы пойдут чаще, чем раз в неделю."
|
+ "повторы пойдут чаще, чем раз в неделю."
|
||||||
);
|
);
|
||||||
|
|
||||||
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
// Остывание считается по числу выходов: за N дней слот выйдет N × (выходов в день) раз,
|
||||||
// и если это больше состава группы, отсекать будет некого.
|
// и если это больше состава группы, отсекать будет некого.
|
||||||
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
var strategy = SlotStrategy.FromJson(slot.StrategyJson);
|
||||||
if (
|
if (
|
||||||
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
strategy is { Type: SlotStrategyType.RandomWithCooldown, CooldownDays: > 0 }
|
||||||
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
&& strategy.CooldownDays * perWeek / 7.0 > group.ItemCount
|
||||||
)
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.CooldownUnreachable,
|
TemplateIssueKind.CooldownUnreachable,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
$"Остывание {strategy.CooldownDays} дней невыполнимо при {group.ItemCount} позициях "
|
||||||
+ $"в группе «{group.Name}»."
|
+ $"в группе «{group.Name}»."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
rules?.AudienceAt(slot.TargetStart) is { } maxAudience
|
||||||
&& strictest.TryGetValue(groupId, out var groupAudience)
|
&& strictest.TryGetValue(groupId, out var groupAudience)
|
||||||
&& groupAudience > maxAudience
|
&& groupAudience > maxAudience
|
||||||
)
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.AudienceConflict,
|
TemplateIssueKind.AudienceConflict,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
slot.Id,
|
slot.Id,
|
||||||
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
$"В группе «{group.Name}» есть контент категории «{groupAudience}», а слот "
|
||||||
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
+ $"«{slot.Title}» стоит во времени не строже «{maxAudience}»."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
/// <summary>Сколько раз слот выходит в неделю: без дня недели — каждый день.</summary>
|
||||||
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
private static int OccurrencesPerWeek(Slot slot) => slot.Weekday is null ? 7 : 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
/// Пересечения слотов внутри одного слоя. Внутри слоя приоритетов нет, поэтому сыграет первый
|
||||||
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
/// по времени, а второй молча пропадёт — это стоит показать до генерации.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
private static IEnumerable<TemplateIssueDto> FindOverlaps(GridLayer layer, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
var slots = layer.Slots.OrderBy(s => OffsetInDay(s.TargetStart, dayStart)).ToList();
|
||||||
|
|
||||||
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
|
// Внешний цикл в фигурных скобках намеренно: без них вложенный for выглядит как соседний,
|
||||||
// и «телом» внешнего цикла его читает только компилятор.
|
// и «телом» внешнего цикла его читает только компилятор.
|
||||||
for (var i = 0; i < slots.Count; i++)
|
for (var i = 0; i < slots.Count; i++)
|
||||||
{
|
{
|
||||||
for (var j = i + 1; j < slots.Count; j++)
|
for (var j = i + 1; j < slots.Count; j++)
|
||||||
{
|
{
|
||||||
var a = slots[i];
|
var a = slots[i];
|
||||||
var b = slots[j];
|
var b = slots[j];
|
||||||
if (!SameDays(a, b))
|
if (!SameDays(a, b))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
var aFrom = OffsetInDay(a.TargetStart, dayStart);
|
||||||
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
var bFrom = OffsetInDay(b.TargetStart, dayStart);
|
||||||
if (
|
if (
|
||||||
aFrom < bFrom + b.TargetDurationMinutes
|
aFrom < bFrom + b.TargetDurationMinutes
|
||||||
&& bFrom < aFrom + a.TargetDurationMinutes
|
&& bFrom < aFrom + a.TargetDurationMinutes
|
||||||
)
|
)
|
||||||
yield return new TemplateIssueDto(
|
yield return new TemplateIssueDto(
|
||||||
TemplateIssueKind.SlotOverlap,
|
TemplateIssueKind.SlotOverlap,
|
||||||
TemplateIssueSeverity.Warning,
|
TemplateIssueSeverity.Warning,
|
||||||
layer.Id,
|
layer.Id,
|
||||||
b.Id,
|
b.Id,
|
||||||
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
$"«{a.Title}» и «{b.Title}» пересекаются в слое «{layer.Name}»."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
/// <summary>Слот без дня недели идёт каждый день, поэтому пересекается с любым.</summary>
|
||||||
private static bool SameDays(Slot a, Slot b) =>
|
private static bool SameDays(Slot a, Slot b) =>
|
||||||
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
a.Weekday is null || b.Weekday is null || a.Weekday == b.Weekday;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
/// Интервалы вещательных суток, не покрытые ни одним слотом. Проверяется по каждому дню недели:
|
||||||
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
/// дыра во вторник ночью не видна, если смотреть на неделю целиком.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static IEnumerable<TemplateIssueDto> FindGaps(
|
private static IEnumerable<TemplateIssueDto> FindGaps(
|
||||||
IReadOnlyList<GridLayer> layers,
|
IReadOnlyList<GridLayer> layers,
|
||||||
TimeOnly dayStart
|
TimeOnly dayStart
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
foreach (var weekday in new[] { 1, 2, 3, 4, 5, 6, 0 })
|
||||||
{
|
{
|
||||||
var intervals = layers
|
var intervals = layers
|
||||||
.SelectMany(l => l.Slots)
|
.SelectMany(l => l.Slots)
|
||||||
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
.Where(s => s.Weekday is null || s.Weekday == weekday)
|
||||||
.Select(s =>
|
.Select(s =>
|
||||||
{
|
{
|
||||||
var from = OffsetInDay(s.TargetStart, dayStart);
|
var from = OffsetInDay(s.TargetStart, dayStart);
|
||||||
return (From: from, To: from + s.TargetDurationMinutes);
|
return (From: from, To: from + s.TargetDurationMinutes);
|
||||||
})
|
})
|
||||||
.OrderBy(i => i.From)
|
.OrderBy(i => i.From)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var cursor = 0;
|
var cursor = 0;
|
||||||
foreach (var interval in intervals)
|
foreach (var interval in intervals)
|
||||||
{
|
{
|
||||||
if (interval.From > cursor)
|
if (interval.From > cursor)
|
||||||
yield return Gap(weekday, cursor, interval.From, dayStart);
|
yield return Gap(weekday, cursor, interval.From, dayStart);
|
||||||
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
cursor = Math.Max(cursor, Math.Min(interval.To, MinutesInDay));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor < MinutesInDay)
|
if (cursor < MinutesInDay)
|
||||||
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
yield return Gap(weekday, cursor, MinutesInDay, dayStart);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
private static TemplateIssueDto Gap(int weekday, int from, int to, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
var day = new[] { "вс", "пн", "вт", "ср", "чт", "пт", "сб" }[weekday];
|
||||||
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
|
// Сутки целиком: «00:00–00:00» читалось бы как пустой интервал, а это ровно наоборот.
|
||||||
var interval =
|
var interval =
|
||||||
to - from >= MinutesInDay
|
to - from >= MinutesInDay
|
||||||
? "весь день"
|
? "весь день"
|
||||||
: $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";
|
: $"{Clock(from, dayStart)}–{Clock(to, dayStart)}";
|
||||||
|
|
||||||
return new TemplateIssueDto(
|
return new TemplateIssueDto(
|
||||||
TemplateIssueKind.GridGap,
|
TemplateIssueKind.GridGap,
|
||||||
TemplateIssueSeverity.Error,
|
TemplateIssueSeverity.Error,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$"Не покрыто: {day} {interval}."
|
$"Не покрыто: {day} {interval}."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
private static string Clock(int offsetMinutes, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
var minutes = ((int)dayStart.ToTimeSpan().TotalMinutes + offsetMinutes) % MinutesInDay;
|
||||||
return $"{minutes / 60:00}:{minutes % 60:00}";
|
return $"{minutes / 60:00}:{minutes % 60:00}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
private static int OffsetInDay(TimeOnly time, TimeOnly dayStart)
|
||||||
{
|
{
|
||||||
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
var diff = (int)(time.ToTimeSpan() - dayStart.ToTimeSpan()).TotalMinutes;
|
||||||
return diff >= 0 ? diff : diff + MinutesInDay;
|
return diff >= 0 ? diff : diff + MinutesInDay;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
|
/// Строжайший рейтинг среди позиций каждой группы: именно он конфликтует с детским временем.
|
||||||
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
/// Коллекция берётся по строжайшей части — франшиза идёт целиком. Шоу без рейтинга в расчёт не
|
||||||
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
/// идут: планировщик их не отсекает, значит и предупреждать не о чем.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
private async Task<Dictionary<Guid, ShowAudience>> LoadStrictestAudienceAsync(
|
||||||
IReadOnlyCollection<Guid> groupIds,
|
IReadOnlyCollection<Guid> groupIds,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (groupIds.Count == 0)
|
if (groupIds.Count == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var items = await dbContext
|
var items = await dbContext
|
||||||
.GroupItems.AsNoTracking()
|
.GroupItems.AsNoTracking()
|
||||||
.Where(i => groupIds.Contains(i.GroupId))
|
.Where(i => groupIds.Contains(i.GroupId))
|
||||||
.Select(i => new
|
.Select(i => new
|
||||||
{
|
{
|
||||||
i.GroupId,
|
i.GroupId,
|
||||||
i.ElementKind,
|
i.ElementKind,
|
||||||
i.ElementId,
|
i.ElementId,
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var showIds = items
|
var showIds = items
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Show)
|
.Where(i => i.ElementKind == GroupElementKind.Show)
|
||||||
.Select(i => i.ElementId)
|
.Select(i => i.ElementId)
|
||||||
.ToList();
|
.ToList();
|
||||||
var collectionIds = items
|
var collectionIds = items
|
||||||
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
.Where(i => i.ElementKind == GroupElementKind.Collection)
|
||||||
.Select(i => i.ElementId)
|
.Select(i => i.ElementId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var partsByCollection = await dbContext
|
var partsByCollection = await dbContext
|
||||||
.CollectionItems.AsNoTracking()
|
.CollectionItems.AsNoTracking()
|
||||||
.Where(i => collectionIds.Contains(i.CollectionId))
|
.Where(i => collectionIds.Contains(i.CollectionId))
|
||||||
.Select(i => new { i.CollectionId, i.ShowId })
|
.Select(i => new { i.CollectionId, i.ShowId })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
|
// Список id собираем до запроса: проекция по материализованной коллекции внутри дерева
|
||||||
// выражений заставляет EF пересобирать её на каждый вызов.
|
// выражений заставляет EF пересобирать её на каждый вызов.
|
||||||
var neededShowIds = showIds
|
var neededShowIds = showIds
|
||||||
.Concat(partsByCollection.Select(p => p.ShowId))
|
.Concat(partsByCollection.Select(p => p.ShowId))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var audiences = await dbContext
|
var audiences = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => neededShowIds.Contains(s.Id))
|
.Where(s => neededShowIds.Contains(s.Id))
|
||||||
.Select(s => new { s.Id, s.Audience })
|
.Select(s => new { s.Id, s.Audience })
|
||||||
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
.ToDictionaryAsync(s => s.Id, s => s.Audience, cancellationToken);
|
||||||
|
|
||||||
var result = new Dictionary<Guid, ShowAudience>();
|
var result = new Dictionary<Guid, ShowAudience>();
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
{
|
{
|
||||||
var candidates =
|
var candidates =
|
||||||
item.ElementKind == GroupElementKind.Show
|
item.ElementKind == GroupElementKind.Show
|
||||||
? [item.ElementId]
|
? [item.ElementId]
|
||||||
: partsByCollection
|
: partsByCollection
|
||||||
.Where(p => p.CollectionId == item.ElementId)
|
.Where(p => p.CollectionId == item.ElementId)
|
||||||
.Select(p => p.ShowId)
|
.Select(p => p.ShowId)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var showId in candidates)
|
foreach (var showId in candidates)
|
||||||
{
|
{
|
||||||
if (audiences.GetValueOrDefault(showId) is not { } audience)
|
if (audiences.GetValueOrDefault(showId) is not { } audience)
|
||||||
continue;
|
continue;
|
||||||
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
if (!result.TryGetValue(item.GroupId, out var current) || audience > current)
|
||||||
result[item.GroupId] = audience;
|
result[item.GroupId] = audience;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-19
@@ -1,19 +1,19 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.GetSiteSettings;
|
namespace TeleWave.Application.Settings.GetSiteSettings;
|
||||||
|
|
||||||
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
public sealed class GetSiteSettingsQueryHandler(ISiteSettings siteSettings)
|
||||||
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
: IQueryHandler<GetSiteSettingsQuery, SiteSettingsDto>
|
||||||
{
|
{
|
||||||
public async Task<SiteSettingsDto> Handle(
|
public async Task<SiteSettingsDto> Handle(
|
||||||
GetSiteSettingsQuery query,
|
GetSiteSettingsQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
var registrationEnabled = await siteSettings.IsRegistrationEnabledAsync(cancellationToken);
|
||||||
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
var preferredAudio = await siteSettings.GetPreferredAudioLanguagesAsync(cancellationToken);
|
||||||
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
|
var channelNumbers = await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken);
|
||||||
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
|
return new SiteSettingsDto(registrationEnabled, preferredAudio, channelNumbers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
namespace TeleWave.Application.Settings;
|
namespace TeleWave.Application.Settings;
|
||||||
|
|
||||||
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
/// <summary>Стабильные ключи глобальных настроек сайта в key-value хранилище (AppSetting).</summary>
|
||||||
public static class SettingKeys
|
public static class SettingKeys
|
||||||
{
|
{
|
||||||
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
/// <summary>Разрешена ли открытая регистрация пользователей (по умолчанию — нет).</summary>
|
||||||
public const string RegistrationEnabled = "registration.enabled";
|
public const string RegistrationEnabled = "registration.enabled";
|
||||||
|
|
||||||
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
/// <summary>Предпочитаемые языки аудиодорожек при обработке, через запятую в порядке приоритета
|
||||||
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
/// (напр. «rus,eng»). При наличии дорожки с таким языком она выбирается первой; иначе — дефолт ffmpeg.</summary>
|
||||||
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
public const string PreferredAudioLanguages = "media.preferredAudioLanguages";
|
||||||
|
|
||||||
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
|
/// <summary>Разрешено ли зрителю переключать каналы по номерам, как на телевизоре (по умолчанию — нет).
|
||||||
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
|
/// Сетка каналов остаётся вторым способом навигации всегда.</summary>
|
||||||
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
|
public const string ChannelNumbersEnabled = "viewer.channelNumbersEnabled";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
namespace TeleWave.Application.Settings;
|
namespace TeleWave.Application.Settings;
|
||||||
|
|
||||||
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
/// <summary>Глобальные настройки сайта, управляемые администратором.</summary>
|
||||||
public sealed record SiteSettingsDto(
|
public sealed record SiteSettingsDto(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string PreferredAudioLanguages,
|
string PreferredAudioLanguages,
|
||||||
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
|
/// <summary>Переключение каналов по номерам у зрителя — сетка каналов остаётся всегда.</summary>
|
||||||
bool ChannelNumbersEnabled
|
bool ChannelNumbersEnabled
|
||||||
);
|
);
|
||||||
|
|||||||
+10
-10
@@ -1,10 +1,10 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
|
|
||||||
public sealed record UpdateSiteSettingsCommand(
|
public sealed record UpdateSiteSettingsCommand(
|
||||||
bool RegistrationEnabled,
|
bool RegistrationEnabled,
|
||||||
string PreferredAudioLanguages,
|
string PreferredAudioLanguages,
|
||||||
bool ChannelNumbersEnabled
|
bool ChannelNumbersEnabled
|
||||||
) : ICommand<Result>;
|
) : ICommand<Result>;
|
||||||
|
|||||||
+34
-34
@@ -1,34 +1,34 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Common.Models;
|
using TeleWave.Application.Common.Models;
|
||||||
|
|
||||||
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
namespace TeleWave.Application.Settings.UpdateSiteSettings;
|
||||||
|
|
||||||
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
public sealed class UpdateSiteSettingsCommandHandler(ISiteSettings siteSettings)
|
||||||
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
: ICommandHandler<UpdateSiteSettingsCommand, Result>
|
||||||
{
|
{
|
||||||
public async Task<Result> Handle(
|
public async Task<Result> Handle(
|
||||||
UpdateSiteSettingsCommand command,
|
UpdateSiteSettingsCommand command,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// Сохранение выполняет UnitOfWorkBehavior команды.
|
// Сохранение выполняет UnitOfWorkBehavior команды.
|
||||||
await siteSettings.SetRegistrationEnabledAsync(
|
await siteSettings.SetRegistrationEnabledAsync(
|
||||||
command.RegistrationEnabled,
|
command.RegistrationEnabled,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
// Нормализуем список языков: без пробелов, в нижнем регистре, пустые отбрасываем.
|
||||||
var languages = string.Join(
|
var languages = string.Join(
|
||||||
',',
|
',',
|
||||||
(command.PreferredAudioLanguages ?? string.Empty)
|
(command.PreferredAudioLanguages ?? string.Empty)
|
||||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
.Select(x => x.ToLowerInvariant())
|
.Select(x => x.ToLowerInvariant())
|
||||||
);
|
);
|
||||||
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
await siteSettings.SetPreferredAudioLanguagesAsync(languages, cancellationToken);
|
||||||
await siteSettings.SetChannelNumbersEnabledAsync(
|
await siteSettings.SetChannelNumbersEnabledAsync(
|
||||||
command.ChannelNumbersEnabled,
|
command.ChannelNumbersEnabled,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+93
-93
@@ -1,93 +1,93 @@
|
|||||||
using LiteCqrs;
|
using LiteCqrs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
namespace TeleWave.Application.Streaming.ListPublicChannels;
|
||||||
|
|
||||||
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
|
||||||
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
|
||||||
{
|
{
|
||||||
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
|
||||||
ListPublicChannelsQuery query,
|
ListPublicChannelsQuery query,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channels = await dbContext
|
var channels = await dbContext
|
||||||
.Channels.AsNoTracking()
|
.Channels.AsNoTracking()
|
||||||
.Where(c => c.IsEnabled)
|
.Where(c => c.IsEnabled)
|
||||||
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
|
// Каналы без номера — в конец: на телевизоре порядок задаёт номер, а имя лишь
|
||||||
// разрешает ничью между ненумерованными.
|
// разрешает ничью между ненумерованными.
|
||||||
.OrderBy(c => c.Number == null)
|
.OrderBy(c => c.Number == null)
|
||||||
.ThenBy(c => c.Number)
|
.ThenBy(c => c.Number)
|
||||||
.ThenBy(c => c.Name)
|
.ThenBy(c => c.Name)
|
||||||
.Select(c => new
|
.Select(c => new
|
||||||
{
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
c.Slug,
|
c.Slug,
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Number,
|
c.Number,
|
||||||
c.LogoImageId,
|
c.LogoImageId,
|
||||||
c.LogoCorner,
|
c.LogoCorner,
|
||||||
c.LogoOpacity,
|
c.LogoOpacity,
|
||||||
c.ShowClock,
|
c.ShowClock,
|
||||||
c.AnalogFilterStrength,
|
c.AnalogFilterStrength,
|
||||||
})
|
})
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
if (channels.Count == 0)
|
if (channels.Count == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var channelIds = channels.Select(c => c.Id).ToList();
|
var channelIds = channels.Select(c => c.Id).ToList();
|
||||||
|
|
||||||
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
// Что идёт прямо сейчас на каждом канале (программа) — для постера-обложки плитки.
|
||||||
var currentByChannel = await dbContext
|
var currentByChannel = await dbContext
|
||||||
.ScheduleEntries.AsNoTracking()
|
.ScheduleEntries.AsNoTracking()
|
||||||
.Where(e =>
|
.Where(e =>
|
||||||
channelIds.Contains(e.ChannelId)
|
channelIds.Contains(e.ChannelId)
|
||||||
&& e.Kind == ScheduleEntryKind.Program
|
&& e.Kind == ScheduleEntryKind.Program
|
||||||
&& e.StartsAtUtc <= now
|
&& e.StartsAtUtc <= now
|
||||||
&& e.EndsAtUtc > now
|
&& e.EndsAtUtc > now
|
||||||
&& e.ShowId != null
|
&& e.ShowId != null
|
||||||
)
|
)
|
||||||
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
.Select(e => new { e.ChannelId, ShowId = e.ShowId!.Value })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
var currentShowByChannel = currentByChannel
|
var currentShowByChannel = currentByChannel
|
||||||
.GroupBy(x => x.ChannelId)
|
.GroupBy(x => x.ChannelId)
|
||||||
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
.ToDictionary(g => g.Key, g => g.First().ShowId);
|
||||||
|
|
||||||
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
var showIds = currentShowByChannel.Values.Distinct().ToList();
|
||||||
var shows = await dbContext
|
var shows = await dbContext
|
||||||
.Shows.AsNoTracking()
|
.Shows.AsNoTracking()
|
||||||
.Where(s => showIds.Contains(s.Id))
|
.Where(s => showIds.Contains(s.Id))
|
||||||
.Select(s => new
|
.Select(s => new
|
||||||
{
|
{
|
||||||
s.Id,
|
s.Id,
|
||||||
s.Name,
|
s.Name,
|
||||||
s.PosterImageId,
|
s.PosterImageId,
|
||||||
})
|
})
|
||||||
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
.ToDictionaryAsync(s => s.Id, cancellationToken);
|
||||||
|
|
||||||
return channels
|
return channels
|
||||||
.Select(c =>
|
.Select(c =>
|
||||||
{
|
{
|
||||||
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
var showId = currentShowByChannel.GetValueOrDefault(c.Id);
|
||||||
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
|
var show = showId != Guid.Empty ? shows.GetValueOrDefault(showId) : null;
|
||||||
return new PublicChannelDto(
|
return new PublicChannelDto(
|
||||||
c.Id,
|
c.Id,
|
||||||
c.Slug,
|
c.Slug,
|
||||||
c.Name,
|
c.Name,
|
||||||
c.Number,
|
c.Number,
|
||||||
show is null ? null : showId,
|
show is null ? null : showId,
|
||||||
show?.Name,
|
show?.Name,
|
||||||
show?.PosterImageId,
|
show?.PosterImageId,
|
||||||
c.LogoImageId,
|
c.LogoImageId,
|
||||||
c.LogoCorner,
|
c.LogoCorner,
|
||||||
c.LogoOpacity,
|
c.LogoOpacity,
|
||||||
c.ShowClock,
|
c.ShowClock,
|
||||||
c.AnalogFilterStrength
|
c.AnalogFilterStrength
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,42 @@
|
|||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Application.Streaming;
|
namespace TeleWave.Application.Streaming;
|
||||||
|
|
||||||
public sealed record PublicChannelDto(
|
public sealed record PublicChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Slug,
|
string Slug,
|
||||||
string Name,
|
string Name,
|
||||||
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
|
/// <summary>Номер канала для переключения по номерам или null, если не задан.</summary>
|
||||||
int? Number,
|
int? Number,
|
||||||
Guid? CurrentShowId,
|
Guid? CurrentShowId,
|
||||||
string? CurrentShowName,
|
string? CurrentShowName,
|
||||||
Guid? CurrentShowPosterImageId,
|
Guid? CurrentShowPosterImageId,
|
||||||
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
|
/// <summary>Что рисовать поверх картинки — оверлеи считает клиент, ffmpeg их не касается.</summary>
|
||||||
Guid? LogoImageId,
|
Guid? LogoImageId,
|
||||||
LogoCorner LogoCorner,
|
LogoCorner LogoCorner,
|
||||||
double LogoOpacity,
|
double LogoOpacity,
|
||||||
bool ShowClock,
|
bool ShowClock,
|
||||||
double AnalogFilterStrength
|
double AnalogFilterStrength
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
/// <summary>Запись публичного телегида с метаданными (без имён файлов и номеров серий).</summary>
|
||||||
public sealed record PublicEpgEntryDto(
|
public sealed record PublicEpgEntryDto(
|
||||||
ScheduleEntryKind Kind,
|
ScheduleEntryKind Kind,
|
||||||
DateTimeOffset StartsAtUtc,
|
DateTimeOffset StartsAtUtc,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset EndsAtUtc,
|
||||||
Guid? ShowId,
|
Guid? ShowId,
|
||||||
string? ShowName,
|
string? ShowName,
|
||||||
Guid? ShowPosterImageId,
|
Guid? ShowPosterImageId,
|
||||||
Guid? EpisodeId,
|
Guid? EpisodeId,
|
||||||
string? EpisodeTitle,
|
string? EpisodeTitle,
|
||||||
string? EpisodeOverview,
|
string? EpisodeOverview,
|
||||||
Guid? EpisodeStillImageId
|
Guid? EpisodeStillImageId
|
||||||
);
|
);
|
||||||
|
|
||||||
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
|
||||||
|
|
||||||
public sealed record LivePlaylistDto(
|
public sealed record LivePlaylistDto(
|
||||||
long MediaSequence,
|
long MediaSequence,
|
||||||
int TargetDuration,
|
int TargetDuration,
|
||||||
IReadOnlyList<LiveSegmentDto> Segments
|
IReadOnlyList<LiveSegmentDto> Segments
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,179 +1,179 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
/// Канал линейного эфира. Что и когда идёт в эфире, определяет шаблон сетки (<see cref="TemplateId"/>,
|
||||||
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
/// см. <c>Domain/Programming</c>); канал хранит только собственные свойства: время, номер, аварийный
|
||||||
/// филлер и общие настройки заставок.
|
/// филлер и общие настройки заставок.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Channel
|
public class Channel
|
||||||
{
|
{
|
||||||
private readonly List<BumperTemplate> _bumperTemplates = [];
|
private readonly List<BumperTemplate> _bumperTemplates = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
public string Slug { get; private set; } = string.Empty;
|
public string Slug { get; private set; } = string.Empty;
|
||||||
public bool IsEnabled { get; private set; }
|
public bool IsEnabled { get; private set; }
|
||||||
|
|
||||||
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
|
/// <summary>Точка отсчёта эфирной ленты (UTC) — база для MEDIA-SEQUENCE на этапе раздачи.</summary>
|
||||||
public DateTimeOffset EpochUtc { get; private set; }
|
public DateTimeOffset EpochUtc { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
|
/// Номер канала — на телевизоре канал это номер, а не карточка в сетке. Переключение по номерам
|
||||||
/// включается глобальным флагом настроек сайта; null — номер не задан.
|
/// включается глобальным флагом настроек сайта; null — номер не задан.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? Number { get; private set; }
|
public int? Number { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
|
/// Смещение времени канала от UTC в минутах (по умолчанию 180 — московское). Фиксированный
|
||||||
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
|
/// оффсет, а не IANA-зона: с переводом часов сутки становятся 23- или 25-часовыми, и сетке
|
||||||
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
|
/// понадобилось бы отдельное правило подрезки. Появятся каналы в зонах с DST — перейдём на IANA.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int UtcOffsetMinutes { get; private set; }
|
public int UtcOffsetMinutes { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
|
/// Начало вещательных суток в времени канала (по умолчанию 06:00). Ночной блок с 00:00 до 06:00
|
||||||
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
|
/// относится к предыдущему дню: «ночь с пятницы на субботу» — это пятница.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public TimeOnly DayStartTime { get; private set; }
|
public TimeOnly DayStartTime { get; private set; }
|
||||||
|
|
||||||
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
|
/// <summary>Активный шаблон сетки канала (один на канал).</summary>
|
||||||
public Guid? TemplateId { get; private set; }
|
public Guid? TemplateId { get; private set; }
|
||||||
|
|
||||||
public const int DefaultUtcOffsetMinutes = 180;
|
public const int DefaultUtcOffsetMinutes = 180;
|
||||||
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
public static readonly TimeOnly DefaultDayStartTime = new(6, 0);
|
||||||
|
|
||||||
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
|
// ── Настройки ТВ-заставок. Условия показа (как часто, на смене шоу или между сериями)
|
||||||
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
|
// живут в элементах стыка; на канале осталось только общее для всех заставок. ──
|
||||||
|
|
||||||
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
|
/// <summary>Вставлять ли ТВ-заставки вообще: общий выключатель канала.</summary>
|
||||||
public bool BumpersEnabled { get; private set; }
|
public bool BumpersEnabled { get; private set; }
|
||||||
|
|
||||||
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
|
/// <summary>Как выбирать подблок заставки на переходе (случайно/по весам/всегда первый).</summary>
|
||||||
public BumperSelection BumperSelection { get; private set; }
|
public BumperSelection BumperSelection { get; private set; }
|
||||||
|
|
||||||
public BumperFont BumperFont { get; private set; }
|
public BumperFont BumperFont { get; private set; }
|
||||||
|
|
||||||
private const string DefaultTemplateName = "Заставка 1";
|
private const string DefaultTemplateName = "Заставка 1";
|
||||||
|
|
||||||
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
/// <summary>Ассет-заглушка на случай пустого расписания (аварийная подстраховка).</summary>
|
||||||
public Guid? FillerAssetId { get; private set; }
|
public Guid? FillerAssetId { get; private set; }
|
||||||
|
|
||||||
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
|
// ── Зрительская часть (см. 6.8). Всё рисуется на клиенте поверх <video>, ffmpeg не трогает,
|
||||||
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
|
// и всё по умолчанию выключено: канал без логотипа и без шума — законная конфигурация. ──
|
||||||
|
|
||||||
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
|
/// <summary>Логотип-оверлей: ссылка на реестр изображений или null (логотипа нет).</summary>
|
||||||
public Guid? LogoImageId { get; private set; }
|
public Guid? LogoImageId { get; private set; }
|
||||||
|
|
||||||
public LogoCorner LogoCorner { get; private set; }
|
public LogoCorner LogoCorner { get; private set; }
|
||||||
|
|
||||||
/// <summary>Прозрачность логотипа, 0..1.</summary>
|
/// <summary>Прозрачность логотипа, 0..1.</summary>
|
||||||
public double LogoOpacity { get; private set; } = 0.8;
|
public double LogoOpacity { get; private set; } = 0.8;
|
||||||
|
|
||||||
/// <summary>Показывать ли часы поверх картинки.</summary>
|
/// <summary>Показывать ли часы поверх картинки.</summary>
|
||||||
public bool ShowClock { get; private set; }
|
public bool ShowClock { get; private set; }
|
||||||
|
|
||||||
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
|
/// <summary>Сила аналогового фильтра, 0..1 (0 — выключен).</summary>
|
||||||
public double AnalogFilterStrength { get; private set; }
|
public double AnalogFilterStrength { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
/// <summary>Блоки заставок (звук+стиль); первый (Position 0) — дефолтный, порядок — по Position.</summary>
|
||||||
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
public IReadOnlyList<BumperTemplate> BumperTemplates => _bumperTemplates;
|
||||||
|
|
||||||
private Channel() { }
|
private Channel() { }
|
||||||
|
|
||||||
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
public static Channel Create(string name, string slug, DateTimeOffset epochUtc)
|
||||||
{
|
{
|
||||||
var channel = new Channel
|
var channel = new Channel
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = name,
|
Name = name,
|
||||||
Slug = slug,
|
Slug = slug,
|
||||||
IsEnabled = true,
|
IsEnabled = true,
|
||||||
EpochUtc = epochUtc,
|
EpochUtc = epochUtc,
|
||||||
BumpersEnabled = false,
|
BumpersEnabled = false,
|
||||||
BumperSelection = BumperSelection.WeightedRandom,
|
BumperSelection = BumperSelection.WeightedRandom,
|
||||||
BumperFont = BumperFont.Sans,
|
BumperFont = BumperFont.Sans,
|
||||||
LogoOpacity = 0.8,
|
LogoOpacity = 0.8,
|
||||||
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
UtcOffsetMinutes = DefaultUtcOffsetMinutes,
|
||||||
DayStartTime = DefaultDayStartTime,
|
DayStartTime = DefaultDayStartTime,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
// На канале всегда есть дефолтный блок заставки (без звука → синтезированный джингл).
|
||||||
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
channel._bumperTemplates.Add(BumperTemplate.Create(channel.Id, 0, DefaultTemplateName));
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void UpdateSettings(
|
public void UpdateSettings(
|
||||||
string name,
|
string name,
|
||||||
bool isEnabled,
|
bool isEnabled,
|
||||||
bool bumpersEnabled,
|
bool bumpersEnabled,
|
||||||
Guid? fillerAssetId
|
Guid? fillerAssetId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
IsEnabled = isEnabled;
|
IsEnabled = isEnabled;
|
||||||
BumpersEnabled = bumpersEnabled;
|
BumpersEnabled = bumpersEnabled;
|
||||||
FillerAssetId = fillerAssetId;
|
FillerAssetId = fillerAssetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
|
/// <summary>Общие настройки ТВ-заставок канала: шрифт и стратегия выбора подблока.</summary>
|
||||||
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
|
public void UpdateBumperSettings(BumperFont font, BumperSelection selection)
|
||||||
{
|
{
|
||||||
BumperFont = font;
|
BumperFont = font;
|
||||||
BumperSelection = selection;
|
BumperSelection = selection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
/// <summary>Оверлеи и фильтр зрительской части. Всё опционально; силы зажимаются в 0..1.</summary>
|
||||||
public void UpdateViewerSettings(
|
public void UpdateViewerSettings(
|
||||||
Guid? logoImageId,
|
Guid? logoImageId,
|
||||||
LogoCorner logoCorner,
|
LogoCorner logoCorner,
|
||||||
double logoOpacity,
|
double logoOpacity,
|
||||||
bool showClock,
|
bool showClock,
|
||||||
double analogFilterStrength
|
double analogFilterStrength
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
LogoImageId = logoImageId;
|
LogoImageId = logoImageId;
|
||||||
LogoCorner = logoCorner;
|
LogoCorner = logoCorner;
|
||||||
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
|
LogoOpacity = Math.Clamp(logoOpacity, 0.0, 1.0);
|
||||||
ShowClock = showClock;
|
ShowClock = showClock;
|
||||||
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
|
AnalogFilterStrength = Math.Clamp(analogFilterStrength, 0.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
/// <summary>Добавить блок заставки в конец списка. Возвращает созданный блок.</summary>
|
||||||
public BumperTemplate AddBumperTemplate(string name)
|
public BumperTemplate AddBumperTemplate(string name)
|
||||||
{
|
{
|
||||||
var nextPosition =
|
var nextPosition =
|
||||||
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
|
_bumperTemplates.Count == 0 ? 0 : _bumperTemplates.Max(t => t.Position) + 1;
|
||||||
var template = BumperTemplate.Create(Id, nextPosition, name);
|
var template = BumperTemplate.Create(Id, nextPosition, name);
|
||||||
_bumperTemplates.Add(template);
|
_bumperTemplates.Add(template);
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
public BumperTemplate? FindBumperTemplate(Guid templateId) =>
|
||||||
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
_bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||||
|
|
||||||
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
/// <summary>Удалить блок заставки. Дефолтный (Position 0) удалить нельзя — вернёт false.</summary>
|
||||||
public bool RemoveBumperTemplate(Guid templateId)
|
public bool RemoveBumperTemplate(Guid templateId)
|
||||||
{
|
{
|
||||||
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
var template = _bumperTemplates.FirstOrDefault(t => t.Id == templateId);
|
||||||
if (template is null || template.IsDefault)
|
if (template is null || template.IsDefault)
|
||||||
return false;
|
return false;
|
||||||
_bumperTemplates.Remove(template);
|
_bumperTemplates.Remove(template);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Привязать активный шаблон сетки.</summary>
|
/// <summary>Привязать активный шаблон сетки.</summary>
|
||||||
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
public void SetTemplate(Guid? templateId) => TemplateId = templateId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
|
/// Настройки времени канала: номер, смещение от UTC и начало вещательных суток. Смещение
|
||||||
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
|
/// ограничено сутками — за пределами этого диапазона сетка потеряла бы связь с календарём.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
|
public void UpdateTimeSettings(int? number, int utcOffsetMinutes, TimeOnly dayStartTime)
|
||||||
{
|
{
|
||||||
Number = number is > 0 ? number : null;
|
Number = number is > 0 ? number : null;
|
||||||
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
|
UtcOffsetMinutes = Math.Clamp(utcOffsetMinutes, -12 * 60, 14 * 60);
|
||||||
DayStartTime = dayStartTime;
|
DayStartTime = dayStartTime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,123 +1,123 @@
|
|||||||
namespace TeleWave.Domain.Broadcast;
|
namespace TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
|
/// Материализованная запись расписания канала: конкретный ассет в конкретное время. Программы и
|
||||||
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
|
/// реклама идут встык (<see cref="EndsAtUtc"/> одной равен <see cref="StartsAtUtc"/> следующей).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScheduleEntry
|
public class ScheduleEntry
|
||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid ChannelId { get; private set; }
|
public Guid ChannelId { get; private set; }
|
||||||
public Guid MediaAssetId { get; private set; }
|
public Guid MediaAssetId { get; private set; }
|
||||||
public ScheduleEntryKind Kind { get; private set; }
|
public ScheduleEntryKind Kind { get; private set; }
|
||||||
public DateTimeOffset StartsAtUtc { get; private set; }
|
public DateTimeOffset StartsAtUtc { get; private set; }
|
||||||
public DateTimeOffset EndsAtUtc { get; private set; }
|
public DateTimeOffset EndsAtUtc { get; private set; }
|
||||||
|
|
||||||
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
|
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
|
||||||
public Guid? ShowId { get; private set; }
|
public Guid? ShowId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
|
/// <summary>Индекс серии в упорядоченном списке шоу (для EPG).</summary>
|
||||||
public int? EpisodeIndex { get; private set; }
|
public int? EpisodeIndex { get; private set; }
|
||||||
|
|
||||||
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
|
/// <summary>Подблок заставки (<see cref="BumperTextVariant"/>), которым отрендерена запись — для метки в админ-расписании.</summary>
|
||||||
public Guid? BumperVariantId { get; private set; }
|
public Guid? BumperVariantId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
|
/// <summary>Слот сетки, породивший запись (null — служебная запись вне слотов).</summary>
|
||||||
public Guid? SlotId { get; private set; }
|
public Guid? SlotId { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
|
/// Коллекция (франшиза), частью которой шла запись, или null. Из шоу её не вывести: одно и то же
|
||||||
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
|
/// шоу попадает в эфир и само по себе, и внутри коллекции, а группа хранит только ссылку.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Guid? CollectionId { get; private set; }
|
public Guid? CollectionId { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
|
/// Цепочка происхождения (JSON): слой, слот, группа, стратегия, дрейф. Пишется в момент
|
||||||
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
|
/// генерации — восстановить её потом невозможно, а без неё отладка сетки превращается
|
||||||
/// в угадывание.
|
/// в угадывание.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? TraceJson { get; private set; }
|
public string? TraceJson { get; private set; }
|
||||||
|
|
||||||
private ScheduleEntry() { }
|
private ScheduleEntry() { }
|
||||||
|
|
||||||
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
|
/// <summary>Запись, порождённая слотом сетки: программа, заполнитель или конец вещания.</summary>
|
||||||
public static ScheduleEntry FromSlot(
|
public static ScheduleEntry FromSlot(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
Guid mediaAssetId,
|
Guid mediaAssetId,
|
||||||
ScheduleEntryKind kind,
|
ScheduleEntryKind kind,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
DateTimeOffset endsAtUtc,
|
DateTimeOffset endsAtUtc,
|
||||||
ScheduleEntryOrigin origin
|
ScheduleEntryOrigin origin
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
MediaAssetId = mediaAssetId,
|
MediaAssetId = mediaAssetId,
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
EndsAtUtc = endsAtUtc,
|
||||||
ShowId = origin.ShowId,
|
ShowId = origin.ShowId,
|
||||||
EpisodeIndex = origin.EpisodeIndex,
|
EpisodeIndex = origin.EpisodeIndex,
|
||||||
SlotId = origin.SlotId,
|
SlotId = origin.SlotId,
|
||||||
TraceJson = origin.TraceJson,
|
TraceJson = origin.TraceJson,
|
||||||
CollectionId = origin.CollectionId,
|
CollectionId = origin.CollectionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ScheduleEntry Program(
|
public static ScheduleEntry Program(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
Guid mediaAssetId,
|
Guid mediaAssetId,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
DateTimeOffset endsAtUtc,
|
DateTimeOffset endsAtUtc,
|
||||||
Guid showId,
|
Guid showId,
|
||||||
int episodeIndex
|
int episodeIndex
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
MediaAssetId = mediaAssetId,
|
MediaAssetId = mediaAssetId,
|
||||||
Kind = ScheduleEntryKind.Program,
|
Kind = ScheduleEntryKind.Program,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
EndsAtUtc = endsAtUtc,
|
||||||
ShowId = showId,
|
ShowId = showId,
|
||||||
EpisodeIndex = episodeIndex,
|
EpisodeIndex = episodeIndex,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static ScheduleEntry Ad(
|
public static ScheduleEntry Ad(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
Guid mediaAssetId,
|
Guid mediaAssetId,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
DateTimeOffset endsAtUtc
|
DateTimeOffset endsAtUtc
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
MediaAssetId = mediaAssetId,
|
MediaAssetId = mediaAssetId,
|
||||||
Kind = ScheduleEntryKind.Ad,
|
Kind = ScheduleEntryKind.Ad,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
EndsAtUtc = endsAtUtc,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
|
/// <summary>Заставка-переход. <paramref name="showId"/> — следующее шоу (для EPG/справки).</summary>
|
||||||
public static ScheduleEntry Bumper(
|
public static ScheduleEntry Bumper(
|
||||||
Guid channelId,
|
Guid channelId,
|
||||||
Guid mediaAssetId,
|
Guid mediaAssetId,
|
||||||
DateTimeOffset startsAtUtc,
|
DateTimeOffset startsAtUtc,
|
||||||
DateTimeOffset endsAtUtc,
|
DateTimeOffset endsAtUtc,
|
||||||
Guid? showId,
|
Guid? showId,
|
||||||
Guid? bumperVariantId
|
Guid? bumperVariantId
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
MediaAssetId = mediaAssetId,
|
MediaAssetId = mediaAssetId,
|
||||||
Kind = ScheduleEntryKind.Bumper,
|
Kind = ScheduleEntryKind.Bumper,
|
||||||
StartsAtUtc = startsAtUtc,
|
StartsAtUtc = startsAtUtc,
|
||||||
EndsAtUtc = endsAtUtc,
|
EndsAtUtc = endsAtUtc,
|
||||||
ShowId = showId,
|
ShowId = showId,
|
||||||
BumperVariantId = bumperVariantId,
|
BumperVariantId = bumperVariantId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +1,176 @@
|
|||||||
namespace TeleWave.Domain.Library;
|
namespace TeleWave.Domain.Library;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
|
/// Переиспользуемое шоу в общей библиотеке: сериал (упорядоченные серии) либо полнометражка.
|
||||||
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
|
/// Серии идут строго в порядке <see cref="ShowEpisode.Position"/>; где остановился показ — знает
|
||||||
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
|
/// состояние слота планировщика (<c>Programming/SlotState</c>), а не само шоу: одно шоу играет
|
||||||
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
|
/// на нескольких каналах и в нескольких слотах, и курсор у каждого свой.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Show
|
public class Show
|
||||||
{
|
{
|
||||||
private readonly List<ShowEpisode> _episodes = [];
|
private readonly List<ShowEpisode> _episodes = [];
|
||||||
private readonly List<ShowGenre> _genres = [];
|
private readonly List<ShowGenre> _genres = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
|
/// <summary>Оригинальное название (обычно на английском) — по нему ищутся метаданные; на экранах
|
||||||
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
|
/// продолжаем показывать <see cref="Name"/>. Null/пусто — ищем по <see cref="Name"/>.</summary>
|
||||||
public string? OriginalName { get; private set; }
|
public string? OriginalName { get; private set; }
|
||||||
|
|
||||||
public string? Description { get; private set; }
|
public string? Description { get; private set; }
|
||||||
public ShowKind Kind { get; private set; }
|
public ShowKind Kind { get; private set; }
|
||||||
|
|
||||||
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
|
/// <summary>Возрастной рейтинг (MPAA) или null, если не проставлен ни источником, ни вручную.
|
||||||
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
|
/// Шоу без рейтинга планировщик не отсекает: неизвестное не значит «взрослое».</summary>
|
||||||
public ShowAudience? Audience { get; private set; }
|
public ShowAudience? Audience { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
// ── Метаданные (TMDb/OMDb/вручную) ──
|
// ── Метаданные (TMDb/OMDb/вручную) ──
|
||||||
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
|
/// <summary>Источник метаданных: «tmdb»/«omdb»/«manual» или null, если не заданы.</summary>
|
||||||
public string? MetadataProvider { get; private set; }
|
public string? MetadataProvider { get; private set; }
|
||||||
|
|
||||||
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
|
/// <summary>Идентификатор шоу во внешнем источнике (для довыгрузки серий).</summary>
|
||||||
public string? MetadataExternalId { get; private set; }
|
public string? MetadataExternalId { get; private set; }
|
||||||
|
|
||||||
public int? Year { get; private set; }
|
public int? Year { get; private set; }
|
||||||
|
|
||||||
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
|
/// <summary>Постер шоу — ссылка на запись общего реестра изображений (<c>Domain/Images</c>) или null.</summary>
|
||||||
public Guid? PosterImageId { get; private set; }
|
public Guid? PosterImageId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
/// <summary>Серии шоу (backing-field для EF). Порядок показа — по <see cref="ShowEpisode.Position"/>;
|
||||||
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
/// потребители сортируют явно (см. загрузчик планировщика).</summary>
|
||||||
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
|
public IReadOnlyList<ShowEpisode> Episodes => _episodes;
|
||||||
|
|
||||||
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
|
/// <summary>Жанры шоу (backing-field для EF). Ровно один помечен основным, если список не пуст.</summary>
|
||||||
public IReadOnlyList<ShowGenre> Genres => _genres;
|
public IReadOnlyList<ShowGenre> Genres => _genres;
|
||||||
|
|
||||||
private Show() { }
|
private Show() { }
|
||||||
|
|
||||||
public static Show Create(
|
public static Show Create(
|
||||||
string name,
|
string name,
|
||||||
ShowKind kind,
|
ShowKind kind,
|
||||||
string? description = null,
|
string? description = null,
|
||||||
string? originalName = null,
|
string? originalName = null,
|
||||||
ShowAudience? audience = null
|
ShowAudience? audience = null
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = name,
|
Name = name,
|
||||||
OriginalName = Normalize(originalName),
|
OriginalName = Normalize(originalName),
|
||||||
Kind = kind,
|
Kind = kind,
|
||||||
Description = description,
|
Description = description,
|
||||||
Audience = audience,
|
Audience = audience,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
|
/// <summary>Задать возрастной рейтинг; null — снять (вернуть в «не проставлен»).</summary>
|
||||||
public void SetAudience(ShowAudience? audience) => Audience = audience;
|
public void SetAudience(ShowAudience? audience) => Audience = audience;
|
||||||
|
|
||||||
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
|
/// <summary>Основной жанр или null, если жанры не проставлены.</summary>
|
||||||
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
|
public Guid? PrimaryGenreId => _genres.FirstOrDefault(g => g.IsPrimary)?.GenreId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
|
/// Полностью заменяет набор жанров; дубликаты и пустые идентификаторы отбрасываются. Основным
|
||||||
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
|
/// становится <paramref name="primaryGenreId"/>, если он попал в набор, иначе первый в списке —
|
||||||
/// так шоу с жанрами никогда не остаётся без основного.
|
/// так шоу с жанрами никогда не остаётся без основного.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
|
public void SetGenres(IEnumerable<Guid> genreIds, Guid? primaryGenreId = null)
|
||||||
{
|
{
|
||||||
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
|
var ids = genreIds.Where(id => id != Guid.Empty).Distinct().ToList();
|
||||||
_genres.Clear();
|
_genres.Clear();
|
||||||
if (ids.Count == 0)
|
if (ids.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var primary =
|
var primary =
|
||||||
primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
|
primaryGenreId is { } candidate && ids.Contains(candidate) ? candidate : ids[0];
|
||||||
foreach (var id in ids)
|
foreach (var id in ids)
|
||||||
_genres.Add(ShowGenre.Create(Id, id, id == primary));
|
_genres.Add(ShowGenre.Create(Id, id, id == primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Rename(string name, string? description)
|
public void Rename(string name, string? description)
|
||||||
{
|
{
|
||||||
Name = name;
|
Name = name;
|
||||||
Description = description;
|
Description = description;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
|
/// <summary>Изменить отображаемое название (на экранах). Метаданные ищутся по <see cref="OriginalName"/>.</summary>
|
||||||
public void SetName(string name) => Name = name;
|
public void SetName(string name) => Name = name;
|
||||||
|
|
||||||
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
|
/// <summary>Задать/снять оригинальное название (пустая строка трактуется как отсутствие).</summary>
|
||||||
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
|
public void SetOriginalName(string? originalName) => OriginalName = Normalize(originalName);
|
||||||
|
|
||||||
private static string? Normalize(string? value) =>
|
private static string? Normalize(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
|
/// <summary>Добавляет серию в конец. Для <see cref="ShowKind.Single"/> допустима ровно одна серия
|
||||||
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
|
/// (инвариант защищён самим агрегатом; вызывающий обычно проверяет <see cref="CanAddEpisode"/> заранее
|
||||||
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
|
/// и возвращает управляемую ошибку — исключение здесь лишь страховка от обхода).</summary>
|
||||||
public ShowEpisode AddEpisode(Guid mediaAssetId)
|
public ShowEpisode AddEpisode(Guid mediaAssetId)
|
||||||
{
|
{
|
||||||
if (!CanAddEpisode)
|
if (!CanAddEpisode)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Только сериал может содержать больше одной серии."
|
"Только сериал может содержать больше одной серии."
|
||||||
);
|
);
|
||||||
|
|
||||||
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
var nextPosition = _episodes.Count == 0 ? 0 : _episodes.Max(e => e.Position) + 1;
|
||||||
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
|
var episode = ShowEpisode.Create(Id, mediaAssetId, nextPosition);
|
||||||
_episodes.Add(episode);
|
_episodes.Add(episode);
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool RemoveEpisode(Guid episodeId)
|
public bool RemoveEpisode(Guid episodeId)
|
||||||
{
|
{
|
||||||
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
|
var episode = _episodes.FirstOrDefault(e => e.Id == episodeId);
|
||||||
if (episode is null)
|
if (episode is null)
|
||||||
return false;
|
return false;
|
||||||
_episodes.Remove(episode);
|
_episodes.Remove(episode);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
|
/// <summary>Несколько серий бывает только у сериала. У полнометражки и у ролика-врезки серия ровно
|
||||||
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
|
/// одна: ролик с тремя сериями вёл бы себя в планировщике как мини-сериал, а задуман как единица.</summary>
|
||||||
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
|
public bool CanAddEpisode => Kind == ShowKind.Series || _episodes.Count == 0;
|
||||||
|
|
||||||
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
/// <summary>Применить метаданные из внешнего источника. Постер (уже зарегистрирован в реестре) может быть null.</summary>
|
||||||
public void ApplyMetadata(
|
public void ApplyMetadata(
|
||||||
string provider,
|
string provider,
|
||||||
string externalId,
|
string externalId,
|
||||||
string? description,
|
string? description,
|
||||||
int? year,
|
int? year,
|
||||||
Guid? posterImageId
|
Guid? posterImageId
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
MetadataProvider = provider;
|
MetadataProvider = provider;
|
||||||
MetadataExternalId = externalId;
|
MetadataExternalId = externalId;
|
||||||
if (!string.IsNullOrWhiteSpace(description))
|
if (!string.IsNullOrWhiteSpace(description))
|
||||||
Description = description;
|
Description = description;
|
||||||
Year = year;
|
Year = year;
|
||||||
if (posterImageId is not null)
|
if (posterImageId is not null)
|
||||||
PosterImageId = posterImageId;
|
PosterImageId = posterImageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
|
/// <summary>Ручная правка метаданных (без внешнего источника).</summary>
|
||||||
public void UpdateMetadataManual(string? description, int? year)
|
public void UpdateMetadataManual(string? description, int? year)
|
||||||
{
|
{
|
||||||
MetadataProvider = "manual";
|
MetadataProvider = "manual";
|
||||||
MetadataExternalId = null;
|
MetadataExternalId = null;
|
||||||
Description = description;
|
Description = description;
|
||||||
Year = year;
|
Year = year;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
|
/// <summary>Привязать/снять постер шоу (ссылка на запись реестра изображений).</summary>
|
||||||
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
|
public void SetPosterImage(Guid? imageId) => PosterImageId = imageId;
|
||||||
|
|
||||||
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
|
/// <summary>Сбросить все метаданные и отвязать постер (сама картинка остаётся в галерее).</summary>
|
||||||
public void ClearMetadata()
|
public void ClearMetadata()
|
||||||
{
|
{
|
||||||
MetadataProvider = null;
|
MetadataProvider = null;
|
||||||
MetadataExternalId = null;
|
MetadataExternalId = null;
|
||||||
Year = null;
|
Year = null;
|
||||||
PosterImageId = null;
|
PosterImageId = null;
|
||||||
Description = null;
|
Description = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
namespace TeleWave.Domain.Media;
|
namespace TeleWave.Domain.Media;
|
||||||
|
|
||||||
/// <summary>Откуда файл попал в хранилище.</summary>
|
/// <summary>Откуда файл попал в хранилище.</summary>
|
||||||
public enum MediaSource
|
public enum MediaSource
|
||||||
{
|
{
|
||||||
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
|
/// <summary>Загружен через админку (chunked/stream upload в uploads/).</summary>
|
||||||
Upload,
|
Upload,
|
||||||
|
|
||||||
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
/// <summary>Положен вручную в inbox/ и подобран сканером.</summary>
|
||||||
Inbox,
|
Inbox,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
|
/// Положен в manual/ и выбран руками в админке. Тот же inbox по смыслу — файл так же уходит
|
||||||
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
|
/// из каталога, — но подхватывается не сканером, а человеком, и сразу привязывается к шоу.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
ManualInbox = 3,
|
ManualInbox = 3,
|
||||||
|
|
||||||
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
|
/// <summary>Сгенерирован системой (например, ТВ-заставка «Сейчас/Далее»), а не загружен человеком.
|
||||||
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
|
/// Такие ассеты не показываются в списке медиа и создаются сразу готовыми (нарезка своя).</summary>
|
||||||
Generated,
|
Generated,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +1,221 @@
|
|||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Programming.Planning;
|
namespace TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
|
/// Единица воспроизведения — серия или фильм с готовым ассетом. Планировщик оперирует ими, а не
|
||||||
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
|
/// шоу: у фильма единица одна, у сериала их столько же, сколько серий, у коллекции — сумма по частям.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
|
public sealed record PlanningUnit(Guid MediaAssetId, TimeSpan Duration, Guid ShowId, int UnitIndex);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
|
/// Элемент группы, развёрнутый в последовательность единиц. <see cref="LastPlayedUtc"/> — когда он
|
||||||
/// в последний раз выходил в этом канале; по нему работает остывание.
|
/// в последний раз выходил в этом канале; по нему работает остывание.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningElement(
|
public sealed record PlanningElement(
|
||||||
GroupElementKind Kind,
|
GroupElementKind Kind,
|
||||||
Guid ElementId,
|
Guid ElementId,
|
||||||
int Weight,
|
int Weight,
|
||||||
int Position,
|
int Position,
|
||||||
IReadOnlyList<PlanningUnit> Units,
|
IReadOnlyList<PlanningUnit> Units,
|
||||||
DateTimeOffset? LastPlayedUtc = null,
|
DateTimeOffset? LastPlayedUtc = null,
|
||||||
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
|
/// <summary>Категория аудитории (у коллекции — строжайшая из частей); по ней работает детское время.</summary>
|
||||||
ShowAudience? Audience = null,
|
ShowAudience? Audience = null,
|
||||||
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
|
/// <summary>Старты недавних показов в этом канале — по ним считается потолок повторов за период.</summary>
|
||||||
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
|
IReadOnlyList<DateTimeOffset>? RecentPlaysUtc = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
|
/// <summary>Потолок повторов: не чаще <paramref name="Max"/> раз за <paramref name="WindowDays"/> суток.</summary>
|
||||||
public sealed record RepeatLimit(int WindowDays, int Max);
|
public sealed record RepeatLimit(int WindowDays, int Max);
|
||||||
|
|
||||||
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
/// <summary>Стратегия выбора элемента, приведённая к виду, понятному чистому планировщику.</summary>
|
||||||
public sealed record PlanningStrategy(
|
public sealed record PlanningStrategy(
|
||||||
SlotStrategyKind Kind,
|
SlotStrategyKind Kind,
|
||||||
bool RestartOnEnd = true,
|
bool RestartOnEnd = true,
|
||||||
int CooldownDays = 0,
|
int CooldownDays = 0,
|
||||||
bool IgnoreCooldownWhenExhausted = false,
|
bool IgnoreCooldownWhenExhausted = false,
|
||||||
Guid? FixedElementId = null
|
Guid? FixedElementId = null
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
|
/// <summary>Как слот выбирает элемент. Дублирует прикладной enum, чтобы домен не зависел от Application.</summary>
|
||||||
public enum SlotStrategyKind
|
public enum SlotStrategyKind
|
||||||
{
|
{
|
||||||
Sequential = 0,
|
Sequential = 0,
|
||||||
RandomWithCooldown = 1,
|
RandomWithCooldown = 1,
|
||||||
Fixed = 2,
|
Fixed = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
|
/// <summary>Где остановились в текущем элементе на момент начала прогона.</summary>
|
||||||
public sealed record PlanningCursor(
|
public sealed record PlanningCursor(
|
||||||
GroupElementKind? ElementKind,
|
GroupElementKind? ElementKind,
|
||||||
Guid? ElementId,
|
Guid? ElementId,
|
||||||
int NextUnitIndex
|
int NextUnitIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
|
/// Слот, привязанный к конкретному моменту эфира. Применимость слоёв уже разрешена: сюда попадают
|
||||||
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
|
/// только те слоты, которые реально действуют в эти сутки, каждый со своим целевым временем в UTC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningSlot(
|
public sealed record PlanningSlot(
|
||||||
Guid SlotId,
|
Guid SlotId,
|
||||||
DateTimeOffset TargetStartUtc,
|
DateTimeOffset TargetStartUtc,
|
||||||
int TargetDurationMinutes,
|
int TargetDurationMinutes,
|
||||||
SlotKind SlotKind,
|
SlotKind SlotKind,
|
||||||
bool IsAnchor,
|
bool IsAnchor,
|
||||||
int MaxDriftMinutes,
|
int MaxDriftMinutes,
|
||||||
int? SnapToMinutes,
|
int? SnapToMinutes,
|
||||||
SlotBlockMode BlockMode,
|
SlotBlockMode BlockMode,
|
||||||
int BlockValue,
|
int BlockValue,
|
||||||
OverflowPolicy OverflowPolicy,
|
OverflowPolicy OverflowPolicy,
|
||||||
PlanningStrategy Strategy,
|
PlanningStrategy Strategy,
|
||||||
IReadOnlyList<PlanningElement> Elements,
|
IReadOnlyList<PlanningElement> Elements,
|
||||||
PlanningCursor? Cursor,
|
PlanningCursor? Cursor,
|
||||||
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
|
/// <summary>Готовые записи для <see cref="SlotKind.Repeat"/> — что играло в источнике повтора.</summary>
|
||||||
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
|
IReadOnlyList<PlanningUnit>? RepeatUnits = null,
|
||||||
/// <summary>Врезки между единицами внутри блока.</summary>
|
/// <summary>Врезки между единицами внутри блока.</summary>
|
||||||
PlanningJunction? JunctionBetween = null,
|
PlanningJunction? JunctionBetween = null,
|
||||||
/// <summary>Врезки в конце блока.</summary>
|
/// <summary>Врезки в конце блока.</summary>
|
||||||
PlanningJunction? JunctionAfter = null,
|
PlanningJunction? JunctionAfter = null,
|
||||||
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
|
/// <summary>Возрастной потолок в это время суток (null — без ограничения). Жёсткий фильтр.</summary>
|
||||||
ShowAudience? MaxAudience = null,
|
ShowAudience? MaxAudience = null,
|
||||||
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
|
/// <summary>Потолок повторов за период (null — без ограничения). Жёсткий фильтр.</summary>
|
||||||
RepeatLimit? RepeatLimit = null
|
RepeatLimit? RepeatLimit = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
public DateTimeOffset TargetEndUtc => TargetStartUtc.AddMinutes(TargetDurationMinutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
/// Врезка стыка, развёрнутая для планировщика: единицы уже подобраны оркестратором, домену остаётся
|
||||||
/// решить, сколько их поставить и влезают ли они.
|
/// решить, сколько их поставить и влезают ли они.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record PlanningJunctionElement(
|
public sealed record PlanningJunctionElement(
|
||||||
JunctionElementKind Kind,
|
JunctionElementKind Kind,
|
||||||
IReadOnlyList<PlanningUnit> Units,
|
IReadOnlyList<PlanningUnit> Units,
|
||||||
JunctionAmountMode AmountMode,
|
JunctionAmountMode AmountMode,
|
||||||
int AmountValue,
|
int AmountValue,
|
||||||
bool IsRequired,
|
bool IsRequired,
|
||||||
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
|
/// <summary>Ставить только при смене элемента (иначе — и между единицами одного).</summary>
|
||||||
bool OnlyOnElementChange = false,
|
bool OnlyOnElementChange = false,
|
||||||
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
/// <summary>Не ставить чаще, чем раз в N минут (0 — без ограничения).</summary>
|
||||||
int MinMinutesBetween = 0,
|
int MinMinutesBetween = 0,
|
||||||
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
/// <summary>Блок заставки — ассет рендерится позже, планировщик резервирует длительность.</summary>
|
||||||
Guid? BumperTemplateId = null,
|
Guid? BumperTemplateId = null,
|
||||||
/// <summary>Длительность резерва под заставку.</summary>
|
/// <summary>Длительность резерва под заставку.</summary>
|
||||||
TimeSpan BumperDuration = default
|
TimeSpan BumperDuration = default
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Стык: последовательность врезок между программами.</summary>
|
/// <summary>Стык: последовательность врезок между программами.</summary>
|
||||||
public sealed record PlanningJunction(
|
public sealed record PlanningJunction(
|
||||||
Guid JunctionId,
|
Guid JunctionId,
|
||||||
IReadOnlyList<PlanningJunctionElement> Elements
|
IReadOnlyList<PlanningJunctionElement> Elements
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Полный вход одного прогона генератора.</summary>
|
/// <summary>Полный вход одного прогона генератора.</summary>
|
||||||
public sealed record PlanningInput(
|
public sealed record PlanningInput(
|
||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
DateTimeOffset StartUtc,
|
DateTimeOffset StartUtc,
|
||||||
DateTimeOffset HorizonEndUtc,
|
DateTimeOffset HorizonEndUtc,
|
||||||
IReadOnlyList<PlanningSlot> Slots,
|
IReadOnlyList<PlanningSlot> Slots,
|
||||||
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
/// <summary>Чем закрывать место, не покрытое слотами и не заполненное контентом.</summary>
|
||||||
IReadOnlyList<PlanningUnit> FallbackUnits,
|
IReadOnlyList<PlanningUnit> FallbackUnits,
|
||||||
int SegmentSeconds
|
int SegmentSeconds
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
/// <summary>Одна запись будущей ленты. Трейс пишется здесь же — восстановить его потом невозможно.</summary>
|
||||||
public sealed record PlannedItem(
|
public sealed record PlannedItem(
|
||||||
Guid MediaAssetId,
|
Guid MediaAssetId,
|
||||||
DateTimeOffset StartsAtUtc,
|
DateTimeOffset StartsAtUtc,
|
||||||
DateTimeOffset EndsAtUtc,
|
DateTimeOffset EndsAtUtc,
|
||||||
Guid? ShowId,
|
Guid? ShowId,
|
||||||
int? UnitIndex,
|
int? UnitIndex,
|
||||||
Guid? SlotId,
|
Guid? SlotId,
|
||||||
PlannedItemKind Kind,
|
PlannedItemKind Kind,
|
||||||
PlanTrace? Trace = null,
|
PlanTrace? Trace = null,
|
||||||
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
|
/// <summary>Для заставки: блок, пара «из/в» и место под ассет, который отрендерят позже.</summary>
|
||||||
Guid? BumperTemplateId = null,
|
Guid? BumperTemplateId = null,
|
||||||
Guid? FromShowId = null,
|
Guid? FromShowId = null,
|
||||||
Guid? ToShowId = null,
|
Guid? ToShowId = null,
|
||||||
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
/// <summary>Коллекция, частью которой шла единица (null — шоу играло само по себе).</summary>
|
||||||
Guid? CollectionId = null
|
Guid? CollectionId = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public enum PlannedItemKind
|
public enum PlannedItemKind
|
||||||
{
|
{
|
||||||
Program = 0,
|
Program = 0,
|
||||||
Fallback = 1,
|
Fallback = 1,
|
||||||
SignOff = 2,
|
SignOff = 2,
|
||||||
Ad = 3,
|
Ad = 3,
|
||||||
Promo = 4,
|
Promo = 4,
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
|
/// Заставка-переход. Ассет пуст: он зависит от пары соседей и рендерится после того, как лента
|
||||||
/// собрана, — планировщик лишь резервирует под неё длительность.
|
/// собрана, — планировщик лишь резервирует под неё длительность.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Bumper = 5,
|
Bumper = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
|
/// <summary>Цепочка происхождения записи — питает экран «почему это здесь».</summary>
|
||||||
public sealed record PlanTrace(
|
public sealed record PlanTrace(
|
||||||
Guid? SlotId,
|
Guid? SlotId,
|
||||||
SlotKind SlotKind,
|
SlotKind SlotKind,
|
||||||
GroupElementKind? ElementKind,
|
GroupElementKind? ElementKind,
|
||||||
Guid? ElementId,
|
Guid? ElementId,
|
||||||
SlotStrategyKind? Strategy,
|
SlotStrategyKind? Strategy,
|
||||||
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
|
/// <summary>Сколько кандидатов осталось после остывания (null — выбор без остывания).</summary>
|
||||||
int? CandidatesAfterCooldown,
|
int? CandidatesAfterCooldown,
|
||||||
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
|
/// <summary>Насколько фактический старт разошёлся с целевым, минуты.</summary>
|
||||||
int DriftMinutes,
|
int DriftMinutes,
|
||||||
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
|
/// <summary>Старт сдвинут вперёд округлением до круглого времени.</summary>
|
||||||
bool Snapped
|
bool Snapped
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
|
/// <summary>Новое состояние слота после прогона — оркестратор сохраняет его в БД.</summary>
|
||||||
public sealed record PlanningCursorUpdate(
|
public sealed record PlanningCursorUpdate(
|
||||||
Guid SlotId,
|
Guid SlotId,
|
||||||
GroupElementKind? ElementKind,
|
GroupElementKind? ElementKind,
|
||||||
Guid? ElementId,
|
Guid? ElementId,
|
||||||
int NextUnitIndex
|
int NextUnitIndex
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
|
/// <summary>Результат прогона: лента, новые курсоры и предупреждения для админа.</summary>
|
||||||
public sealed record PlanningResult(
|
public sealed record PlanningResult(
|
||||||
IReadOnlyList<PlannedItem> Items,
|
IReadOnlyList<PlannedItem> Items,
|
||||||
IReadOnlyList<PlanningCursorUpdate> Cursors,
|
IReadOnlyList<PlanningCursorUpdate> Cursors,
|
||||||
IReadOnlyList<PlanningWarning> Warnings
|
IReadOnlyList<PlanningWarning> Warnings
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
|
/// <summary>Предупреждение по результату генерации: не ошибка, но админу это видеть нужно.</summary>
|
||||||
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
|
public sealed record PlanningWarning(PlanningWarningKind Kind, Guid? SlotId, string Details);
|
||||||
|
|
||||||
public enum PlanningWarningKind
|
public enum PlanningWarningKind
|
||||||
{
|
{
|
||||||
/// <summary>Слот не дал контента — место закрыл фон.</summary>
|
/// <summary>Слот не дал контента — место закрыл фон.</summary>
|
||||||
SlotEmpty = 0,
|
SlotEmpty = 0,
|
||||||
|
|
||||||
/// <summary>Фактический старт ушёл дальше допуска.</summary>
|
/// <summary>Фактический старт ушёл дальше допуска.</summary>
|
||||||
DriftExceeded = 1,
|
DriftExceeded = 1,
|
||||||
|
|
||||||
/// <summary>Остывание отсекло всех кандидатов.</summary>
|
/// <summary>Остывание отсекло всех кандидатов.</summary>
|
||||||
CooldownExhausted = 2,
|
CooldownExhausted = 2,
|
||||||
|
|
||||||
/// <summary>Не нашлось, что повторить.</summary>
|
/// <summary>Не нашлось, что повторить.</summary>
|
||||||
RepeatSourceEmpty = 3,
|
RepeatSourceEmpty = 3,
|
||||||
|
|
||||||
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
/// <summary>Пусто даже в фоне — в ленте образуется дыра.</summary>
|
||||||
FallbackEmpty = 4,
|
FallbackEmpty = 4,
|
||||||
|
|
||||||
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
/// <summary>Жёсткие фильтры (детское время, потолок повторов) не оставили ни одного кандидата.</summary>
|
||||||
CandidatesFiltered = 5,
|
CandidatesFiltered = 5,
|
||||||
|
|
||||||
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
|
// ── Пост-проверки: считаются по готовой ленте и ничего не переигрывают (см. 3.8). ──
|
||||||
|
|
||||||
/// <summary>Врезок в часе больше заданного потолка.</summary>
|
/// <summary>Врезок в часе больше заданного потолка.</summary>
|
||||||
BreakLimitExceeded = 6,
|
BreakLimitExceeded = 6,
|
||||||
|
|
||||||
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
|
/// <summary>Доля одного жанра за сутки выше заданной.</summary>
|
||||||
GenreShareExceeded = 7,
|
GenreShareExceeded = 7,
|
||||||
|
|
||||||
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
|
/// <summary>Фон занял больше эфира, чем считается нормой.</summary>
|
||||||
FallbackShareExceeded = 8,
|
FallbackShareExceeded = 8,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,465 +1,465 @@
|
|||||||
using TeleWave.Domain.Broadcast.Scheduling;
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Programming.Planning;
|
namespace TeleWave.Domain.Programming.Planning;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
|
/// Чистая эфирная математика: разворачивает сетку в непрерывную ленту от <see cref="PlanningInput.StartUtc"/>
|
||||||
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
|
/// до горизонта. Без БД, ФС и ffmpeg — полностью юнит-тестируемо.
|
||||||
///
|
///
|
||||||
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
|
/// Сетка эластичная: времена слотов — цели, а не границы. Контент идёт встык, слот исчерпывается по
|
||||||
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
|
/// бюджету, расхождение переносится дальше. Опорные точки держат якоря (жёсткий старт, перед которым
|
||||||
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
|
/// не начинают то, что через него перелезет) и мягкое округление (сдвиг старта до круглого времени).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class SchedulePlanner
|
public static class SchedulePlanner
|
||||||
{
|
{
|
||||||
private const int IterationBackstop = 100_000;
|
private const int IterationBackstop = 100_000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
|
/// Накопители одного прогона: собираемая лента, новые курсоры слотов, предупреждения, история
|
||||||
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
|
/// врезок и шоу последней поставленной единицы. Всё это протаскивалось через сигнатуры десятком
|
||||||
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
|
/// параметров (включая <c>ref</c>), хотя принадлежит прогону целиком, а не отдельному шагу.
|
||||||
///
|
///
|
||||||
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
|
/// История врезок общая на прогон намеренно: «не чаще раза в полчаса» должно работать и через
|
||||||
/// границу слота.
|
/// границу слота.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
|
private sealed class PlanningRun(PlanningInput input, IRandomSource random)
|
||||||
{
|
{
|
||||||
public PlanningInput Input { get; } = input;
|
public PlanningInput Input { get; } = input;
|
||||||
public IRandomSource Random { get; } = random;
|
public IRandomSource Random { get; } = random;
|
||||||
public List<PlannedItem> Items { get; } = [];
|
public List<PlannedItem> Items { get; } = [];
|
||||||
public List<PlanningCursorUpdate> Cursors { get; } = [];
|
public List<PlanningCursorUpdate> Cursors { get; } = [];
|
||||||
public List<PlanningWarning> Warnings { get; } = [];
|
public List<PlanningWarning> Warnings { get; } = [];
|
||||||
public JunctionHistory Junctions { get; } = new();
|
public JunctionHistory Junctions { get; } = new();
|
||||||
|
|
||||||
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
/// <summary>Шоу последней поставленной единицы — по нему стык понимает, сменился ли элемент.</summary>
|
||||||
public Guid? PreviousShowId { get; set; }
|
public Guid? PreviousShowId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
public static PlanningResult Plan(PlanningInput input, IRandomSource random)
|
||||||
{
|
{
|
||||||
var run = new PlanningRun(input, random);
|
var run = new PlanningRun(input, random);
|
||||||
|
|
||||||
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
|
var slots = input.Slots.OrderBy(s => s.TargetStartUtc).ToList();
|
||||||
var cursor = input.StartUtc;
|
var cursor = input.StartUtc;
|
||||||
var iterations = 0;
|
var iterations = 0;
|
||||||
|
|
||||||
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
|
for (var i = 0; i < slots.Count && cursor < input.HorizonEndUtc; i++)
|
||||||
{
|
{
|
||||||
if (iterations++ > IterationBackstop)
|
if (iterations++ > IterationBackstop)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
var slot = slots[i];
|
var slot = slots[i];
|
||||||
|
|
||||||
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
|
// Слот, чьё окно целиком в прошлом относительно курсора, пропускаем: догонять уже нечего,
|
||||||
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
|
// а поставив его сейчас, мы сдвинули бы всё последующее ещё дальше.
|
||||||
if (slot.TargetEndUtc <= cursor)
|
if (slot.TargetEndUtc <= cursor)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var nextAnchor = FindNextAnchor(slots, i + 1);
|
var nextAnchor = FindNextAnchor(slots, i + 1);
|
||||||
|
|
||||||
cursor = OpenSlot(slot, cursor, run, out var trace);
|
cursor = OpenSlot(slot, cursor, run, out var trace);
|
||||||
if (cursor >= input.HorizonEndUtc)
|
if (cursor >= input.HorizonEndUtc)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
|
cursor = FillSlot(slot, cursor, nextAnchor, trace, run);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
// Хвост до горизонта закрывает фон: лента обязана быть непрерывной, иначе живой край
|
||||||
// упрётся в дыру.
|
// упрётся в дыру.
|
||||||
if (cursor < input.HorizonEndUtc)
|
if (cursor < input.HorizonEndUtc)
|
||||||
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
|
cursor = FillWithFallback(cursor, input.HorizonEndUtc, run, null);
|
||||||
|
|
||||||
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
if (cursor < input.HorizonEndUtc && input.FallbackUnits.Count == 0)
|
||||||
run.Warnings.Add(
|
run.Warnings.Add(
|
||||||
new PlanningWarning(
|
new PlanningWarning(
|
||||||
PlanningWarningKind.FallbackEmpty,
|
PlanningWarningKind.FallbackEmpty,
|
||||||
null,
|
null,
|
||||||
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
|
"Нет ни одной единицы для заполнения пауз — в ленте останутся дыры."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
|
return new PlanningResult(run.Items, run.Cursors, run.Warnings);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
|
/// Подводит курсор к старту слота: добирает фоном до якоря либо до круглой отметки. Возвращает
|
||||||
/// фактический старт и заполняет трейс сведениями о дрейфе.
|
/// фактический старт и заполняет трейс сведениями о дрейфе.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DateTimeOffset OpenSlot(
|
private static DateTimeOffset OpenSlot(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
PlanningRun run,
|
PlanningRun run,
|
||||||
out PlanTrace trace
|
out PlanTrace trace
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var snapped = false;
|
var snapped = false;
|
||||||
|
|
||||||
if (cursor < slot.TargetStartUtc)
|
if (cursor < slot.TargetStartUtc)
|
||||||
{
|
{
|
||||||
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
// До целевого времени ещё есть место — закрываем его фоном. Для якоря это обязательно,
|
||||||
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
// для обычного слота тоже: иначе он начнётся раньше объявленного в программе времени.
|
||||||
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
cursor = FillWithFallback(cursor, slot.TargetStartUtc, run, slot.SlotId);
|
||||||
}
|
}
|
||||||
else if (slot.SnapToMinutes is { } snap && snap > 0)
|
else if (slot.SnapToMinutes is { } snap && snap > 0)
|
||||||
{
|
{
|
||||||
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
|
// Слот опаздывает. Округление мягкое: если добирать пришлось бы дольше допуска, лучше
|
||||||
// начать в 19:47, чем девять минут крутить фон.
|
// начать в 19:47, чем девять минут крутить фон.
|
||||||
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
|
var target = RoundUp(cursor, TimeSpan.FromMinutes(snap));
|
||||||
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
|
if (target - cursor <= TimeSpan.FromMinutes(slot.MaxDriftMinutes))
|
||||||
{
|
{
|
||||||
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
|
var afterFill = FillWithFallback(cursor, target, run, slot.SlotId);
|
||||||
snapped = afterFill > cursor;
|
snapped = afterFill > cursor;
|
||||||
cursor = afterFill;
|
cursor = afterFill;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
|
var drift = (int)Math.Round((cursor - slot.TargetStartUtc).TotalMinutes);
|
||||||
if (Math.Abs(drift) > slot.MaxDriftMinutes)
|
if (Math.Abs(drift) > slot.MaxDriftMinutes)
|
||||||
run.Warnings.Add(
|
run.Warnings.Add(
|
||||||
new PlanningWarning(
|
new PlanningWarning(
|
||||||
PlanningWarningKind.DriftExceeded,
|
PlanningWarningKind.DriftExceeded,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
$"Фактический старт разошёлся с целевым на {drift} мин."
|
$"Фактический старт разошёлся с целевым на {drift} мин."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
|
trace = new PlanTrace(slot.SlotId, slot.SlotKind, null, null, null, null, drift, snapped);
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
|
/// <summary>Наполняет слот по его типу и возвращает курсор после него.</summary>
|
||||||
private static DateTimeOffset FillSlot(
|
private static DateTimeOffset FillSlot(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset? nextAnchor,
|
DateTimeOffset? nextAnchor,
|
||||||
PlanTrace trace,
|
PlanTrace trace,
|
||||||
PlanningRun run
|
PlanningRun run
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
|
var limit = Min(run.Input.HorizonEndUtc, nextAnchor);
|
||||||
|
|
||||||
switch (slot.SlotKind)
|
switch (slot.SlotKind)
|
||||||
{
|
{
|
||||||
case SlotKind.SignOff:
|
case SlotKind.SignOff:
|
||||||
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
|
// Конец вещания: место занимает зацикленный фон, но в программе это помечено особо.
|
||||||
return FillWithFallback(
|
return FillWithFallback(
|
||||||
cursor,
|
cursor,
|
||||||
Min(slot.TargetEndUtc, limit),
|
Min(slot.TargetEndUtc, limit),
|
||||||
run,
|
run,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
PlannedItemKind.SignOff,
|
PlannedItemKind.SignOff,
|
||||||
trace
|
trace
|
||||||
);
|
);
|
||||||
|
|
||||||
case SlotKind.Repeat:
|
case SlotKind.Repeat:
|
||||||
return FillRepeat(slot, cursor, limit, trace, run);
|
return FillRepeat(slot, cursor, limit, trace, run);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return FillContent(slot, cursor, limit, trace, run);
|
return FillContent(slot, cursor, limit, trace, run);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset FillRepeat(
|
private static DateTimeOffset FillRepeat(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
PlanTrace trace,
|
PlanTrace trace,
|
||||||
PlanningRun run
|
PlanningRun run
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var units = slot.RepeatUnits ?? [];
|
var units = slot.RepeatUnits ?? [];
|
||||||
if (units.Count == 0)
|
if (units.Count == 0)
|
||||||
{
|
{
|
||||||
run.Warnings.Add(
|
run.Warnings.Add(
|
||||||
new PlanningWarning(
|
new PlanningWarning(
|
||||||
PlanningWarningKind.RepeatSourceEmpty,
|
PlanningWarningKind.RepeatSourceEmpty,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
"В источнике повтора ничего не нашлось — слот закрыт фоном."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var slotEnd = Min(slot.TargetEndUtc, limit);
|
var slotEnd = Min(slot.TargetEndUtc, limit);
|
||||||
foreach (var unit in units)
|
foreach (var unit in units)
|
||||||
{
|
{
|
||||||
if (cursor + unit.Duration > slotEnd)
|
if (cursor + unit.Duration > slotEnd)
|
||||||
break;
|
break;
|
||||||
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
|
run.Items.Add(Program(unit, cursor, slot.SlotId, trace));
|
||||||
cursor += unit.Duration;
|
cursor += unit.Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset FillContent(
|
private static DateTimeOffset FillContent(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset limit,
|
DateTimeOffset limit,
|
||||||
PlanTrace trace,
|
PlanTrace trace,
|
||||||
PlanningRun run
|
PlanningRun run
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var pick = ElementSelector.Select(slot, cursor, run.Random);
|
var pick = ElementSelector.Select(slot, cursor, run.Random);
|
||||||
if (pick is null)
|
if (pick is null)
|
||||||
{
|
{
|
||||||
run.Warnings.Add(NoCandidatesWarning(slot));
|
run.Warnings.Add(NoCandidatesWarning(slot));
|
||||||
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
return FillWithFallback(cursor, Min(slot.TargetEndUtc, limit), run, slot.SlotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pick.CooldownExhausted)
|
if (pick.CooldownExhausted)
|
||||||
run.Warnings.Add(
|
run.Warnings.Add(
|
||||||
new PlanningWarning(
|
new PlanningWarning(
|
||||||
PlanningWarningKind.CooldownExhausted,
|
PlanningWarningKind.CooldownExhausted,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
"Остывание отсекло всех кандидатов — взят самый давний."
|
"Остывание отсекло всех кандидатов — взят самый давний."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
var element = pick.Element;
|
var element = pick.Element;
|
||||||
var slotTrace = trace with
|
var slotTrace = trace with
|
||||||
{
|
{
|
||||||
ElementKind = element.Kind,
|
ElementKind = element.Kind,
|
||||||
ElementId = element.ElementId,
|
ElementId = element.ElementId,
|
||||||
Strategy = slot.Strategy.Kind,
|
Strategy = slot.Strategy.Kind,
|
||||||
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
|
CandidatesAfterCooldown = pick.CandidatesAfterCooldown,
|
||||||
};
|
};
|
||||||
|
|
||||||
var unitIndex = pick.StartUnitIndex;
|
var unitIndex = pick.StartUnitIndex;
|
||||||
var budgetEnd = Min(slot.TargetEndUtc, limit);
|
var budgetEnd = Min(slot.TargetEndUtc, limit);
|
||||||
var placed = 0;
|
var placed = 0;
|
||||||
var accumulated = TimeSpan.Zero;
|
var accumulated = TimeSpan.Zero;
|
||||||
|
|
||||||
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
|
// SkipIfNotFits решается до постановки: если элемент целиком не помещается, слот не начинают.
|
||||||
if (
|
if (
|
||||||
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
|
slot.OverflowPolicy == OverflowPolicy.SkipIfNotFits
|
||||||
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
|
&& !FitsEntirely(element, unitIndex, cursor, budgetEnd)
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||||
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
return FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (unitIndex < element.Units.Count && cursor < limit)
|
while (unitIndex < element.Units.Count && cursor < limit)
|
||||||
{
|
{
|
||||||
var unit = element.Units[unitIndex];
|
var unit = element.Units[unitIndex];
|
||||||
|
|
||||||
// Врезки между единицами: перед каждой, кроме первой в блоке.
|
// Врезки между единицами: перед каждой, кроме первой в блоке.
|
||||||
if (placed > 0)
|
if (placed > 0)
|
||||||
cursor = JunctionFiller.Fill(
|
cursor = JunctionFiller.Fill(
|
||||||
slot.JunctionBetween,
|
slot.JunctionBetween,
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
new JunctionPlacement(
|
new JunctionPlacement(
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
run.PreviousShowId,
|
run.PreviousShowId,
|
||||||
unit.ShowId,
|
unit.ShowId,
|
||||||
ElementChanged: run.PreviousShowId != unit.ShowId
|
ElementChanged: run.PreviousShowId != unit.ShowId
|
||||||
),
|
),
|
||||||
run.Junctions,
|
run.Junctions,
|
||||||
run.Items,
|
run.Items,
|
||||||
slotTrace
|
slotTrace
|
||||||
);
|
);
|
||||||
|
|
||||||
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
|
// Через якорь не перелезаем: то, что не влезает до него, не начинают вовсе.
|
||||||
if (cursor + unit.Duration > limit)
|
if (cursor + unit.Duration > limit)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
if (!WithinBudget(slot, placed, accumulated, cursor, unit, budgetEnd))
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
|
run.Items.Add(Program(unit, cursor, slot.SlotId, slotTrace, CollectionOf(element)));
|
||||||
cursor += unit.Duration;
|
cursor += unit.Duration;
|
||||||
accumulated += unit.Duration;
|
accumulated += unit.Duration;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
placed++;
|
placed++;
|
||||||
run.PreviousShowId = unit.ShowId;
|
run.PreviousShowId = unit.ShowId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
|
// Врезки в конце блока ставятся до добора фоном: иначе реклама оказалась бы после заполнителя.
|
||||||
if (placed > 0)
|
if (placed > 0)
|
||||||
cursor = JunctionFiller.Fill(
|
cursor = JunctionFiller.Fill(
|
||||||
slot.JunctionAfter,
|
slot.JunctionAfter,
|
||||||
cursor,
|
cursor,
|
||||||
limit,
|
limit,
|
||||||
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
new JunctionPlacement(slot.SlotId, run.PreviousShowId, null, ElementChanged: true),
|
||||||
run.Junctions,
|
run.Junctions,
|
||||||
run.Items,
|
run.Items,
|
||||||
slotTrace
|
slotTrace
|
||||||
);
|
);
|
||||||
|
|
||||||
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
run.Cursors.Add(CursorUpdate(slot, element, unitIndex));
|
||||||
|
|
||||||
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
// Недобор до целевого конца закрываем фоном — только для слотов, чей бюджет привязан ко времени.
|
||||||
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
if (slot.BlockMode == SlotBlockMode.FillSlot && cursor < budgetEnd)
|
||||||
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
cursor = FillWithFallback(cursor, budgetEnd, run, slot.SlotId);
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
|
/// Почему слот остался без контента. Пустая группа и отсечённая фильтром — разные беды: во втором
|
||||||
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
|
/// случае контент есть, но не подходит по правилам, и админу надо чинить правило, а не состав
|
||||||
/// группы.
|
/// группы.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
|
private static PlanningWarning NoCandidatesWarning(PlanningSlot slot)
|
||||||
{
|
{
|
||||||
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
|
var hasPlayable = slot.Elements.Any(e => e.Units.Count > 0);
|
||||||
var hasAllowed = slot.Elements.Any(e =>
|
var hasAllowed = slot.Elements.Any(e =>
|
||||||
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
e.Units.Count > 0 && ElementSelector.IsAllowedByAudience(slot, e)
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasPlayable && !hasAllowed
|
return hasPlayable && !hasAllowed
|
||||||
? new PlanningWarning(
|
? new PlanningWarning(
|
||||||
PlanningWarningKind.CandidatesFiltered,
|
PlanningWarningKind.CandidatesFiltered,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
"Возрастной потолок отсёк всех кандидатов — место закрыл фон."
|
||||||
)
|
)
|
||||||
: new PlanningWarning(
|
: new PlanningWarning(
|
||||||
PlanningWarningKind.SlotEmpty,
|
PlanningWarningKind.SlotEmpty,
|
||||||
slot.SlotId,
|
slot.SlotId,
|
||||||
"Слот не дал контента — место закрыл фон."
|
"Слот не дал контента — место закрыл фон."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
/// Влезает ли ещё одна единица в бюджет слота. <see cref="OverflowPolicy.ExtendSlot"/> бюджет
|
||||||
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
/// игнорирует: элемент доигрывается целиком, а разбег подберёт ближайший якорь.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool WithinBudget(
|
private static bool WithinBudget(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
int placed,
|
int placed,
|
||||||
TimeSpan accumulated,
|
TimeSpan accumulated,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
PlanningUnit unit,
|
PlanningUnit unit,
|
||||||
DateTimeOffset budgetEnd
|
DateTimeOffset budgetEnd
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
|
if (slot.OverflowPolicy == OverflowPolicy.ExtendSlot)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return slot.BlockMode switch
|
return slot.BlockMode switch
|
||||||
{
|
{
|
||||||
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
|
SlotBlockMode.Count => placed < Math.Max(1, slot.BlockValue),
|
||||||
// Последняя единица входит целиком: обрезать видеофайл нельзя.
|
// Последняя единица входит целиком: обрезать видеофайл нельзя.
|
||||||
SlotBlockMode.Duration => accumulated
|
SlotBlockMode.Duration => accumulated
|
||||||
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
|
< TimeSpan.FromMinutes(Math.Max(1, slot.BlockValue)),
|
||||||
_ => cursor + unit.Duration <= budgetEnd,
|
_ => cursor + unit.Duration <= budgetEnd,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool FitsEntirely(
|
private static bool FitsEntirely(
|
||||||
PlanningElement element,
|
PlanningElement element,
|
||||||
int fromUnitIndex,
|
int fromUnitIndex,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
DateTimeOffset budgetEnd
|
DateTimeOffset budgetEnd
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var total = element
|
var total = element
|
||||||
.Units.Skip(fromUnitIndex)
|
.Units.Skip(fromUnitIndex)
|
||||||
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
|
.Aggregate(TimeSpan.Zero, (sum, unit) => sum + unit.Duration);
|
||||||
return cursor + total <= budgetEnd;
|
return cursor + total <= budgetEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
|
/// Закрывает интервал зацикленными единицами фона. Ставит только те, что влезают целиком:
|
||||||
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
|
/// обрезать нельзя, а перехлёст сдвинул бы следующий якорь. Остаток короче одной единицы
|
||||||
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
/// остаётся незакрытым — раздача покажет там аварийный филлер канала.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static DateTimeOffset FillWithFallback(
|
private static DateTimeOffset FillWithFallback(
|
||||||
DateTimeOffset from,
|
DateTimeOffset from,
|
||||||
DateTimeOffset until,
|
DateTimeOffset until,
|
||||||
PlanningRun run,
|
PlanningRun run,
|
||||||
Guid? slotId,
|
Guid? slotId,
|
||||||
PlannedItemKind kind = PlannedItemKind.Fallback,
|
PlannedItemKind kind = PlannedItemKind.Fallback,
|
||||||
PlanTrace? trace = null
|
PlanTrace? trace = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var fallback = run.Input.FallbackUnits;
|
var fallback = run.Input.FallbackUnits;
|
||||||
if (fallback.Count == 0 || until <= from)
|
if (fallback.Count == 0 || until <= from)
|
||||||
return from;
|
return from;
|
||||||
|
|
||||||
var cursor = from;
|
var cursor = from;
|
||||||
var index = 0;
|
var index = 0;
|
||||||
var guard = 0;
|
var guard = 0;
|
||||||
|
|
||||||
while (cursor < until && guard++ < IterationBackstop)
|
while (cursor < until && guard++ < IterationBackstop)
|
||||||
{
|
{
|
||||||
var unit = fallback[index % fallback.Count];
|
var unit = fallback[index % fallback.Count];
|
||||||
index++;
|
index++;
|
||||||
|
|
||||||
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
|
if (unit.Duration <= TimeSpan.Zero || cursor + unit.Duration > until)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
run.Items.Add(
|
run.Items.Add(
|
||||||
new PlannedItem(
|
new PlannedItem(
|
||||||
unit.MediaAssetId,
|
unit.MediaAssetId,
|
||||||
cursor,
|
cursor,
|
||||||
cursor + unit.Duration,
|
cursor + unit.Duration,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
slotId,
|
slotId,
|
||||||
kind,
|
kind,
|
||||||
trace
|
trace
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
cursor += unit.Duration;
|
cursor += unit.Duration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
|
/// <summary>Коллекция элемента или null, если в эфир шло отдельное шоу.</summary>
|
||||||
private static Guid? CollectionOf(PlanningElement element) =>
|
private static Guid? CollectionOf(PlanningElement element) =>
|
||||||
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
|
element.Kind == GroupElementKind.Collection ? element.ElementId : null;
|
||||||
|
|
||||||
private static PlannedItem Program(
|
private static PlannedItem Program(
|
||||||
PlanningUnit unit,
|
PlanningUnit unit,
|
||||||
DateTimeOffset start,
|
DateTimeOffset start,
|
||||||
Guid slotId,
|
Guid slotId,
|
||||||
PlanTrace trace,
|
PlanTrace trace,
|
||||||
Guid? collectionId = null
|
Guid? collectionId = null
|
||||||
) =>
|
) =>
|
||||||
new(
|
new(
|
||||||
unit.MediaAssetId,
|
unit.MediaAssetId,
|
||||||
start,
|
start,
|
||||||
start + unit.Duration,
|
start + unit.Duration,
|
||||||
unit.ShowId,
|
unit.ShowId,
|
||||||
unit.UnitIndex,
|
unit.UnitIndex,
|
||||||
slotId,
|
slotId,
|
||||||
PlannedItemKind.Program,
|
PlannedItemKind.Program,
|
||||||
trace,
|
trace,
|
||||||
CollectionId: collectionId
|
CollectionId: collectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
private static PlanningCursorUpdate CursorUpdate(
|
private static PlanningCursorUpdate CursorUpdate(
|
||||||
PlanningSlot slot,
|
PlanningSlot slot,
|
||||||
PlanningElement element,
|
PlanningElement element,
|
||||||
int nextUnitIndex
|
int nextUnitIndex
|
||||||
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
|
) => new(slot.SlotId, element.Kind, element.ElementId, nextUnitIndex);
|
||||||
|
|
||||||
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
/// <summary>Ближайший якорь среди последующих слотов — до него нельзя перелезать контентом.</summary>
|
||||||
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
|
private static DateTimeOffset? FindNextAnchor(IReadOnlyList<PlanningSlot> slots, int fromIndex)
|
||||||
{
|
{
|
||||||
for (var i = fromIndex; i < slots.Count; i++)
|
for (var i = fromIndex; i < slots.Count; i++)
|
||||||
if (slots[i].IsAnchor)
|
if (slots[i].IsAnchor)
|
||||||
return slots[i].TargetStartUtc;
|
return slots[i].TargetStartUtc;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
|
private static DateTimeOffset Min(DateTimeOffset value, DateTimeOffset? other) =>
|
||||||
other is { } o && o < value ? o : value;
|
other is { } o && o < value ? o : value;
|
||||||
|
|
||||||
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
|
private static DateTimeOffset Min(DateTimeOffset a, DateTimeOffset b) => a < b ? a : b;
|
||||||
|
|
||||||
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
|
/// <summary>Округление момента вверх до кратного шага — от начала суток UTC.</summary>
|
||||||
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
|
private static DateTimeOffset RoundUp(DateTimeOffset moment, TimeSpan step)
|
||||||
{
|
{
|
||||||
if (step <= TimeSpan.Zero)
|
if (step <= TimeSpan.Zero)
|
||||||
return moment;
|
return moment;
|
||||||
|
|
||||||
var ticks = step.Ticks;
|
var ticks = step.Ticks;
|
||||||
var remainder = moment.UtcTicks % ticks;
|
var remainder = moment.UtcTicks % ticks;
|
||||||
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
|
return remainder == 0 ? moment : moment.AddTicks(ticks - remainder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,110 @@
|
|||||||
namespace TeleWave.Domain.Programming;
|
namespace TeleWave.Domain.Programming;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
|
/// Шаблон сетки канала: слои со слотами плюс аварийные настройки. Один активный шаблон на канал —
|
||||||
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
|
/// сезонность выражается слоями внутри него, а не вторым шаблоном, иначе получились бы два механизма
|
||||||
/// для одного и того же. На другой канал переносится глубокой копией.
|
/// для одного и того же. На другой канал переносится глубокой копией.
|
||||||
///
|
///
|
||||||
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
|
/// <see cref="Revision"/> растёт при любой правке правил. Эфир при этом не меняется: правка помечает
|
||||||
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
|
/// шаблон изменённым, а хвост пересобирается отдельной командой применения.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ScheduleTemplate
|
public class ScheduleTemplate
|
||||||
{
|
{
|
||||||
private readonly List<GridLayer> _layers = [];
|
private readonly List<GridLayer> _layers = [];
|
||||||
|
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid ChannelId { get; private set; }
|
public Guid ChannelId { get; private set; }
|
||||||
public string Name { get; private set; } = string.Empty;
|
public string Name { get; private set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
|
/// <summary>Аварийная группа, если пуст даже фоновый слой.</summary>
|
||||||
public Guid? FallbackGroupId { get; private set; }
|
public Guid? FallbackGroupId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
/// <summary>Стык по умолчанию — для слотов, у которых свой не задан.</summary>
|
||||||
public Guid? DefaultJunctionId { get; private set; }
|
public Guid? DefaultJunctionId { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
/// Правила отбора кандидатов (детское время, потолок повторов) в JSON. Домен их не разбирает —
|
||||||
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
/// схема живёт в Application, как и у стратегий слотов и условий стыка.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? RulesJson { get; private set; }
|
public string? RulesJson { get; private set; }
|
||||||
|
|
||||||
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
/// <summary>Номер правки правил; входит в кэш-ключи и историю.</summary>
|
||||||
public int Revision { get; private set; }
|
public int Revision { get; private set; }
|
||||||
|
|
||||||
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
|
/// <summary>Ревизия, по которой собрано текущее будущее расписание.</summary>
|
||||||
public int AppliedRevision { get; private set; }
|
public int AppliedRevision { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; private set; }
|
public DateTimeOffset CreatedAt { get; private set; }
|
||||||
|
|
||||||
public IReadOnlyList<GridLayer> Layers => _layers;
|
public IReadOnlyList<GridLayer> Layers => _layers;
|
||||||
|
|
||||||
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
|
/// <summary>Есть ли правки, не применённые к эфиру.</summary>
|
||||||
public bool HasPendingChanges => Revision != AppliedRevision;
|
public bool HasPendingChanges => Revision != AppliedRevision;
|
||||||
|
|
||||||
private const string BackgroundLayerName = "Фон";
|
private const string BackgroundLayerName = "Фон";
|
||||||
|
|
||||||
private ScheduleTemplate() { }
|
private ScheduleTemplate() { }
|
||||||
|
|
||||||
public static ScheduleTemplate Create(Guid channelId, string name)
|
public static ScheduleTemplate Create(Guid channelId, string name)
|
||||||
{
|
{
|
||||||
var template = new ScheduleTemplate
|
var template = new ScheduleTemplate
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
ChannelId = channelId,
|
ChannelId = channelId,
|
||||||
Name = name.Trim(),
|
Name = name.Trim(),
|
||||||
Revision = 0,
|
Revision = 0,
|
||||||
AppliedRevision = 0,
|
AppliedRevision = 0,
|
||||||
CreatedAt = DateTimeOffset.UtcNow,
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
|
// Фоновый слой заводится сразу: без него первая же дыра в сетке осталась бы нечем закрыть.
|
||||||
template._layers.Add(
|
template._layers.Add(
|
||||||
GridLayer.Create(
|
GridLayer.Create(
|
||||||
template.Id,
|
template.Id,
|
||||||
BackgroundLayerName,
|
BackgroundLayerName,
|
||||||
GridLayer.BackgroundPriority,
|
GridLayer.BackgroundPriority,
|
||||||
isBackground: true
|
isBackground: true
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Rename(string name) => Name = name.Trim();
|
public void Rename(string name) => Name = name.Trim();
|
||||||
|
|
||||||
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
|
public void SetFallbackGroup(Guid? groupId) => FallbackGroupId = groupId;
|
||||||
|
|
||||||
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
public void SetDefaultJunction(Guid? junctionId) => DefaultJunctionId = junctionId;
|
||||||
|
|
||||||
public void SetRules(string? rulesJson) =>
|
public void SetRules(string? rulesJson) =>
|
||||||
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
RulesJson = string.IsNullOrWhiteSpace(rulesJson) ? null : rulesJson;
|
||||||
|
|
||||||
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
/// <summary>Отметить, что правила изменились — эфир пойдёт по старым до применения.</summary>
|
||||||
public void MarkChanged() => Revision++;
|
public void MarkChanged() => Revision++;
|
||||||
|
|
||||||
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
|
/// <summary>Отметить, что хвост пересобран по текущей ревизии.</summary>
|
||||||
public void MarkApplied() => AppliedRevision = Revision;
|
public void MarkApplied() => AppliedRevision = Revision;
|
||||||
|
|
||||||
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
|
public GridLayer? FindLayer(Guid layerId) => _layers.FirstOrDefault(l => l.Id == layerId);
|
||||||
|
|
||||||
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
|
public GridLayer? Background => _layers.FirstOrDefault(l => l.IsBackground);
|
||||||
|
|
||||||
public GridLayer AddLayer(string name, int priority)
|
public GridLayer AddLayer(string name, int priority)
|
||||||
{
|
{
|
||||||
var layer = GridLayer.Create(Id, name, priority);
|
var layer = GridLayer.Create(Id, name, priority);
|
||||||
_layers.Add(layer);
|
_layers.Add(layer);
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
|
/// <summary>Удаляет слой. Фоновый удалить нельзя — вернёт false.</summary>
|
||||||
public bool RemoveLayer(Guid layerId)
|
public bool RemoveLayer(Guid layerId)
|
||||||
{
|
{
|
||||||
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
|
var layer = _layers.FirstOrDefault(l => l.Id == layerId);
|
||||||
if (layer is null || layer.IsBackground)
|
if (layer is null || layer.IsBackground)
|
||||||
return false;
|
return false;
|
||||||
_layers.Remove(layer);
|
_layers.Remove(layer);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Слой, содержащий слот, или null.</summary>
|
/// <summary>Слой, содержащий слот, или null.</summary>
|
||||||
public GridLayer? FindLayerOfSlot(Guid slotId) =>
|
public GridLayer? FindLayerOfSlot(Guid slotId) =>
|
||||||
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
|
_layers.FirstOrDefault(l => l.FindSlot(slotId) is not null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,131 @@
|
|||||||
namespace TeleWave.Domain.Programming;
|
namespace TeleWave.Domain.Programming;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
|
/// Слот сетки: когда и чем заполнять эфир. Времена — цели, а не жёсткие границы: контент идёт встык,
|
||||||
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
|
/// слот считается исчерпанным по бюджету, расхождение переносится на следующий. Опорные точки держат
|
||||||
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
|
/// якоря (<see cref="IsAnchor"/>) и мягкое округление (<see cref="SnapToMinutes"/>).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class Slot
|
public class Slot
|
||||||
{
|
{
|
||||||
public Guid Id { get; private set; }
|
public Guid Id { get; private set; }
|
||||||
public Guid LayerId { get; private set; }
|
public Guid LayerId { get; private set; }
|
||||||
|
|
||||||
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
|
/// <summary>День недели вещательных суток (0=Вс..6=Сб) или null — каждый день.</summary>
|
||||||
public int? Weekday { get; private set; }
|
public int? Weekday { get; private set; }
|
||||||
|
|
||||||
/// <summary>Целевое время старта в сутках канала.</summary>
|
/// <summary>Целевое время старта в сутках канала.</summary>
|
||||||
public TimeOnly TargetStart { get; private set; }
|
public TimeOnly TargetStart { get; private set; }
|
||||||
|
|
||||||
/// <summary>Бюджет слота в минутах.</summary>
|
/// <summary>Бюджет слота в минутах.</summary>
|
||||||
public int TargetDurationMinutes { get; private set; }
|
public int TargetDurationMinutes { get; private set; }
|
||||||
|
|
||||||
public string Title { get; private set; } = string.Empty;
|
public string Title { get; private set; } = string.Empty;
|
||||||
public Daypart Daypart { get; private set; }
|
public Daypart Daypart { get; private set; }
|
||||||
public SlotKind SlotKind { get; private set; }
|
public SlotKind SlotKind { get; private set; }
|
||||||
|
|
||||||
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
|
/// <summary>Группа контента — для <see cref="SlotKind.Content"/>.</summary>
|
||||||
public Guid? GroupId { get; private set; }
|
public Guid? GroupId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
|
/// <summary>Стратегия выбора элемента (JSON). Домен её не интерпретирует — схема в Application.</summary>
|
||||||
public string? StrategyJson { get; private set; }
|
public string? StrategyJson { get; private set; }
|
||||||
|
|
||||||
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
|
/// <summary>Откуда брать повтор (JSON) — для <see cref="SlotKind.Repeat"/>.</summary>
|
||||||
public string? RepeatSourceJson { get; private set; }
|
public string? RepeatSourceJson { get; private set; }
|
||||||
|
|
||||||
public SlotBlockMode BlockMode { get; private set; }
|
public SlotBlockMode BlockMode { get; private set; }
|
||||||
|
|
||||||
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
|
/// <summary>Единиц (<see cref="SlotBlockMode.Count"/>) или минут (<see cref="SlotBlockMode.Duration"/>).</summary>
|
||||||
public int BlockValue { get; private set; }
|
public int BlockValue { get; private set; }
|
||||||
|
|
||||||
public OverflowPolicy OverflowPolicy { get; private set; }
|
public OverflowPolicy OverflowPolicy { get; private set; }
|
||||||
|
|
||||||
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
|
/// <summary>Старт жёсткий: генератор не начнёт единицу, которая через него перелезет.</summary>
|
||||||
public bool IsAnchor { get; private set; }
|
public bool IsAnchor { get; private set; }
|
||||||
|
|
||||||
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
|
/// <summary>Допуск отклонения фактического старта от целевого, минуты.</summary>
|
||||||
public int MaxDriftMinutes { get; private set; }
|
public int MaxDriftMinutes { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
|
/// Округлять старт до кратного N минут (5/10/15/30) или null. Мягкое, в отличие от якоря: если
|
||||||
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
|
/// добирать пришлось бы дольше <see cref="MaxDriftMinutes"/>, округление пропускается.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? SnapToMinutes { get; private set; }
|
public int? SnapToMinutes { get; private set; }
|
||||||
|
|
||||||
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
|
/// <summary>Стык между единицами внутри блока (null — врезок внутри блока нет).</summary>
|
||||||
public Guid? JunctionBetweenId { get; private set; }
|
public Guid? JunctionBetweenId { get; private set; }
|
||||||
|
|
||||||
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
/// <summary>Стык в конце блока (null — берётся стык шаблона по умолчанию).</summary>
|
||||||
public Guid? JunctionAfterId { get; private set; }
|
public Guid? JunctionAfterId { get; private set; }
|
||||||
|
|
||||||
public const int DefaultMaxDriftMinutes = 5;
|
public const int DefaultMaxDriftMinutes = 5;
|
||||||
|
|
||||||
private Slot() { }
|
private Slot() { }
|
||||||
|
|
||||||
public static Slot Create(
|
public static Slot Create(
|
||||||
Guid layerId,
|
Guid layerId,
|
||||||
string title,
|
string title,
|
||||||
TimeOnly targetStart,
|
TimeOnly targetStart,
|
||||||
int targetDurationMinutes,
|
int targetDurationMinutes,
|
||||||
Daypart daypart = Daypart.Day,
|
Daypart daypart = Daypart.Day,
|
||||||
SlotKind slotKind = SlotKind.Content,
|
SlotKind slotKind = SlotKind.Content,
|
||||||
int? weekday = null
|
int? weekday = null
|
||||||
) =>
|
) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
LayerId = layerId,
|
LayerId = layerId,
|
||||||
Title = title.Trim(),
|
Title = title.Trim(),
|
||||||
TargetStart = targetStart,
|
TargetStart = targetStart,
|
||||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
|
TargetDurationMinutes = Math.Max(1, targetDurationMinutes),
|
||||||
Daypart = daypart,
|
Daypart = daypart,
|
||||||
SlotKind = slotKind,
|
SlotKind = slotKind,
|
||||||
Weekday = weekday,
|
Weekday = weekday,
|
||||||
BlockMode = SlotBlockMode.FillSlot,
|
BlockMode = SlotBlockMode.FillSlot,
|
||||||
BlockValue = 1,
|
BlockValue = 1,
|
||||||
OverflowPolicy = OverflowPolicy.ContinueNext,
|
OverflowPolicy = OverflowPolicy.ContinueNext,
|
||||||
IsAnchor = false,
|
IsAnchor = false,
|
||||||
MaxDriftMinutes = DefaultMaxDriftMinutes,
|
MaxDriftMinutes = DefaultMaxDriftMinutes,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
|
/// <summary>Правит расписание слота: когда, сколько и как выравнивать.</summary>
|
||||||
public void UpdateTiming(
|
public void UpdateTiming(
|
||||||
int? weekday,
|
int? weekday,
|
||||||
TimeOnly targetStart,
|
TimeOnly targetStart,
|
||||||
int targetDurationMinutes,
|
int targetDurationMinutes,
|
||||||
Daypart daypart,
|
Daypart daypart,
|
||||||
bool isAnchor,
|
bool isAnchor,
|
||||||
int maxDriftMinutes,
|
int maxDriftMinutes,
|
||||||
int? snapToMinutes
|
int? snapToMinutes
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
|
Weekday = weekday is >= 0 and <= 6 ? weekday : null;
|
||||||
TargetStart = targetStart;
|
TargetStart = targetStart;
|
||||||
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
|
TargetDurationMinutes = Math.Max(1, targetDurationMinutes);
|
||||||
Daypart = daypart;
|
Daypart = daypart;
|
||||||
IsAnchor = isAnchor;
|
IsAnchor = isAnchor;
|
||||||
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
|
MaxDriftMinutes = Math.Max(0, maxDriftMinutes);
|
||||||
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
|
SnapToMinutes = snapToMinutes is > 0 ? snapToMinutes : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
/// <summary>Правит наполнение слота: чем, в каком объёме и с какими врезками.</summary>
|
||||||
public void UpdateContent(SlotContent content)
|
public void UpdateContent(SlotContent content)
|
||||||
{
|
{
|
||||||
JunctionBetweenId = content.JunctionBetweenId;
|
JunctionBetweenId = content.JunctionBetweenId;
|
||||||
JunctionAfterId = content.JunctionAfterId;
|
JunctionAfterId = content.JunctionAfterId;
|
||||||
Title = content.Title.Trim();
|
Title = content.Title.Trim();
|
||||||
SlotKind = content.SlotKind;
|
SlotKind = content.SlotKind;
|
||||||
BlockMode = content.BlockMode;
|
BlockMode = content.BlockMode;
|
||||||
BlockValue = Math.Max(1, content.BlockValue);
|
BlockValue = Math.Max(1, content.BlockValue);
|
||||||
OverflowPolicy = content.OverflowPolicy;
|
OverflowPolicy = content.OverflowPolicy;
|
||||||
|
|
||||||
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
// Поля, не относящиеся к типу слота, гасим: повтор и конец вещания стратегии не имеют,
|
||||||
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
// и оставленный от прежнего типа мусор потом читался бы генератором как настройка.
|
||||||
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
|
GroupId = content.SlotKind == SlotKind.Content ? content.GroupId : null;
|
||||||
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
|
StrategyJson = content.SlotKind == SlotKind.Content ? content.StrategyJson : null;
|
||||||
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
|
RepeatSourceJson = content.SlotKind == SlotKind.Repeat ? content.RepeatSourceJson : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
/// <summary>Конец слота в сутках канала. Может выйти за полночь — вещательные сутки длиннее суток.</summary>
|
||||||
public TimeSpan TargetEndOffset =>
|
public TimeSpan TargetEndOffset =>
|
||||||
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
|
TargetStart.ToTimeSpan() + TimeSpan.FromMinutes(TargetDurationMinutes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +1,96 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using TeleWave.Application.Broadcast.Scheduling;
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Broadcast;
|
namespace TeleWave.Infrastructure.Broadcast;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
|
/// Периодически достраивает расписание каждого включённого канала до горизонта. Расширение хвоста —
|
||||||
/// без удаления существующих записей, поэтому эфир не «дёргается».
|
/// без удаления существующих записей, поэтому эфир не «дёргается».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SchedulingBackgroundService(
|
public sealed class SchedulingBackgroundService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IOptions<SchedulerOptions> options,
|
IOptions<SchedulerOptions> options,
|
||||||
ILogger<SchedulingBackgroundService> logger
|
ILogger<SchedulingBackgroundService> logger
|
||||||
) : BackgroundService
|
) : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly SchedulerOptions _options = options.Value;
|
private readonly SchedulerOptions _options = options.Value;
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
|
// Небольшая задержка на старте — дать примениться миграциям/сидингу.
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var timer = new PeriodicTimer(
|
using var timer = new PeriodicTimer(
|
||||||
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
|
TimeSpan.FromMinutes(Math.Max(1, _options.TickMinutes))
|
||||||
);
|
);
|
||||||
|
|
||||||
do
|
do
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await TickAsync(stoppingToken);
|
await TickAsync(stoppingToken);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Ошибка тика планировщика");
|
logger.LogError(ex, "Ошибка тика планировщика");
|
||||||
}
|
}
|
||||||
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
} while (await timer.WaitForNextTickAsync(stoppingToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task TickAsync(CancellationToken cancellationToken)
|
private async Task TickAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await using var scope = scopeFactory.CreateAsyncScope();
|
await using var scope = scopeFactory.CreateAsyncScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<IAppDbContext>();
|
||||||
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
|
var generator = scope.ServiceProvider.GetRequiredService<GridScheduleGenerator>();
|
||||||
|
|
||||||
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
|
// Эфир строит только шаблон сетки: канал без шаблона вещать не может и молча пропускается.
|
||||||
var channelIds = await db
|
var channelIds = await db
|
||||||
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
|
.Channels.Where(c => c.IsEnabled && c.TemplateId != null)
|
||||||
.Select(c => c.Id)
|
.Select(c => c.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
foreach (var channelId in channelIds)
|
foreach (var channelId in channelIds)
|
||||||
{
|
{
|
||||||
var report = await generator.GenerateAsync(
|
var report = await generator.GenerateAsync(
|
||||||
channelId,
|
channelId,
|
||||||
now,
|
now,
|
||||||
rebuildFuture: false,
|
rebuildFuture: false,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
if (report.Added > 0)
|
if (report.Added > 0)
|
||||||
logger.LogInformation(
|
logger.LogInformation(
|
||||||
"Канал {ChannelId}: добавлено {Count} записей расписания",
|
"Канал {ChannelId}: добавлено {Count} записей расписания",
|
||||||
channelId,
|
channelId,
|
||||||
report.Added
|
report.Added
|
||||||
);
|
);
|
||||||
|
|
||||||
foreach (var warning in report.Warnings)
|
foreach (var warning in report.Warnings)
|
||||||
logger.LogWarning(
|
logger.LogWarning(
|
||||||
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
|
"Канал {ChannelId}, слот {SlotId}: {Warning} — {Details}",
|
||||||
channelId,
|
channelId,
|
||||||
warning.SlotId,
|
warning.SlotId,
|
||||||
warning.Kind,
|
warning.Kind,
|
||||||
warning.Details
|
warning.Details
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +1,168 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Broadcast.Scheduling;
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast.Scheduling;
|
using TeleWave.Domain.Broadcast.Scheduling;
|
||||||
using TeleWave.Infrastructure.Broadcast;
|
using TeleWave.Infrastructure.Broadcast;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
using TeleWave.Infrastructure.Library;
|
using TeleWave.Infrastructure.Library;
|
||||||
using TeleWave.Infrastructure.Media;
|
using TeleWave.Infrastructure.Media;
|
||||||
using TeleWave.Infrastructure.Metadata;
|
using TeleWave.Infrastructure.Metadata;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using TeleWave.Infrastructure.Settings;
|
using TeleWave.Infrastructure.Settings;
|
||||||
using TeleWave.Infrastructure.Streaming;
|
using TeleWave.Infrastructure.Streaming;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure;
|
namespace TeleWave.Infrastructure;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
|
/// Регистрация сервисов инфраструктуры: EF Core (PostgreSQL), Identity/JWT-аутентификация.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DependencyInjection
|
public static class DependencyInjection
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddInfrastructure(
|
public static IServiceCollection AddInfrastructure(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration
|
IConfiguration configuration
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
services.AddDbContext<AppDbContext>(options =>
|
services.AddDbContext<AppDbContext>(options =>
|
||||||
options.UseNpgsql(
|
options.UseNpgsql(
|
||||||
configuration["ConnectionStrings:Default"]
|
configuration["ConnectionStrings:Default"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
|
"Строка подключения 'ConnectionStrings:Default' не сконфигурирована."
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
|
services.AddScoped<IAppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
|
||||||
|
|
||||||
services
|
services
|
||||||
.AddIdentityCore<AppUser>(options =>
|
.AddIdentityCore<AppUser>(options =>
|
||||||
{
|
{
|
||||||
options.User.RequireUniqueEmail = false;
|
options.User.RequireUniqueEmail = false;
|
||||||
options.Password.RequiredLength = 8;
|
options.Password.RequiredLength = 8;
|
||||||
options.Password.RequireDigit = true;
|
options.Password.RequireDigit = true;
|
||||||
options.Password.RequireUppercase = true;
|
options.Password.RequireUppercase = true;
|
||||||
options.Password.RequireNonAlphanumeric = false;
|
options.Password.RequireNonAlphanumeric = false;
|
||||||
options.Lockout.MaxFailedAccessAttempts = 5;
|
options.Lockout.MaxFailedAccessAttempts = 5;
|
||||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
|
||||||
options.Lockout.AllowedForNewUsers = true;
|
options.Lockout.AllowedForNewUsers = true;
|
||||||
})
|
})
|
||||||
.AddRoles<AppRole>()
|
.AddRoles<AppRole>()
|
||||||
.AddEntityFrameworkStores<AppDbContext>()
|
.AddEntityFrameworkStores<AppDbContext>()
|
||||||
.AddSignInManager()
|
.AddSignInManager()
|
||||||
.AddDefaultTokenProviders();
|
.AddDefaultTokenProviders();
|
||||||
|
|
||||||
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
services.Configure<JwtOptions>(configuration.GetSection(JwtOptions.SectionName));
|
||||||
services.Configure<AdminSeedOptions>(
|
services.Configure<AdminSeedOptions>(
|
||||||
configuration.GetSection(AdminSeedOptions.SectionName)
|
configuration.GetSection(AdminSeedOptions.SectionName)
|
||||||
);
|
);
|
||||||
|
|
||||||
var jwtOptions =
|
var jwtOptions =
|
||||||
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
|
configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>()
|
||||||
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
|
?? throw new InvalidOperationException("Секция конфигурации 'Jwt' не задана.");
|
||||||
|
|
||||||
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
|
// Fail-fast на подписывающем ключе: этот же ключ подписывает и JWT, и stream-токены
|
||||||
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
|
// (StreamTokenService), поэтому placeholder/короткий ключ из appsettings.json = полный обход
|
||||||
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
|
// авторизации (можно сфорджить admin-JWT). Лучше не стартовать вовсе, чем стартовать уязвимым.
|
||||||
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
|
// HMAC-SHA256 требует ключ не короче размера хеша (32 байта), иначе он ослаблен нулевым паддингом.
|
||||||
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
|
if (Encoding.UTF8.GetByteCount(jwtOptions.SigningKey) < 32)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
|
"Jwt:SigningKey должен быть не короче 32 байт. Задайте криптостойкий секрет через конфигурацию/переменную окружения Jwt__SigningKey."
|
||||||
);
|
);
|
||||||
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
|
if (jwtOptions.SigningKey.Contains("change-me", StringComparison.OrdinalIgnoreCase))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
|
"Jwt:SigningKey использует значение-заглушку из appsettings.json. Переопределите его криптостойким секретом (Jwt__SigningKey)."
|
||||||
);
|
);
|
||||||
|
|
||||||
services
|
services
|
||||||
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
{
|
{
|
||||||
options.TokenValidationParameters = new TokenValidationParameters
|
options.TokenValidationParameters = new TokenValidationParameters
|
||||||
{
|
{
|
||||||
ValidateIssuer = true,
|
ValidateIssuer = true,
|
||||||
ValidIssuer = jwtOptions.Issuer,
|
ValidIssuer = jwtOptions.Issuer,
|
||||||
ValidateAudience = true,
|
ValidateAudience = true,
|
||||||
ValidAudience = jwtOptions.Audience,
|
ValidAudience = jwtOptions.Audience,
|
||||||
ValidateIssuerSigningKey = true,
|
ValidateIssuerSigningKey = true,
|
||||||
IssuerSigningKey = new SymmetricSecurityKey(
|
IssuerSigningKey = new SymmetricSecurityKey(
|
||||||
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
|
Encoding.UTF8.GetBytes(jwtOptions.SigningKey)
|
||||||
),
|
),
|
||||||
ValidateLifetime = true,
|
ValidateLifetime = true,
|
||||||
ClockSkew = TimeSpan.FromSeconds(30),
|
ClockSkew = TimeSpan.FromSeconds(30),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization();
|
services.AddAuthorization();
|
||||||
|
|
||||||
services.AddScoped<IIdentityService, IdentityService>();
|
services.AddScoped<IIdentityService, IdentityService>();
|
||||||
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
services.AddScoped<IJwtTokenService, JwtTokenService>();
|
||||||
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
services.AddScoped<IRefreshTokenService, RefreshTokenService>();
|
||||||
services.AddScoped<IRoleService, RoleService>();
|
services.AddScoped<IRoleService, RoleService>();
|
||||||
services.AddScoped<ICurrentUser, CurrentUser>();
|
services.AddScoped<ICurrentUser, CurrentUser>();
|
||||||
services.AddScoped<ISiteSettings, SiteSettings>();
|
services.AddScoped<ISiteSettings, SiteSettings>();
|
||||||
services.AddScoped<DbInitializer>();
|
services.AddScoped<DbInitializer>();
|
||||||
services.AddScoped<GenreSeeder>();
|
services.AddScoped<GenreSeeder>();
|
||||||
|
|
||||||
AddMedia(services, configuration);
|
AddMedia(services, configuration);
|
||||||
AddBroadcast(services, configuration);
|
AddBroadcast(services, configuration);
|
||||||
AddMetadata(services, configuration);
|
AddMetadata(services, configuration);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
|
/// <summary>Метаданные шоу/серий: провайдеры TMDb/OMDb, резолвер, локальное хранилище картинок.</summary>
|
||||||
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
|
private static void AddMetadata(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
|
services.Configure<MetadataOptions>(configuration.GetSection(MetadataOptions.SectionName));
|
||||||
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
|
services.AddHttpClient("metadata", client => client.Timeout = TimeSpan.FromSeconds(15));
|
||||||
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
|
services.AddSingleton<IMetadataProvider, TmdbMetadataProvider>();
|
||||||
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
|
services.AddSingleton<IMetadataProvider, OmdbMetadataProvider>();
|
||||||
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
|
services.AddSingleton<IMetadataProviderResolver, MetadataProviderResolver>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
|
/// <summary>Планировщик расписания: генератор, источник случайности и фоновый сервис горизонта.</summary>
|
||||||
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
|
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.Configure<SchedulerOptions>(
|
services.Configure<SchedulerOptions>(
|
||||||
configuration.GetSection(SchedulerOptions.SectionName)
|
configuration.GetSection(SchedulerOptions.SectionName)
|
||||||
);
|
);
|
||||||
services.Configure<StreamingOptions>(
|
services.Configure<StreamingOptions>(
|
||||||
configuration.GetSection(StreamingOptions.SectionName)
|
configuration.GetSection(StreamingOptions.SectionName)
|
||||||
);
|
);
|
||||||
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
|
services.Configure<BumperOptions>(configuration.GetSection(BumperOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<IRandomSource, SystemRandomSource>();
|
services.AddSingleton<IRandomSource, SystemRandomSource>();
|
||||||
services.AddSingleton<StreamTokenService>();
|
services.AddSingleton<StreamTokenService>();
|
||||||
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
|
services.AddSingleton<IBumperRenderer, FfmpegBumperRenderer>();
|
||||||
services.AddHostedService<SchedulingBackgroundService>();
|
services.AddHostedService<SchedulingBackgroundService>();
|
||||||
services.AddHostedService<MaintenanceBackgroundService>();
|
services.AddHostedService<MaintenanceBackgroundService>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
|
/// <summary>Хранилище медиа, обработка ffmpeg, очередь и фоновые сервисы (очередь + inbox-сканер).</summary>
|
||||||
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
|
private static void AddMedia(IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
|
services.Configure<StorageOptions>(configuration.GetSection(StorageOptions.SectionName));
|
||||||
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
services.Configure<MediaOptions>(configuration.GetSection(MediaOptions.SectionName));
|
||||||
|
|
||||||
services.AddSingleton<MediaPathResolver>();
|
services.AddSingleton<MediaPathResolver>();
|
||||||
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
services.AddSingleton<IMediaStorage, FileSystemMediaStorage>();
|
||||||
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
services.AddSingleton<IBumperTemplateStorage, BumperTemplateStorage>();
|
||||||
services.AddSingleton<IImageStore, ImageStore>();
|
services.AddSingleton<IImageStore, ImageStore>();
|
||||||
services.AddSingleton<IImageDownloader, ImageDownloader>();
|
services.AddSingleton<IImageDownloader, ImageDownloader>();
|
||||||
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
services.AddSingleton<IAudioProbe, FfprobeAudioProbe>();
|
||||||
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
services.AddSingleton<IMediaProcessor, FfmpegMediaProcessor>();
|
||||||
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
services.AddSingleton<IMediaProcessingQueue, MediaProcessingQueue>();
|
||||||
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
|
services.AddSingleton<IBumperRenderQueue, BumperRenderQueue>();
|
||||||
|
|
||||||
services.AddHostedService<MediaProcessingBackgroundService>();
|
services.AddHostedService<MediaProcessingBackgroundService>();
|
||||||
services.AddHostedService<InboxScannerBackgroundService>();
|
services.AddHostedService<InboxScannerBackgroundService>();
|
||||||
services.AddHostedService<BumperRenderBackgroundService>();
|
services.AddHostedService<BumperRenderBackgroundService>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,171 @@
|
|||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Media;
|
using TeleWave.Application.Media;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Media;
|
namespace TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
|
/// <summary>Файловая реализация <see cref="IMediaStorage"/> поверх <see cref="MediaPathResolver"/>.</summary>
|
||||||
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
|
public sealed class FileSystemMediaStorage(MediaPathResolver paths) : IMediaStorage
|
||||||
{
|
{
|
||||||
private const int CopyBufferSize = 1024 * 1024;
|
private const int CopyBufferSize = 1024 * 1024;
|
||||||
|
|
||||||
public long GetAvailableFreeSpaceBytes()
|
public long GetAvailableFreeSpaceBytes()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
|
return new DriveInfo(paths.AssetsDir).AvailableFreeSpace;
|
||||||
}
|
}
|
||||||
catch (Exception ex) when (ex is ArgumentException or IOException)
|
catch (Exception ex) when (ex is ArgumentException or IOException)
|
||||||
{
|
{
|
||||||
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
|
// Не блокируем загрузку, если ФС не отдаёт метрику (например экзотическая точка монтирования).
|
||||||
return long.MaxValue;
|
return long.MaxValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> SaveUploadAsync(
|
public async Task<string> SaveUploadAsync(
|
||||||
Stream content,
|
Stream content,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(paths.UploadsDir);
|
Directory.CreateDirectory(paths.UploadsDir);
|
||||||
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
var token = Guid.NewGuid().ToString("N") + extension.ToLowerInvariant();
|
||||||
var path = paths.UploadPath(token);
|
var path = paths.UploadPath(token);
|
||||||
|
|
||||||
await using var file = new FileStream(
|
await using var file = new FileStream(
|
||||||
path,
|
path,
|
||||||
FileMode.CreateNew,
|
FileMode.CreateNew,
|
||||||
FileAccess.Write,
|
FileAccess.Write,
|
||||||
FileShare.None,
|
FileShare.None,
|
||||||
CopyBufferSize,
|
CopyBufferSize,
|
||||||
useAsync: true
|
useAsync: true
|
||||||
);
|
);
|
||||||
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
await content.CopyToAsync(file, CopyBufferSize, cancellationToken);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
public Task DeleteUploadAsync(string uploadToken, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var path = paths.UploadPath(uploadToken);
|
var path = paths.UploadPath(uploadToken);
|
||||||
if (File.Exists(path))
|
if (File.Exists(path))
|
||||||
File.Delete(path);
|
File.Delete(path);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
|
public IReadOnlyList<IMediaStorage.ManualInboxFile> ListManualInbox(int max)
|
||||||
{
|
{
|
||||||
if (!Directory.Exists(paths.ManualDir))
|
if (!Directory.Exists(paths.ManualDir))
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
return Directory
|
return Directory
|
||||||
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
|
.EnumerateFiles(paths.ManualDir, "*", SearchOption.AllDirectories)
|
||||||
.Take(Math.Max(1, max))
|
.Take(Math.Max(1, max))
|
||||||
.Select(path => new FileInfo(path))
|
.Select(path => new FileInfo(path))
|
||||||
.Where(file => file.Exists)
|
.Where(file => file.Exists)
|
||||||
.Select(file => new IMediaStorage.ManualInboxFile(
|
.Select(file => new IMediaStorage.ManualInboxFile(
|
||||||
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
|
// Разделитель нормализуем: путь уезжает в URL и обратно приходит строкой запроса.
|
||||||
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
|
Path.GetRelativePath(paths.ManualDir, file.FullName).Replace('\\', '/'),
|
||||||
file.Name,
|
file.Name,
|
||||||
file.Length
|
file.Length
|
||||||
))
|
))
|
||||||
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(f => f.RelativePath, StringComparer.OrdinalIgnoreCase)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task CleanupManualLeftoversAsync(
|
public Task CleanupManualLeftoversAsync(
|
||||||
string relativePath,
|
string relativePath,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var path = paths.ManualPath(relativePath);
|
var path = paths.ManualPath(relativePath);
|
||||||
var directory = Path.GetDirectoryName(path);
|
var directory = Path.GetDirectoryName(path);
|
||||||
if (directory is null || !Directory.Exists(directory))
|
if (directory is null || !Directory.Exists(directory))
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
|
|
||||||
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
|
// Спутник — файл, чьё имя начинается с имени забранного (без расширения) и точки:
|
||||||
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
|
// так ловятся и «Серия.srt», и «Серия.ru.srt». Видеофайлы исключены намеренно —
|
||||||
// «Серия.Extended.mkv» это не мусор, а другой материал.
|
// «Серия.Extended.mkv» это не мусор, а другой материал.
|
||||||
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
|
// Отбор — своим сравнением, а не маской поиска: в имени файла на Linux законно встречается
|
||||||
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
|
// «*», и маска захватила бы чужие файлы. Код удаляет — он обязан быть буквальным.
|
||||||
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
|
var prefix = Path.GetFileNameWithoutExtension(path) + ".";
|
||||||
foreach (var sibling in Directory.EnumerateFiles(directory))
|
foreach (var sibling in Directory.EnumerateFiles(directory))
|
||||||
{
|
{
|
||||||
var name = Path.GetFileName(sibling);
|
var name = Path.GetFileName(sibling);
|
||||||
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
if (!name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||||
continue;
|
continue;
|
||||||
if (MediaFormats.IsAllowed(name))
|
if (MediaFormats.IsAllowed(name))
|
||||||
continue;
|
continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
File.Delete(sibling);
|
File.Delete(sibling);
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
{
|
{
|
||||||
// Файл занят или уже удалён — не повод валить импорт целиком.
|
// Файл занят или уже удалён — не повод валить импорт целиком.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
|
// Опустевший подкаталог тоже мусор. Корень manual/ не трогаем: он нужен всегда.
|
||||||
if (
|
if (
|
||||||
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
|
!string.Equals(directory, paths.ManualDir, StringComparison.Ordinal)
|
||||||
&& !Directory.EnumerateFileSystemEntries(directory).Any()
|
&& !Directory.EnumerateFileSystemEntries(directory).Any()
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Directory.Delete(directory);
|
Directory.Delete(directory);
|
||||||
}
|
}
|
||||||
catch (IOException)
|
catch (IOException)
|
||||||
{
|
{
|
||||||
// Каталог занят — оставим как есть.
|
// Каталог занят — оставим как есть.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task PromoteToOriginalAsync(
|
public Task PromoteToOriginalAsync(
|
||||||
MediaSource source,
|
MediaSource source,
|
||||||
string sourceToken,
|
string sourceToken,
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var sourcePath = source switch
|
var sourcePath = source switch
|
||||||
{
|
{
|
||||||
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
MediaSource.Inbox => paths.InboxPath(sourceToken),
|
||||||
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
|
MediaSource.ManualInbox => paths.ManualPath(sourceToken),
|
||||||
_ => paths.UploadPath(sourceToken),
|
_ => paths.UploadPath(sourceToken),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!File.Exists(sourcePath))
|
if (!File.Exists(sourcePath))
|
||||||
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
throw new FileNotFoundException("Исходный файл не найден в хранилище.", sourcePath);
|
||||||
|
|
||||||
Directory.CreateDirectory(paths.OriginalsDir);
|
Directory.CreateDirectory(paths.OriginalsDir);
|
||||||
var destination = paths.OriginalPath(assetId, extension);
|
var destination = paths.OriginalPath(assetId, extension);
|
||||||
File.Move(sourcePath, destination, overwrite: true);
|
File.Move(sourcePath, destination, overwrite: true);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task DeleteAssetArtifactsAsync(
|
public Task DeleteAssetArtifactsAsync(
|
||||||
Guid assetId,
|
Guid assetId,
|
||||||
string extension,
|
string extension,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) =>
|
) =>
|
||||||
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
// Каталог сегментов удаляется рекурсивно (может быть много .ts) — офлоадим с вызывающего потока
|
||||||
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
// (запрос/фоновый сервис), чтобы не блокировать его на время файлового I/O.
|
||||||
Task.Run(
|
Task.Run(
|
||||||
() =>
|
() =>
|
||||||
{
|
{
|
||||||
var original = paths.OriginalPath(assetId, extension);
|
var original = paths.OriginalPath(assetId, extension);
|
||||||
if (File.Exists(original))
|
if (File.Exists(original))
|
||||||
File.Delete(original);
|
File.Delete(original);
|
||||||
|
|
||||||
var assetDir = paths.AssetDir(assetId);
|
var assetDir = paths.AssetDir(assetId);
|
||||||
if (Directory.Exists(assetDir))
|
if (Directory.Exists(assetDir))
|
||||||
Directory.Delete(assetDir, recursive: true);
|
Directory.Delete(assetDir, recursive: true);
|
||||||
},
|
},
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,111 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Media;
|
namespace TeleWave.Infrastructure.Media;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
|
/// Единая точка резолва путей хранилища + защита от path traversal. Любой путь, собранный из
|
||||||
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
|
/// внешних данных (имя загруженного файла, имя из inbox/), проверяется на нахождение внутри корня.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class MediaPathResolver
|
public sealed class MediaPathResolver
|
||||||
{
|
{
|
||||||
private readonly string _root;
|
private readonly string _root;
|
||||||
|
|
||||||
public MediaPathResolver(IOptions<StorageOptions> options)
|
public MediaPathResolver(IOptions<StorageOptions> options)
|
||||||
{
|
{
|
||||||
_root = Path.GetFullPath(options.Value.RootPath);
|
_root = Path.GetFullPath(options.Value.RootPath);
|
||||||
InboxDir = Path.Combine(_root, "inbox");
|
InboxDir = Path.Combine(_root, "inbox");
|
||||||
ManualDir = Path.Combine(_root, "manual");
|
ManualDir = Path.Combine(_root, "manual");
|
||||||
UploadsDir = Path.Combine(_root, "uploads");
|
UploadsDir = Path.Combine(_root, "uploads");
|
||||||
OriginalsDir = Path.Combine(_root, "originals");
|
OriginalsDir = Path.Combine(_root, "originals");
|
||||||
AssetsDir = Path.Combine(_root, "assets");
|
AssetsDir = Path.Combine(_root, "assets");
|
||||||
BumpersDir = Path.Combine(_root, "bumpers");
|
BumpersDir = Path.Combine(_root, "bumpers");
|
||||||
ImagesDir = Path.Combine(_root, "images");
|
ImagesDir = Path.Combine(_root, "images");
|
||||||
}
|
}
|
||||||
|
|
||||||
public string InboxDir { get; }
|
public string InboxDir { get; }
|
||||||
|
|
||||||
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
|
/// <summary>Ручной inbox: сканером не разбирается, файлы забирает админ из UI сразу в шоу.</summary>
|
||||||
public string ManualDir { get; }
|
public string ManualDir { get; }
|
||||||
public string UploadsDir { get; }
|
public string UploadsDir { get; }
|
||||||
public string OriginalsDir { get; }
|
public string OriginalsDir { get; }
|
||||||
public string AssetsDir { get; }
|
public string AssetsDir { get; }
|
||||||
|
|
||||||
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
|
/// <summary>Сырые файлы шаблонов заставок (фон/музыка) по каналам — не режутся на HLS.</summary>
|
||||||
public string BumpersDir { get; }
|
public string BumpersDir { get; }
|
||||||
|
|
||||||
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
|
/// <summary>Общий реестр изображений (галерея): файлы images/{imageId}{ext}.</summary>
|
||||||
public string ImagesDir { get; }
|
public string ImagesDir { get; }
|
||||||
|
|
||||||
public void EnsureDirectories()
|
public void EnsureDirectories()
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(InboxDir);
|
Directory.CreateDirectory(InboxDir);
|
||||||
Directory.CreateDirectory(ManualDir);
|
Directory.CreateDirectory(ManualDir);
|
||||||
Directory.CreateDirectory(UploadsDir);
|
Directory.CreateDirectory(UploadsDir);
|
||||||
Directory.CreateDirectory(OriginalsDir);
|
Directory.CreateDirectory(OriginalsDir);
|
||||||
Directory.CreateDirectory(AssetsDir);
|
Directory.CreateDirectory(AssetsDir);
|
||||||
Directory.CreateDirectory(BumpersDir);
|
Directory.CreateDirectory(BumpersDir);
|
||||||
Directory.CreateDirectory(ImagesDir);
|
Directory.CreateDirectory(ImagesDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
public string BumperTemplateDir(Guid templateId) =>
|
public string BumperTemplateDir(Guid templateId) =>
|
||||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
|
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N")));
|
||||||
|
|
||||||
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
|
/// <summary>Путь к файлу блока заставки (kind — «audio»/«background», extension — с точкой).</summary>
|
||||||
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
|
public string BumperTemplateFilePath(Guid templateId, string kind, string extension) =>
|
||||||
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
|
EnsureWithinRoot(Path.Combine(BumpersDir, templateId.ToString("N"), kind + extension));
|
||||||
|
|
||||||
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
|
/// <summary>Путь к файлу изображения общего реестра (extension — с точкой).</summary>
|
||||||
public string ImagePath(Guid imageId, string extension) =>
|
public string ImagePath(Guid imageId, string extension) =>
|
||||||
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
|
EnsureWithinRoot(Path.Combine(ImagesDir, imageId.ToString("N") + extension));
|
||||||
|
|
||||||
public string OriginalPath(Guid assetId, string extension) =>
|
public string OriginalPath(Guid assetId, string extension) =>
|
||||||
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
EnsureWithinRoot(Path.Combine(OriginalsDir, assetId.ToString("N") + extension));
|
||||||
|
|
||||||
public string AssetDir(Guid assetId) =>
|
public string AssetDir(Guid assetId) =>
|
||||||
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
|
EnsureWithinRoot(Path.Combine(AssetsDir, assetId.ToString("N")));
|
||||||
|
|
||||||
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
|
public static string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
|
||||||
|
|
||||||
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
|
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
|
||||||
public string SegmentPath(Guid assetId, string fileName)
|
public string SegmentPath(Guid assetId, string fileName)
|
||||||
{
|
{
|
||||||
var assetDir = AssetDir(assetId);
|
var assetDir = AssetDir(assetId);
|
||||||
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
|
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
|
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
|
||||||
public string UploadPath(string token) =>
|
public string UploadPath(string token) =>
|
||||||
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
|
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
|
||||||
|
|
||||||
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
|
/// <summary>Резолвит имя файла внутри inbox/, проверяя выход за пределы каталога.</summary>
|
||||||
public string InboxPath(string fileName) =>
|
public string InboxPath(string fileName) =>
|
||||||
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
EnsureWithin(InboxDir, Path.Combine(InboxDir, fileName));
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
|
/// Резолвит путь внутри manual/. Путь относительный и может содержать подкаталоги — качалки
|
||||||
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
|
/// раскладывают файлы по папкам, — поэтому проверка на выход за пределы каталога здесь
|
||||||
/// обязательна: строка приходит из запроса.
|
/// обязательна: строка приходит из запроса.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string ManualPath(string relativePath) =>
|
public string ManualPath(string relativePath) =>
|
||||||
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
|
EnsureWithin(ManualDir, Path.Combine(ManualDir, relativePath));
|
||||||
|
|
||||||
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
private string EnsureWithinRoot(string candidate) => EnsureWithin(_root, candidate);
|
||||||
|
|
||||||
private static string EnsureWithin(string baseDir, string candidate)
|
private static string EnsureWithin(string baseDir, string candidate)
|
||||||
{
|
{
|
||||||
var full = Path.GetFullPath(candidate);
|
var full = Path.GetFullPath(candidate);
|
||||||
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
|
var normalizedBase = baseDir.EndsWith(Path.DirectorySeparatorChar)
|
||||||
? baseDir
|
? baseDir
|
||||||
: baseDir + Path.DirectorySeparatorChar;
|
: baseDir + Path.DirectorySeparatorChar;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
|
!full.StartsWith(normalizedBase, StringComparison.Ordinal)
|
||||||
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
|
&& !string.Equals(full, baseDir, StringComparison.Ordinal)
|
||||||
)
|
)
|
||||||
throw new UnauthorizedAccessException(
|
throw new UnauthorizedAccessException(
|
||||||
$"Путь '{candidate}' выходит за пределы каталога хранилища."
|
$"Путь '{candidate}' выходит за пределы каталога хранилища."
|
||||||
);
|
);
|
||||||
|
|
||||||
return full;
|
return full;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,330 +1,330 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class InitialCreate : Migration
|
public partial class InitialCreate : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetRoles",
|
name: "AspNetRoles",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
|
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
NormalizedName = table.Column<string>(
|
NormalizedName = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetUsers",
|
name: "AspNetUsers",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
|
IsBlocked = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
UserName = table.Column<string>(
|
UserName = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
NormalizedUserName = table.Column<string>(
|
NormalizedUserName = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
Email = table.Column<string>(
|
Email = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
NormalizedEmail = table.Column<string>(
|
NormalizedEmail = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
PasswordHash = table.Column<string>(type: "text", nullable: true),
|
PasswordHash = table.Column<string>(type: "text", nullable: true),
|
||||||
SecurityStamp = table.Column<string>(type: "text", nullable: true),
|
SecurityStamp = table.Column<string>(type: "text", nullable: true),
|
||||||
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
ConcurrencyStamp = table.Column<string>(type: "text", nullable: true),
|
||||||
PhoneNumber = table.Column<string>(type: "text", nullable: true),
|
PhoneNumber = table.Column<string>(type: "text", nullable: true),
|
||||||
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
LockoutEnd = table.Column<DateTimeOffset>(
|
LockoutEnd = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
|
AccessFailedCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "RefreshTokens",
|
name: "RefreshTokens",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
TokenHash = table.Column<string>(type: "text", nullable: false),
|
TokenHash = table.Column<string>(type: "text", nullable: false),
|
||||||
ExpiresAt = table.Column<DateTimeOffset>(
|
ExpiresAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
RevokedAt = table.Column<DateTimeOffset>(
|
RevokedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
|
ReplacedByTokenHash = table.Column<string>(type: "text", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
table.PrimaryKey("PK_RefreshTokens", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetRoleClaims",
|
name: "AspNetRoleClaims",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table
|
Id = table
|
||||||
.Column<int>(type: "integer", nullable: false)
|
.Column<int>(type: "integer", nullable: false)
|
||||||
.Annotation(
|
.Annotation(
|
||||||
"Npgsql:ValueGenerationStrategy",
|
"Npgsql:ValueGenerationStrategy",
|
||||||
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
|
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
|
||||||
),
|
),
|
||||||
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
|
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||||
ClaimValue = table.Column<string>(type: "text", nullable: true),
|
ClaimValue = table.Column<string>(type: "text", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||||
column: x => x.RoleId,
|
column: x => x.RoleId,
|
||||||
principalTable: "AspNetRoles",
|
principalTable: "AspNetRoles",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetUserClaims",
|
name: "AspNetUserClaims",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table
|
Id = table
|
||||||
.Column<int>(type: "integer", nullable: false)
|
.Column<int>(type: "integer", nullable: false)
|
||||||
.Annotation(
|
.Annotation(
|
||||||
"Npgsql:ValueGenerationStrategy",
|
"Npgsql:ValueGenerationStrategy",
|
||||||
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
|
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
|
||||||
),
|
),
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ClaimType = table.Column<string>(type: "text", nullable: true),
|
ClaimType = table.Column<string>(type: "text", nullable: true),
|
||||||
ClaimValue = table.Column<string>(type: "text", nullable: true),
|
ClaimValue = table.Column<string>(type: "text", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||||
column: x => x.UserId,
|
column: x => x.UserId,
|
||||||
principalTable: "AspNetUsers",
|
principalTable: "AspNetUsers",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetUserLogins",
|
name: "AspNetUserLogins",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||||
ProviderKey = table.Column<string>(type: "text", nullable: false),
|
ProviderKey = table.Column<string>(type: "text", nullable: false),
|
||||||
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
|
ProviderDisplayName = table.Column<string>(type: "text", nullable: true),
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey(
|
table.PrimaryKey(
|
||||||
"PK_AspNetUserLogins",
|
"PK_AspNetUserLogins",
|
||||||
x => new { x.LoginProvider, x.ProviderKey }
|
x => new { x.LoginProvider, x.ProviderKey }
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||||
column: x => x.UserId,
|
column: x => x.UserId,
|
||||||
principalTable: "AspNetUsers",
|
principalTable: "AspNetUsers",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetUserRoles",
|
name: "AspNetUserRoles",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
|
RoleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||||
column: x => x.RoleId,
|
column: x => x.RoleId,
|
||||||
principalTable: "AspNetRoles",
|
principalTable: "AspNetRoles",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||||
column: x => x.UserId,
|
column: x => x.UserId,
|
||||||
principalTable: "AspNetUsers",
|
principalTable: "AspNetUsers",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AspNetUserTokens",
|
name: "AspNetUserTokens",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
LoginProvider = table.Column<string>(type: "text", nullable: false),
|
||||||
Name = table.Column<string>(type: "text", nullable: false),
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
Value = table.Column<string>(type: "text", nullable: true),
|
Value = table.Column<string>(type: "text", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey(
|
table.PrimaryKey(
|
||||||
"PK_AspNetUserTokens",
|
"PK_AspNetUserTokens",
|
||||||
x => new
|
x => new
|
||||||
{
|
{
|
||||||
x.UserId,
|
x.UserId,
|
||||||
x.LoginProvider,
|
x.LoginProvider,
|
||||||
x.Name,
|
x.Name,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||||
column: x => x.UserId,
|
column: x => x.UserId,
|
||||||
principalTable: "AspNetUsers",
|
principalTable: "AspNetUsers",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_AspNetRoleClaims_RoleId",
|
name: "IX_AspNetRoleClaims_RoleId",
|
||||||
table: "AspNetRoleClaims",
|
table: "AspNetRoleClaims",
|
||||||
column: "RoleId"
|
column: "RoleId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "RoleNameIndex",
|
name: "RoleNameIndex",
|
||||||
table: "AspNetRoles",
|
table: "AspNetRoles",
|
||||||
column: "NormalizedName",
|
column: "NormalizedName",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_AspNetUserClaims_UserId",
|
name: "IX_AspNetUserClaims_UserId",
|
||||||
table: "AspNetUserClaims",
|
table: "AspNetUserClaims",
|
||||||
column: "UserId"
|
column: "UserId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_AspNetUserLogins_UserId",
|
name: "IX_AspNetUserLogins_UserId",
|
||||||
table: "AspNetUserLogins",
|
table: "AspNetUserLogins",
|
||||||
column: "UserId"
|
column: "UserId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_AspNetUserRoles_RoleId",
|
name: "IX_AspNetUserRoles_RoleId",
|
||||||
table: "AspNetUserRoles",
|
table: "AspNetUserRoles",
|
||||||
column: "RoleId"
|
column: "RoleId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "EmailIndex",
|
name: "EmailIndex",
|
||||||
table: "AspNetUsers",
|
table: "AspNetUsers",
|
||||||
column: "NormalizedEmail"
|
column: "NormalizedEmail"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "UserNameIndex",
|
name: "UserNameIndex",
|
||||||
table: "AspNetUsers",
|
table: "AspNetUsers",
|
||||||
column: "NormalizedUserName",
|
column: "NormalizedUserName",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_RefreshTokens_TokenHash",
|
name: "IX_RefreshTokens_TokenHash",
|
||||||
table: "RefreshTokens",
|
table: "RefreshTokens",
|
||||||
column: "TokenHash",
|
column: "TokenHash",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_RefreshTokens_UserId",
|
name: "IX_RefreshTokens_UserId",
|
||||||
table: "RefreshTokens",
|
table: "RefreshTokens",
|
||||||
column: "UserId"
|
column: "UserId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "AspNetRoleClaims");
|
migrationBuilder.DropTable(name: "AspNetRoleClaims");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetUserClaims");
|
migrationBuilder.DropTable(name: "AspNetUserClaims");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetUserLogins");
|
migrationBuilder.DropTable(name: "AspNetUserLogins");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetUserRoles");
|
migrationBuilder.DropTable(name: "AspNetUserRoles");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetUserTokens");
|
migrationBuilder.DropTable(name: "AspNetUserTokens");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "RefreshTokens");
|
migrationBuilder.DropTable(name: "RefreshTokens");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetRoles");
|
migrationBuilder.DropTable(name: "AspNetRoles");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "AspNetUsers");
|
migrationBuilder.DropTable(name: "AspNetUsers");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,90 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddMediaAssets : Migration
|
public partial class AddMediaAssets : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "MediaAssets",
|
name: "MediaAssets",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
OriginalFileName = table.Column<string>(
|
OriginalFileName = table.Column<string>(
|
||||||
type: "character varying(512)",
|
type: "character varying(512)",
|
||||||
maxLength: 512,
|
maxLength: 512,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
OriginalExtension = table.Column<string>(
|
OriginalExtension = table.Column<string>(
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Source = table.Column<int>(type: "integer", nullable: false),
|
Source = table.Column<int>(type: "integer", nullable: false),
|
||||||
Status = table.Column<int>(type: "integer", nullable: false),
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
|
Duration = table.Column<TimeSpan>(type: "interval", nullable: true),
|
||||||
SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
|
SegmentSeconds = table.Column<int>(type: "integer", nullable: true),
|
||||||
SegmentCount = table.Column<int>(type: "integer", nullable: true),
|
SegmentCount = table.Column<int>(type: "integer", nullable: true),
|
||||||
Width = table.Column<int>(type: "integer", nullable: true),
|
Width = table.Column<int>(type: "integer", nullable: true),
|
||||||
Height = table.Column<int>(type: "integer", nullable: true),
|
Height = table.Column<int>(type: "integer", nullable: true),
|
||||||
VideoCodec = table.Column<string>(
|
VideoCodec = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
AudioCodec = table.Column<string>(
|
AudioCodec = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
RelativePath = table.Column<string>(
|
RelativePath = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
ErrorMessage = table.Column<string>(
|
ErrorMessage = table.Column<string>(
|
||||||
type: "character varying(2048)",
|
type: "character varying(2048)",
|
||||||
maxLength: 2048,
|
maxLength: 2048,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
UpdatedAt = table.Column<DateTimeOffset>(
|
UpdatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_MediaAssets", x => x.Id);
|
table.PrimaryKey("PK_MediaAssets", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_MediaAssets_CreatedAt",
|
name: "IX_MediaAssets_CreatedAt",
|
||||||
table: "MediaAssets",
|
table: "MediaAssets",
|
||||||
column: "CreatedAt"
|
column: "CreatedAt"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_MediaAssets_Status",
|
name: "IX_MediaAssets_Status",
|
||||||
table: "MediaAssets",
|
table: "MediaAssets",
|
||||||
column: "Status"
|
column: "Status"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "MediaAssets");
|
migrationBuilder.DropTable(name: "MediaAssets");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+308
-308
@@ -1,308 +1,308 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddBroadcastAndLibrary : Migration
|
public partial class AddBroadcastAndLibrary : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Channels",
|
name: "Channels",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Slug = table.Column<string>(
|
Slug = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
EpochUtc = table.Column<DateTimeOffset>(
|
EpochUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
AdInsertion = table.Column<int>(type: "integer", nullable: false),
|
AdInsertion = table.Column<int>(type: "integer", nullable: false),
|
||||||
AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
|
AdsPerBreak = table.Column<int>(type: "integer", nullable: false),
|
||||||
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
|
FillerAssetId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
NextAdIndex = table.Column<int>(type: "integer", nullable: false),
|
NextAdIndex = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Channels", x => x.Id);
|
table.PrimaryKey("PK_Channels", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ScheduleEntries",
|
name: "ScheduleEntries",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||||
StartsAtUtc = table.Column<DateTimeOffset>(
|
StartsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
EndsAtUtc = table.Column<DateTimeOffset>(
|
EndsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: true),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
EpisodeIndex = table.Column<int>(type: "integer", nullable: true),
|
EpisodeIndex = table.Column<int>(type: "integer", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
|
table.PrimaryKey("PK_ScheduleEntries", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Shows",
|
name: "Shows",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Description = table.Column<string>(
|
Description = table.Column<string>(
|
||||||
type: "character varying(2048)",
|
type: "character varying(2048)",
|
||||||
maxLength: 2048,
|
maxLength: 2048,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Shows", x => x.Id);
|
table.PrimaryKey("PK_Shows", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelAd",
|
name: "ChannelAd",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelAd", x => x.Id);
|
table.PrimaryKey("PK_ChannelAd", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelAd_Channels_ChannelId",
|
name: "FK_ChannelAd_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelShow",
|
name: "ChannelShow",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weight = table.Column<int>(type: "integer", nullable: false),
|
Weight = table.Column<int>(type: "integer", nullable: false),
|
||||||
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
||||||
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
||||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
|
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelShow", x => x.Id);
|
table.PrimaryKey("PK_ChannelShow", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelShow_Channels_ChannelId",
|
name: "FK_ChannelShow_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ProgrammingOverride",
|
name: "ProgrammingOverride",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Mode = table.Column<int>(type: "integer", nullable: false),
|
Mode = table.Column<int>(type: "integer", nullable: false),
|
||||||
StartsAtUtc = table.Column<DateTimeOffset>(
|
StartsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
EndsAtUtc = table.Column<DateTimeOffset>(
|
EndsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
|
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ProgrammingOverride_Channels_ChannelId",
|
name: "FK_ProgrammingOverride_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ShowEpisode",
|
name: "ShowEpisode",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ShowEpisode", x => x.Id);
|
table.PrimaryKey("PK_ShowEpisode", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ShowEpisode_Shows_ShowId",
|
name: "FK_ShowEpisode_Shows_ShowId",
|
||||||
column: x => x.ShowId,
|
column: x => x.ShowId,
|
||||||
principalTable: "Shows",
|
principalTable: "Shows",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "OverrideShow",
|
name: "OverrideShow",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
|
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weight = table.Column<int>(type: "integer", nullable: false),
|
Weight = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_OverrideShow", x => x.Id);
|
table.PrimaryKey("PK_OverrideShow", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
|
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
|
||||||
column: x => x.ProgrammingOverrideId,
|
column: x => x.ProgrammingOverrideId,
|
||||||
principalTable: "ProgrammingOverride",
|
principalTable: "ProgrammingOverride",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelAd_ChannelId_Position",
|
name: "IX_ChannelAd_ChannelId_Position",
|
||||||
table: "ChannelAd",
|
table: "ChannelAd",
|
||||||
columns: new[] { "ChannelId", "Position" }
|
columns: new[] { "ChannelId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Channels_Slug",
|
name: "IX_Channels_Slug",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
column: "Slug",
|
column: "Slug",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelShow_ChannelId_ShowId",
|
name: "IX_ChannelShow_ChannelId_ShowId",
|
||||||
table: "ChannelShow",
|
table: "ChannelShow",
|
||||||
columns: new[] { "ChannelId", "ShowId" }
|
columns: new[] { "ChannelId", "ShowId" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_OverrideShow_ProgrammingOverrideId",
|
name: "IX_OverrideShow_ProgrammingOverrideId",
|
||||||
table: "OverrideShow",
|
table: "OverrideShow",
|
||||||
column: "ProgrammingOverrideId"
|
column: "ProgrammingOverrideId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
|
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
|
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
|
name: "IX_ScheduleEntries_ChannelId_EndsAtUtc",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
columns: new[] { "ChannelId", "EndsAtUtc" }
|
columns: new[] { "ChannelId", "EndsAtUtc" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
columns: new[] { "ChannelId", "ShowId" }
|
columns: new[] { "ChannelId", "ShowId" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
|
name: "IX_ScheduleEntries_ChannelId_StartsAtUtc",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
columns: new[] { "ChannelId", "StartsAtUtc" }
|
columns: new[] { "ChannelId", "StartsAtUtc" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ShowEpisode_MediaAssetId",
|
name: "IX_ShowEpisode_MediaAssetId",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
column: "MediaAssetId"
|
column: "MediaAssetId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ShowEpisode_ShowId_Position",
|
name: "IX_ShowEpisode_ShowId_Position",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
columns: new[] { "ShowId", "Position" }
|
columns: new[] { "ShowId", "Position" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "ChannelAd");
|
migrationBuilder.DropTable(name: "ChannelAd");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ChannelShow");
|
migrationBuilder.DropTable(name: "ChannelShow");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "OverrideShow");
|
migrationBuilder.DropTable(name: "OverrideShow");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ScheduleEntries");
|
migrationBuilder.DropTable(name: "ScheduleEntries");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ShowEpisode");
|
migrationBuilder.DropTable(name: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ProgrammingOverride");
|
migrationBuilder.DropTable(name: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Shows");
|
migrationBuilder.DropTable(name: "Shows");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Channels");
|
migrationBuilder.DropTable(name: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +1,61 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddBumpers : Migration
|
public partial class AddBumpers : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "BumpersEnabled",
|
name: "BumpersEnabled",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "BumperAssets",
|
name: "BumperAssets",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
FromShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ToShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Signature = table.Column<string>(
|
Signature = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_BumperAssets", x => x.Id);
|
table.PrimaryKey("PK_BumperAssets", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
|
name: "IX_BumperAssets_FromShowId_ToShowId_Signature",
|
||||||
table: "BumperAssets",
|
table: "BumperAssets",
|
||||||
columns: new[] { "FromShowId", "ToShowId", "Signature" }
|
columns: new[] { "FromShowId", "ToShowId", "Signature" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "BumperAssets");
|
migrationBuilder.DropTable(name: "BumperAssets");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumpersEnabled", table: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+118
-118
@@ -1,118 +1,118 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ChannelBumperSettings : Migration
|
public partial class ChannelBumperSettings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperAccentColor",
|
name: "BumperAccentColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "0x38bdf8"
|
defaultValue: "0x38bdf8"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundColor",
|
name: "BumperBackgroundColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "0x0b1020"
|
defaultValue: "0x0b1020"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundColor2",
|
name: "BumperBackgroundColor2",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "0x1e293b"
|
defaultValue: "0x1e293b"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperDurationSeconds",
|
name: "BumperDurationSeconds",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 8
|
defaultValue: 8
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperFont",
|
name: "BumperFont",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperMinIntervalMinutes",
|
name: "BumperMinIntervalMinutes",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperNextLabel",
|
name: "BumperNextLabel",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "ДАЛЕЕ"
|
defaultValue: "ДАЛЕЕ"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperNowLabel",
|
name: "BumperNowLabel",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "СЕЙЧАС"
|
defaultValue: "СЕЙЧАС"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "BumperOnlyBetweenDifferentShows",
|
name: "BumperOnlyBetweenDifferentShows",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: true
|
defaultValue: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperTextColor",
|
name: "BumperTextColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "white"
|
defaultValue: "white"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperFont", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-97
@@ -1,97 +1,97 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class BumperFilesAndJingles : Migration
|
public partial class BumperFilesAndJingles : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundExtension",
|
name: "BumperBackgroundExtension",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperMode",
|
name: "BumperMode",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperMusicExtension",
|
name: "BumperMusicExtension",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperRevision",
|
name: "BumperRevision",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "NextJingleIndex",
|
name: "NextJingleIndex",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelJingle",
|
name: "ChannelJingle",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
|
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelJingle_Channels_ChannelId",
|
name: "FK_ChannelJingle_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelJingle_ChannelId_Position",
|
name: "IX_ChannelJingle_ChannelId_Position",
|
||||||
table: "ChannelJingle",
|
table: "ChannelJingle",
|
||||||
columns: new[] { "ChannelId", "Position" }
|
columns: new[] { "ChannelId", "Position" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "ChannelJingle");
|
migrationBuilder.DropTable(name: "ChannelJingle");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels");
|
migrationBuilder.DropColumn(name: "NextJingleIndex", table: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AppSettings : Migration
|
public partial class AppSettings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "AppSettings",
|
name: "AppSettings",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Key = table.Column<string>(
|
Key = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Value = table.Column<string>(
|
Value = table.Column<string>(
|
||||||
type: "character varying(1024)",
|
type: "character varying(1024)",
|
||||||
maxLength: 1024,
|
maxLength: 1024,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
table.PrimaryKey("PK_AppSettings", x => x.Key);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "AppSettings");
|
migrationBuilder.DropTable(name: "AppSettings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,57 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ShowMetadata : Migration
|
public partial class ShowMetadata : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "MetadataExternalId",
|
name: "MetadataExternalId",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "MetadataProvider",
|
name: "MetadataProvider",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "PosterPath",
|
name: "PosterPath",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Year",
|
name: "Year",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows");
|
migrationBuilder.DropColumn(name: "MetadataExternalId", table: "Shows");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows");
|
migrationBuilder.DropColumn(name: "MetadataProvider", table: "Shows");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
|
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Year", table: "Shows");
|
migrationBuilder.DropColumn(name: "Year", table: "Shows");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,76 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class EpisodeMetadata : Migration
|
public partial class EpisodeMetadata : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<DateOnly>(
|
migrationBuilder.AddColumn<DateOnly>(
|
||||||
name: "AirDate",
|
name: "AirDate",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "date",
|
type: "date",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Episode",
|
name: "Episode",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Overview",
|
name: "Overview",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "character varying(4096)",
|
type: "character varying(4096)",
|
||||||
maxLength: 4096,
|
maxLength: 4096,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Season",
|
name: "Season",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "StillPath",
|
name: "StillPath",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Title",
|
name: "Title",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "character varying(512)",
|
type: "character varying(512)",
|
||||||
maxLength: 512,
|
maxLength: 512,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "AirDate", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "Episode", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "Overview", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "Season", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "Title", table: "ShowEpisode");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+246
-246
@@ -1,246 +1,246 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class BumperTemplates : Migration
|
public partial class BumperTemplates : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "ChannelJingle");
|
migrationBuilder.DropTable(name: "ChannelJingle");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperAccentColor", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundColor", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundColor2", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperBackgroundExtension", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperDurationSeconds", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMode", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMusicExtension", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperTextColor", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
migrationBuilder.RenameColumn(
|
||||||
name: "NextJingleIndex",
|
name: "NextJingleIndex",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
newName: "NextBumperIndex"
|
newName: "NextBumperIndex"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
|
// Ревизия файлов уехала на блоки заставок — старый счётчик не переносим, стратегия по
|
||||||
// умолчанию Rotation (0).
|
// умолчанию Rotation (0).
|
||||||
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperRevision", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperSelection",
|
name: "BumperSelection",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "BumperTemplate",
|
name: "BumperTemplate",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
BackgroundColor = table.Column<string>(
|
BackgroundColor = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
BackgroundColor2 = table.Column<string>(
|
BackgroundColor2 = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
AccentColor = table.Column<string>(
|
AccentColor = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
TextColor = table.Column<string>(
|
TextColor = table.Column<string>(
|
||||||
type: "character varying(32)",
|
type: "character varying(32)",
|
||||||
maxLength: 32,
|
maxLength: 32,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
BackgroundImageExtension = table.Column<string>(
|
BackgroundImageExtension = table.Column<string>(
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
AudioExtension = table.Column<string>(
|
AudioExtension = table.Column<string>(
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
AudioDurationSeconds = table.Column<double>(
|
AudioDurationSeconds = table.Column<double>(
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
Revision = table.Column<int>(type: "integer", nullable: false),
|
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
|
table.PrimaryKey("PK_BumperTemplate", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_BumperTemplate_Channels_ChannelId",
|
name: "FK_BumperTemplate_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_BumperTemplate_ChannelId_Position",
|
name: "IX_BumperTemplate_ChannelId_Position",
|
||||||
table: "BumperTemplate",
|
table: "BumperTemplate",
|
||||||
columns: new[] { "ChannelId", "Position" }
|
columns: new[] { "ChannelId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
|
// Каждому существующему каналу — дефолтный блок заставки (Position 0, без звука/фона).
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""
|
"""
|
||||||
INSERT INTO "BumperTemplate"
|
INSERT INTO "BumperTemplate"
|
||||||
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
|
("Id", "ChannelId", "Position", "Name", "BackgroundColor", "BackgroundColor2",
|
||||||
"AccentColor", "TextColor", "Revision", "CreatedAt")
|
"AccentColor", "TextColor", "Revision", "CreatedAt")
|
||||||
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
|
SELECT gen_random_uuid(), c."Id", 0, 'Заставка 1', '0x0b1020', '0x1e293b',
|
||||||
'0x38bdf8', 'white', 0, now()
|
'0x38bdf8', 'white', 0, now()
|
||||||
FROM "Channels" c;
|
FROM "Channels" c;
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "BumperTemplate");
|
migrationBuilder.DropTable(name: "BumperTemplate");
|
||||||
|
|
||||||
migrationBuilder.RenameColumn(
|
migrationBuilder.RenameColumn(
|
||||||
name: "NextBumperIndex",
|
name: "NextBumperIndex",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
newName: "NextJingleIndex"
|
newName: "NextJingleIndex"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperSelection", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperRevision",
|
name: "BumperRevision",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperAccentColor",
|
name: "BumperAccentColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundColor",
|
name: "BumperBackgroundColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundColor2",
|
name: "BumperBackgroundColor2",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperBackgroundExtension",
|
name: "BumperBackgroundExtension",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperDurationSeconds",
|
name: "BumperDurationSeconds",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperMode",
|
name: "BumperMode",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperMusicExtension",
|
name: "BumperMusicExtension",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperTextColor",
|
name: "BumperTextColor",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelJingle",
|
name: "ChannelJingle",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
|
table.PrimaryKey("PK_ChannelJingle", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelJingle_Channels_ChannelId",
|
name: "FK_ChannelJingle_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelJingle_ChannelId_Position",
|
name: "IX_ChannelJingle_ChannelId_Position",
|
||||||
table: "ChannelJingle",
|
table: "ChannelJingle",
|
||||||
columns: new[] { "ChannelId", "Position" }
|
columns: new[] { "ChannelId", "Position" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ShowOriginalName : Migration
|
public partial class ShowOriginalName : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "OriginalName",
|
name: "OriginalName",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "OriginalName", table: "Shows");
|
migrationBuilder.DropColumn(name: "OriginalName", table: "Shows");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +1,54 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ImageRegistry : Migration
|
public partial class ImageRegistry : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Images",
|
name: "Images",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Category = table.Column<int>(type: "integer", nullable: false),
|
Category = table.Column<int>(type: "integer", nullable: false),
|
||||||
FileExtension = table.Column<string>(
|
FileExtension = table.Column<string>(
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
OriginalFileName = table.Column<string>(
|
OriginalFileName = table.Column<string>(
|
||||||
type: "character varying(512)",
|
type: "character varying(512)",
|
||||||
maxLength: 512,
|
maxLength: 512,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Images", x => x.Id);
|
table.PrimaryKey("PK_Images", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Images_Category_CreatedAt",
|
name: "IX_Images_Category_CreatedAt",
|
||||||
table: "Images",
|
table: "Images",
|
||||||
columns: new[] { "Category", "CreatedAt" }
|
columns: new[] { "Category", "CreatedAt" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "Images");
|
migrationBuilder.DropTable(name: "Images");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,78 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ShowPosterImage : Migration
|
public partial class ShowPosterImage : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "OriginalName",
|
name: "OriginalName",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "text",
|
oldType: "text",
|
||||||
oldNullable: true
|
oldNullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "PosterImageId",
|
name: "PosterImageId",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
|
// Переносим существующие постеры шоу в общий реестр изображений: на каждый постер —
|
||||||
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
|
// запись Images (Category=1 ShowPoster) с расширением из старого пути; файлы перекладывает
|
||||||
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
|
// startup-шаг RelocateLegacyImagesAsync. Затем удаляем колонку PosterPath.
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE r RECORD; img uuid;
|
DECLARE r RECORD; img uuid;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
|
FOR r IN SELECT "Id", "PosterPath" FROM "Shows" WHERE "PosterPath" IS NOT NULL LOOP
|
||||||
img := gen_random_uuid();
|
img := gen_random_uuid();
|
||||||
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
||||||
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
|
VALUES (img, 1, lower(coalesce(substring(r."PosterPath" from '\.[^.]*$'), '.jpg')), 'poster', now());
|
||||||
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
|
UPDATE "Shows" SET "PosterImageId" = img WHERE "Id" = r."Id";
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
|
migrationBuilder.DropColumn(name: "PosterPath", table: "Shows");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows");
|
migrationBuilder.DropColumn(name: "PosterImageId", table: "Shows");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<string>(
|
migrationBuilder.AlterColumn<string>(
|
||||||
name: "OriginalName",
|
name: "OriginalName",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(string),
|
oldClrType: typeof(string),
|
||||||
oldType: "character varying(256)",
|
oldType: "character varying(256)",
|
||||||
oldMaxLength: 256,
|
oldMaxLength: 256,
|
||||||
oldNullable: true
|
oldNullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "PosterPath",
|
name: "PosterPath",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-89
@@ -1,89 +1,89 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class EpisodeStillAndBumperBgImages : Migration
|
public partial class EpisodeStillAndBumperBgImages : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "StillImageId",
|
name: "StillImageId",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "BackgroundImageId",
|
name: "BackgroundImageId",
|
||||||
table: "BumperTemplate",
|
table: "BumperTemplate",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
|
// Кадры серий → реестр (Category=2 EpisodeStill); файлы перекладывает RelocateLegacyImagesAsync.
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE r RECORD; img uuid;
|
DECLARE r RECORD; img uuid;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
|
FOR r IN SELECT "Id", "StillPath" FROM "ShowEpisode" WHERE "StillPath" IS NOT NULL LOOP
|
||||||
img := gen_random_uuid();
|
img := gen_random_uuid();
|
||||||
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
||||||
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
|
VALUES (img, 2, lower(coalesce(substring(r."StillPath" from '\.[^.]*$'), '.jpg')), 'still', now());
|
||||||
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
|
UPDATE "ShowEpisode" SET "StillImageId" = img WHERE "Id" = r."Id";
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
// Фоны блоков заставок → реестр (Category=3 BumperBackground).
|
// Фоны блоков заставок → реестр (Category=3 BumperBackground).
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE r RECORD; img uuid;
|
DECLARE r RECORD; img uuid;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
|
FOR r IN SELECT "Id", "BackgroundImageExtension" FROM "BumperTemplate" WHERE "BackgroundImageExtension" IS NOT NULL LOOP
|
||||||
img := gen_random_uuid();
|
img := gen_random_uuid();
|
||||||
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
INSERT INTO "Images" ("Id", "Category", "FileExtension", "OriginalFileName", "CreatedAt")
|
||||||
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
|
VALUES (img, 3, lower(r."BackgroundImageExtension"), 'background', now());
|
||||||
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
|
UPDATE "BumperTemplate" SET "BackgroundImageId" = img WHERE "Id" = r."Id";
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "StillPath", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate");
|
migrationBuilder.DropColumn(name: "BackgroundImageExtension", table: "BumperTemplate");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode");
|
migrationBuilder.DropColumn(name: "StillImageId", table: "ShowEpisode");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate");
|
migrationBuilder.DropColumn(name: "BackgroundImageId", table: "BumperTemplate");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "StillPath",
|
name: "StillPath",
|
||||||
table: "ShowEpisode",
|
table: "ShowEpisode",
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BackgroundImageExtension",
|
name: "BackgroundImageExtension",
|
||||||
table: "BumperTemplate",
|
table: "BumperTemplate",
|
||||||
type: "character varying(16)",
|
type: "character varying(16)",
|
||||||
maxLength: 16,
|
maxLength: 16,
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+131
-131
@@ -1,131 +1,131 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class BumperTextVariants : Migration
|
public partial class BumperTextVariants : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "BumperTextVariant",
|
name: "BumperTextVariant",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||||
NowLabel = table.Column<string>(
|
NowLabel = table.Column<string>(
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
NextLabel = table.Column<string>(
|
NextLabel = table.Column<string>(
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Line1 = table.Column<string>(
|
Line1 = table.Column<string>(
|
||||||
type: "character varying(120)",
|
type: "character varying(120)",
|
||||||
maxLength: 120,
|
maxLength: 120,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Line2 = table.Column<string>(
|
Line2 = table.Column<string>(
|
||||||
type: "character varying(120)",
|
type: "character varying(120)",
|
||||||
maxLength: 120,
|
maxLength: 120,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Trigger = table.Column<int>(type: "integer", nullable: false),
|
Trigger = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_BumperTextVariant", x => x.Id);
|
table.PrimaryKey("PK_BumperTextVariant", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
||||||
column: x => x.BumperTemplateId,
|
column: x => x.BumperTemplateId,
|
||||||
principalTable: "BumperTemplate",
|
principalTable: "BumperTemplate",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_BumperTextVariant_BumperTemplateId_Position",
|
name: "IX_BumperTextVariant_BumperTemplateId_Position",
|
||||||
table: "BumperTextVariant",
|
table: "BumperTextVariant",
|
||||||
columns: new[] { "BumperTemplateId", "Position" }
|
columns: new[] { "BumperTemplateId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило
|
// Каждому блоку — дефолтный подблок «Сейчас/Далее» с прежними подписями канала; правило
|
||||||
// показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2).
|
// показа переносим из старой галочки (только на смене шоу → OnShowChange=0, иначе Both=2).
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""
|
"""
|
||||||
DO $$
|
DO $$
|
||||||
DECLARE r RECORD;
|
DECLARE r RECORD;
|
||||||
BEGIN
|
BEGIN
|
||||||
FOR r IN
|
FOR r IN
|
||||||
SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl,
|
SELECT t."Id" AS tid, c."BumperNowLabel" AS nl, c."BumperNextLabel" AS xl,
|
||||||
c."BumperOnlyBetweenDifferentShows" AS only_diff
|
c."BumperOnlyBetweenDifferentShows" AS only_diff
|
||||||
FROM "BumperTemplate" t
|
FROM "BumperTemplate" t
|
||||||
JOIN "Channels" c ON c."Id" = t."ChannelId"
|
JOIN "Channels" c ON c."Id" = t."ChannelId"
|
||||||
LOOP
|
LOOP
|
||||||
INSERT INTO "BumperTextVariant"
|
INSERT INTO "BumperTextVariant"
|
||||||
("Id", "BumperTemplateId", "Position", "Name", "Kind",
|
("Id", "BumperTemplateId", "Position", "Name", "Kind",
|
||||||
"NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt")
|
"NowLabel", "NextLabel", "Line1", "Line2", "Trigger", "CreatedAt")
|
||||||
VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0,
|
VALUES (gen_random_uuid(), r.tid, 0, 'Текст 1', 0,
|
||||||
COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'),
|
COALESCE(NULLIF(r.nl, ''), 'СЕЙЧАС'), COALESCE(NULLIF(r.xl, ''), 'ДАЛЕЕ'),
|
||||||
'', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now());
|
'', '', CASE WHEN r.only_diff THEN 0 ELSE 2 END, now());
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
"""
|
"""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperNextLabel", table: "Channels");
|
||||||
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperNowLabel", table: "Channels");
|
||||||
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperOnlyBetweenDifferentShows", table: "Channels");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "BumperTextVariant");
|
migrationBuilder.DropTable(name: "BumperTextVariant");
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperNextLabel",
|
name: "BumperNextLabel",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "BumperNowLabel",
|
name: "BumperNowLabel",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "text",
|
type: "text",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: ""
|
defaultValue: ""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "BumperOnlyBetweenDifferentShows",
|
name: "BumperOnlyBetweenDifferentShows",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-125
@@ -1,125 +1,125 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class BumperChancesWeightsAndScheduleVariant : Migration
|
public partial class BumperChancesWeightsAndScheduleVariant : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
||||||
table: "BumperTextVariant"
|
table: "BumperTextVariant"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropPrimaryKey(
|
migrationBuilder.DropPrimaryKey(
|
||||||
name: "PK_BumperTextVariant",
|
name: "PK_BumperTextVariant",
|
||||||
table: "BumperTextVariant"
|
table: "BumperTextVariant"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants");
|
migrationBuilder.RenameTable(name: "BumperTextVariant", newName: "BumperTextVariants");
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
migrationBuilder.RenameIndex(
|
||||||
name: "IX_BumperTextVariant_BumperTemplateId_Position",
|
name: "IX_BumperTextVariant_BumperTemplateId_Position",
|
||||||
table: "BumperTextVariants",
|
table: "BumperTextVariants",
|
||||||
newName: "IX_BumperTextVariants_BumperTemplateId_Position"
|
newName: "IX_BumperTextVariants_BumperTemplateId_Position"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "BumperVariantId",
|
name: "BumperVariantId",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
|
// Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи).
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "BumperEpisodeChangeChance",
|
name: "BumperEpisodeChangeChance",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 1.0
|
defaultValue: 1.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "BumperShowChangeChance",
|
name: "BumperShowChangeChance",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 1.0
|
defaultValue: 1.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Weight",
|
name: "Weight",
|
||||||
table: "BumperTextVariants",
|
table: "BumperTextVariants",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 1
|
defaultValue: 1
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddPrimaryKey(
|
migrationBuilder.AddPrimaryKey(
|
||||||
name: "PK_BumperTextVariants",
|
name: "PK_BumperTextVariants",
|
||||||
table: "BumperTextVariants",
|
table: "BumperTextVariants",
|
||||||
column: "Id"
|
column: "Id"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
||||||
table: "BumperTextVariants",
|
table: "BumperTextVariants",
|
||||||
column: "BumperTemplateId",
|
column: "BumperTemplateId",
|
||||||
principalTable: "BumperTemplate",
|
principalTable: "BumperTemplate",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropForeignKey(
|
migrationBuilder.DropForeignKey(
|
||||||
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId",
|
||||||
table: "BumperTextVariants"
|
table: "BumperTextVariants"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropPrimaryKey(
|
migrationBuilder.DropPrimaryKey(
|
||||||
name: "PK_BumperTextVariants",
|
name: "PK_BumperTextVariants",
|
||||||
table: "BumperTextVariants"
|
table: "BumperTextVariants"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries");
|
migrationBuilder.DropColumn(name: "BumperVariantId", table: "ScheduleEntries");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants");
|
migrationBuilder.DropColumn(name: "Weight", table: "BumperTextVariants");
|
||||||
|
|
||||||
migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant");
|
migrationBuilder.RenameTable(name: "BumperTextVariants", newName: "BumperTextVariant");
|
||||||
|
|
||||||
migrationBuilder.RenameIndex(
|
migrationBuilder.RenameIndex(
|
||||||
name: "IX_BumperTextVariants_BumperTemplateId_Position",
|
name: "IX_BumperTextVariants_BumperTemplateId_Position",
|
||||||
table: "BumperTextVariant",
|
table: "BumperTextVariant",
|
||||||
newName: "IX_BumperTextVariant_BumperTemplateId_Position"
|
newName: "IX_BumperTextVariant_BumperTemplateId_Position"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddPrimaryKey(
|
migrationBuilder.AddPrimaryKey(
|
||||||
name: "PK_BumperTextVariant",
|
name: "PK_BumperTextVariant",
|
||||||
table: "BumperTextVariant",
|
table: "BumperTextVariant",
|
||||||
column: "Id"
|
column: "Id"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddForeignKey(
|
migrationBuilder.AddForeignKey(
|
||||||
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId",
|
||||||
table: "BumperTextVariant",
|
table: "BumperTextVariant",
|
||||||
column: "BumperTemplateId",
|
column: "BumperTemplateId",
|
||||||
principalTable: "BumperTemplate",
|
principalTable: "BumperTemplate",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-59
@@ -1,59 +1,59 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ChannelShowPreferredHours : Migration
|
public partial class ChannelShowPreferredHours : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "PreferredWeightMultiplier",
|
name: "PreferredWeightMultiplier",
|
||||||
table: "ChannelShow",
|
table: "ChannelShow",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 3
|
defaultValue: 3
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelShowHour",
|
name: "ChannelShowHour",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
StartHour = table.Column<int>(type: "integer", nullable: false),
|
StartHour = table.Column<int>(type: "integer", nullable: false),
|
||||||
EndHour = table.Column<int>(type: "integer", nullable: false),
|
EndHour = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
|
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
|
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
|
||||||
column: x => x.ChannelShowId,
|
column: x => x.ChannelShowId,
|
||||||
principalTable: "ChannelShow",
|
principalTable: "ChannelShow",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelShowHour_ChannelShowId",
|
name: "IX_ChannelShowHour_ChannelShowId",
|
||||||
table: "ChannelShowHour",
|
table: "ChannelShowHour",
|
||||||
column: "ChannelShowId"
|
column: "ChannelShowId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "ChannelShowHour");
|
migrationBuilder.DropTable(name: "ChannelShowHour");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow");
|
migrationBuilder.DropColumn(name: "PreferredWeightMultiplier", table: "ChannelShow");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+102
-102
@@ -1,102 +1,102 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class WeeklyProgrammingOverrides : Migration
|
public partial class WeeklyProgrammingOverrides : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterColumn<DateTimeOffset>(
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
name: "StartsAtUtc",
|
name: "StartsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(DateTimeOffset),
|
oldClrType: typeof(DateTimeOffset),
|
||||||
oldType: "timestamp with time zone"
|
oldType: "timestamp with time zone"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<DateTimeOffset>(
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
name: "EndsAtUtc",
|
name: "EndsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(DateTimeOffset),
|
oldClrType: typeof(DateTimeOffset),
|
||||||
oldType: "timestamp with time zone"
|
oldType: "timestamp with time zone"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "DayOfWeek",
|
name: "DayOfWeek",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "EndMinute",
|
name: "EndMinute",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Recurrence",
|
name: "Recurrence",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "StartMinute",
|
name: "StartMinute",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride");
|
migrationBuilder.DropColumn(name: "DayOfWeek", table: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride");
|
migrationBuilder.DropColumn(name: "EndMinute", table: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride");
|
migrationBuilder.DropColumn(name: "Recurrence", table: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride");
|
migrationBuilder.DropColumn(name: "StartMinute", table: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<DateTimeOffset>(
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
name: "StartsAtUtc",
|
name: "StartsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new DateTimeOffset(
|
defaultValue: new DateTimeOffset(
|
||||||
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
new TimeSpan(0, 0, 0, 0, 0)
|
new TimeSpan(0, 0, 0, 0, 0)
|
||||||
),
|
),
|
||||||
oldClrType: typeof(DateTimeOffset),
|
oldClrType: typeof(DateTimeOffset),
|
||||||
oldType: "timestamp with time zone",
|
oldType: "timestamp with time zone",
|
||||||
oldNullable: true
|
oldNullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AlterColumn<DateTimeOffset>(
|
migrationBuilder.AlterColumn<DateTimeOffset>(
|
||||||
name: "EndsAtUtc",
|
name: "EndsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new DateTimeOffset(
|
defaultValue: new DateTimeOffset(
|
||||||
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
|
||||||
new TimeSpan(0, 0, 0, 0, 0)
|
new TimeSpan(0, 0, 0, 0, 0)
|
||||||
),
|
),
|
||||||
oldClrType: typeof(DateTimeOffset),
|
oldClrType: typeof(DateTimeOffset),
|
||||||
oldType: "timestamp with time zone",
|
oldType: "timestamp with time zone",
|
||||||
oldNullable: true
|
oldNullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+49
-49
@@ -1,49 +1,49 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class BumperAssetRenderContext : Migration
|
public partial class BumperAssetRenderContext : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "ChannelId",
|
name: "ChannelId",
|
||||||
table: "BumperAssets",
|
table: "BumperAssets",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "TemplateId",
|
name: "TemplateId",
|
||||||
table: "BumperAssets",
|
table: "BumperAssets",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "VariantId",
|
name: "VariantId",
|
||||||
table: "BumperAssets",
|
table: "BumperAssets",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
defaultValue: new Guid("00000000-0000-0000-0000-000000000000")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
|
migrationBuilder.DropColumn(name: "ChannelId", table: "BumperAssets");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
|
migrationBuilder.DropColumn(name: "TemplateId", table: "BumperAssets");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
|
migrationBuilder.DropColumn(name: "VariantId", table: "BumperAssets");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-37
@@ -1,37 +1,37 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class MediaProcessingTiming : Migration
|
public partial class MediaProcessingTiming : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<TimeSpan>(
|
migrationBuilder.AddColumn<TimeSpan>(
|
||||||
name: "ProcessingDuration",
|
name: "ProcessingDuration",
|
||||||
table: "MediaAssets",
|
table: "MediaAssets",
|
||||||
type: "interval",
|
type: "interval",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<DateTimeOffset>(
|
migrationBuilder.AddColumn<DateTimeOffset>(
|
||||||
name: "ProcessingStartedAt",
|
name: "ProcessingStartedAt",
|
||||||
table: "MediaAssets",
|
table: "MediaAssets",
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
|
migrationBuilder.DropColumn(name: "ProcessingDuration", table: "MediaAssets");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
|
migrationBuilder.DropColumn(name: "ProcessingStartedAt", table: "MediaAssets");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ShowAudience : Migration
|
public partial class ShowAudience : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Audience",
|
name: "Audience",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "Audience", table: "Shows");
|
migrationBuilder.DropColumn(name: "Audience", table: "Shows");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,132 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddGenres : Migration
|
public partial class AddGenres : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Genres",
|
name: "Genres",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Slug = table.Column<string>(
|
Slug = table.Column<string>(
|
||||||
type: "character varying(64)",
|
type: "character varying(64)",
|
||||||
maxLength: 64,
|
maxLength: 64,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||||
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
|
IsSystem = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Genres", x => x.Id);
|
table.PrimaryKey("PK_Genres", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "GenreAliases",
|
name: "GenreAliases",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
|
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Value = table.Column<string>(
|
Value = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_GenreAliases", x => x.Id);
|
table.PrimaryKey("PK_GenreAliases", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_GenreAliases_Genres_GenreId",
|
name: "FK_GenreAliases_Genres_GenreId",
|
||||||
column: x => x.GenreId,
|
column: x => x.GenreId,
|
||||||
principalTable: "Genres",
|
principalTable: "Genres",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ShowGenres",
|
name: "ShowGenres",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
|
GenreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
|
IsPrimary = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
|
table.PrimaryKey("PK_ShowGenres", x => new { x.ShowId, x.GenreId });
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ShowGenres_Genres_GenreId",
|
name: "FK_ShowGenres_Genres_GenreId",
|
||||||
column: x => x.GenreId,
|
column: x => x.GenreId,
|
||||||
principalTable: "Genres",
|
principalTable: "Genres",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict
|
onDelete: ReferentialAction.Restrict
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ShowGenres_Shows_ShowId",
|
name: "FK_ShowGenres_Shows_ShowId",
|
||||||
column: x => x.ShowId,
|
column: x => x.ShowId,
|
||||||
principalTable: "Shows",
|
principalTable: "Shows",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GenreAliases_GenreId",
|
name: "IX_GenreAliases_GenreId",
|
||||||
table: "GenreAliases",
|
table: "GenreAliases",
|
||||||
column: "GenreId"
|
column: "GenreId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GenreAliases_Value",
|
name: "IX_GenreAliases_Value",
|
||||||
table: "GenreAliases",
|
table: "GenreAliases",
|
||||||
column: "Value",
|
column: "Value",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Genres_Slug",
|
name: "IX_Genres_Slug",
|
||||||
table: "Genres",
|
table: "Genres",
|
||||||
column: "Slug",
|
column: "Slug",
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ShowGenres_GenreId",
|
name: "IX_ShowGenres_GenreId",
|
||||||
table: "ShowGenres",
|
table: "ShowGenres",
|
||||||
column: "GenreId"
|
column: "GenreId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "GenreAliases");
|
migrationBuilder.DropTable(name: "GenreAliases");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ShowGenres");
|
migrationBuilder.DropTable(name: "ShowGenres");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Genres");
|
migrationBuilder.DropTable(name: "Genres");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,98 +1,98 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddCollections : Migration
|
public partial class AddCollections : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Collections",
|
name: "Collections",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Description = table.Column<string>(
|
Description = table.Column<string>(
|
||||||
type: "character varying(2048)",
|
type: "character varying(2048)",
|
||||||
maxLength: 2048,
|
maxLength: 2048,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
|
PosterImageId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Collections", x => x.Id);
|
table.PrimaryKey("PK_Collections", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "CollectionItems",
|
name: "CollectionItems",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
|
CollectionId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_CollectionItems", x => x.Id);
|
table.PrimaryKey("PK_CollectionItems", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_CollectionItems_Collections_CollectionId",
|
name: "FK_CollectionItems_Collections_CollectionId",
|
||||||
column: x => x.CollectionId,
|
column: x => x.CollectionId,
|
||||||
principalTable: "Collections",
|
principalTable: "Collections",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_CollectionItems_Shows_ShowId",
|
name: "FK_CollectionItems_Shows_ShowId",
|
||||||
column: x => x.ShowId,
|
column: x => x.ShowId,
|
||||||
principalTable: "Shows",
|
principalTable: "Shows",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_CollectionItems_CollectionId_Position",
|
name: "IX_CollectionItems_CollectionId_Position",
|
||||||
table: "CollectionItems",
|
table: "CollectionItems",
|
||||||
columns: new[] { "CollectionId", "Position" }
|
columns: new[] { "CollectionId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_CollectionItems_CollectionId_ShowId",
|
name: "IX_CollectionItems_CollectionId_ShowId",
|
||||||
table: "CollectionItems",
|
table: "CollectionItems",
|
||||||
columns: new[] { "CollectionId", "ShowId" },
|
columns: new[] { "CollectionId", "ShowId" },
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_CollectionItems_ShowId",
|
name: "IX_CollectionItems_ShowId",
|
||||||
table: "CollectionItems",
|
table: "CollectionItems",
|
||||||
column: "ShowId"
|
column: "ShowId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "CollectionItems");
|
migrationBuilder.DropTable(name: "CollectionItems");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Collections");
|
migrationBuilder.DropTable(name: "Collections");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddGroups : Migration
|
public partial class AddGroups : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Groups",
|
name: "Groups",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Description = table.Column<string>(
|
Description = table.Column<string>(
|
||||||
type: "character varying(2048)",
|
type: "character varying(2048)",
|
||||||
maxLength: 2048,
|
maxLength: 2048,
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
FilterJson = table.Column<string>(type: "jsonb", nullable: true),
|
FilterJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
ItemCount = table.Column<int>(type: "integer", nullable: false),
|
ItemCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
UnitCount = table.Column<int>(type: "integer", nullable: false),
|
UnitCount = table.Column<int>(type: "integer", nullable: false),
|
||||||
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
|
TotalDuration = table.Column<TimeSpan>(type: "interval", nullable: false),
|
||||||
StatsComputedAt = table.Column<DateTimeOffset>(
|
StatsComputedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Groups", x => x.Id);
|
table.PrimaryKey("PK_Groups", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "GroupItems",
|
name: "GroupItems",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
|
GroupId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ElementKind = table.Column<int>(type: "integer", nullable: false),
|
ElementKind = table.Column<int>(type: "integer", nullable: false),
|
||||||
ElementId = table.Column<Guid>(type: "uuid", nullable: false),
|
ElementId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weight = table.Column<int>(type: "integer", nullable: false),
|
Weight = table.Column<int>(type: "integer", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_GroupItems", x => x.Id);
|
table.PrimaryKey("PK_GroupItems", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_GroupItems_Groups_GroupId",
|
name: "FK_GroupItems_Groups_GroupId",
|
||||||
column: x => x.GroupId,
|
column: x => x.GroupId,
|
||||||
principalTable: "Groups",
|
principalTable: "Groups",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GroupItems_ElementKind_ElementId",
|
name: "IX_GroupItems_ElementKind_ElementId",
|
||||||
table: "GroupItems",
|
table: "GroupItems",
|
||||||
columns: new[] { "ElementKind", "ElementId" }
|
columns: new[] { "ElementKind", "ElementId" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GroupItems_GroupId_ElementKind_ElementId",
|
name: "IX_GroupItems_GroupId_ElementKind_ElementId",
|
||||||
table: "GroupItems",
|
table: "GroupItems",
|
||||||
columns: new[] { "GroupId", "ElementKind", "ElementId" },
|
columns: new[] { "GroupId", "ElementKind", "ElementId" },
|
||||||
unique: true
|
unique: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GroupItems_GroupId_Position",
|
name: "IX_GroupItems_GroupId_Position",
|
||||||
table: "GroupItems",
|
table: "GroupItems",
|
||||||
columns: new[] { "GroupId", "Position" }
|
columns: new[] { "GroupId", "Position" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "GroupItems");
|
migrationBuilder.DropTable(name: "GroupItems");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Groups");
|
migrationBuilder.DropTable(name: "Groups");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+224
-224
@@ -1,224 +1,224 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddScheduleTemplate : Migration
|
public partial class AddScheduleTemplate : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<TimeOnly>(
|
migrationBuilder.AddColumn<TimeOnly>(
|
||||||
name: "DayStartTime",
|
name: "DayStartTime",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "time without time zone",
|
type: "time without time zone",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: new TimeOnly(0, 0, 0)
|
defaultValue: new TimeOnly(0, 0, 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "Number",
|
name: "Number",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "TemplateId",
|
name: "TemplateId",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "UtcOffsetMinutes",
|
name: "UtcOffsetMinutes",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ScheduleTemplates",
|
name: "ScheduleTemplates",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
FallbackGroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
Revision = table.Column<int>(type: "integer", nullable: false),
|
Revision = table.Column<int>(type: "integer", nullable: false),
|
||||||
AppliedRevision = table.Column<int>(type: "integer", nullable: false),
|
AppliedRevision = table.Column<int>(type: "integer", nullable: false),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
|
table.PrimaryKey("PK_ScheduleTemplates", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "GridLayers",
|
name: "GridLayers",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
TemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Priority = table.Column<int>(type: "integer", nullable: false),
|
Priority = table.Column<int>(type: "integer", nullable: false),
|
||||||
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
|
ApplicabilityJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
IsBackground = table.Column<bool>(type: "boolean", nullable: false),
|
IsBackground = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_GridLayers", x => x.Id);
|
table.PrimaryKey("PK_GridLayers", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_GridLayers_ScheduleTemplates_TemplateId",
|
name: "FK_GridLayers_ScheduleTemplates_TemplateId",
|
||||||
column: x => x.TemplateId,
|
column: x => x.TemplateId,
|
||||||
principalTable: "ScheduleTemplates",
|
principalTable: "ScheduleTemplates",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "Slots",
|
name: "Slots",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
LayerId = table.Column<Guid>(type: "uuid", nullable: false),
|
LayerId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weekday = table.Column<int>(type: "integer", nullable: true),
|
Weekday = table.Column<int>(type: "integer", nullable: true),
|
||||||
TargetStart = table.Column<TimeOnly>(
|
TargetStart = table.Column<TimeOnly>(
|
||||||
type: "time without time zone",
|
type: "time without time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
|
TargetDurationMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||||
Title = table.Column<string>(
|
Title = table.Column<string>(
|
||||||
type: "character varying(256)",
|
type: "character varying(256)",
|
||||||
maxLength: 256,
|
maxLength: 256,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
Daypart = table.Column<int>(type: "integer", nullable: false),
|
Daypart = table.Column<int>(type: "integer", nullable: false),
|
||||||
SlotKind = table.Column<int>(type: "integer", nullable: false),
|
SlotKind = table.Column<int>(type: "integer", nullable: false),
|
||||||
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
|
StrategyJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
|
RepeatSourceJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
||||||
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
||||||
OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
|
OverflowPolicy = table.Column<int>(type: "integer", nullable: false),
|
||||||
IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
|
IsAnchor = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
|
MaxDriftMinutes = table.Column<int>(type: "integer", nullable: false),
|
||||||
SnapToMinutes = table.Column<int>(type: "integer", nullable: true),
|
SnapToMinutes = table.Column<int>(type: "integer", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_Slots", x => x.Id);
|
table.PrimaryKey("PK_Slots", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Slots_GridLayers_LayerId",
|
name: "FK_Slots_GridLayers_LayerId",
|
||||||
column: x => x.LayerId,
|
column: x => x.LayerId,
|
||||||
principalTable: "GridLayers",
|
principalTable: "GridLayers",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Slots_Groups_GroupId",
|
name: "FK_Slots_Groups_GroupId",
|
||||||
column: x => x.GroupId,
|
column: x => x.GroupId,
|
||||||
principalTable: "Groups",
|
principalTable: "Groups",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict
|
onDelete: ReferentialAction.Restrict
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "SlotStates",
|
name: "SlotStates",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
SlotId = table.Column<Guid>(type: "uuid", nullable: false),
|
SlotId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
|
CurrentElementKind = table.Column<int>(type: "integer", nullable: true),
|
||||||
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
|
CurrentElementId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
NextUnitIndex = table.Column<int>(type: "integer", nullable: false),
|
NextUnitIndex = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_SlotStates", x => x.SlotId);
|
table.PrimaryKey("PK_SlotStates", x => x.SlotId);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_SlotStates_Slots_SlotId",
|
name: "FK_SlotStates_Slots_SlotId",
|
||||||
column: x => x.SlotId,
|
column: x => x.SlotId,
|
||||||
principalTable: "Slots",
|
principalTable: "Slots",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Channels_Number",
|
name: "IX_Channels_Number",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
column: "Number",
|
column: "Number",
|
||||||
unique: true,
|
unique: true,
|
||||||
filter: "\"Number\" IS NOT NULL"
|
filter: "\"Number\" IS NOT NULL"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_GridLayers_TemplateId_Priority",
|
name: "IX_GridLayers_TemplateId_Priority",
|
||||||
table: "GridLayers",
|
table: "GridLayers",
|
||||||
columns: new[] { "TemplateId", "Priority" }
|
columns: new[] { "TemplateId", "Priority" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleTemplates_ChannelId",
|
name: "IX_ScheduleTemplates_ChannelId",
|
||||||
table: "ScheduleTemplates",
|
table: "ScheduleTemplates",
|
||||||
column: "ChannelId"
|
column: "ChannelId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Slots_GroupId",
|
name: "IX_Slots_GroupId",
|
||||||
table: "Slots",
|
table: "Slots",
|
||||||
column: "GroupId"
|
column: "GroupId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_Slots_LayerId_TargetStart",
|
name: "IX_Slots_LayerId_TargetStart",
|
||||||
table: "Slots",
|
table: "Slots",
|
||||||
columns: new[] { "LayerId", "TargetStart" }
|
columns: new[] { "LayerId", "TargetStart" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "SlotStates");
|
migrationBuilder.DropTable(name: "SlotStates");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "Slots");
|
migrationBuilder.DropTable(name: "Slots");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "GridLayers");
|
migrationBuilder.DropTable(name: "GridLayers");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ScheduleTemplates");
|
migrationBuilder.DropTable(name: "ScheduleTemplates");
|
||||||
|
|
||||||
migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels");
|
migrationBuilder.DropIndex(name: "IX_Channels_Number", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels");
|
migrationBuilder.DropColumn(name: "DayStartTime", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "Number", table: "Channels");
|
migrationBuilder.DropColumn(name: "Number", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "TemplateId", table: "Channels");
|
migrationBuilder.DropColumn(name: "TemplateId", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels");
|
migrationBuilder.DropColumn(name: "UtcOffsetMinutes", table: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-59
@@ -1,59 +1,59 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ScheduleEntryTrace : Migration
|
public partial class ScheduleEntryTrace : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
||||||
table: "ScheduleEntries"
|
table: "ScheduleEntries"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "SlotId",
|
name: "SlotId",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "TraceJson",
|
name: "TraceJson",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
type: "jsonb",
|
type: "jsonb",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
|
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }
|
columns: new[] { "ChannelId", "ShowId", "StartsAtUtc" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropIndex(
|
migrationBuilder.DropIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
|
name: "IX_ScheduleEntries_ChannelId_ShowId_StartsAtUtc",
|
||||||
table: "ScheduleEntries"
|
table: "ScheduleEntries"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries");
|
migrationBuilder.DropColumn(name: "SlotId", table: "ScheduleEntries");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries");
|
migrationBuilder.DropColumn(name: "TraceJson", table: "ScheduleEntries");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
name: "IX_ScheduleEntries_ChannelId_ShowId",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
columns: new[] { "ChannelId", "ShowId" }
|
columns: new[] { "ChannelId", "ShowId" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+219
-219
@@ -1,219 +1,219 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class DropLegacyRotation : Migration
|
public partial class DropLegacyRotation : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "ChannelAd");
|
migrationBuilder.DropTable(name: "ChannelAd");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ChannelShowHour");
|
migrationBuilder.DropTable(name: "ChannelShowHour");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "OverrideShow");
|
migrationBuilder.DropTable(name: "OverrideShow");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ChannelShow");
|
migrationBuilder.DropTable(name: "ChannelShow");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "ProgrammingOverride");
|
migrationBuilder.DropTable(name: "ProgrammingOverride");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels");
|
migrationBuilder.DropColumn(name: "AdInsertion", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels");
|
migrationBuilder.DropColumn(name: "AdsPerBreak", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels");
|
migrationBuilder.DropColumn(name: "NextAdIndex", table: "Channels");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "AdInsertion",
|
name: "AdInsertion",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "AdsPerBreak",
|
name: "AdsPerBreak",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "NextAdIndex",
|
name: "NextAdIndex",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelAd",
|
name: "ChannelAd",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
MediaAssetId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelAd", x => x.Id);
|
table.PrimaryKey("PK_ChannelAd", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelAd_Channels_ChannelId",
|
name: "FK_ChannelAd_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelShow",
|
name: "ChannelShow",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
BlockMode = table.Column<int>(type: "integer", nullable: false),
|
||||||
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
BlockValue = table.Column<int>(type: "integer", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
IsEnabled = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
|
NextEpisodeIndex = table.Column<int>(type: "integer", nullable: false),
|
||||||
PreferredWeightMultiplier = table.Column<int>(
|
PreferredWeightMultiplier = table.Column<int>(
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 3
|
defaultValue: 3
|
||||||
),
|
),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weight = table.Column<int>(type: "integer", nullable: false),
|
Weight = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelShow", x => x.Id);
|
table.PrimaryKey("PK_ChannelShow", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelShow_Channels_ChannelId",
|
name: "FK_ChannelShow_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ProgrammingOverride",
|
name: "ProgrammingOverride",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
DayOfWeek = table.Column<int>(type: "integer", nullable: true),
|
DayOfWeek = table.Column<int>(type: "integer", nullable: true),
|
||||||
EndMinute = table.Column<int>(type: "integer", nullable: true),
|
EndMinute = table.Column<int>(type: "integer", nullable: true),
|
||||||
EndsAtUtc = table.Column<DateTimeOffset>(
|
EndsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
Mode = table.Column<int>(type: "integer", nullable: false),
|
Mode = table.Column<int>(type: "integer", nullable: false),
|
||||||
Recurrence = table.Column<int>(type: "integer", nullable: false),
|
Recurrence = table.Column<int>(type: "integer", nullable: false),
|
||||||
StartMinute = table.Column<int>(type: "integer", nullable: true),
|
StartMinute = table.Column<int>(type: "integer", nullable: true),
|
||||||
StartsAtUtc = table.Column<DateTimeOffset>(
|
StartsAtUtc = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: true
|
nullable: true
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
|
table.PrimaryKey("PK_ProgrammingOverride", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ProgrammingOverride_Channels_ChannelId",
|
name: "FK_ProgrammingOverride_Channels_ChannelId",
|
||||||
column: x => x.ChannelId,
|
column: x => x.ChannelId,
|
||||||
principalTable: "Channels",
|
principalTable: "Channels",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "ChannelShowHour",
|
name: "ChannelShowHour",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
EndHour = table.Column<int>(type: "integer", nullable: false),
|
EndHour = table.Column<int>(type: "integer", nullable: false),
|
||||||
StartHour = table.Column<int>(type: "integer", nullable: false),
|
StartHour = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
|
table.PrimaryKey("PK_ChannelShowHour", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
|
name: "FK_ChannelShowHour_ChannelShow_ChannelShowId",
|
||||||
column: x => x.ChannelShowId,
|
column: x => x.ChannelShowId,
|
||||||
principalTable: "ChannelShow",
|
principalTable: "ChannelShow",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "OverrideShow",
|
name: "OverrideShow",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
|
ProgrammingOverrideId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
ShowId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Weight = table.Column<int>(type: "integer", nullable: false),
|
Weight = table.Column<int>(type: "integer", nullable: false),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_OverrideShow", x => x.Id);
|
table.PrimaryKey("PK_OverrideShow", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
|
name: "FK_OverrideShow_ProgrammingOverride_ProgrammingOverrideId",
|
||||||
column: x => x.ProgrammingOverrideId,
|
column: x => x.ProgrammingOverrideId,
|
||||||
principalTable: "ProgrammingOverride",
|
principalTable: "ProgrammingOverride",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelAd_ChannelId_Position",
|
name: "IX_ChannelAd_ChannelId_Position",
|
||||||
table: "ChannelAd",
|
table: "ChannelAd",
|
||||||
columns: new[] { "ChannelId", "Position" }
|
columns: new[] { "ChannelId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelShow_ChannelId_ShowId",
|
name: "IX_ChannelShow_ChannelId_ShowId",
|
||||||
table: "ChannelShow",
|
table: "ChannelShow",
|
||||||
columns: new[] { "ChannelId", "ShowId" }
|
columns: new[] { "ChannelId", "ShowId" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ChannelShowHour_ChannelShowId",
|
name: "IX_ChannelShowHour_ChannelShowId",
|
||||||
table: "ChannelShowHour",
|
table: "ChannelShowHour",
|
||||||
column: "ChannelShowId"
|
column: "ChannelShowId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_OverrideShow_ProgrammingOverrideId",
|
name: "IX_OverrideShow_ProgrammingOverrideId",
|
||||||
table: "OverrideShow",
|
table: "OverrideShow",
|
||||||
column: "ProgrammingOverrideId"
|
column: "ProgrammingOverrideId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
|
name: "IX_ProgrammingOverride_ChannelId_StartsAtUtc_EndsAtUtc",
|
||||||
table: "ProgrammingOverride",
|
table: "ProgrammingOverride",
|
||||||
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
|
columns: new[] { "ChannelId", "StartsAtUtc", "EndsAtUtc" }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-125
@@ -1,125 +1,125 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddJunctionTemplates : Migration
|
public partial class AddJunctionTemplates : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "JunctionAfterId",
|
name: "JunctionAfterId",
|
||||||
table: "Slots",
|
table: "Slots",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "JunctionBetweenId",
|
name: "JunctionBetweenId",
|
||||||
table: "Slots",
|
table: "Slots",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "DefaultJunctionId",
|
name: "DefaultJunctionId",
|
||||||
table: "ScheduleTemplates",
|
table: "ScheduleTemplates",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "JunctionTemplates",
|
name: "JunctionTemplates",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
ChannelId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Name = table.Column<string>(
|
Name = table.Column<string>(
|
||||||
type: "character varying(128)",
|
type: "character varying(128)",
|
||||||
maxLength: 128,
|
maxLength: 128,
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
CreatedAt = table.Column<DateTimeOffset>(
|
CreatedAt = table.Column<DateTimeOffset>(
|
||||||
type: "timestamp with time zone",
|
type: "timestamp with time zone",
|
||||||
nullable: false
|
nullable: false
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
|
table.PrimaryKey("PK_JunctionTemplates", x => x.Id);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "JunctionElements",
|
name: "JunctionElements",
|
||||||
columns: table => new
|
columns: table => new
|
||||||
{
|
{
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
JunctionTemplateId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
Position = table.Column<int>(type: "integer", nullable: false),
|
Position = table.Column<int>(type: "integer", nullable: false),
|
||||||
Kind = table.Column<int>(type: "integer", nullable: false),
|
Kind = table.Column<int>(type: "integer", nullable: false),
|
||||||
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
GroupId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
|
BumperTemplateId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
AmountMode = table.Column<int>(type: "integer", nullable: false),
|
AmountMode = table.Column<int>(type: "integer", nullable: false),
|
||||||
AmountValue = table.Column<int>(type: "integer", nullable: false),
|
AmountValue = table.Column<int>(type: "integer", nullable: false),
|
||||||
IsRequired = table.Column<bool>(type: "boolean", nullable: false),
|
IsRequired = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true),
|
ConditionsJson = table.Column<string>(type: "jsonb", nullable: true),
|
||||||
},
|
},
|
||||||
constraints: table =>
|
constraints: table =>
|
||||||
{
|
{
|
||||||
table.PrimaryKey("PK_JunctionElements", x => x.Id);
|
table.PrimaryKey("PK_JunctionElements", x => x.Id);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_JunctionElements_Groups_GroupId",
|
name: "FK_JunctionElements_Groups_GroupId",
|
||||||
column: x => x.GroupId,
|
column: x => x.GroupId,
|
||||||
principalTable: "Groups",
|
principalTable: "Groups",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Restrict
|
onDelete: ReferentialAction.Restrict
|
||||||
);
|
);
|
||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
|
name: "FK_JunctionElements_JunctionTemplates_JunctionTemplateId",
|
||||||
column: x => x.JunctionTemplateId,
|
column: x => x.JunctionTemplateId,
|
||||||
principalTable: "JunctionTemplates",
|
principalTable: "JunctionTemplates",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade
|
onDelete: ReferentialAction.Cascade
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JunctionElements_GroupId",
|
name: "IX_JunctionElements_GroupId",
|
||||||
table: "JunctionElements",
|
table: "JunctionElements",
|
||||||
column: "GroupId"
|
column: "GroupId"
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JunctionElements_JunctionTemplateId_Position",
|
name: "IX_JunctionElements_JunctionTemplateId_Position",
|
||||||
table: "JunctionElements",
|
table: "JunctionElements",
|
||||||
columns: new[] { "JunctionTemplateId", "Position" }
|
columns: new[] { "JunctionTemplateId", "Position" }
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JunctionTemplates_ChannelId",
|
name: "IX_JunctionTemplates_ChannelId",
|
||||||
table: "JunctionTemplates",
|
table: "JunctionTemplates",
|
||||||
column: "ChannelId"
|
column: "ChannelId"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropTable(name: "JunctionElements");
|
migrationBuilder.DropTable(name: "JunctionElements");
|
||||||
|
|
||||||
migrationBuilder.DropTable(name: "JunctionTemplates");
|
migrationBuilder.DropTable(name: "JunctionTemplates");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots");
|
migrationBuilder.DropColumn(name: "JunctionAfterId", table: "Slots");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots");
|
migrationBuilder.DropColumn(name: "JunctionBetweenId", table: "Slots");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates");
|
migrationBuilder.DropColumn(name: "DefaultJunctionId", table: "ScheduleTemplates");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-64
@@ -1,64 +1,64 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class DropDeadBumperSettings : Migration
|
public partial class DropDeadBumperSettings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
|
// Значение 0 (прежняя «ротация») из перечисления убрано: курсора ротации больше нет,
|
||||||
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
|
// и выбор молча вырождался в случайный. Переводим такие каналы на выбор по весам.
|
||||||
migrationBuilder.Sql(
|
migrationBuilder.Sql(
|
||||||
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
|
"""UPDATE "Channels" SET "BumperSelection" = 3 WHERE "BumperSelection" = 0;"""
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperEpisodeChangeChance", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperMinIntervalMinutes", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
|
migrationBuilder.DropColumn(name: "BumperShowChangeChance", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels");
|
migrationBuilder.DropColumn(name: "NextBumperIndex", table: "Channels");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "BumperEpisodeChangeChance",
|
name: "BumperEpisodeChangeChance",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0.0
|
defaultValue: 0.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "BumperMinIntervalMinutes",
|
name: "BumperMinIntervalMinutes",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "BumperShowChangeChance",
|
name: "BumperShowChangeChance",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0.0
|
defaultValue: 0.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "NextBumperIndex",
|
name: "NextBumperIndex",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-27
@@ -1,27 +1,27 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class TemplatePlanningRules : Migration
|
public partial class TemplatePlanningRules : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "RulesJson",
|
name: "RulesJson",
|
||||||
table: "ScheduleTemplates",
|
table: "ScheduleTemplates",
|
||||||
type: "jsonb",
|
type: "jsonb",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates");
|
migrationBuilder.DropColumn(name: "RulesJson", table: "ScheduleTemplates");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-68
@@ -1,68 +1,68 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ChannelViewerSettings : Migration
|
public partial class ChannelViewerSettings : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "AnalogFilterStrength",
|
name: "AnalogFilterStrength",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0.0
|
defaultValue: 0.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<int>(
|
migrationBuilder.AddColumn<int>(
|
||||||
name: "LogoCorner",
|
name: "LogoCorner",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0
|
defaultValue: 0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "LogoImageId",
|
name: "LogoImageId",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<double>(
|
migrationBuilder.AddColumn<double>(
|
||||||
name: "LogoOpacity",
|
name: "LogoOpacity",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "double precision",
|
type: "double precision",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0.0
|
defaultValue: 0.0
|
||||||
);
|
);
|
||||||
|
|
||||||
migrationBuilder.AddColumn<bool>(
|
migrationBuilder.AddColumn<bool>(
|
||||||
name: "ShowClock",
|
name: "ShowClock",
|
||||||
table: "Channels",
|
table: "Channels",
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels");
|
migrationBuilder.DropColumn(name: "AnalogFilterStrength", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels");
|
migrationBuilder.DropColumn(name: "LogoCorner", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels");
|
migrationBuilder.DropColumn(name: "LogoImageId", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels");
|
migrationBuilder.DropColumn(name: "LogoOpacity", table: "Channels");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(name: "ShowClock", table: "Channels");
|
migrationBuilder.DropColumn(name: "ShowClock", table: "Channels");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-28
@@ -1,28 +1,28 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ScheduleEntryCollection : Migration
|
public partial class ScheduleEntryCollection : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<Guid>(
|
migrationBuilder.AddColumn<Guid>(
|
||||||
name: "CollectionId",
|
name: "CollectionId",
|
||||||
table: "ScheduleEntries",
|
table: "ScheduleEntries",
|
||||||
type: "uuid",
|
type: "uuid",
|
||||||
nullable: true
|
nullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries");
|
migrationBuilder.DropColumn(name: "CollectionId", table: "ScheduleEntries");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Migrations
|
namespace TeleWave.Infrastructure.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class ShowAudienceMpaa : Migration
|
public partial class ShowAudienceMpaa : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "Audience",
|
name: "Audience",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: true,
|
nullable: true,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "integer"
|
oldType: "integer"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AlterColumn<int>(
|
migrationBuilder.AlterColumn<int>(
|
||||||
name: "Audience",
|
name: "Audience",
|
||||||
table: "Shows",
|
table: "Shows",
|
||||||
type: "integer",
|
type: "integer",
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
oldClrType: typeof(int),
|
oldClrType: typeof(int),
|
||||||
oldType: "integer",
|
oldType: "integer",
|
||||||
oldNullable: true
|
oldNullable: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,83 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Domain.Auth;
|
using TeleWave.Domain.Auth;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Images;
|
using TeleWave.Domain.Images;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
using TeleWave.Infrastructure.Identity;
|
using TeleWave.Infrastructure.Identity;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Persistence;
|
namespace TeleWave.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
|
/// Корневой DbContext приложения: Identity-схема (пользователи/роли) + сущности домена
|
||||||
/// (добавляются по мере реализации фич).
|
/// (добавляются по мере реализации фич).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
public class AppDbContext(DbContextOptions<AppDbContext> options)
|
||||||
: IdentityDbContext<AppUser, AppRole, Guid>(options),
|
: IdentityDbContext<AppUser, AppRole, Guid>(options),
|
||||||
IAppDbContext
|
IAppDbContext
|
||||||
{
|
{
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
|
public DbSet<MediaAsset> MediaAssets => Set<MediaAsset>();
|
||||||
public DbSet<Show> Shows => Set<Show>();
|
public DbSet<Show> Shows => Set<Show>();
|
||||||
public DbSet<Genre> Genres => Set<Genre>();
|
public DbSet<Genre> Genres => Set<Genre>();
|
||||||
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
|
public DbSet<GenreAlias> GenreAliases => Set<GenreAlias>();
|
||||||
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
|
public DbSet<ShowGenre> ShowGenres => Set<ShowGenre>();
|
||||||
public DbSet<Collection> Collections => Set<Collection>();
|
public DbSet<Collection> Collections => Set<Collection>();
|
||||||
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
|
public DbSet<CollectionItem> CollectionItems => Set<CollectionItem>();
|
||||||
public DbSet<Group> Groups => Set<Group>();
|
public DbSet<Group> Groups => Set<Group>();
|
||||||
public DbSet<GroupItem> GroupItems => Set<GroupItem>();
|
public DbSet<GroupItem> GroupItems => Set<GroupItem>();
|
||||||
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
|
public DbSet<ScheduleTemplate> ScheduleTemplates => Set<ScheduleTemplate>();
|
||||||
public DbSet<GridLayer> GridLayers => Set<GridLayer>();
|
public DbSet<GridLayer> GridLayers => Set<GridLayer>();
|
||||||
public DbSet<Slot> Slots => Set<Slot>();
|
public DbSet<Slot> Slots => Set<Slot>();
|
||||||
public DbSet<SlotState> SlotStates => Set<SlotState>();
|
public DbSet<SlotState> SlotStates => Set<SlotState>();
|
||||||
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
|
public DbSet<JunctionTemplate> JunctionTemplates => Set<JunctionTemplate>();
|
||||||
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
|
public DbSet<JunctionElement> JunctionElements => Set<JunctionElement>();
|
||||||
public DbSet<Channel> Channels => Set<Channel>();
|
public DbSet<Channel> Channels => Set<Channel>();
|
||||||
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
public DbSet<ScheduleEntry> ScheduleEntries => Set<ScheduleEntry>();
|
||||||
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
|
public DbSet<BumperTextVariant> BumperTextVariants => Set<BumperTextVariant>();
|
||||||
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
public DbSet<BumperAsset> BumperAssets => Set<BumperAsset>();
|
||||||
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
public DbSet<AppSetting> AppSettings => Set<AppSetting>();
|
||||||
public DbSet<Image> Images => Set<Image>();
|
public DbSet<Image> Images => Set<Image>();
|
||||||
|
|
||||||
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken cancellationToken) =>
|
||||||
Database.BeginTransactionAsync(cancellationToken);
|
Database.BeginTransactionAsync(cancellationToken);
|
||||||
|
|
||||||
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
|
public Task AcquireChannelLockAsync(Guid channelId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
|
// pg_advisory_xact_lock(bigint) освобождается автоматически при завершении транзакции.
|
||||||
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
|
// Ключ — стабильный int64 из GUID канала; коллизии между каналами лишь сериализуют их генерацию,
|
||||||
// корректности не нарушают.
|
// корректности не нарушают.
|
||||||
var key = BitConverter.ToInt64(channelId.ToByteArray());
|
var key = BitConverter.ToInt64(channelId.ToByteArray());
|
||||||
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
|
return Database.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock({key})", cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
protected override void OnModelCreating(ModelBuilder builder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
builder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
|
||||||
|
|
||||||
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
|
// Guid-ключи доменных сущностей мы задаём сами в фабриках. Без этого EF считает выставленный
|
||||||
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
|
// ключ признаком уже существующей строки и при добавлении дочерней сущности через коллекцию
|
||||||
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
|
// отслеживаемого родителя (напр. show.AddEpisode) делает UPDATE вместо INSERT → «affected 0».
|
||||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||||
{
|
{
|
||||||
if (
|
if (
|
||||||
entityType.ClrType.Namespace?.StartsWith(
|
entityType.ClrType.Namespace?.StartsWith(
|
||||||
"TeleWave.Domain",
|
"TeleWave.Domain",
|
||||||
StringComparison.Ordinal
|
StringComparison.Ordinal
|
||||||
) != true
|
) != true
|
||||||
)
|
)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
var idProperty = entityType.FindProperty("Id");
|
var idProperty = entityType.FindProperty("Id");
|
||||||
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
|
if (idProperty is not null && idProperty.ClrType == typeof(Guid))
|
||||||
idProperty.ValueGenerated = ValueGenerated.Never;
|
idProperty.ValueGenerated = ValueGenerated.Never;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-61
@@ -1,61 +1,61 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
namespace TeleWave.Infrastructure.Persistence.Configurations;
|
||||||
|
|
||||||
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
public class ChannelConfiguration : IEntityTypeConfiguration<Channel>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<Channel> builder)
|
public void Configure(EntityTypeBuilder<Channel> builder)
|
||||||
{
|
{
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(256);
|
||||||
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
|
builder.Property(x => x.Slug).IsRequired().HasMaxLength(128);
|
||||||
builder.HasIndex(x => x.Slug).IsUnique();
|
builder.HasIndex(x => x.Slug).IsUnique();
|
||||||
|
|
||||||
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
|
// Номер канала уникален среди заданных: переключение вверх-вниз по номерам иначе неоднозначно.
|
||||||
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
|
builder.HasIndex(x => x.Number).IsUnique().HasFilter("\"Number\" IS NOT NULL");
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasMany(x => x.BumperTemplates)
|
.HasMany(x => x.BumperTemplates)
|
||||||
.WithOne()
|
.WithOne()
|
||||||
.HasForeignKey(t => t.ChannelId)
|
.HasForeignKey(t => t.ChannelId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
|
builder.Navigation(x => x.BumperTemplates).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
|
public class BumperTemplateConfiguration : IEntityTypeConfiguration<BumperTemplate>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
|
public void Configure(EntityTypeBuilder<BumperTemplate> builder)
|
||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.ChannelId, x.Position });
|
builder.HasIndex(x => new { x.ChannelId, x.Position });
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.BackgroundColor).IsRequired().HasMaxLength(32);
|
||||||
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.BackgroundColor2).IsRequired().HasMaxLength(32);
|
||||||
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.AccentColor).IsRequired().HasMaxLength(32);
|
||||||
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
|
builder.Property(x => x.TextColor).IsRequired().HasMaxLength(32);
|
||||||
builder.Property(x => x.AudioExtension).HasMaxLength(16);
|
builder.Property(x => x.AudioExtension).HasMaxLength(16);
|
||||||
|
|
||||||
builder
|
builder
|
||||||
.HasMany(x => x.Variants)
|
.HasMany(x => x.Variants)
|
||||||
.WithOne()
|
.WithOne()
|
||||||
.HasForeignKey(v => v.BumperTemplateId)
|
.HasForeignKey(v => v.BumperTemplateId)
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
|
builder.Navigation(x => x.Variants).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
|
public class BumperTextVariantConfiguration : IEntityTypeConfiguration<BumperTextVariant>
|
||||||
{
|
{
|
||||||
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
|
public void Configure(EntityTypeBuilder<BumperTextVariant> builder)
|
||||||
{
|
{
|
||||||
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
|
builder.HasIndex(x => new { x.BumperTemplateId, x.Position });
|
||||||
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.Name).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.NowLabel).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
|
builder.Property(x => x.NextLabel).IsRequired().HasMaxLength(64);
|
||||||
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
|
builder.Property(x => x.Line1).IsRequired().HasMaxLength(120);
|
||||||
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
|
builder.Property(x => x.Line2).IsRequired().HasMaxLength(120);
|
||||||
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
|
builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,53 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Settings;
|
using TeleWave.Application.Settings;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
|
|
||||||
namespace TeleWave.Infrastructure.Settings;
|
namespace TeleWave.Infrastructure.Settings;
|
||||||
|
|
||||||
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
|
/// <summary>Настройки сайта поверх key-value таблицы AppSetting. Запись не сохраняет сама —
|
||||||
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
|
/// сохранение выполняет UnitOfWorkBehavior команды (используется общий scoped-контекст).</summary>
|
||||||
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
|
public sealed class SiteSettings(IAppDbContext dbContext) : ISiteSettings
|
||||||
{
|
{
|
||||||
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
|
public Task<bool> IsRegistrationEnabledAsync(CancellationToken cancellationToken) =>
|
||||||
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
|
dbContext.GetBoolSettingAsync(SettingKeys.RegistrationEnabled, false, cancellationToken);
|
||||||
|
|
||||||
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
|
public async Task SetRegistrationEnabledAsync(bool enabled, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await UpsertAsync(
|
await UpsertAsync(
|
||||||
SettingKeys.RegistrationEnabled,
|
SettingKeys.RegistrationEnabled,
|
||||||
enabled ? "true" : "false",
|
enabled ? "true" : "false",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
|
public Task<string> GetPreferredAudioLanguagesAsync(CancellationToken cancellationToken) =>
|
||||||
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
|
dbContext.GetStringSettingAsync(SettingKeys.PreferredAudioLanguages, "", cancellationToken);
|
||||||
|
|
||||||
public Task SetPreferredAudioLanguagesAsync(
|
public Task SetPreferredAudioLanguagesAsync(
|
||||||
string value,
|
string value,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
) => UpsertAsync(SettingKeys.PreferredAudioLanguages, value, cancellationToken);
|
||||||
|
|
||||||
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
|
public Task<bool> AreChannelNumbersEnabledAsync(CancellationToken cancellationToken) =>
|
||||||
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
|
dbContext.GetBoolSettingAsync(SettingKeys.ChannelNumbersEnabled, false, cancellationToken);
|
||||||
|
|
||||||
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
|
public Task SetChannelNumbersEnabledAsync(bool enabled, CancellationToken cancellationToken) =>
|
||||||
UpsertAsync(
|
UpsertAsync(
|
||||||
SettingKeys.ChannelNumbersEnabled,
|
SettingKeys.ChannelNumbersEnabled,
|
||||||
enabled ? "true" : "false",
|
enabled ? "true" : "false",
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
private async Task UpsertAsync(string key, string value, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
var existing = await dbContext.AppSettings.FirstOrDefaultAsync(
|
||||||
s => s.Key == key,
|
s => s.Key == key,
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
dbContext.AppSettings.Add(AppSetting.Create(key, value));
|
dbContext.AppSettings.Add(AppSetting.Create(key, value));
|
||||||
else
|
else
|
||||||
existing.SetValue(value);
|
existing.SetValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +1,69 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.CreateChannel;
|
using TeleWave.Application.Broadcast.CreateChannel;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
using TeleWave.Application.Tests.Support;
|
using TeleWave.Application.Tests.Support;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Application.Tests.Broadcast;
|
namespace TeleWave.Application.Tests.Broadcast;
|
||||||
|
|
||||||
public class ChannelHandlersTests
|
public class ChannelHandlersTests
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
|
public async Task CreateChannel_Succeeds_AndRejectsDuplicateSlug()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
|
|
||||||
var ok = await new CreateChannelCommandHandler(db).Handle(
|
var ok = await new CreateChannelCommandHandler(db).Handle(
|
||||||
new CreateChannelCommand("News", "news"),
|
new CreateChannelCommand("News", "news"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(ok.IsSuccess);
|
Assert.True(ok.IsSuccess);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
var dup = await new CreateChannelCommandHandler(db).Handle(
|
var dup = await new CreateChannelCommandHandler(db).Handle(
|
||||||
new CreateChannelCommand("Other", "news"),
|
new CreateChannelCommand("Other", "news"),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.False(dup.IsSuccess);
|
Assert.False(dup.IsSuccess);
|
||||||
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
|
Assert.Equal(ChannelErrors.DuplicateSlug, dup.Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task UpdateChannelSettings_UpdatesBumperChances()
|
public async Task UpdateChannelSettings_UpdatesBumperChances()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.Channels.Add(channel);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
var result = await new UpdateChannelSettingsCommandHandler(db).Handle(
|
||||||
new UpdateChannelSettingsCommand(
|
new UpdateChannelSettingsCommand(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
"c",
|
"c",
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom),
|
||||||
null
|
null
|
||||||
),
|
),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
|
|
||||||
await using var verify = fixture.New();
|
await using var verify = fixture.New();
|
||||||
var stored = await verify.Channels.FindAsync(channel.Id);
|
var stored = await verify.Channels.FindAsync(channel.Id);
|
||||||
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
Assert.Equal(BumperFont.Sans, stored!.BumperFont);
|
||||||
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
Assert.Equal(BumperSelection.WeightedRandom, stored.BumperSelection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,201 +1,201 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Broadcast.GetChannel;
|
using TeleWave.Application.Broadcast.GetChannel;
|
||||||
using TeleWave.Application.Broadcast.GetSchedule;
|
using TeleWave.Application.Broadcast.GetSchedule;
|
||||||
using TeleWave.Application.Broadcast.ListChannels;
|
using TeleWave.Application.Broadcast.ListChannels;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Library.DeleteShow;
|
using TeleWave.Application.Library.DeleteShow;
|
||||||
using TeleWave.Application.Library.GetShow;
|
using TeleWave.Application.Library.GetShow;
|
||||||
using TeleWave.Application.Programming.Groups;
|
using TeleWave.Application.Programming.Groups;
|
||||||
using TeleWave.Application.Tests.Support;
|
using TeleWave.Application.Tests.Support;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Application.Tests.Broadcast;
|
namespace TeleWave.Application.Tests.Broadcast;
|
||||||
|
|
||||||
public class QueryHandlersTests
|
public class QueryHandlersTests
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
private static GroupMembershipCleaner GroupCleaner(IAppDbContext db) =>
|
||||||
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
new(db, new GroupStatsService(db, new GroupElementResolver(db)));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
public async Task GetChannel_UnknownId_ReturnsNotFound()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new GetChannelQueryHandler(db).Handle(
|
var result = await new GetChannelQueryHandler(db).Handle(
|
||||||
new GetChannelQuery(Guid.NewGuid()),
|
new GetChannelQuery(Guid.NewGuid()),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.False(result.IsSuccess);
|
Assert.False(result.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListChannels_ReturnsSummaries()
|
public async Task ListChannels_ReturnsSummaries()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(Channel.Create("A", "a", T0));
|
seed.Channels.Add(Channel.Create("A", "a", T0));
|
||||||
seed.Channels.Add(Channel.Create("B", "b", T0));
|
seed.Channels.Add(Channel.Create("B", "b", T0));
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new ListChannelsQueryHandler(db).Handle(
|
var result = await new ListChannelsQueryHandler(db).Handle(
|
||||||
new ListChannelsQuery(),
|
new ListChannelsQuery(),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.Equal(2, result.Count);
|
Assert.Equal(2, result.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
|
public async Task GetChannelSchedule_ReturnsEntriesWithBumperLabel()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
var show = Show.Create("Show A", ShowKind.Series);
|
var show = Show.Create("Show A", ShowKind.Series);
|
||||||
var variant = channel.BumperTemplates[0].Variants[0];
|
var variant = channel.BumperTemplates[0].Variants[0];
|
||||||
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register("Show.A.S01E01.mkv", ".mkv", MediaSource.Upload);
|
||||||
var program = ScheduleEntry.Program(
|
var program = ScheduleEntry.Program(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
asset.Id,
|
asset.Id,
|
||||||
T0,
|
T0,
|
||||||
T0.AddMinutes(20),
|
T0.AddMinutes(20),
|
||||||
show.Id,
|
show.Id,
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
var bumper = ScheduleEntry.Bumper(
|
var bumper = ScheduleEntry.Bumper(
|
||||||
channel.Id,
|
channel.Id,
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
T0.AddMinutes(20),
|
T0.AddMinutes(20),
|
||||||
T0.AddMinutes(20).AddSeconds(8),
|
T0.AddMinutes(20).AddSeconds(8),
|
||||||
show.Id,
|
show.Id,
|
||||||
variant.Id
|
variant.Id
|
||||||
);
|
);
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Shows.Add(show);
|
seed.Shows.Add(show);
|
||||||
seed.Channels.Add(channel);
|
seed.Channels.Add(channel);
|
||||||
seed.MediaAssets.Add(asset);
|
seed.MediaAssets.Add(asset);
|
||||||
seed.ScheduleEntries.Add(program);
|
seed.ScheduleEntries.Add(program);
|
||||||
seed.ScheduleEntries.Add(bumper);
|
seed.ScheduleEntries.Add(bumper);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var result = await new GetChannelScheduleQueryHandler(db).Handle(
|
var result = await new GetChannelScheduleQueryHandler(db).Handle(
|
||||||
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
|
new GetChannelScheduleQuery(channel.Id, T0.AddMinutes(-5), T0.AddHours(1)),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(2, result.Value.Count);
|
Assert.Equal(2, result.Value.Count);
|
||||||
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
var bumperDto = result.Value.Single(e => e.Kind == ScheduleEntryKind.Bumper);
|
||||||
Assert.NotNull(bumperDto.BumperName);
|
Assert.NotNull(bumperDto.BumperName);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetShow_ReturnsDtoOrNotFound()
|
public async Task GetShow_ReturnsDtoOrNotFound()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var show = Show.Create("A", ShowKind.Series);
|
var show = Show.Create("A", ShowKind.Series);
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Shows.Add(show);
|
seed.Shows.Add(show);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var ok = await new GetShowQueryHandler(db).Handle(
|
var ok = await new GetShowQueryHandler(db).Handle(
|
||||||
new GetShowQuery(show.Id),
|
new GetShowQuery(show.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(ok.IsSuccess);
|
Assert.True(ok.IsSuccess);
|
||||||
|
|
||||||
var missing = await new GetShowQueryHandler(db).Handle(
|
var missing = await new GetShowQueryHandler(db).Handle(
|
||||||
new GetShowQuery(Guid.NewGuid()),
|
new GetShowQuery(Guid.NewGuid()),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.False(missing.IsSuccess);
|
Assert.False(missing.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteShow_RemovesOrNotFound()
|
public async Task DeleteShow_RemovesOrNotFound()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var show = Show.Create("A", ShowKind.Series);
|
var show = Show.Create("A", ShowKind.Series);
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Shows.Add(show);
|
seed.Shows.Add(show);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var db = fixture.New();
|
await using var db = fixture.New();
|
||||||
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
var missing = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||||
new DeleteShowCommand(Guid.NewGuid()),
|
new DeleteShowCommand(Guid.NewGuid()),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.False(missing.IsSuccess);
|
Assert.False(missing.IsSuccess);
|
||||||
|
|
||||||
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
var ok = await new DeleteShowCommandHandler(db, GroupCleaner(db)).Handle(
|
||||||
new DeleteShowCommand(show.Id),
|
new DeleteShowCommand(show.Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(ok.IsSuccess);
|
Assert.True(ok.IsSuccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
|
public async Task AddAndRemoveBumperTemplate_WorkThroughStorage()
|
||||||
{
|
{
|
||||||
var fixture = new TestDb();
|
var fixture = new TestDb();
|
||||||
var channel = Channel.Create("c", "c", T0);
|
var channel = Channel.Create("c", "c", T0);
|
||||||
await using (var seed = fixture.New())
|
await using (var seed = fixture.New())
|
||||||
{
|
{
|
||||||
seed.Channels.Add(channel);
|
seed.Channels.Add(channel);
|
||||||
await seed.SaveChangesAsync(CancellationToken.None);
|
await seed.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
Guid templateId;
|
Guid templateId;
|
||||||
await using (var db = fixture.New())
|
await using (var db = fixture.New())
|
||||||
{
|
{
|
||||||
var added = await new AddBumperTemplateCommandHandler(db).Handle(
|
var added = await new AddBumperTemplateCommandHandler(db).Handle(
|
||||||
new AddBumperTemplateCommand(channel.Id, ""),
|
new AddBumperTemplateCommand(channel.Id, ""),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(added.IsSuccess);
|
Assert.True(added.IsSuccess);
|
||||||
templateId = added.Value;
|
templateId = added.Value;
|
||||||
await db.SaveChangesAsync(CancellationToken.None);
|
await db.SaveChangesAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
var storage = Substitute.For<IBumperTemplateStorage>();
|
var storage = Substitute.For<IBumperTemplateStorage>();
|
||||||
await using (var db = fixture.New())
|
await using (var db = fixture.New())
|
||||||
{
|
{
|
||||||
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
var removed = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||||
new RemoveBumperTemplateCommand(channel.Id, templateId),
|
new RemoveBumperTemplateCommand(channel.Id, templateId),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.True(removed.IsSuccess);
|
Assert.True(removed.IsSuccess);
|
||||||
}
|
}
|
||||||
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
await storage.Received(1).DeleteTemplateAsync(templateId, Arg.Any<CancellationToken>());
|
||||||
|
|
||||||
// дефолтный блок удалить нельзя
|
// дефолтный блок удалить нельзя
|
||||||
await using (var db = fixture.New())
|
await using (var db = fixture.New())
|
||||||
{
|
{
|
||||||
var def = await db
|
var def = await db
|
||||||
.Channels.Include(c => c.BumperTemplates)
|
.Channels.Include(c => c.BumperTemplates)
|
||||||
.FirstAsync(c => c.Id == channel.Id);
|
.FirstAsync(c => c.Id == channel.Id);
|
||||||
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
var result = await new RemoveBumperTemplateCommandHandler(db, storage).Handle(
|
||||||
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
|
new RemoveBumperTemplateCommand(channel.Id, def.BumperTemplates[0].Id),
|
||||||
CancellationToken.None
|
CancellationToken.None
|
||||||
);
|
);
|
||||||
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
|
Assert.Equal(ChannelErrors.CannotRemoveDefaultBumperTemplate, result.Error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +1,150 @@
|
|||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Application.Tests.Programming;
|
namespace TeleWave.Application.Tests.Programming;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
/// Применимость слоёв (см. 3.4) и разрешение перекрытий: какие слоты реально действуют в сутки.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LayerApplicabilityTests
|
public class LayerApplicabilityTests
|
||||||
{
|
{
|
||||||
private static readonly TimeOnly DayStart = new(6, 0);
|
private static readonly TimeOnly DayStart = new(6, 0);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Empty_CoversEveryDate()
|
public void Empty_CoversEveryDate()
|
||||||
{
|
{
|
||||||
var applicability = new LayerApplicability();
|
var applicability = new LayerApplicability();
|
||||||
|
|
||||||
Assert.True(applicability.IsEmpty);
|
Assert.True(applicability.IsEmpty);
|
||||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 17)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
[InlineData(2026, 12, 25, true)] // внутри, до Нового года
|
||||||
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
[InlineData(2026, 1, 3, true)] // внутри, после Нового года
|
||||||
[InlineData(2026, 12, 20, true)] // ровно начало
|
[InlineData(2026, 12, 20, true)] // ровно начало
|
||||||
[InlineData(2026, 1, 8, true)] // ровно конец
|
[InlineData(2026, 1, 8, true)] // ровно конец
|
||||||
[InlineData(2026, 12, 19, false)] // за день до начала
|
[InlineData(2026, 12, 19, false)] // за день до начала
|
||||||
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
[InlineData(2026, 1, 9, false)] // на следующий день после конца
|
||||||
[InlineData(2026, 6, 15, false)] // середина года
|
[InlineData(2026, 6, 15, false)] // середина года
|
||||||
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
public void AnnualRange_CrossingNewYear_IsInclusiveOnBothEnds(
|
||||||
int year,
|
int year,
|
||||||
int month,
|
int month,
|
||||||
int day,
|
int day,
|
||||||
bool expected
|
bool expected
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
// «20 декабря — 8 января» задаётся один раз и работает в любом году, поэтому сравнение идёт
|
||||||
// по паре (месяц, день), а не по датам.
|
// по паре (месяц, день), а не по датам.
|
||||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
|
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]);
|
||||||
|
|
||||||
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
Assert.Equal(expected, applicability.Covers(new DateOnly(year, month, day)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
public void AnnualRange_WithinOneYear_DoesNotWrap()
|
||||||
{
|
{
|
||||||
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
var applicability = new LayerApplicability(AnnualRanges: [new AnnualRange(6, 1, 8, 31)]);
|
||||||
|
|
||||||
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
Assert.True(applicability.Covers(new DateOnly(2026, 7, 4)));
|
||||||
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
Assert.False(applicability.Covers(new DateOnly(2026, 1, 4)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Sections_AreCombinedWithOr()
|
public void Sections_AreCombinedWithOr()
|
||||||
{
|
{
|
||||||
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
// Понедельник ИЛИ конкретная дата: суббота из списка дат проходит, обычная суббота — нет.
|
||||||
var applicability = new LayerApplicability(
|
var applicability = new LayerApplicability(
|
||||||
Weekdays: [1],
|
Weekdays: [1],
|
||||||
SpecificDates: [new DateOnly(2026, 3, 21)]
|
SpecificDates: [new DateOnly(2026, 3, 21)]
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 16))); // понедельник
|
||||||
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
Assert.True(applicability.Covers(new DateOnly(2026, 3, 21))); // суббота из списка
|
||||||
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
Assert.False(applicability.Covers(new DateOnly(2026, 3, 14))); // другая суббота
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
public void Build_SkipsSlotsCoveredByHigherPriorityLayer()
|
||||||
{
|
{
|
||||||
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
// Слот младшего слоя пропускается целиком, а не обрезается: половина слота означала бы
|
||||||
// половину настройки — своей группы и стратегии у половинки нет.
|
// половину настройки — своей группы и стратегии у половинки нет.
|
||||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
var top = template.AddLayer("Прайм", 100);
|
var top = template.AddLayer("Прайм", 100);
|
||||||
var bottom = template.AddLayer("Обычный", 50);
|
var bottom = template.AddLayer("Обычный", 50);
|
||||||
|
|
||||||
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
AddSlot(top, "Кино", new TimeOnly(20, 0), 120);
|
||||||
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
AddSlot(bottom, "Сериал", new TimeOnly(20, 30), 60);
|
||||||
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
AddSlot(bottom, "Ночь", new TimeOnly(23, 0), 60);
|
||||||
|
|
||||||
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
var titles = Build(template).Select(s => s.Slot.Title).Distinct().ToList();
|
||||||
|
|
||||||
Assert.Contains("Кино", titles);
|
Assert.Contains("Кино", titles);
|
||||||
Assert.Contains("Ночь", titles);
|
Assert.Contains("Ночь", titles);
|
||||||
Assert.DoesNotContain("Сериал", titles);
|
Assert.DoesNotContain("Сериал", titles);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
public void Build_DropsLayersThatDoNotApplyOnTheDate()
|
||||||
{
|
{
|
||||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
var newYear = template.AddLayer("Новогодний", 100);
|
var newYear = template.AddLayer("Новогодний", 100);
|
||||||
newYear.Update(
|
newYear.Update(
|
||||||
newYear.Name,
|
newYear.Name,
|
||||||
newYear.Priority,
|
newYear.Priority,
|
||||||
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
new LayerApplicability(AnnualRanges: [new AnnualRange(12, 20, 1, 8)]).ToJson(),
|
||||||
isEnabled: true
|
isEnabled: true
|
||||||
);
|
);
|
||||||
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
AddSlot(newYear, "Ирония судьбы", new TimeOnly(20, 0), 180);
|
||||||
|
|
||||||
var usual = template.AddLayer("Обычный", 50);
|
var usual = template.AddLayer("Обычный", 50);
|
||||||
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
AddSlot(usual, "Вечерний сериал", new TimeOnly(20, 0), 60);
|
||||||
|
|
||||||
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
var march = Build(template, new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero))
|
||||||
.Select(s => s.Slot.Title)
|
.Select(s => s.Slot.Title)
|
||||||
.ToList();
|
.ToList();
|
||||||
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
var december = Build(template, new DateTimeOffset(2026, 12, 25, 12, 0, 0, TimeSpan.Zero))
|
||||||
.Select(s => s.Slot.Title)
|
.Select(s => s.Slot.Title)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.Contains("Вечерний сериал", march);
|
Assert.Contains("Вечерний сериал", march);
|
||||||
Assert.DoesNotContain("Ирония судьбы", march);
|
Assert.DoesNotContain("Ирония судьбы", march);
|
||||||
Assert.Contains("Ирония судьбы", december);
|
Assert.Contains("Ирония судьбы", december);
|
||||||
Assert.DoesNotContain("Вечерний сериал", december);
|
Assert.DoesNotContain("Вечерний сериал", december);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_DisabledLayerIsIgnored()
|
public void Build_DisabledLayerIsIgnored()
|
||||||
{
|
{
|
||||||
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
var template = ScheduleTemplate.Create(Guid.NewGuid(), "Сетка");
|
||||||
var layer = template.AddLayer("Выключённый", 100);
|
var layer = template.AddLayer("Выключённый", 100);
|
||||||
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
AddSlot(layer, "Ничего", new TimeOnly(20, 0), 60);
|
||||||
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
layer.Update(layer.Name, layer.Priority, null, isEnabled: false);
|
||||||
|
|
||||||
Assert.Empty(Build(template));
|
Assert.Empty(Build(template));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void AddSlot(
|
private static void AddSlot(
|
||||||
GridLayer layer,
|
GridLayer layer,
|
||||||
string title,
|
string title,
|
||||||
TimeOnly start,
|
TimeOnly start,
|
||||||
int durationMinutes
|
int durationMinutes
|
||||||
) => layer.AddSlot(title, start, durationMinutes);
|
) => layer.AddSlot(title, start, durationMinutes);
|
||||||
|
|
||||||
private static IReadOnlyList<ScheduledSlot> Build(
|
private static IReadOnlyList<ScheduledSlot> Build(
|
||||||
ScheduleTemplate template,
|
ScheduleTemplate template,
|
||||||
DateTimeOffset? from = null
|
DateTimeOffset? from = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
var start = from ?? new DateTimeOffset(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||||
return EffectiveGridBuilder.Build(
|
return EffectiveGridBuilder.Build(
|
||||||
template,
|
template,
|
||||||
utcOffsetMinutes: 180,
|
utcOffsetMinutes: 180,
|
||||||
DayStart,
|
DayStart,
|
||||||
start,
|
start,
|
||||||
start.AddHours(18)
|
start.AddHours(18)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,77 @@
|
|||||||
using TeleWave.Application.Admin.Users.CreateUser;
|
using TeleWave.Application.Admin.Users.CreateUser;
|
||||||
using TeleWave.Application.Admin.Users.ResetPassword;
|
using TeleWave.Application.Admin.Users.ResetPassword;
|
||||||
using TeleWave.Application.Broadcast;
|
using TeleWave.Application.Broadcast;
|
||||||
using TeleWave.Application.Broadcast.Bumpers;
|
using TeleWave.Application.Broadcast.Bumpers;
|
||||||
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
using TeleWave.Application.Broadcast.UpdateChannelSettings;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Application.Tests.Validators;
|
namespace TeleWave.Application.Tests.Validators;
|
||||||
|
|
||||||
public class ValidatorTests
|
public class ValidatorTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
|
public void UpdateBumperTextVariant_ChecksLengthsAndWeight()
|
||||||
{
|
{
|
||||||
var v = new UpdateBumperTextVariantCommandValidator();
|
var v = new UpdateBumperTextVariantCommandValidator();
|
||||||
|
|
||||||
var good = new UpdateBumperTextVariantCommand(
|
var good = new UpdateBumperTextVariantCommand(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
"Name",
|
"Name",
|
||||||
BumperTextKind.NowNext,
|
BumperTextKind.NowNext,
|
||||||
"NOW",
|
"NOW",
|
||||||
"NEXT",
|
"NEXT",
|
||||||
"l1",
|
"l1",
|
||||||
"l2",
|
"l2",
|
||||||
BumperTrigger.Both,
|
BumperTrigger.Both,
|
||||||
3
|
3
|
||||||
);
|
);
|
||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||||
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
|
Assert.False(v.Validate(good with { Weight = -1 }).IsValid);
|
||||||
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
|
Assert.False(v.Validate(good with { Line1 = new string('x', 121) }).IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateChannelSettings_ChecksRanges()
|
public void UpdateChannelSettings_ChecksRanges()
|
||||||
{
|
{
|
||||||
var v = new UpdateChannelSettingsCommandValidator();
|
var v = new UpdateChannelSettingsCommandValidator();
|
||||||
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
var bumper = new BumperSettingsInput(BumperFont.Sans, BumperSelection.WeightedRandom);
|
||||||
|
|
||||||
var good = new UpdateChannelSettingsCommand(
|
var good = new UpdateChannelSettingsCommand(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
"Name",
|
"Name",
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
bumper,
|
bumper,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
Assert.True(v.Validate(good).IsValid);
|
Assert.True(v.Validate(good).IsValid);
|
||||||
|
|
||||||
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
Assert.False(v.Validate(good with { Name = "" }).IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResetUserPassword_RequiresMinLength()
|
public void ResetUserPassword_RequiresMinLength()
|
||||||
{
|
{
|
||||||
var v = new ResetUserPasswordCommandValidator();
|
var v = new ResetUserPasswordCommandValidator();
|
||||||
|
|
||||||
Assert.True(v.Validate(new ResetUserPasswordCommand(Guid.NewGuid(), "Password1")).IsValid);
|
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.NewGuid(), "short")).IsValid);
|
||||||
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
|
Assert.False(v.Validate(new ResetUserPasswordCommand(Guid.Empty, "Password1")).IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void CreateUser_ChecksNameAndPassword()
|
public void CreateUser_ChecksNameAndPassword()
|
||||||
{
|
{
|
||||||
var v = new CreateUserCommandValidator();
|
var v = new CreateUserCommandValidator();
|
||||||
|
|
||||||
Assert.True(v.Validate(new CreateUserCommand("bob", "Password1", Guid.NewGuid())).IsValid);
|
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("ab", "Password1", Guid.NewGuid())).IsValid);
|
||||||
Assert.False(v.Validate(new CreateUserCommand("bob", "short", 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);
|
Assert.False(v.Validate(new CreateUserCommand("bob", "Password1", Guid.Empty)).IsValid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,145 +1,145 @@
|
|||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Tests.Broadcast;
|
namespace TeleWave.Domain.Tests.Broadcast;
|
||||||
|
|
||||||
public class ChannelTests
|
public class ChannelTests
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
|
private static Channel NewChannel() => Channel.Create("News", "news", Epoch);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
|
public void Create_SetsDefaults_AndSeedsDefaultBumperTemplate()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
Assert.NotEqual(Guid.Empty, channel.Id);
|
Assert.NotEqual(Guid.Empty, channel.Id);
|
||||||
Assert.Equal("News", channel.Name);
|
Assert.Equal("News", channel.Name);
|
||||||
Assert.Equal("news", channel.Slug);
|
Assert.Equal("news", channel.Slug);
|
||||||
Assert.True(channel.IsEnabled);
|
Assert.True(channel.IsEnabled);
|
||||||
Assert.Equal(Epoch, channel.EpochUtc);
|
Assert.Equal(Epoch, channel.EpochUtc);
|
||||||
Assert.False(channel.BumpersEnabled);
|
Assert.False(channel.BumpersEnabled);
|
||||||
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
Assert.Equal(BumperSelection.WeightedRandom, channel.BumperSelection);
|
||||||
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
Assert.Equal(Channel.DefaultUtcOffsetMinutes, channel.UtcOffsetMinutes);
|
||||||
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
Assert.Equal(Channel.DefaultDayStartTime, channel.DayStartTime);
|
||||||
|
|
||||||
var template = Assert.Single(channel.BumperTemplates);
|
var template = Assert.Single(channel.BumperTemplates);
|
||||||
Assert.True(template.IsDefault);
|
Assert.True(template.IsDefault);
|
||||||
Assert.Equal(0, template.Position);
|
Assert.Equal(0, template.Position);
|
||||||
Assert.Single(template.Variants); // дефолтный подблок
|
Assert.Single(template.Variants); // дефолтный подблок
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateSettings_ChangesFields()
|
public void UpdateSettings_ChangesFields()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
var filler = Guid.NewGuid();
|
var filler = Guid.NewGuid();
|
||||||
|
|
||||||
channel.UpdateSettings("N2", false, true, filler);
|
channel.UpdateSettings("N2", false, true, filler);
|
||||||
|
|
||||||
Assert.Equal("N2", channel.Name);
|
Assert.Equal("N2", channel.Name);
|
||||||
Assert.False(channel.IsEnabled);
|
Assert.False(channel.IsEnabled);
|
||||||
Assert.True(channel.BumpersEnabled);
|
Assert.True(channel.BumpersEnabled);
|
||||||
Assert.Equal(filler, channel.FillerAssetId);
|
Assert.Equal(filler, channel.FillerAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateBumperSettings_ChangesFontAndSelection()
|
public void UpdateBumperSettings_ChangesFontAndSelection()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
|
channel.UpdateBumperSettings(BumperFont.Serif, BumperSelection.AlwaysFirst);
|
||||||
|
|
||||||
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
Assert.Equal(BumperFont.Serif, channel.BumperFont);
|
||||||
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
Assert.Equal(BumperSelection.AlwaysFirst, channel.BumperSelection);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(-1.0, 2.0, 0.0, 1.0)]
|
[InlineData(-1.0, 2.0, 0.0, 1.0)]
|
||||||
[InlineData(0.4, 0.25, 0.4, 0.25)]
|
[InlineData(0.4, 0.25, 0.4, 0.25)]
|
||||||
public void UpdateViewerSettings_ClampsStrengths(
|
public void UpdateViewerSettings_ClampsStrengths(
|
||||||
double opacity,
|
double opacity,
|
||||||
double filter,
|
double filter,
|
||||||
double expectedOpacity,
|
double expectedOpacity,
|
||||||
double expectedFilter
|
double expectedFilter
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
var logo = Guid.NewGuid();
|
var logo = Guid.NewGuid();
|
||||||
|
|
||||||
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
|
channel.UpdateViewerSettings(logo, LogoCorner.BottomRight, opacity, true, filter);
|
||||||
|
|
||||||
Assert.Equal(logo, channel.LogoImageId);
|
Assert.Equal(logo, channel.LogoImageId);
|
||||||
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
|
Assert.Equal(LogoCorner.BottomRight, channel.LogoCorner);
|
||||||
Assert.Equal(expectedOpacity, channel.LogoOpacity);
|
Assert.Equal(expectedOpacity, channel.LogoOpacity);
|
||||||
Assert.True(channel.ShowClock);
|
Assert.True(channel.ShowClock);
|
||||||
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
|
Assert.Equal(expectedFilter, channel.AnalogFilterStrength);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void NewChannel_HasViewerOverlaysOff()
|
public void NewChannel_HasViewerOverlaysOff()
|
||||||
{
|
{
|
||||||
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
|
// Канал без логотипа, часов и шума — законная конфигурация, а не недонастроенная.
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
Assert.Null(channel.LogoImageId);
|
Assert.Null(channel.LogoImageId);
|
||||||
Assert.False(channel.ShowClock);
|
Assert.False(channel.ShowClock);
|
||||||
Assert.Equal(0.0, channel.AnalogFilterStrength);
|
Assert.Equal(0.0, channel.AnalogFilterStrength);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AddBumperTemplate_AppendsWithNextPosition()
|
public void AddBumperTemplate_AppendsWithNextPosition()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
var t1 = channel.AddBumperTemplate("Block 2");
|
var t1 = channel.AddBumperTemplate("Block 2");
|
||||||
var t2 = channel.AddBumperTemplate("Block 3");
|
var t2 = channel.AddBumperTemplate("Block 3");
|
||||||
|
|
||||||
Assert.Equal(1, t1.Position);
|
Assert.Equal(1, t1.Position);
|
||||||
Assert.Equal(2, t2.Position);
|
Assert.Equal(2, t2.Position);
|
||||||
Assert.False(t1.IsDefault);
|
Assert.False(t1.IsDefault);
|
||||||
Assert.Equal(3, channel.BumperTemplates.Count);
|
Assert.Equal(3, channel.BumperTemplates.Count);
|
||||||
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
|
Assert.Equal(t1, channel.FindBumperTemplate(t1.Id));
|
||||||
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
|
Assert.Null(channel.FindBumperTemplate(Guid.NewGuid()));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
|
public void RemoveBumperTemplate_CannotRemoveDefault_CanRemoveOthers()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
var def = channel.BumperTemplates[0];
|
var def = channel.BumperTemplates[0];
|
||||||
var extra = channel.AddBumperTemplate("Block 2");
|
var extra = channel.AddBumperTemplate("Block 2");
|
||||||
|
|
||||||
Assert.False(channel.RemoveBumperTemplate(def.Id));
|
Assert.False(channel.RemoveBumperTemplate(def.Id));
|
||||||
Assert.True(channel.RemoveBumperTemplate(extra.Id));
|
Assert.True(channel.RemoveBumperTemplate(extra.Id));
|
||||||
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
|
Assert.False(channel.RemoveBumperTemplate(Guid.NewGuid()));
|
||||||
Assert.Single(channel.BumperTemplates);
|
Assert.Single(channel.BumperTemplates);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
|
public void UpdateTimeSettings_ClampsOffsetAndDropsNonPositiveNumber()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
|
|
||||||
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
|
channel.UpdateTimeSettings(0, 99 * 60, new TimeOnly(5, 0));
|
||||||
|
|
||||||
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
|
// Номер 0 — не номер; смещение за пределами суток оторвало бы сетку от календаря.
|
||||||
Assert.Null(channel.Number);
|
Assert.Null(channel.Number);
|
||||||
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
|
Assert.Equal(14 * 60, channel.UtcOffsetMinutes);
|
||||||
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
|
Assert.Equal(new TimeOnly(5, 0), channel.DayStartTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SetTemplate_LinksAndUnlinks()
|
public void SetTemplate_LinksAndUnlinks()
|
||||||
{
|
{
|
||||||
var channel = NewChannel();
|
var channel = NewChannel();
|
||||||
var templateId = Guid.NewGuid();
|
var templateId = Guid.NewGuid();
|
||||||
|
|
||||||
channel.SetTemplate(templateId);
|
channel.SetTemplate(templateId);
|
||||||
Assert.Equal(templateId, channel.TemplateId);
|
Assert.Equal(templateId, channel.TemplateId);
|
||||||
|
|
||||||
channel.SetTemplate(null);
|
channel.SetTemplate(null);
|
||||||
Assert.Null(channel.TemplateId);
|
Assert.Null(channel.TemplateId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Settings;
|
using TeleWave.Domain.Settings;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Domain.Tests.Broadcast;
|
namespace TeleWave.Domain.Tests.Broadcast;
|
||||||
|
|
||||||
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
|
/// <summary>Мелкие фабрики/сеттеры простых сущностей, которые нигде не покрыты напрямую.</summary>
|
||||||
public class DomainRecordFactoryTests
|
public class DomainRecordFactoryTests
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BumperAsset_Create_StoresPair()
|
public void BumperAsset_Create_StoresPair()
|
||||||
{
|
{
|
||||||
var channel = Guid.NewGuid();
|
var channel = Guid.NewGuid();
|
||||||
var template = Guid.NewGuid();
|
var template = Guid.NewGuid();
|
||||||
var variant = Guid.NewGuid();
|
var variant = Guid.NewGuid();
|
||||||
var from = Guid.NewGuid();
|
var from = Guid.NewGuid();
|
||||||
var to = Guid.NewGuid();
|
var to = Guid.NewGuid();
|
||||||
var asset = Guid.NewGuid();
|
var asset = Guid.NewGuid();
|
||||||
|
|
||||||
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
||||||
|
|
||||||
Assert.Equal(channel, b.ChannelId);
|
Assert.Equal(channel, b.ChannelId);
|
||||||
Assert.Equal(template, b.TemplateId);
|
Assert.Equal(template, b.TemplateId);
|
||||||
Assert.Equal(variant, b.VariantId);
|
Assert.Equal(variant, b.VariantId);
|
||||||
Assert.Equal(from, b.FromShowId);
|
Assert.Equal(from, b.FromShowId);
|
||||||
Assert.Equal(to, b.ToShowId);
|
Assert.Equal(to, b.ToShowId);
|
||||||
Assert.Equal("sig", b.Signature);
|
Assert.Equal("sig", b.Signature);
|
||||||
Assert.Equal(asset, b.MediaAssetId);
|
Assert.Equal(asset, b.MediaAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AppSetting_CreateAndSetValue()
|
public void AppSetting_CreateAndSetValue()
|
||||||
{
|
{
|
||||||
var s = AppSetting.Create("site.title", "TeleWave");
|
var s = AppSetting.Create("site.title", "TeleWave");
|
||||||
Assert.Equal("site.title", s.Key);
|
Assert.Equal("site.title", s.Key);
|
||||||
Assert.Equal("TeleWave", s.Value);
|
Assert.Equal("TeleWave", s.Value);
|
||||||
|
|
||||||
s.SetValue("New");
|
s.SetValue("New");
|
||||||
Assert.Equal("New", s.Value);
|
Assert.Equal("New", s.Value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+298
-298
@@ -1,298 +1,298 @@
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Broadcast.Scheduling;
|
using TeleWave.Application.Broadcast.Scheduling;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Programming.Planning;
|
using TeleWave.Application.Programming.Planning;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Streaming;
|
using TeleWave.Application.Streaming;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Integration.Tests;
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
|
/// Конвейер генерации против настоящей БД: разворот групп, наполнение слотов, запись ленты,
|
||||||
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
|
/// продвижение курсоров. Юнит-тесты покрывают чистую математику планировщика, а здесь проверяется
|
||||||
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
|
/// то, чего они не видят, — реальные запросы EF, транзакция, advisory-lock и ExecuteDelete.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("postgres")]
|
[Collection("postgres")]
|
||||||
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
public sealed class GridScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||||
{
|
{
|
||||||
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
private static readonly DateTimeOffset Now = new(2026, 3, 17, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
|
public async Task Generate_FillsSlotFromGroup_AndAdvancesCursor()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 3);
|
var world = await SeedAsync(seedDb, episodes: 3);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||||
|
|
||||||
Assert.False(report.ChannelSkipped);
|
Assert.False(report.ChannelSkipped);
|
||||||
Assert.True(report.Added > 0);
|
Assert.True(report.Added > 0);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var entries = verify
|
var entries = verify
|
||||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
|
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId)
|
||||||
.OrderBy(e => e.StartsAtUtc)
|
.OrderBy(e => e.StartsAtUtc)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.NotEmpty(entries);
|
Assert.NotEmpty(entries);
|
||||||
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
|
// Лента обязана быть непрерывной: живой край иначе упрётся в дыру.
|
||||||
for (var i = 1; i < entries.Count; i++)
|
for (var i = 1; i < entries.Count; i++)
|
||||||
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
|
Assert.Equal(entries[i - 1].EndsAtUtc, entries[i].StartsAtUtc);
|
||||||
|
|
||||||
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
var programs = entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList();
|
||||||
Assert.NotEmpty(programs);
|
Assert.NotEmpty(programs);
|
||||||
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
|
Assert.All(programs, e => Assert.Equal(world.SlotId, e.SlotId));
|
||||||
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
|
Assert.All(programs, e => Assert.NotNull(e.TraceJson));
|
||||||
|
|
||||||
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
|
// Курсор слота сдвинулся — следующий прогон продолжит с той же серии, а не с первой.
|
||||||
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
|
var state = verify.SlotStates.Single(s => s.SlotId == world.SlotId);
|
||||||
Assert.Equal(world.ShowId, state.CurrentElementId);
|
Assert.Equal(world.ShowId, state.CurrentElementId);
|
||||||
Assert.True(state.NextUnitIndex > 0);
|
Assert.True(state.NextUnitIndex > 0);
|
||||||
|
|
||||||
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
|
// И шаблон помечен применённым: баннер «правила изменены» должен погаснуть.
|
||||||
Assert.False(
|
Assert.False(
|
||||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Generate_IsIdempotentWithinHorizon()
|
public async Task Generate_IsIdempotentWithinHorizon()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 3);
|
var world = await SeedAsync(seedDb, episodes: 3);
|
||||||
|
|
||||||
await using var first = fixture.CreateContext();
|
await using var first = fixture.CreateContext();
|
||||||
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
|
await Generator(first).GenerateAsync(world.ChannelId, Now, false, default);
|
||||||
|
|
||||||
await using var second = fixture.CreateContext();
|
await using var second = fixture.CreateContext();
|
||||||
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
|
var again = await Generator(second).GenerateAsync(world.ChannelId, Now, false, default);
|
||||||
|
|
||||||
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
|
// Горизонт уже заполнен — второй прогон не должен дописывать хвост поверх существующего.
|
||||||
Assert.Equal(0, again.Added);
|
Assert.Equal(0, again.Added);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
|
public async Task Generate_Rebuild_KeepsPastAndReplacesFuture()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 3);
|
var world = await SeedAsync(seedDb, episodes: 3);
|
||||||
|
|
||||||
await using var first = fixture.CreateContext();
|
await using var first = fixture.CreateContext();
|
||||||
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
|
await Generator(first).GenerateAsync(world.ChannelId, Now.AddHours(-2), false, default);
|
||||||
|
|
||||||
await using var beforeDb = fixture.CreateContext();
|
await using var beforeDb = fixture.CreateContext();
|
||||||
var past = beforeDb
|
var past = beforeDb
|
||||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
|
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && e.EndsAtUtc <= Now)
|
||||||
.Select(e => e.Id)
|
.Select(e => e.Id)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
Assert.NotEmpty(past);
|
Assert.NotEmpty(past);
|
||||||
|
|
||||||
await using var rebuild = fixture.CreateContext();
|
await using var rebuild = fixture.CreateContext();
|
||||||
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
|
await Generator(rebuild).GenerateAsync(world.ChannelId, Now, true, default);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var survived = verify
|
var survived = verify
|
||||||
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
|
.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId && past.Contains(e.Id))
|
||||||
.Select(e => e.Id)
|
.Select(e => e.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
|
// Прошлое неприкосновенно: зритель не должен обнаружить, что у него вырезали программу.
|
||||||
Assert.Equal(past.Count, survived.Count);
|
Assert.Equal(past.Count, survived.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
public async Task Generate_CollectionInGroup_StampsCollectionOnEntries()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
|
var world = await SeedAsync(seedDb, episodes: 2, asCollection: true);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var programs = verify
|
var programs = verify
|
||||||
.ScheduleEntries.Where(e =>
|
.ScheduleEntries.Where(e =>
|
||||||
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
|
e.ChannelId == world.ChannelId && e.Kind == ScheduleEntryKind.Program
|
||||||
)
|
)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.NotEmpty(programs);
|
Assert.NotEmpty(programs);
|
||||||
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
|
// Из шоу коллекцию не вывести — она должна прийти из плана и осесть в ленте.
|
||||||
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
|
Assert.All(programs, e => Assert.Equal(world.CollectionId, e.CollectionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Preview_WritesNothing()
|
public async Task Preview_WritesNothing()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 3);
|
var world = await SeedAsync(seedDb, episodes: 3);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
|
var result = await Generator(db).PreviewAsync(world.ChannelId, Now, 1, default);
|
||||||
|
|
||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
Assert.NotEmpty(result.Items);
|
Assert.NotEmpty(result.Items);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
|
// Сухой прогон: ни ленты, ни курсоров, ни отметки о применении.
|
||||||
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
|
Assert.Empty(verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId));
|
||||||
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
|
Assert.Empty(verify.SlotStates.Where(s => s.SlotId == world.SlotId));
|
||||||
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
|
// Отметку о применении сухой прогон тоже не ставит: применять по-прежнему есть что.
|
||||||
Assert.True(
|
Assert.True(
|
||||||
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
verify.ScheduleTemplates.Single(t => t.Id == world.TemplateId).HasPendingChanges
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Generate_EmptyGroup_FallsBackAndWarns()
|
public async Task Generate_EmptyGroup_FallsBackAndWarns()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var world = await SeedAsync(seedDb, episodes: 0);
|
var world = await SeedAsync(seedDb, episodes: 0);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
var report = await Generator(db).GenerateAsync(world.ChannelId, Now, false, default);
|
||||||
|
|
||||||
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
|
// Пустая группа — не ошибка генерации: слот закрывает фон, а админ получает предупреждение.
|
||||||
Assert.Contains(
|
Assert.Contains(
|
||||||
report.Warnings,
|
report.Warnings,
|
||||||
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
|
w => w.Kind == Domain.Programming.Planning.PlanningWarningKind.SlotEmpty
|
||||||
);
|
);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
Assert.All(
|
Assert.All(
|
||||||
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
|
verify.ScheduleEntries.Where(e => e.ChannelId == world.ChannelId).ToList(),
|
||||||
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
|
e => Assert.NotEqual(ScheduleEntryKind.Program, e.Kind)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Обвязка ─────────────────────────────────────────────────────────────
|
// ── Обвязка ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static GridScheduleGenerator Generator(AppDbContext db)
|
private static GridScheduleGenerator Generator(AppDbContext db)
|
||||||
{
|
{
|
||||||
var random = new SequenceRandom(0);
|
var random = new SequenceRandom(0);
|
||||||
return new GridScheduleGenerator(
|
return new GridScheduleGenerator(
|
||||||
db,
|
db,
|
||||||
new GroupExpander(db),
|
new GroupExpander(db),
|
||||||
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
new BumperResolver(db, Substitute.For<IBumperRenderQueue>(), random),
|
||||||
new PostCheckRunner(db),
|
new PostCheckRunner(db),
|
||||||
random,
|
random,
|
||||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionDays = 90 }),
|
||||||
Options.Create(new StreamingOptions())
|
Options.Create(new StreamingOptions())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record World(
|
private sealed record World(
|
||||||
Guid ChannelId,
|
Guid ChannelId,
|
||||||
Guid TemplateId,
|
Guid TemplateId,
|
||||||
Guid SlotId,
|
Guid SlotId,
|
||||||
Guid ShowId,
|
Guid ShowId,
|
||||||
Guid? CollectionId
|
Guid? CollectionId
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
|
/// Минимальный работающий канал: шоу с готовыми сериями, группа, шаблон со слотом на весь день
|
||||||
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
|
/// и филлер для пауз. <paramref name="asCollection"/> кладёт в группу коллекцию, а не шоу.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static async Task<World> SeedAsync(
|
private static async Task<World> SeedAsync(
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
int episodes,
|
int episodes,
|
||||||
bool asCollection = false
|
bool asCollection = false
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
|
var show = Show.Create($"Шоу {suffix}", ShowKind.Series);
|
||||||
for (var i = 0; i < episodes; i++)
|
for (var i = 0; i < episodes; i++)
|
||||||
{
|
{
|
||||||
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
var asset = ReadyAsset(db, $"ep{i}-{suffix}.mkv", TimeSpan.FromMinutes(30));
|
||||||
show.AddEpisode(asset.Id);
|
show.AddEpisode(asset.Id);
|
||||||
}
|
}
|
||||||
db.Shows.Add(show);
|
db.Shows.Add(show);
|
||||||
|
|
||||||
Collection? collection = null;
|
Collection? collection = null;
|
||||||
if (asCollection)
|
if (asCollection)
|
||||||
{
|
{
|
||||||
collection = Collection.Create($"Франшиза {suffix}");
|
collection = Collection.Create($"Франшиза {suffix}");
|
||||||
collection.AddShow(show.Id);
|
collection.AddShow(show.Id);
|
||||||
db.Collections.Add(collection);
|
db.Collections.Add(collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
var group = Group.Create($"Группа {suffix}");
|
var group = Group.Create($"Группа {suffix}");
|
||||||
group.AddElement(
|
group.AddElement(
|
||||||
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
|
asCollection ? GroupElementKind.Collection : GroupElementKind.Show,
|
||||||
asCollection ? collection!.Id : show.Id
|
asCollection ? collection!.Id : show.Id
|
||||||
);
|
);
|
||||||
db.Groups.Add(group);
|
db.Groups.Add(group);
|
||||||
|
|
||||||
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
var filler = ReadyAsset(db, $"filler-{suffix}.mkv", TimeSpan.FromMinutes(1));
|
||||||
|
|
||||||
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
var channel = Channel.Create($"Канал {suffix}", $"ch-{suffix}", Now.AddDays(-7));
|
||||||
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
|
channel.UpdateSettings(channel.Name, isEnabled: true, bumpersEnabled: false, filler.Id);
|
||||||
|
|
||||||
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
var template = ScheduleTemplate.Create(channel.Id, "Сетка");
|
||||||
var layer = template.AddLayer("Базовый", 10);
|
var layer = template.AddLayer("Базовый", 10);
|
||||||
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
var slot = layer.AddSlot("Дневной блок", new TimeOnly(6, 0), 24 * 60);
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
new SlotContent(
|
new SlotContent(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
SlotKind.Content,
|
SlotKind.Content,
|
||||||
group.Id,
|
group.Id,
|
||||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||||
null,
|
null,
|
||||||
SlotBlockMode.FillSlot,
|
SlotBlockMode.FillSlot,
|
||||||
1,
|
1,
|
||||||
OverflowPolicy.ContinueNext
|
OverflowPolicy.ContinueNext
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
channel.SetTemplate(template.Id);
|
channel.SetTemplate(template.Id);
|
||||||
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
// Правка правил помечает шаблон изменённым — воспроизводим состояние «есть что применить».
|
||||||
template.MarkChanged();
|
template.MarkChanged();
|
||||||
|
|
||||||
db.Channels.Add(channel);
|
db.Channels.Add(channel);
|
||||||
db.ScheduleTemplates.Add(template);
|
db.ScheduleTemplates.Add(template);
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
|
return new World(channel.Id, template.Id, slot.Id, show.Id, collection?.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
|
/// <summary>Ассет, доведённый до Ready: в эфир попадают только такие.</summary>
|
||||||
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
private static MediaAsset ReadyAsset(AppDbContext db, string fileName, TimeSpan duration)
|
||||||
{
|
{
|
||||||
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
var asset = MediaAsset.Register(fileName, ".mkv", MediaSource.Upload);
|
||||||
asset.MarkProcessing();
|
asset.MarkProcessing();
|
||||||
asset.MarkReady(
|
asset.MarkReady(
|
||||||
new MediaReadyInfo(
|
new MediaReadyInfo(
|
||||||
duration,
|
duration,
|
||||||
SegmentSeconds: 2,
|
SegmentSeconds: 2,
|
||||||
SegmentCount: (int)(duration.TotalSeconds / 2),
|
SegmentCount: (int)(duration.TotalSeconds / 2),
|
||||||
Width: 1920,
|
Width: 1920,
|
||||||
Height: 1080,
|
Height: 1080,
|
||||||
VideoCodec: "h264",
|
VideoCodec: "h264",
|
||||||
AudioCodec: "aac",
|
AudioCodec: "aac",
|
||||||
RelativePath: $"segments/{asset.Id}"
|
RelativePath: $"segments/{asset.Id}"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
db.MediaAssets.Add(asset);
|
db.MediaAssets.Add(asset);
|
||||||
return asset;
|
return asset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,176 +1,176 @@
|
|||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using TeleWave.Application.Common.Interfaces;
|
using TeleWave.Application.Common.Interfaces;
|
||||||
using TeleWave.Application.Media.ManualInbox;
|
using TeleWave.Application.Media.ManualInbox;
|
||||||
using TeleWave.Domain.Library;
|
using TeleWave.Domain.Library;
|
||||||
using TeleWave.Domain.Media;
|
using TeleWave.Domain.Media;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Integration.Tests;
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
|
/// Ручной разбор <c>manual/</c>: выбранные файлы уходят из каталога в шоу. Проверяется против
|
||||||
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
|
/// настоящей БД, потому что вся суть операции — в связке «ассет + серия шоу + файл на диске».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("postgres")]
|
[Collection("postgres")]
|
||||||
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
public sealed class ManualInboxIntegrationTests(PostgresFixture fixture)
|
||||||
{
|
{
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
|
public async Task Import_AddsEpisodesToShow_AndConsumesFiles()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var showId = await SeedShowAsync(seedDb);
|
var showId = await SeedShowAsync(seedDb);
|
||||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||||
var first = $"{tag}-s01e01.mkv";
|
var first = $"{tag}-s01e01.mkv";
|
||||||
var second = $"{tag}-s01e02.mkv";
|
var second = $"{tag}-s01e02.mkv";
|
||||||
|
|
||||||
var storage = Substitute.For<IMediaStorage>();
|
var storage = Substitute.For<IMediaStorage>();
|
||||||
storage
|
storage
|
||||||
.ListManualInbox(Arg.Any<int>())
|
.ListManualInbox(Arg.Any<int>())
|
||||||
.Returns([
|
.Returns([
|
||||||
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
|
new IMediaStorage.ManualInboxFile($"Сериал/{first}", first, 1000),
|
||||||
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
|
new IMediaStorage.ManualInboxFile($"Сериал/{second}", second, 1000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand(
|
new ImportManualInboxCommand(
|
||||||
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
|
[Item($"Сериал/{first}"), Item($"Сериал/{second}")],
|
||||||
showId
|
showId
|
||||||
),
|
),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(2, result.Value.Imported);
|
Assert.Equal(2, result.Value.Imported);
|
||||||
Assert.Empty(result.Value.Failed);
|
Assert.Empty(result.Value.Failed);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
|
// Файлы должны быть перенесены из manual/ — ровно как из обычного inbox.
|
||||||
await storage
|
await storage
|
||||||
.Received(2)
|
.Received(2)
|
||||||
.PromoteToOriginalAsync(
|
.PromoteToOriginalAsync(
|
||||||
MediaSource.ManualInbox,
|
MediaSource.ManualInbox,
|
||||||
Arg.Any<string>(),
|
Arg.Any<string>(),
|
||||||
Arg.Any<Guid>(),
|
Arg.Any<Guid>(),
|
||||||
".mkv",
|
".mkv",
|
||||||
Arg.Any<CancellationToken>()
|
Arg.Any<CancellationToken>()
|
||||||
);
|
);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var episodes = verify
|
var episodes = verify
|
||||||
.Shows.Where(s => s.Id == showId)
|
.Shows.Where(s => s.Id == showId)
|
||||||
.SelectMany(s => s.Episodes)
|
.SelectMany(s => s.Episodes)
|
||||||
.OrderBy(e => e.Position)
|
.OrderBy(e => e.Position)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
Assert.Equal(2, episodes.Count);
|
Assert.Equal(2, episodes.Count);
|
||||||
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
|
// Номера сезона и серии разбираются из имени файла, как при обычном добавлении.
|
||||||
Assert.Equal(1, episodes[0].Season);
|
Assert.Equal(1, episodes[0].Season);
|
||||||
Assert.Equal(1, episodes[0].Episode);
|
Assert.Equal(1, episodes[0].Episode);
|
||||||
Assert.Equal(2, episodes[1].Episode);
|
Assert.Equal(2, episodes[1].Episode);
|
||||||
|
|
||||||
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
|
// Считаем ассеты своего шоу: база в интеграционных тестах общая, и соседний тест тоже
|
||||||
// заводит записи из ручного inbox.
|
// заводит записи из ручного inbox.
|
||||||
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
var assetIds = episodes.Select(e => e.MediaAssetId).ToList();
|
||||||
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
|
var assets = verify.MediaAssets.Where(a => assetIds.Contains(a.Id)).ToList();
|
||||||
Assert.Equal(2, assets.Count);
|
Assert.Equal(2, assets.Count);
|
||||||
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
|
Assert.All(assets, a => Assert.Equal(MediaSource.ManualInbox, a.Source));
|
||||||
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
|
Assert.All(assets, a => Assert.Equal(MediaAssetStatus.Pending, a.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
|
public async Task Import_SkipsUnsupportedAndMissing_WithoutFailingTheBatch()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var showId = await SeedShowAsync(seedDb);
|
var showId = await SeedShowAsync(seedDb);
|
||||||
|
|
||||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||||
var good = $"{tag}-ok.mkv";
|
var good = $"{tag}-ok.mkv";
|
||||||
|
|
||||||
var storage = Substitute.For<IMediaStorage>();
|
var storage = Substitute.For<IMediaStorage>();
|
||||||
storage
|
storage
|
||||||
.ListManualInbox(Arg.Any<int>())
|
.ListManualInbox(Arg.Any<int>())
|
||||||
.Returns([
|
.Returns([
|
||||||
new IMediaStorage.ManualInboxFile(good, good, 1000),
|
new IMediaStorage.ManualInboxFile(good, good, 1000),
|
||||||
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
|
new IMediaStorage.ManualInboxFile("readme.txt", "readme.txt", 10),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand(
|
new ImportManualInboxCommand(
|
||||||
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
|
[Item(good), Item("readme.txt"), Item("ушёл.mkv")],
|
||||||
showId
|
showId
|
||||||
),
|
),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
|
|
||||||
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
|
// Один взяли, два отклонили — и каждый со своей причиной, а не одной ошибкой на пакет.
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
Assert.Equal(1, result.Value.Imported);
|
Assert.Equal(1, result.Value.Imported);
|
||||||
Assert.Equal(2, result.Value.Failed.Count);
|
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 == "readme.txt");
|
||||||
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
|
Assert.Contains(result.Value.Failed, f => f.RelativePath == "ушёл.mkv");
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
|
public async Task Import_WithExplicitNumbers_UsesThemInsteadOfFileName()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var showId = await SeedShowAsync(seedDb);
|
var showId = await SeedShowAsync(seedDb);
|
||||||
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
|
var name = $"{Guid.NewGuid():N}"[..8] + "-без-номеров.mkv";
|
||||||
|
|
||||||
var storage = Substitute.For<IMediaStorage>();
|
var storage = Substitute.For<IMediaStorage>();
|
||||||
storage
|
storage
|
||||||
.ListManualInbox(Arg.Any<int>())
|
.ListManualInbox(Arg.Any<int>())
|
||||||
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
|
.Returns([new IMediaStorage.ManualInboxFile(name, name, 1000)]);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ImportManualInboxCommandHandler(
|
var result = await new ImportManualInboxCommandHandler(
|
||||||
db,
|
db,
|
||||||
storage,
|
storage,
|
||||||
Substitute.For<IMediaProcessingQueue>()
|
Substitute.For<IMediaProcessingQueue>()
|
||||||
).Handle(
|
).Handle(
|
||||||
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
new ImportManualInboxCommand([new ManualImportItem(name, 4, 12)], showId),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
|
var episode = verify.Shows.Where(s => s.Id == showId).SelectMany(s => s.Episodes).Single();
|
||||||
|
|
||||||
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
|
// Из имени номера не вытащить — значит сохранилось ровно то, что видел пользователь.
|
||||||
Assert.Equal(4, episode.Season);
|
Assert.Equal(4, episode.Season);
|
||||||
Assert.Equal(12, episode.Episode);
|
Assert.Equal(12, episode.Episode);
|
||||||
|
|
||||||
// И спутники рядом с забранным файлом убираются.
|
// И спутники рядом с забранным файлом убираются.
|
||||||
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
|
await storage.Received(1).CleanupManualLeftoversAsync(name, Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
|
/// <summary>Файл без явных номеров — сервер разбирает имя сам, как при обычном добавлении.</summary>
|
||||||
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
|
private static ManualImportItem Item(string relativePath) => new(relativePath, null, null);
|
||||||
|
|
||||||
private static async Task<Guid> SeedShowAsync(AppDbContext db)
|
private static async Task<Guid> SeedShowAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
var show = Show.Create($"Сериал {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
||||||
db.Shows.Add(show);
|
db.Shows.Add(show);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return show.Id;
|
return show.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,70 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Integration.Tests;
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PostgresFixture : IAsyncLifetime
|
public sealed class PostgresFixture : IAsyncLifetime
|
||||||
{
|
{
|
||||||
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||||
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||||
private PostgreSqlContainer? _container;
|
private PostgreSqlContainer? _container;
|
||||||
|
|
||||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||||
public bool Available { get; private set; }
|
public bool Available { get; private set; }
|
||||||
|
|
||||||
public string ConnectionString =>
|
public string ConnectionString =>
|
||||||
_container?.GetConnectionString()
|
_container?.GetConnectionString()
|
||||||
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
||||||
await _container.StartAsync();
|
await _container.StartAsync();
|
||||||
await using var db = CreateContext();
|
await using var db = CreateContext();
|
||||||
await db.Database.MigrateAsync();
|
await db.Database.MigrateAsync();
|
||||||
Available = true;
|
Available = true;
|
||||||
}
|
}
|
||||||
catch (Exception)
|
catch (Exception)
|
||||||
{
|
{
|
||||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||||
Available = false;
|
Available = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public AppDbContext CreateContext() =>
|
public AppDbContext CreateContext() =>
|
||||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||||
|
|
||||||
public async Task DisposeAsync()
|
public async Task DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_container is not null)
|
if (_container is not null)
|
||||||
await _container.DisposeAsync();
|
await _container.DisposeAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[CollectionDefinition("postgres")]
|
[CollectionDefinition("postgres")]
|
||||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||||
|
|
||||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||||
internal sealed class SequenceRandom(params int[] sequence)
|
internal sealed class SequenceRandom(params int[] sequence)
|
||||||
: Domain.Broadcast.Scheduling.IRandomSource
|
: Domain.Broadcast.Scheduling.IRandomSource
|
||||||
{
|
{
|
||||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||||
private int _i;
|
private int _i;
|
||||||
|
|
||||||
public int Next(int maxExclusive)
|
public int Next(int maxExclusive)
|
||||||
{
|
{
|
||||||
if (maxExclusive <= 0)
|
if (maxExclusive <= 0)
|
||||||
return 0;
|
return 0;
|
||||||
var value = _sequence[_i++ % _sequence.Length];
|
var value = _sequence[_i++ % _sequence.Length];
|
||||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,183 +1,183 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TeleWave.Application.Programming.Templates;
|
using TeleWave.Application.Programming.Templates;
|
||||||
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
using TeleWave.Application.Programming.Templates.CopyTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
using TeleWave.Application.Programming.Templates.CreateTemplate;
|
||||||
using TeleWave.Application.Programming.Templates.Validate;
|
using TeleWave.Application.Programming.Templates.Validate;
|
||||||
using TeleWave.Domain.Broadcast;
|
using TeleWave.Domain.Broadcast;
|
||||||
using TeleWave.Domain.Programming;
|
using TeleWave.Domain.Programming;
|
||||||
using TeleWave.Infrastructure.Persistence;
|
using TeleWave.Infrastructure.Persistence;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace TeleWave.Integration.Tests;
|
namespace TeleWave.Integration.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
|
/// Операции над шаблоном против настоящей БД: копирование на другой канал и проверки по правилам.
|
||||||
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
|
/// Обе штуки — сплошные запросы EF и перекладывание графов, в юнит-тестах их не поймать.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Collection("postgres")]
|
[Collection("postgres")]
|
||||||
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
|
||||||
{
|
{
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
|
public async Task Copy_MovesLayersSlotsAndJunctions_AndReplacesTargetGrid()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
|
var (sourceId, targetId, groupId) = await SeedPairAsync(seedDb);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new CopyTemplateCommandHandler(db).Handle(
|
var result = await new CopyTemplateCommandHandler(db).Handle(
|
||||||
new CopyTemplateCommand(sourceId, targetId),
|
new CopyTemplateCommand(sourceId, targetId),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
Assert.Equal(1, result.Value.Layers);
|
Assert.Equal(1, result.Value.Layers);
|
||||||
Assert.Equal(1, result.Value.Slots);
|
Assert.Equal(1, result.Value.Slots);
|
||||||
Assert.Equal(1, result.Value.Junctions);
|
Assert.Equal(1, result.Value.Junctions);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var target = verify.Channels.Single(c => c.Id == targetId);
|
var target = verify.Channels.Single(c => c.Id == targetId);
|
||||||
var copied = verify
|
var copied = verify
|
||||||
.ScheduleTemplates.Include(t => t.Layers)
|
.ScheduleTemplates.Include(t => t.Layers)
|
||||||
.ThenInclude(l => l.Slots)
|
.ThenInclude(l => l.Slots)
|
||||||
.Single(t => t.ChannelId == targetId);
|
.Single(t => t.ChannelId == targetId);
|
||||||
|
|
||||||
// У приёмника ровно один шаблон, и канал смотрит именно на него.
|
// У приёмника ровно один шаблон, и канал смотрит именно на него.
|
||||||
Assert.Equal(copied.Id, target.TemplateId);
|
Assert.Equal(copied.Id, target.TemplateId);
|
||||||
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
|
Assert.Single(verify.ScheduleTemplates.Where(t => t.ChannelId == targetId));
|
||||||
|
|
||||||
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
|
var slot = copied.Layers.SelectMany(l => l.Slots).Single();
|
||||||
// Группы общие — ссылка переносится как есть, а не копией группы.
|
// Группы общие — ссылка переносится как есть, а не копией группы.
|
||||||
Assert.Equal(groupId, slot.GroupId);
|
Assert.Equal(groupId, slot.GroupId);
|
||||||
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
|
Assert.Single(verify.Groups.Where(g => g.Id == groupId));
|
||||||
|
|
||||||
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
|
// Стык переехал своей копией, и слот ссылается на неё, а не на стык чужого канала.
|
||||||
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
|
var junction = verify.JunctionTemplates.Single(j => j.ChannelId == targetId);
|
||||||
Assert.Equal(junction.Id, slot.JunctionAfterId);
|
Assert.Equal(junction.Id, slot.JunctionAfterId);
|
||||||
Assert.NotEqual(
|
Assert.NotEqual(
|
||||||
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
|
verify.JunctionTemplates.Single(j => j.ChannelId == sourceId).Id,
|
||||||
slot.JunctionAfterId
|
slot.JunctionAfterId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Copy_ToItself_IsRejectedByValidator()
|
public void Copy_ToItself_IsRejectedByValidator()
|
||||||
{
|
{
|
||||||
var validator = new CopyTemplateCommandValidator();
|
var validator = new CopyTemplateCommandValidator();
|
||||||
var id = Guid.NewGuid();
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
|
Assert.False(validator.Validate(new CopyTemplateCommand(id, id)).IsValid);
|
||||||
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
|
Assert.True(validator.Validate(new CopyTemplateCommand(id, Guid.NewGuid())).IsValid);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task Validate_ReportsEmptyGroupAndGridGap()
|
public async Task Validate_ReportsEmptyGroupAndGridGap()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
var (sourceId, _, _) = await SeedPairAsync(seedDb);
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
var result = await new ValidateTemplateQueryHandler(db).Handle(
|
||||||
new ValidateTemplateQuery(sourceId),
|
new ValidateTemplateQuery(sourceId),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
|
|
||||||
Assert.True(result.IsSuccess);
|
Assert.True(result.IsSuccess);
|
||||||
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
// Группа в сиде пустая, а слот занимает лишь два часа суток — обе проверки должны сработать.
|
||||||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
|
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GroupEmpty);
|
||||||
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
|
Assert.Contains(result.Value, i => i.Kind == TemplateIssueKind.GridGap);
|
||||||
}
|
}
|
||||||
|
|
||||||
[SkippableFact]
|
[SkippableFact]
|
||||||
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
|
public async Task CreateTemplate_ForChannelWithoutGrid_LinksItAndIsIdempotent()
|
||||||
{
|
{
|
||||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||||
|
|
||||||
// Канал из старой ротации: сетки нет, ссылки на неё тоже.
|
// Канал из старой ротации: сетки нет, ссылки на неё тоже.
|
||||||
await using var seedDb = fixture.CreateContext();
|
await using var seedDb = fixture.CreateContext();
|
||||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
var channel = Channel.Create(
|
var channel = Channel.Create(
|
||||||
$"Без сетки {suffix}",
|
$"Без сетки {suffix}",
|
||||||
$"nogrid-{suffix}",
|
$"nogrid-{suffix}",
|
||||||
DateTimeOffset.UtcNow
|
DateTimeOffset.UtcNow
|
||||||
);
|
);
|
||||||
seedDb.Channels.Add(channel);
|
seedDb.Channels.Add(channel);
|
||||||
await seedDb.SaveChangesAsync();
|
await seedDb.SaveChangesAsync();
|
||||||
|
|
||||||
await using var db = fixture.CreateContext();
|
await using var db = fixture.CreateContext();
|
||||||
var created = await new CreateChannelTemplateCommandHandler(db).Handle(
|
var created = await new CreateChannelTemplateCommandHandler(db).Handle(
|
||||||
new CreateChannelTemplateCommand(channel.Id),
|
new CreateChannelTemplateCommand(channel.Id),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
Assert.True(created.IsSuccess);
|
Assert.True(created.IsSuccess);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
await using var again = fixture.CreateContext();
|
await using var again = fixture.CreateContext();
|
||||||
var second = await new CreateChannelTemplateCommandHandler(again).Handle(
|
var second = await new CreateChannelTemplateCommandHandler(again).Handle(
|
||||||
new CreateChannelTemplateCommand(channel.Id),
|
new CreateChannelTemplateCommand(channel.Id),
|
||||||
default
|
default
|
||||||
);
|
);
|
||||||
await again.SaveChangesAsync();
|
await again.SaveChangesAsync();
|
||||||
|
|
||||||
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
|
// Повторный вызов возвращает ту же сетку: одна на канал, второй не появляется.
|
||||||
Assert.True(second.IsSuccess);
|
Assert.True(second.IsSuccess);
|
||||||
Assert.Equal(created.Value, second.Value);
|
Assert.Equal(created.Value, second.Value);
|
||||||
|
|
||||||
await using var verify = fixture.CreateContext();
|
await using var verify = fixture.CreateContext();
|
||||||
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
|
var template = verify.ScheduleTemplates.Single(t => t.ChannelId == channel.Id);
|
||||||
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
|
Assert.Equal(template.Id, verify.Channels.Single(c => c.Id == channel.Id).TemplateId);
|
||||||
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
|
// Фоновый слой заводится сразу — без него первую же дыру в сетке нечем закрыть.
|
||||||
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
|
Assert.Single(verify.GridLayers.Where(l => l.TemplateId == template.Id && l.IsBackground));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
|
/// <summary>Два канала: у источника слой со слотом, группой и стыком; у приёмника — пустая сетка.</summary>
|
||||||
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
|
private static async Task<(Guid Source, Guid Target, Guid GroupId)> SeedPairAsync(
|
||||||
AppDbContext db
|
AppDbContext db
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var suffix = Guid.NewGuid().ToString("N")[..8];
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
var group = Group.Create($"Группа {suffix}");
|
var group = Group.Create($"Группа {suffix}");
|
||||||
db.Groups.Add(group);
|
db.Groups.Add(group);
|
||||||
|
|
||||||
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
|
var source = Channel.Create($"Источник {suffix}", $"src-{suffix}", DateTimeOffset.UtcNow);
|
||||||
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
|
var target = Channel.Create($"Приёмник {suffix}", $"dst-{suffix}", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
var junction = JunctionTemplate.Create(source.Id, "Прайм");
|
var junction = JunctionTemplate.Create(source.Id, "Прайм");
|
||||||
var ad = junction.AddElement(JunctionElementKind.Ad);
|
var ad = junction.AddElement(JunctionElementKind.Ad);
|
||||||
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
|
ad.Update(JunctionElementKind.Ad, group.Id, null, JunctionAmountMode.Count, 2, true, null);
|
||||||
db.JunctionTemplates.Add(junction);
|
db.JunctionTemplates.Add(junction);
|
||||||
|
|
||||||
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
|
var sourceTemplate = ScheduleTemplate.Create(source.Id, "Сетка источника");
|
||||||
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
var layer = sourceTemplate.AddLayer("Прайм", 10);
|
||||||
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
var slot = layer.AddSlot("Вечернее кино", new TimeOnly(20, 0), 120);
|
||||||
slot.UpdateContent(
|
slot.UpdateContent(
|
||||||
new SlotContent(
|
new SlotContent(
|
||||||
slot.Title,
|
slot.Title,
|
||||||
SlotKind.Content,
|
SlotKind.Content,
|
||||||
group.Id,
|
group.Id,
|
||||||
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
new SlotStrategy(SlotStrategyType.Sequential).ToJson(),
|
||||||
null,
|
null,
|
||||||
SlotBlockMode.FillSlot,
|
SlotBlockMode.FillSlot,
|
||||||
1,
|
1,
|
||||||
OverflowPolicy.ContinueNext,
|
OverflowPolicy.ContinueNext,
|
||||||
JunctionAfterId: junction.Id
|
JunctionAfterId: junction.Id
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
sourceTemplate.SetDefaultJunction(junction.Id);
|
sourceTemplate.SetDefaultJunction(junction.Id);
|
||||||
source.SetTemplate(sourceTemplate.Id);
|
source.SetTemplate(sourceTemplate.Id);
|
||||||
|
|
||||||
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
|
var targetTemplate = ScheduleTemplate.Create(target.Id, "Сетка приёмника");
|
||||||
target.SetTemplate(targetTemplate.Id);
|
target.SetTemplate(targetTemplate.Id);
|
||||||
|
|
||||||
db.Channels.AddRange(source, target);
|
db.Channels.AddRange(source, target);
|
||||||
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
|
db.ScheduleTemplates.AddRange(sourceTemplate, targetTemplate);
|
||||||
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return (source.Id, target.Id, group.Id);
|
return (source.Id, target.Id, group.Id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,85 @@
|
|||||||
import { HelpCircle } from 'lucide-react'
|
import { HelpCircle } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ScheduleEntryDto } from '@/shared/api/types'
|
import type { ScheduleEntryDto } from '@/shared/api/types'
|
||||||
import { Badge } from '@/shared/ui/badge'
|
import { Badge } from '@/shared/ui/badge'
|
||||||
import { formatTime } from '../lib/format'
|
import { formatTime } from '../lib/format'
|
||||||
|
|
||||||
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
/** Что стоит в строке расписания: реклама, заставка-переход или программа с номером серии. */
|
||||||
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
function EntryLabel({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
if (entry.kind === 'Ad') return <Badge variant="muted">{t('air.ad')}</Badge>
|
||||||
|
|
||||||
if (entry.kind === 'Bumper')
|
if (entry.kind === 'Bumper')
|
||||||
return (
|
return (
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<Badge variant="muted" className="shrink-0">
|
<Badge variant="muted" className="shrink-0">
|
||||||
{t('air.bumper')}
|
{t('air.bumper')}
|
||||||
</Badge>
|
</Badge>
|
||||||
{(entry.bumperName || entry.bumperText) && (
|
{(entry.bumperName || entry.bumperText) && (
|
||||||
<span className="min-w-0 truncate text-muted-foreground">
|
<span className="min-w-0 truncate text-muted-foreground">
|
||||||
{entry.bumperName}
|
{entry.bumperName}
|
||||||
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
{entry.bumperName && entry.bumperText ? ' · ' : ''}
|
||||||
{entry.bumperText}
|
{entry.bumperText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
{entry.showName ?? '—'}
|
{entry.showName ?? '—'}
|
||||||
<EpisodeSuffix entry={entry} />
|
<EpisodeSuffix entry={entry} />
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
/** «· S02E05» либо «· серия N» — что удалось распознать; ничего, если ни того ни другого нет. */
|
||||||
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
function EpisodeSuffix({ entry }: Readonly<{ entry: ScheduleEntryDto }>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (entry.seasonEpisode)
|
if (entry.seasonEpisode)
|
||||||
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
return <span className="text-muted-foreground"> · {entry.seasonEpisode}</span>
|
||||||
|
|
||||||
if (entry.episodeIndex == null) return null
|
if (entry.episodeIndex == null) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{' '}
|
{' '}
|
||||||
· {t('air.episode')} {entry.episodeIndex + 1}
|
· {t('air.episode')} {entry.episodeIndex + 1}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SchedulePreview({
|
export function SchedulePreview({
|
||||||
entries,
|
entries,
|
||||||
onShowTrace,
|
onShowTrace,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
entries: ScheduleEntryDto[]
|
entries: ScheduleEntryDto[]
|
||||||
onShowTrace: (entryId: string) => void
|
onShowTrace: (entryId: string) => void
|
||||||
}>) {
|
}>) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
if (entries.length === 0)
|
if (entries.length === 0)
|
||||||
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
return <p className="text-muted-foreground">{t('admin.channels.noSchedule')}</p>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col divide-y divide-border text-sm">
|
<ul className="flex flex-col divide-y divide-border text-sm">
|
||||||
{entries.slice(0, 40).map((e) => (
|
{entries.slice(0, 40).map((e) => (
|
||||||
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
<li key={e.id} className="group flex items-center gap-3 py-1.5">
|
||||||
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
<span className="shrink-0 whitespace-nowrap tabular-nums text-muted-foreground">
|
||||||
{formatTime(e.startsAtUtc)}
|
{formatTime(e.startsAtUtc)}
|
||||||
</span>
|
</span>
|
||||||
<EntryLabel entry={e} />
|
<EntryLabel entry={e} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={t('admin.channels.whyHere')}
|
title={t('admin.channels.whyHere')}
|
||||||
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
|
className="ml-auto shrink-0 text-muted-foreground opacity-0 hover:text-foreground group-hover:opacity-100"
|
||||||
onClick={() => onShowTrace(e.id)}
|
onClick={() => onShowTrace(e.id)}
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-4 w-4" />
|
<HelpCircle className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user