diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index 6c2dd00..668feaa 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -209,7 +209,9 @@ public static class ChannelEndpoints body.Weight, body.BlockMode, body.BlockValue, - body.IsEnabled + body.IsEnabled, + body.PreferredWeightMultiplier, + body.PreferredHours ?? [] ), cancellationToken ); @@ -401,7 +403,8 @@ public static class ChannelEndpoints body.NextLabel, body.Line1, body.Line2, - body.Trigger + body.Trigger, + body.Weight ), cancellationToken ); @@ -604,7 +607,9 @@ public sealed record UpdateChannelShowBody( int Weight, BlockMode BlockMode, int BlockValue, - bool IsEnabled + bool IsEnabled, + int PreferredWeightMultiplier, + IReadOnlyList PreferredHours ); public sealed record AddChannelAdBody(Guid MediaAssetId); @@ -622,7 +627,8 @@ public sealed record UpdateBumperVariantBody( string NextLabel, string Line1, string Line2, - BumperTrigger Trigger + BumperTrigger Trigger, + int Weight ); public sealed record UpdateBumperTemplateBody( diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs index 25e7c5e..fc407bf 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommand.cs @@ -4,7 +4,7 @@ using TeleWave.Domain.Broadcast; namespace TeleWave.Application.Broadcast.Bumpers; -/// Обновить подблок: имя, режим текста, текст и правило показа. +/// Обновить подблок: имя, режим текста, текст, правило показа и вес. public sealed record UpdateBumperTextVariantCommand( Guid ChannelId, Guid TemplateId, @@ -15,5 +15,6 @@ public sealed record UpdateBumperTextVariantCommand( string NextLabel, string Line1, string Line2, - BumperTrigger Trigger + BumperTrigger Trigger, + int Weight ) : ICommand; diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs index 7dfcc21..488db92 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandHandler.cs @@ -35,7 +35,8 @@ public sealed class UpdateBumperTextVariantCommandHandler(IAppDbContext dbContex command.NextLabel, command.Line1, command.Line2, - command.Trigger + command.Trigger, + command.Weight ); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs index e8ea288..7864035 100644 --- a/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Bumpers/UpdateBumperTextVariantCommandValidator.cs @@ -12,5 +12,6 @@ public sealed class UpdateBumperTextVariantCommandValidator RuleFor(x => x.NextLabel).MaximumLength(64); RuleFor(x => x.Line1).MaximumLength(120); RuleFor(x => x.Line2).MaximumLength(120); + RuleFor(x => x.Weight).InclusiveBetween(0, 1000); } } diff --git a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs index 85100a2..a3b3b4a 100644 --- a/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs +++ b/backend/src/TeleWave.Application/Broadcast/ChannelDtos.cs @@ -12,9 +12,14 @@ public sealed record ChannelShowDto( BlockMode BlockMode, int BlockValue, bool IsEnabled, - int NextEpisodeIndex + int NextEpisodeIndex, + int PreferredWeightMultiplier, + IReadOnlyList PreferredHours ); +/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC). +public sealed record HourWindowDto(int StartHour, int EndHour); + public sealed record ChannelAdDto(Guid Id, Guid MediaAssetId, string? AssetName, int Position); public sealed record OverrideShowDto(Guid ShowId, string ShowName, int Weight); @@ -31,10 +36,12 @@ public sealed record ProgrammingOverrideDto( public sealed record BumperSettingsDto( BumperFont Font, int MinIntervalMinutes, - BumperSelection Selection + BumperSelection Selection, + double ShowChangeChance, + double EpisodeChangeChance ); -/// Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. +/// Подблок (текст-вариант): свой текст + правило показа + вес поверх стиля/звука блока. public sealed record BumperTextVariantDto( Guid Id, int Position, @@ -44,7 +51,8 @@ public sealed record BumperTextVariantDto( string NextLabel, string Line1, string Line2, - BumperTrigger Trigger + BumperTrigger Trigger, + int Weight ); /// Блок заставки: своё оформление + звук + подблоки. — длина звука (сек). diff --git a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs index 97a7e7f..9cec779 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetChannel/GetChannelQueryHandler.cs @@ -15,6 +15,7 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) { var channel = await dbContext.Channels.AsNoTracking() .Include(c => c.Shows) + .ThenInclude(s => s.PreferredHours) .Include(c => c.Ads) .Include(c => c.BumperTemplates) .ThenInclude(t => t.Variants) @@ -51,7 +52,12 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) s.BlockMode, s.BlockValue, s.IsEnabled, - s.NextEpisodeIndex + s.NextEpisodeIndex, + s.PreferredWeightMultiplier, + s.PreferredHours + .OrderBy(h => h.StartHour) + .Select(h => new HourWindowDto(h.StartHour, h.EndHour)) + .ToList() )) .ToList(); @@ -90,7 +96,8 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) v.NextLabel, v.Line1, v.Line2, - v.Trigger + v.Trigger, + v.Weight )) .ToList() )) @@ -121,7 +128,9 @@ public sealed class GetChannelQueryHandler(IAppDbContext dbContext) new BumperSettingsDto( channel.BumperFont, channel.BumperMinIntervalMinutes, - channel.BumperSelection + channel.BumperSelection, + channel.BumperShowChangeChance, + channel.BumperEpisodeChangeChance ), bumperTemplates, channel.FillerAssetId, diff --git a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs index b6a3cf9..6b516c1 100644 --- a/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/GetSchedule/GetChannelScheduleQueryHandler.cs @@ -37,6 +37,19 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(s => new { s.Id, s.Name }) .ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken); + // Подблоки заставок в окне — чтобы показать в расписании, какая именно заставка и с каким текстом. + var variantIds = entries + .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper && e.BumperVariantId != null) + .Select(e => e.BumperVariantId!.Value) + .Distinct() + .ToList(); + var variants = variantIds.Count == 0 + ? [] + : await dbContext.BumperTextVariants.AsNoTracking() + .Where(v => variantIds.Contains(v.Id)) + .ToListAsync(cancellationToken); + var variantsById = variants.ToDictionary(v => v.Id); + // Имена ассетов программ — чтобы показать реальную метку S16E03 в расписании админки. var assetIds = entries .Where(e => e.Kind == Domain.Broadcast.ScheduleEntryKind.Program) @@ -48,20 +61,66 @@ public sealed class GetChannelScheduleQueryHandler(IAppDbContext dbContext) .Select(a => new { a.Id, a.OriginalFileName }) .ToDictionaryAsync(a => a.Id, a => a.OriginalFileName, cancellationToken); - var dtos = entries - .Select(e => new ScheduleEntryDto( - e.Id, - e.Kind, - e.MediaAssetId, - e.StartsAtUtc, - e.EndsAtUtc, - e.ShowId, - e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, - e.EpisodeIndex, - assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null - )) - .ToList(); + // «Из какого шоу» для заставки берём из ближайшей предыдущей программы в упорядоченном окне. + Guid? prevProgramShowId = null; + var dtos = new List(entries.Count); + foreach (var e in entries) + { + string? bumperName = null; + string? bumperText = null; + if ( + e.Kind == Domain.Broadcast.ScheduleEntryKind.Bumper + && e.BumperVariantId is { } vid + && variantsById.TryGetValue(vid, out var variant) + ) + { + bumperName = variant.Name; + bumperText = BumperText(variant, prevProgramShowId, e.ShowId, showNames); + } + + dtos.Add( + new ScheduleEntryDto( + e.Id, + e.Kind, + e.MediaAssetId, + e.StartsAtUtc, + e.EndsAtUtc, + e.ShowId, + e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null, + e.EpisodeIndex, + assetNames.TryGetValue(e.MediaAssetId, out var name) ? EpisodeName.ParseLabel(name) : null, + bumperName, + bumperText + ) + ); + + if (e.Kind == Domain.Broadcast.ScheduleEntryKind.Program) + prevProgramShowId = e.ShowId; + } return Result.Success>(dtos); } + + /// + /// Текст заставки для метки в расписании: для «Сейчас/Далее» — подписи + названия шоу (из→в), + /// для свободного текста — заданные строки. Возвращает null, если показывать нечего. + /// + private static string? BumperText( + Domain.Broadcast.BumperTextVariant variant, + Guid? fromShowId, + Guid? toShowId, + IReadOnlyDictionary showNames + ) + { + if (variant.Kind == Domain.Broadcast.BumperTextKind.Free) + { + var parts = new[] { variant.Line1, variant.Line2 } + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + return parts.Length == 0 ? null : string.Join(" · ", parts); + } + + string Name(Guid? id) => id is { } g ? showNames.GetValueOrDefault(g, "…") : "…"; + return $"{variant.NowLabel} {Name(fromShowId)} · {variant.NextLabel} {Name(toShowId)}"; + } } diff --git a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs index b6f58ad..86a6218 100644 --- a/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs +++ b/backend/src/TeleWave.Application/Broadcast/ScheduleEntryDto.cs @@ -11,5 +11,8 @@ public sealed record ScheduleEntryDto( Guid? ShowId, string? ShowName, int? EpisodeIndex, - string? SeasonEpisode + string? SeasonEpisode, + // Для заставок (Kind == Bumper): имя подблока и его текст — для метки в админ-расписании. + string? BumperName = null, + string? BumperText = null ); diff --git a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs index 1fa774c..657227f 100644 --- a/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Broadcast/Scheduling/ScheduleGenerator.cs @@ -51,6 +51,7 @@ public sealed class ScheduleGenerator( { var channel = await dbContext.Channels .Include(c => c.Shows) + .ThenInclude(s => s.PreferredHours) .Include(c => c.Ads) .Include(c => c.BumperTemplates) .ThenInclude(t => t.Variants) @@ -161,7 +162,14 @@ public sealed class ScheduleGenerator( ) return null; - return ScheduleEntry.Bumper(channelId, assetId, entry.StartsAtUtc, entry.EndsAtUtc, entry.ToShowId); + return ScheduleEntry.Bumper( + channelId, + assetId, + entry.StartsAtUtc, + entry.EndsAtUtc, + entry.ToShowId, + entry.BumperVariantId + ); } /// @@ -525,7 +533,11 @@ public sealed class ScheduleGenerator( channelShow.BlockMode, channelShow.BlockValue, ready, - channelShow.NextEpisodeIndex + channelShow.NextEpisodeIndex, + channelShow.PreferredHours + .Select(h => new PlannerHourWindow(h.StartHour, h.EndHour)) + .ToList(), + channelShow.PreferredWeightMultiplier ) ); } @@ -544,7 +556,7 @@ public sealed class ScheduleGenerator( var dur = TimeSpan.FromSeconds(AlignedDurationSeconds(TemplateDurationSeconds(t))); return t.Variants .OrderBy(v => v.Position) - .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger)); + .Select(v => new PlannerBumperVariant(v.Id, t.Id, dur, v.Trigger, v.Weight)); }) .ToList(); @@ -561,7 +573,9 @@ public sealed class ScheduleGenerator( channel.BumpersEnabled, TimeSpan.FromMinutes(channel.BumperMinIntervalMinutes), channel.BumperSelection, - bumperVariants + bumperVariants, + channel.BumperShowChangeChance, + channel.BumperEpisodeChangeChance ); return new PlannerInput( diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs index 58eaaf4..e31920d 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommand.cs @@ -19,5 +19,7 @@ public sealed record UpdateChannelSettingsCommand( public sealed record BumperSettingsInput( BumperFont Font, int MinIntervalMinutes, - BumperSelection Selection + BumperSelection Selection, + double ShowChangeChance, + double EpisodeChangeChance ); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs index 4379730..ef3a6b5 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandHandler.cs @@ -38,7 +38,9 @@ public sealed class UpdateChannelSettingsCommandHandler(IAppDbContext dbContext) channel.UpdateBumperSettings( command.Bumper.Font, command.Bumper.MinIntervalMinutes, - command.Bumper.Selection + command.Bumper.Selection, + command.Bumper.ShowChangeChance, + command.Bumper.EpisodeChangeChance ); return Result.Success(); } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs index 5e2871a..21d5eb3 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelSettings/UpdateChannelSettingsCommandValidator.cs @@ -11,5 +11,7 @@ public sealed class UpdateChannelSettingsCommandValidator RuleFor(x => x.AdsPerBreak).InclusiveBetween(0, 10); RuleFor(x => x.Bumper.MinIntervalMinutes).InclusiveBetween(0, 1440); + RuleFor(x => x.Bumper.ShowChangeChance).InclusiveBetween(0.0, 1.0); + RuleFor(x => x.Bumper.EpisodeChangeChance).InclusiveBetween(0.0, 1.0); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs index 904386f..8dc101f 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommand.cs @@ -10,5 +10,10 @@ public sealed record UpdateChannelShowCommand( int Weight, BlockMode BlockMode, int BlockValue, - bool IsEnabled + bool IsEnabled, + int PreferredWeightMultiplier, + IReadOnlyList PreferredHours ) : ICommand; + +/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC). +public sealed record HourWindowInput(int StartHour, int EndHour); diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs index aeb748e..08acd52 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandHandler.cs @@ -15,6 +15,7 @@ public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext) { var channel = await dbContext.Channels .Include(c => c.Shows) + .ThenInclude(s => s.PreferredHours) .FirstOrDefaultAsync(c => c.Id == command.ChannelId, cancellationToken); if (channel is null) return Result.Failure(ChannelErrors.NotFound); @@ -24,6 +25,10 @@ public sealed class UpdateChannelShowCommandHandler(IAppDbContext dbContext) return Result.Failure(ChannelErrors.ChannelShowNotFound); channelShow.Update(command.Weight, command.BlockMode, command.BlockValue, command.IsEnabled); + channelShow.SetPreferredHours( + command.PreferredWeightMultiplier, + command.PreferredHours.Select(h => (h.StartHour, h.EndHour)) + ); return Result.Success(); } } diff --git a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs index bce812f..aaeed8d 100644 --- a/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs +++ b/backend/src/TeleWave.Application/Broadcast/UpdateChannelShow/UpdateChannelShowCommandValidator.cs @@ -8,5 +8,13 @@ public sealed class UpdateChannelShowCommandValidator : AbstractValidator x.Weight).InclusiveBetween(1, 1000); RuleFor(x => x.BlockValue).InclusiveBetween(1, 10000); + RuleFor(x => x.PreferredWeightMultiplier).InclusiveBetween(1, 100); + RuleForEach(x => x.PreferredHours).ChildRules(w => + { + w.RuleFor(h => h.StartHour).InclusiveBetween(0, 23); + w.RuleFor(h => h.EndHour).InclusiveBetween(1, 24); + w.RuleFor(h => h).Must(h => h.StartHour < h.EndHour) + .WithMessage("Начало окна должно быть раньше конца."); + }); } } diff --git a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs index d429af9..8b5e331 100644 --- a/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs +++ b/backend/src/TeleWave.Application/Common/Interfaces/IAppDbContext.cs @@ -15,6 +15,7 @@ public interface IAppDbContext DbSet Shows { get; } DbSet Channels { get; } DbSet ScheduleEntries { get; } + DbSet BumperTextVariants { get; } DbSet BumperAssets { get; } DbSet AppSettings { get; } DbSet Images { get; } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs index 1d8756c..f2496a5 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperSelection.cs @@ -6,9 +6,12 @@ public enum BumperSelection /// По кругу в порядке блоков (курсор ). Rotation, - /// Случайный блок на каждом переходе. + /// Случайный блок на каждом переходе (равновероятно). Random, /// Всегда первый (дефолтный) блок. AlwaysFirst, + + /// Случайный блок с учётом веса подблока (). + WeightedRandom, } diff --git a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs index 07f719d..ee8503d 100644 --- a/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs +++ b/backend/src/TeleWave.Domain/Broadcast/BumperTextVariant.cs @@ -24,8 +24,13 @@ public class BumperTextVariant public BumperTrigger Trigger { get; private set; } + /// Вес при стратегии (0 — не выбирается). Иначе игнорируется. + public int Weight { get; private set; } = DefaultWeight; + public DateTimeOffset CreatedAt { get; private set; } + public const int DefaultWeight = 1; + public const string DefaultNowLabel = "СЕЙЧАС"; public const string DefaultNextLabel = "ДАЛЕЕ"; @@ -49,6 +54,7 @@ public class BumperTextVariant Line1 = string.Empty, Line2 = string.Empty, Trigger = trigger, + Weight = DefaultWeight, CreatedAt = DateTimeOffset.UtcNow, }; @@ -59,7 +65,8 @@ public class BumperTextVariant string nextLabel, string line1, string line2, - BumperTrigger trigger + BumperTrigger trigger, + int weight ) { Name = name; @@ -69,6 +76,7 @@ public class BumperTextVariant Line1 = line1; Line2 = line2; Trigger = trigger; + Weight = Math.Max(0, weight); } /// Подходит ли подблок для перехода: — сменилось ли шоу. diff --git a/backend/src/TeleWave.Domain/Broadcast/Channel.cs b/backend/src/TeleWave.Domain/Broadcast/Channel.cs index 0c0e93b..ca173d0 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Channel.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Channel.cs @@ -39,6 +39,12 @@ public class Channel /// Не вставлять заставку чаще, чем раз в N минут (0 — на каждом подходящем переходе). public int BumperMinIntervalMinutes { get; private set; } + /// Вероятность заставки на смене шоу (0..1; 1 — на каждой смене, 0 — никогда). + public double BumperShowChangeChance { get; private set; } = 1.0; + + /// Вероятность заставки между блоками одного шоу (0..1; напр. 0.3 — примерно в 30% случаев). + public double BumperEpisodeChangeChance { get; private set; } = 1.0; + private const string DefaultTemplateName = "Заставка 1"; /// Ассет-заглушка на случай пустого расписания (аварийная подстраховка). @@ -76,6 +82,8 @@ public class Channel NextBumperIndex = 0, BumperFont = BumperFont.Sans, BumperMinIntervalMinutes = 0, + BumperShowChangeChance = 1.0, + BumperEpisodeChangeChance = 1.0, NextAdIndex = 0, CreatedAt = DateTimeOffset.UtcNow, }; @@ -101,16 +109,23 @@ public class Channel FillerAssetId = fillerAssetId; } - /// Общие настройки ТВ-заставок канала: шрифт, мин. интервал и стратегия выбора подблока. + /// + /// Общие настройки ТВ-заставок канала: шрифт, мин. интервал, стратегия выбора подблока и + /// вероятности появления на смене шоу / между блоками одного шоу (0..1). + /// public void UpdateBumperSettings( BumperFont font, int minIntervalMinutes, - BumperSelection selection + BumperSelection selection, + double showChangeChance, + double episodeChangeChance ) { BumperFont = font; BumperMinIntervalMinutes = Math.Max(0, minIntervalMinutes); BumperSelection = selection; + BumperShowChangeChance = Math.Clamp(showChangeChance, 0.0, 1.0); + BumperEpisodeChangeChance = Math.Clamp(episodeChangeChance, 0.0, 1.0); } /// Добавить блок заставки в конец списка. Возвращает созданный блок. diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs index c2aaf32..0e29fca 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ChannelShow.cs @@ -7,6 +7,8 @@ namespace TeleWave.Domain.Broadcast; /// public class ChannelShow { + private readonly List _preferredHours = new(); + public Guid Id { get; private set; } public Guid ChannelId { get; private set; } public Guid ShowId { get; private set; } @@ -21,6 +23,14 @@ public class ChannelShow /// Индекс следующей серии для этого канала (0-based в упорядоченном списке серий шоу). public int NextEpisodeIndex { get; private set; } + /// Во сколько раз усиливать вес шоу в предпочтительные часы (1 — без буста). + public int PreferredWeightMultiplier { get; private set; } = DefaultPreferredWeightMultiplier; + + /// Окна предпочтительных часов (пусто — шоу без предпочтений, вес не меняется). + public IReadOnlyList PreferredHours => _preferredHours; + + public const int DefaultPreferredWeightMultiplier = 3; + private ChannelShow() { } internal static ChannelShow Create( @@ -40,6 +50,7 @@ public class ChannelShow BlockValue = blockValue, IsEnabled = true, NextEpisodeIndex = 0, + PreferredWeightMultiplier = DefaultPreferredWeightMultiplier, }; public void Update(int weight, BlockMode blockMode, int blockValue, bool isEnabled) @@ -50,6 +61,25 @@ public class ChannelShow IsEnabled = isEnabled; } + /// + /// Задаёт множитель веса и полностью заменяет набор окон предпочтительных часов. Окна нормализуются: + /// отбрасываются некорректные ([0,24], start < end), совпадающие схлопываются. + /// + public void SetPreferredHours(int multiplier, IEnumerable<(int StartHour, int EndHour)> windows) + { + PreferredWeightMultiplier = Math.Max(1, multiplier); + _preferredHours.Clear(); + foreach (var (start, end) in windows + .Where(w => w.StartHour >= 0 && w.EndHour <= 24 && w.StartHour < w.EndHour) + .Distinct()) + { + _preferredHours.Add(ChannelShowHour.Create(Id, start, end)); + } + } + + /// Час суток (0..23) попадает в одно из окон предпочтительных часов. + public bool IsPreferredAt(int hour) => _preferredHours.Any(w => w.Contains(hour)); + /// Планировщик двигает курсор по мере постановки серий в расписание. public void SetNextEpisodeIndex(int index) => NextEpisodeIndex = index; } diff --git a/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs b/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs new file mode 100644 index 0000000..529908e --- /dev/null +++ b/backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs @@ -0,0 +1,32 @@ +namespace TeleWave.Domain.Broadcast; + +/// +/// Окно предпочтительных часов для шоу на канале: полуинтервал часов суток [StartHour, EndHour) +/// в UTC. В эти часы вес шоу в ротации умножается на . +/// Ночные окна задаются двумя записями (напр. 22–24 и 0–2), заворот через полночь не поддерживается. +/// +public class ChannelShowHour +{ + public Guid Id { get; private set; } + public Guid ChannelShowId { get; private set; } + + /// Начало окна — час суток (0..23). + public int StartHour { get; private set; } + + /// Конец окна (исключительно) — час суток (1..24). + public int EndHour { get; private set; } + + private ChannelShowHour() { } + + internal static ChannelShowHour Create(Guid channelShowId, int startHour, int endHour) => + new() + { + Id = Guid.NewGuid(), + ChannelShowId = channelShowId, + StartHour = startHour, + EndHour = endHour, + }; + + /// Попадает ли час суток (0..23) в это окно. + public bool Contains(int hour) => hour >= StartHour && hour < EndHour; +} diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index b91fb6d..39aa52c 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -19,6 +19,9 @@ public class ScheduleEntry /// Индекс серии в упорядоченном списке шоу (для EPG). public int? EpisodeIndex { get; private set; } + /// Подблок заставки (), которым отрендерена запись — для метки в админ-расписании. + public Guid? BumperVariantId { get; private set; } + private ScheduleEntry() { } public static ScheduleEntry Program( @@ -63,7 +66,8 @@ public class ScheduleEntry Guid mediaAssetId, DateTimeOffset startsAtUtc, DateTimeOffset endsAtUtc, - Guid? showId + Guid? showId, + Guid? bumperVariantId ) => new() { @@ -74,5 +78,6 @@ public class ScheduleEntry StartsAtUtc = startsAtUtc, EndsAtUtc = endsAtUtc, ShowId = showId, + BumperVariantId = bumperVariantId, }; } diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs index a56a1d8..47142a7 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlanner.cs @@ -40,7 +40,8 @@ public static class SchedulePlanner var pick = WeightedPick(candidates, random); // ТВ-заставка на переходе. Из подходящих подблоков (по правилу показа vs контексту) - // резервируем слот выбранного блока — ассет подставит оркестратор. + // резервируем слот выбранного блока — ассет подставит оркестратор. Само появление + // ограничено мин. интервалом и вероятностью для типа перехода (смена шоу / между блоками). if ( prevShowId is { } prev && input.Bumpers is { Enabled: true } bumper @@ -51,9 +52,14 @@ public static class SchedulePlanner ) ) { - var bumperStart = cursor; - if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor)) - lastBumperAt = bumperStart; + var isShowChange = prev != pick.ShowId; + var chance = isShowChange ? bumper.ShowChangeChance : bumper.EpisodeChangeChance; + if (RollChance(chance, random)) + { + var bumperStart = cursor; + if (TryPlaceBumper(entries, bumper, prev, pick.ShowId, random, ref nextBumper, ref cursor)) + lastBumperAt = bumperStart; + } } var blockStart = cursor; @@ -121,6 +127,9 @@ public static class SchedulePlanner case BumperSelection.Random: variant = eligible[random.Next(eligible.Count)]; break; + case BumperSelection.WeightedRandom: + variant = WeightedPickVariant(eligible, random); + break; case BumperSelection.AlwaysFirst: variant = eligible[0]; break; @@ -158,6 +167,38 @@ public static class SchedulePlanner _ => true, }; + /// Прошла ли проверка вероятности появления (chance 0..1). 1 — всегда, 0 — никогда. + private static bool RollChance(double chance, IRandomSource random) + { + if (chance >= 1.0) + return true; + if (chance <= 0.0) + return false; + return random.Next(10000) < (int)Math.Round(chance * 10000); + } + + /// Взвешенный случайный выбор подблока по (нулевые веса → равновероятно). + private static PlannerBumperVariant WeightedPickVariant( + List eligible, + IRandomSource random + ) + { + var total = eligible.Sum(v => Math.Max(0, v.Weight)); + if (total <= 0) + return eligible[random.Next(eligible.Count)]; + + var roll = random.Next(total); + var acc = 0; + foreach (var v in eligible) + { + acc += Math.Max(0, v.Weight); + if (roll < acc) + return v; + } + + return eligible[^1]; + } + private static List<(PlannerShow Show, int Weight)> ResolvePolicy( DateTimeOffset moment, PlannerInput input, @@ -182,12 +223,25 @@ public static class SchedulePlanner // Override ссылается на пустые/неготовые шоу — откатываемся к базовой ротации. } + var hour = moment.UtcDateTime.Hour; return input.Shows .Where(s => s.Weight > 0 && s.EpisodeAssetIds.Count > 0) - .Select(s => (s, s.Weight)) + .Select(s => (s, EffectiveWeight(s, hour))) .ToList(); } + /// Вес шоу с учётом предпочтительных часов: в окне — усиливается множителем, иначе базовый. + private static int EffectiveWeight(PlannerShow show, int hour) + { + if ( + show.PreferredWeightMultiplier > 1 + && show.PreferredHours is { Count: > 0 } windows + && windows.Any(w => w.Contains(hour)) + ) + return show.Weight * show.PreferredWeightMultiplier; + return show.Weight; + } + private static PlannerShow WeightedPick( List<(PlannerShow Show, int Weight)> candidates, IRandomSource random diff --git a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs index 1eef3ea..5dd88b8 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Scheduling/SchedulePlannerModels.cs @@ -8,9 +8,17 @@ public sealed record PlannerShow( BlockMode BlockMode, int BlockValue, IReadOnlyList EpisodeAssetIds, - int NextEpisodeIndex + int NextEpisodeIndex, + IReadOnlyList? PreferredHours = null, + int PreferredWeightMultiplier = 1 ); +/// Окно предпочтительных часов [StartHour, EndHour) суток (UTC) для планировщика. +public sealed record PlannerHourWindow(int StartHour, int EndHour) +{ + public bool Contains(int hour) => hour >= StartHour && hour < EndHour; +} + /// Override в терминах планировщика: окно + режим + шоу с весами. public sealed record PlannerOverride( DateTimeOffset StartsAtUtc, @@ -25,23 +33,28 @@ public sealed record PlannerOverrideShow(Guid ShowId, int Weight); /// Политика ТВ-заставок на переходах. Планировщик из подходящих подблоков (, /// фильтр по и контексту перехода) выбирает один по стратегии /// и резервирует слот длины его блока. Ассет подставляет оркестратор. +/// / — вероятность самого появления +/// заставки на смене шоу / между блоками одного шоу (0..1). /// public sealed record PlannerBumperConfig( bool Enabled, TimeSpan MinInterval, BumperSelection Selection, - IReadOnlyList Variants + IReadOnlyList Variants, + double ShowChangeChance = 1.0, + double EpisodeChangeChance = 1.0 ); /// /// Подблок заставки в терминах планировщика: id варианта + id родительского блока (стиль/звук) + -/// длительность слота (кратна сегменту) + правило показа. +/// длительность слота (кратна сегменту) + правило показа + вес (для ). /// public sealed record PlannerBumperVariant( Guid VariantId, Guid TemplateId, TimeSpan Duration, - BumperTrigger Trigger + BumperTrigger Trigger, + int Weight = 1 ); /// Полный вход планировщика для одного прогона по каналу. diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.Designer.cs new file mode 100644 index 0000000..7cde61f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.Designer.cs @@ -0,0 +1,956 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725105210_BumperChancesWeightsAndScheduleVariant")] + partial class BumperChancesWeightsAndScheduleVariant + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.cs new file mode 100644 index 0000000..7342eb1 --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.cs @@ -0,0 +1,123 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class BumperChancesWeightsAndScheduleVariant : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", + table: "BumperTextVariant"); + + migrationBuilder.DropPrimaryKey( + name: "PK_BumperTextVariant", + table: "BumperTextVariant"); + + migrationBuilder.RenameTable( + name: "BumperTextVariant", + newName: "BumperTextVariants"); + + migrationBuilder.RenameIndex( + name: "IX_BumperTextVariant_BumperTemplateId_Position", + table: "BumperTextVariants", + newName: "IX_BumperTextVariants_BumperTemplateId_Position"); + + migrationBuilder.AddColumn( + name: "BumperVariantId", + table: "ScheduleEntries", + type: "uuid", + nullable: true); + + // Существующим каналам — 1.0 (заставка на каждом подходящем переходе, как было до фичи). + migrationBuilder.AddColumn( + name: "BumperEpisodeChangeChance", + table: "Channels", + type: "double precision", + nullable: false, + defaultValue: 1.0); + + migrationBuilder.AddColumn( + name: "BumperShowChangeChance", + table: "Channels", + type: "double precision", + nullable: false, + defaultValue: 1.0); + + migrationBuilder.AddColumn( + name: "Weight", + table: "BumperTextVariants", + type: "integer", + nullable: false, + defaultValue: 1); + + migrationBuilder.AddPrimaryKey( + name: "PK_BumperTextVariants", + table: "BumperTextVariants", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", + table: "BumperTextVariants", + column: "BumperTemplateId", + principalTable: "BumperTemplate", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_BumperTextVariants_BumperTemplate_BumperTemplateId", + table: "BumperTextVariants"); + + migrationBuilder.DropPrimaryKey( + name: "PK_BumperTextVariants", + table: "BumperTextVariants"); + + migrationBuilder.DropColumn( + name: "BumperVariantId", + table: "ScheduleEntries"); + + migrationBuilder.DropColumn( + name: "BumperEpisodeChangeChance", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "BumperShowChangeChance", + table: "Channels"); + + migrationBuilder.DropColumn( + name: "Weight", + table: "BumperTextVariants"); + + migrationBuilder.RenameTable( + name: "BumperTextVariants", + newName: "BumperTextVariant"); + + migrationBuilder.RenameIndex( + name: "IX_BumperTextVariants_BumperTemplateId_Position", + table: "BumperTextVariant", + newName: "IX_BumperTextVariant_BumperTemplateId_Position"); + + migrationBuilder.AddPrimaryKey( + name: "PK_BumperTextVariant", + table: "BumperTextVariant", + column: "Id"); + + migrationBuilder.AddForeignKey( + name: "FK_BumperTextVariant_BumperTemplate_BumperTemplateId", + table: "BumperTextVariant", + column: "BumperTemplateId", + principalTable: "BumperTemplate", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.Designer.cs new file mode 100644 index 0000000..5243a6d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.Designer.cs @@ -0,0 +1,996 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeleWave.Infrastructure.Persistence; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260725154034_ChannelShowPreferredHours")] + partial class ChannelShowPreferredHours + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AdInsertion") + .HasColumnType("integer"); + + b.Property("AdsPerBreak") + .HasColumnType("integer"); + + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + + b.Property("BumperFont") + .HasColumnType("integer"); + + b.Property("BumperMinIntervalMinutes") + .HasColumnType("integer"); + + b.Property("BumperSelection") + .HasColumnType("integer"); + + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + + b.Property("BumpersEnabled") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NextAdIndex") + .HasColumnType("integer"); + + b.Property("NextBumperIndex") + .HasColumnType("integer"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("ChannelAd"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("NextEpisodeIndex") + .HasColumnType("integer"); + + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "ShowId"); + + b.ToTable("ChannelShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ProgrammingOverrideId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProgrammingOverrideId"); + + b.ToTable("OverrideShow"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "StartsAtUtc", "EndsAtUtc"); + + b.ToTable("ProgrammingOverride"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EpisodeIndex") + .HasColumnType("integer"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "ShowId"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.ToTable("ScheduleEntries"); + }); + + modelBuilder.Entity("TeleWave.Domain.Images.Image", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("Category", "CreatedAt"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Settings.AppSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.HasKey("Key"); + + b.ToTable("AppSettings"); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("TeleWave.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TeleWave.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("BumperTemplates") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelAd", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Ads") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Shows") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) + .WithMany("Shows") + .HasForeignKey("ProgrammingOverrideId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.HasOne("TeleWave.Domain.Broadcast.Channel", null) + .WithMany("Overrides") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Episodes") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Navigation("Ads"); + + b.Navigation("BumperTemplates"); + + b.Navigation("Overrides"); + + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => + { + b.Navigation("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.cs new file mode 100644 index 0000000..4061f3f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.cs @@ -0,0 +1,58 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ChannelShowPreferredHours : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "PreferredWeightMultiplier", + table: "ChannelShow", + type: "integer", + nullable: false, + defaultValue: 3); + + migrationBuilder.CreateTable( + name: "ChannelShowHour", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + ChannelShowId = table.Column(type: "uuid", nullable: false), + StartHour = table.Column(type: "integer", nullable: false), + EndHour = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ChannelShowHour", x => x.Id); + table.ForeignKey( + name: "FK_ChannelShowHour_ChannelShow_ChannelShowId", + column: x => x.ChannelShowId, + principalTable: "ChannelShow", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ChannelShowHour_ChannelShowId", + table: "ChannelShowHour", + column: "ChannelShowId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChannelShowHour"); + + migrationBuilder.DropColumn( + name: "PreferredWeightMultiplier", + table: "ChannelShow"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 578bc05..3afb010 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -292,11 +292,16 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Trigger") .HasColumnType("integer"); + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + b.HasKey("Id"); b.HasIndex("BumperTemplateId", "Position"); - b.ToTable("BumperTextVariant"); + b.ToTable("BumperTextVariants"); }); modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => @@ -310,6 +315,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("AdsPerBreak") .HasColumnType("integer"); + b.Property("BumperEpisodeChangeChance") + .HasColumnType("double precision"); + b.Property("BumperFont") .HasColumnType("integer"); @@ -319,6 +327,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("BumperSelection") .HasColumnType("integer"); + b.Property("BumperShowChangeChance") + .HasColumnType("double precision"); + b.Property("BumpersEnabled") .HasColumnType("boolean"); @@ -399,6 +410,11 @@ namespace TeleWave.Infrastructure.Migrations b.Property("NextEpisodeIndex") .HasColumnType("integer"); + b.Property("PreferredWeightMultiplier") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(3); + b.Property("ShowId") .HasColumnType("uuid"); @@ -412,6 +428,27 @@ namespace TeleWave.Infrastructure.Migrations b.ToTable("ChannelShow"); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelShowId") + .HasColumnType("uuid"); + + b.Property("EndHour") + .HasColumnType("integer"); + + b.Property("StartHour") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChannelShowId"); + + b.ToTable("ChannelShowHour"); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => { b.Property("Id") @@ -462,6 +499,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("BumperVariantId") + .HasColumnType("uuid"); + b.Property("ChannelId") .HasColumnType("uuid"); @@ -881,6 +921,15 @@ namespace TeleWave.Infrastructure.Migrations .IsRequired(); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShowHour", b => + { + b.HasOne("TeleWave.Domain.Broadcast.ChannelShow", null) + .WithMany("PreferredHours") + .HasForeignKey("ChannelShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.OverrideShow", b => { b.HasOne("TeleWave.Domain.Broadcast.ProgrammingOverride", null) @@ -924,6 +973,11 @@ namespace TeleWave.Infrastructure.Migrations b.Navigation("Shows"); }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ChannelShow", b => + { + b.Navigation("PreferredHours"); + }); + modelBuilder.Entity("TeleWave.Domain.Broadcast.ProgrammingOverride", b => { b.Navigation("Shows"); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs index a78fc0b..36be854 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/AppDbContext.cs @@ -25,6 +25,7 @@ public class AppDbContext(DbContextOptions options) public DbSet Shows => Set(); public DbSet Channels => Set(); public DbSet ScheduleEntries => Set(); + public DbSet BumperTextVariants => Set(); public DbSet BumperAssets => Set(); public DbSet AppSettings => Set(); public DbSet Images => Set(); diff --git a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs index 4fa13a9..136aace 100644 --- a/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs +++ b/backend/src/TeleWave.Infrastructure/Persistence/Configurations/ChannelConfiguration.cs @@ -48,6 +48,23 @@ public class ChannelShowConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasIndex(x => new { x.ChannelId, x.ShowId }); + builder.Property(x => x.PreferredWeightMultiplier) + .HasDefaultValue(ChannelShow.DefaultPreferredWeightMultiplier); + + builder + .HasMany(x => x.PreferredHours) + .WithOne() + .HasForeignKey(h => h.ChannelShowId) + .OnDelete(DeleteBehavior.Cascade); + builder.Navigation(x => x.PreferredHours).UsePropertyAccessMode(PropertyAccessMode.Field); + } +} + +public class ChannelShowHourConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasIndex(x => x.ChannelShowId); } } @@ -90,6 +107,7 @@ public class BumperTextVariantConfiguration : IEntityTypeConfiguration x.NextLabel).IsRequired().HasMaxLength(64); builder.Property(x => x.Line1).IsRequired().HasMaxLength(120); builder.Property(x => x.Line2).IsRequired().HasMaxLength(120); + builder.Property(x => x.Weight).HasDefaultValue(BumperTextVariant.DefaultWeight); } } diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs index 884dbad..afaede4 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/SchedulePlannerTests.cs @@ -415,6 +415,108 @@ public class SchedulePlannerTests Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); } + [Fact] + public void Bumpers_ShowChangeChanceZero_SuppressesBumpersOnShowChange() + { + // Два шоу, ротация выбора подблока (без random в пути заставки) → чередование гарантировано, + // каждый переход — смена шоу. ShowChangeChance=0 глушит их, EpisodeChangeChance=1 не при чём. + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(120)) with + { + Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { ShowChangeChance = 0.0 }, + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1)); + + Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); + } + + [Fact] + public void Bumpers_EpisodeChangeChanceZero_SuppressesBumpersBetweenEpisodes() + { + // Одно шоу → каждый переход между блоками одного шоу. EpisodeChangeChance=0 глушит все заставки. + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a], durations, Start.AddMinutes(120)) with + { + Bumpers = Bumper(true, BumperTrigger.Both, TimeSpan.Zero) with { EpisodeChangeChance = 0.0 }, + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0)); + + Assert.DoesNotContain(result.Entries, e => e.Kind == ScheduleEntryKind.Bumper); + } + + [Fact] + public void Bumpers_WeightedRandom_NeverPicksZeroWeightVariant() + { + // Взвешенный выбор: подблок с весом 0 не выбирается никогда, весь трафик уходит на t1 (вес 5). + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var durations = Durations((a.EpisodeAssetIds[0], 20)); + Guid t0 = Guid.NewGuid(), + t1 = Guid.NewGuid(); + + var input = BaseInput([a], durations, Start.AddMinutes(120)) with + { + Bumpers = Bumper( + true, + BumperTrigger.Both, + TimeSpan.Zero, + BumperSelection.WeightedRandom, + new PlannerBumperVariant(Guid.NewGuid(), t0, TimeSpan.FromSeconds(8), BumperTrigger.Both, 0), + new PlannerBumperVariant(Guid.NewGuid(), t1, TimeSpan.FromSeconds(8), BumperTrigger.Both, 5) + ), + }; + + var result = SchedulePlanner.Plan(input, new FixedRandom(0, 1, 2, 3, 4)); + + var bumpers = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Bumper).ToList(); + Assert.NotEmpty(bumpers); + Assert.All(bumpers, e => Assert.Equal(t1, e.BumperTemplateId)); + } + + [Fact] + public void PreferredHours_BoostsWeight_InsideWindow() + { + // Час старта — 0 (UTC). Окно b покрывает его, множитель 100 → b доминирует, хотя базовые веса равны. + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow( + Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0, + PreferredHours: [new PlannerHourWindow(0, 24)], PreferredWeightMultiplier: 100); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(60)); + + var result = SchedulePlanner.Plan(input, new FixedRandom(50)); + + var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); + Assert.NotEmpty(programs); + Assert.All(programs, e => Assert.Equal(b.ShowId, e.ShowId)); + } + + [Fact] + public void PreferredHours_OutsideWindow_KeepsBaseWeight() + { + // Час старта — 0, окно b — [10,12): не покрывает → буста нет, при FixedRandom(50) берётся первое шоу (a). + var a = new PlannerShow(Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0); + var b = new PlannerShow( + Guid.NewGuid(), Guid.NewGuid(), 1, BlockMode.Count, 1, [Guid.NewGuid()], 0, + PreferredHours: [new PlannerHourWindow(10, 12)], PreferredWeightMultiplier: 100); + var durations = Durations((a.EpisodeAssetIds[0], 20), (b.EpisodeAssetIds[0], 20)); + + var input = BaseInput([a, b], durations, Start.AddMinutes(60)); + + var result = SchedulePlanner.Plan(input, new FixedRandom(50)); + + var programs = result.Entries.Where(e => e.Kind == ScheduleEntryKind.Program).ToList(); + Assert.NotEmpty(programs); + Assert.All(programs, e => Assert.Equal(a.ShowId, e.ShowId)); + } + [Fact] public void NoPlayableShows_ReturnsEmpty() { diff --git a/frontend/src/features/admin/channels/ChannelDetail.tsx b/frontend/src/features/admin/channels/ChannelDetail.tsx index c601c3e..ce3f31a 100644 --- a/frontend/src/features/admin/channels/ChannelDetail.tsx +++ b/frontend/src/features/admin/channels/ChannelDetail.tsx @@ -18,6 +18,7 @@ import type { BumperTextVariantDto, BumperTrigger, ChannelShowDto, + HourWindow, OverrideMode, ScheduleEntryDto, } from '@/shared/api/types' @@ -381,6 +382,13 @@ function cssColor(value: string): string { return v } +/** Ограничивает вероятность появления заставки диапазоном 0..1 (пустой ввод → 0). */ +function clampChance(value: string): number { + const n = Number(value) + if (Number.isNaN(n)) return 0 + return Math.min(1, Math.max(0, n)) +} + function BumperCard({ channel, onSaved, @@ -461,6 +469,9 @@ function BumperCard({ {t('admin.channels.bumperSelectionRotation')} {t('admin.channels.bumperSelectionRandom')} + + {t('admin.channels.bumperSelectionWeighted')} + {t('admin.channels.bumperSelectionAlwaysFirst')} @@ -489,6 +500,34 @@ function BumperCard({ onChange={(e) => setField('minIntervalMinutes', Number(e.target.value))} /> +
+ + setField('showChangeChance', clampChance(e.target.value))} + /> + + {t('admin.channels.bumperShowChangeChanceHint')} + +
+
+ + setField('episodeChangeChance', clampChance(e.target.value))} + /> + + {t('admin.channels.bumperEpisodeChangeChanceHint')} + +
+
+ + set('weight', Math.max(0, Math.round(Number(e.target.value)) || 0))} + /> + + {t('admin.channels.bumperVariantWeightHint')} + +
@@ -1188,64 +1242,176 @@ function ChannelShowRow({ const [blockMode, setBlockMode] = useState(row.blockMode) const [blockValue, setBlockValue] = useState(row.blockValue) const [isEnabled, setIsEnabled] = useState(row.isEnabled) + const [expanded, setExpanded] = useState(false) + const [multiplier, setMultiplier] = useState(row.preferredWeightMultiplier) + const [hours, setHours] = useState(row.preferredHours) const save = useMutation({ mutationFn: () => - updateChannelShow(channelId, row.id, { weight, blockMode, blockValue, isEnabled }), + updateChannelShow(channelId, row.id, { + weight, + blockMode, + blockValue, + isEnabled, + preferredWeightMultiplier: multiplier, + preferredHours: hours.filter((h) => h.startHour < h.endHour), + }), onSuccess: onChanged, onError, }) + const addHour = () => setHours((h) => [...h, { startHour: 18, endHour: 23 }]) + const setHour = (i: number, patch: Partial) => + setHours((h) => h.map((w, idx) => (idx === i ? { ...w, ...patch } : w))) + const removeHour = (i: number) => setHours((h) => h.filter((_, idx) => idx !== i)) + return ( - - {row.showName} - - setWeight(Number(e.target.value))} - className="h-8 w-16" - /> - - -
- + <> + + {row.showName} + setBlockValue(Number(e.target.value))} + value={weight} + onChange={(e) => setWeight(Number(e.target.value))} className="h-8 w-16" /> -
- - - setIsEnabled(e.target.checked)} - /> - - -
- - removeChannelShow(channelId, row.id).then(onChanged).catch(onError)} + + +
+ + setBlockValue(Number(e.target.value))} + className="h-8 w-16" + /> +
+ + + setIsEnabled(e.target.checked)} /> -
- - + + +
+ + + removeChannelShow(channelId, row.id).then(onChanged).catch(onError)} + /> +
+ + + {expanded && ( + + +
+
+ + setMultiplier(Math.max(1, Math.round(Number(e.target.value)) || 1))} + className="h-8 w-20" + /> + + {t('admin.channels.preferredHoursHint')} + +
+ {hours.length === 0 && ( +

{t('admin.channels.preferredNone')}

+ )} + {hours.map((w, i) => ( +
+ setHour(i, { startHour: v })} + /> + + setHour(i, { endHour: v })} + /> + {w.startHour >= w.endHour && ( + + {t('admin.channels.preferredBadRange')} + + )} + +
+ ))} +
+ +
+
+ + + )} + + ) +} + +/** Выпадающий выбор часа суток (значения from..to включительно), формат «HH:00». */ +function HourSelect({ + value, + from, + to, + onChange, +}: { + value: number + from: number + to: number + onChange: (v: number) => void +}) { + const options = Array.from({ length: to - from + 1 }, (_, i) => from + i) + return ( + ) } @@ -1386,7 +1552,18 @@ function SchedulePreview({ entries }: { entries: ScheduleEntryDto[] }) { {e.kind === 'Ad' ? ( {t('air.ad')} ) : e.kind === 'Bumper' ? ( - {t('air.bumper')} + + + {t('air.bumper')} + + {(e.bumperName || e.bumperText) && ( + + {e.bumperName} + {e.bumperName && e.bumperText ? ' · ' : ''} + {e.bumperText} + + )} + ) : ( {e.showName ?? '—'} diff --git a/frontend/src/features/admin/channels/api.ts b/frontend/src/features/admin/channels/api.ts index bd58017..c47d2c7 100644 --- a/frontend/src/features/admin/channels/api.ts +++ b/frontend/src/features/admin/channels/api.ts @@ -8,6 +8,7 @@ import type { ChannelDto, ChannelSummaryDto, CreatedIdResponse, + HourWindow, OverrideMode, ScheduleEntryDto, } from '@/shared/api/types' @@ -52,7 +53,14 @@ export function addChannelShow(id: string, body: ChannelShowBody) { export function updateChannelShow( id: string, channelShowId: string, - body: { weight: number; blockMode: BlockMode; blockValue: number; isEnabled: boolean }, + body: { + weight: number + blockMode: BlockMode + blockValue: number + isEnabled: boolean + preferredWeightMultiplier: number + preferredHours: HourWindow[] + }, ) { return apiRequest(`/admin/channels/${id}/shows/${channelShowId}`, { method: 'PUT', body }) } @@ -147,6 +155,7 @@ export type BumperVariantBody = { line1: string line2: string trigger: BumperTrigger + weight: number } export function addBumperVariant(id: string, templateId: string, name: string) { diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 062a9c4..8d56dfa 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -127,7 +127,7 @@ export type AdInsertion = 'BetweenBlocks' | 'BetweenEpisodes' export type OverrideMode = 'Exclusive' | 'Boost' export type ScheduleEntryKind = 'Program' | 'Ad' | 'Bumper' export type BumperFont = 'Sans' | 'Serif' -export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' +export type BumperSelection = 'Rotation' | 'Random' | 'AlwaysFirst' | 'WeightedRandom' export type BumperTextKind = 'NowNext' | 'Free' export type BumperTrigger = 'OnShowChange' | 'BetweenEpisodes' | 'Both' @@ -136,6 +136,10 @@ export type BumperSettings = { font: BumperFont minIntervalMinutes: number selection: BumperSelection + /** Вероятность заставки на смене шоу (0..1). */ + showChangeChance: number + /** Вероятность заставки между блоками одного шоу (0..1). */ + episodeChangeChance: number } /** Подблок (текст-вариант): свой текст + правило показа поверх стиля/звука блока. */ @@ -149,6 +153,8 @@ export type BumperTextVariantDto = { line1: string line2: string trigger: BumperTrigger + /** Вес при стратегии выбора «случайно взвешенный» (0 — не выбирается). */ + weight: number } export type BumperTemplateDto = { @@ -173,6 +179,9 @@ export type ChannelSummaryDto = { isEnabled: boolean } +/** Окно предпочтительных часов [startHour, endHour) суток (UTC). */ +export type HourWindow = { startHour: number; endHour: number } + export type ChannelShowDto = { id: string showId: string @@ -182,6 +191,9 @@ export type ChannelShowDto = { blockValue: number isEnabled: boolean nextEpisodeIndex: number + /** Во сколько раз усиливать вес в предпочтительные часы (1 — без буста). */ + preferredWeightMultiplier: number + preferredHours: HourWindow[] } export type ChannelAdDto = { @@ -227,6 +239,9 @@ export type ScheduleEntryDto = { showName: string | null episodeIndex: number | null seasonEpisode: string | null + /** Для заставок: имя подблока и его текст — для метки в расписании. */ + bumperName: string | null + bumperText: string | null } // ── Публичный эфир ───────────────────────────────────────────────────────── diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 53a6d20..8313335 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -203,8 +203,13 @@ const resources = { bumperSelection: 'Выбор блока', bumperSelectionRotation: 'По кругу', bumperSelectionRandom: 'Случайно', + bumperSelectionWeighted: 'Случайно взвешенный', bumperSelectionAlwaysFirst: 'Всегда первый', bumperMinInterval: 'Мин. интервал, мин', + bumperShowChangeChance: 'Вероятность на смене шоу', + bumperShowChangeChanceHint: '0..1: 1 — на каждой смене, 0 — никогда', + bumperEpisodeChangeChance: 'Вероятность между сериями', + bumperEpisodeChangeChanceHint: '0..1: напр. 0.3 — примерно в 30% переходов между сериями', bumperFont: 'Шрифт', bumperFontSans: 'Гротеск', bumperFontSerif: 'Антиква', @@ -234,6 +239,8 @@ const resources = { bumperTriggerOnShowChange: 'При смене шоу', bumperTriggerBetweenEpisodes: 'Между сериями', bumperTriggerBoth: 'Оба', + bumperVariantWeight: 'Вес', + bumperVariantWeightHint: 'Для стратегии «случайно взвешенный»: чем больше — тем чаще (0 — не выбирается)', bumperDefault: 'по умолчанию', bumperSeconds: 'с', bumperDefaultDuration: '≈8 с (джингл)', @@ -262,6 +269,13 @@ const resources = { blockDuration: 'По времени', episodes: 'серий', minutes: 'минут', + preferredHours: 'Часы', + preferredMultiplier: 'Множитель веса', + preferredHoursHint: + 'В выбранные часы вес шоу умножается — оно чаще попадает в эфир. Время в UTC.', + preferredNone: 'Окна не заданы — предпочтений по времени нет.', + preferredAddWindow: 'Добавить окно', + preferredBadRange: 'начало ≥ конца', ads: 'Реклама', pickAd: 'Выберите ролик', noAds: 'Пул рекламы пуст', @@ -523,8 +537,13 @@ const resources = { bumperSelection: 'Block selection', bumperSelectionRotation: 'Rotation', bumperSelectionRandom: 'Random', + bumperSelectionWeighted: 'Weighted random', bumperSelectionAlwaysFirst: 'Always first', bumperMinInterval: 'Min interval, min', + bumperShowChangeChance: 'Chance on show change', + bumperShowChangeChanceHint: '0..1: 1 — every change, 0 — never', + bumperEpisodeChangeChance: 'Chance between episodes', + bumperEpisodeChangeChanceHint: '0..1: e.g. 0.3 — about 30% of same-show transitions', bumperFont: 'Font', bumperFontSans: 'Sans', bumperFontSerif: 'Serif', @@ -554,6 +573,8 @@ const resources = { bumperTriggerOnShowChange: 'Show change', bumperTriggerBetweenEpisodes: 'Between episodes', bumperTriggerBoth: 'Both', + bumperVariantWeight: 'Weight', + bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)', bumperDefault: 'default', bumperSeconds: 's', bumperDefaultDuration: '≈8 s (jingle)', @@ -582,6 +603,13 @@ const resources = { blockDuration: 'By time', episodes: 'episodes', minutes: 'minutes', + preferredHours: 'Hours', + preferredMultiplier: 'Weight multiplier', + preferredHoursHint: + 'During the selected hours the show’s weight is multiplied, so it airs more often. Times are UTC.', + preferredNone: 'No windows set — no time preference.', + preferredAddWindow: 'Add window', + preferredBadRange: 'start ≥ end', ads: 'Ads', pickAd: 'Pick an ad', noAds: 'Ad pool is empty',