From 8718e8b6bfa470415eebdfab768ed076e52297f0 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 25 Jul 2026 18:44:28 +0300 Subject: [PATCH] Enhance channel management: add support for preferred weight multipliers and preferred hours in channel shows, update related data models and API endpoints, and implement validation for new properties. Refactor scheduling logic to utilize preferred hours for weight adjustments during show planning. --- .../Endpoints/ChannelEndpoints.cs | 14 +- .../Bumpers/UpdateBumperTextVariantCommand.cs | 5 +- .../UpdateBumperTextVariantCommandHandler.cs | 3 +- ...UpdateBumperTextVariantCommandValidator.cs | 1 + .../Broadcast/ChannelDtos.cs | 16 +- .../GetChannel/GetChannelQueryHandler.cs | 15 +- .../GetChannelScheduleQueryHandler.cs | 85 +- .../Broadcast/ScheduleEntryDto.cs | 5 +- .../Broadcast/Scheduling/ScheduleGenerator.cs | 22 +- .../UpdateChannelSettingsCommand.cs | 4 +- .../UpdateChannelSettingsCommandHandler.cs | 4 +- .../UpdateChannelSettingsCommandValidator.cs | 2 + .../UpdateChannelShowCommand.cs | 7 +- .../UpdateChannelShowCommandHandler.cs | 5 + .../UpdateChannelShowCommandValidator.cs | 8 + .../Common/Interfaces/IAppDbContext.cs | 1 + .../Broadcast/BumperSelection.cs | 5 +- .../Broadcast/BumperTextVariant.cs | 10 +- .../src/TeleWave.Domain/Broadcast/Channel.cs | 19 +- .../TeleWave.Domain/Broadcast/ChannelShow.cs | 30 + .../Broadcast/ChannelShowHour.cs | 32 + .../Broadcast/ScheduleEntry.cs | 7 +- .../Broadcast/Scheduling/SchedulePlanner.cs | 64 +- .../Scheduling/SchedulePlannerModels.cs | 21 +- ...ancesWeightsAndScheduleVariant.Designer.cs | 956 +++++++++++++++++ ..._BumperChancesWeightsAndScheduleVariant.cs | 123 +++ ...4034_ChannelShowPreferredHours.Designer.cs | 996 ++++++++++++++++++ ...0260725154034_ChannelShowPreferredHours.cs | 58 + .../Migrations/AppDbContextModelSnapshot.cs | 56 +- .../Persistence/AppDbContext.cs | 1 + .../Configurations/ChannelConfiguration.cs | 18 + .../Broadcast/SchedulePlannerTests.cs | 102 ++ .../features/admin/channels/ChannelDetail.tsx | 267 ++++- frontend/src/features/admin/channels/api.ts | 11 +- frontend/src/shared/api/types.ts | 17 +- frontend/src/shared/lib/i18n.ts | 28 + 36 files changed, 2921 insertions(+), 97 deletions(-) create mode 100644 backend/src/TeleWave.Domain/Broadcast/ChannelShowHour.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725105210_BumperChancesWeightsAndScheduleVariant.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725154034_ChannelShowPreferredHours.cs 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',