From 89628406541ab83bbd432f474d030bcf38924046 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 26 Jul 2026 02:06:08 +0300 Subject: [PATCH] Add audience categorization for shows: implement ShowAudience type, update related API endpoints, and enhance frontend components for audience selection and display. Modify data structures to support audience information in show creation and retrieval processes. --- .../TeleWave.Api/Endpoints/ShowEndpoints.cs | 19 + .../Library/CreateShow/CreateShowCommand.cs | 3 +- .../CreateShow/CreateShowCommandHandler.cs | 3 +- .../Library/GetShow/GetShowQueryHandler.cs | 1 + .../ListShows/ListShowsQueryHandler.cs | 1 + .../SetShowAudience/SetShowAudienceCommand.cs | 8 + .../SetShowAudienceCommandHandler.cs | 26 + .../TeleWave.Application/Library/ShowDtos.cs | 2 + backend/src/TeleWave.Domain/Library/Show.cs | 11 +- .../TeleWave.Domain/Library/ShowAudience.cs | 14 + .../20260725225737_ShowAudience.Designer.cs | 1026 +++++++++++++++++ .../Migrations/20260725225737_ShowAudience.cs | 29 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + docs/tv-scheduler-architecture.md | 691 +++++++++++ .../src/features/admin/shows/ShowDetail.tsx | 24 +- .../src/features/admin/shows/ShowsPanel.tsx | 32 +- frontend/src/features/admin/shows/api.ts | 6 + frontend/src/shared/api/types.ts | 5 + frontend/src/shared/lib/i18n.ts | 4 + 19 files changed, 1900 insertions(+), 8 deletions(-) create mode 100644 backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs create mode 100644 backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommandHandler.cs create mode 100644 backend/src/TeleWave.Domain/Library/ShowAudience.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.cs create mode 100644 docs/tv-scheduler-architecture.md diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index 6a25fe9..1a0680b 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -8,7 +8,9 @@ using TeleWave.Application.Library.GetShow; using TeleWave.Application.Library.ListShows; using TeleWave.Application.Library.RemoveEpisode; using TeleWave.Application.Library.RenameShow; +using TeleWave.Application.Library.SetShowAudience; using TeleWave.Application.Library.SetShowOriginalName; +using TeleWave.Domain.Library; using TeleWave.Infrastructure.Identity; namespace TeleWave.Api.Endpoints; @@ -28,6 +30,7 @@ public static class ShowEndpoints admin .MapPut("/{id:guid}/original-name", SetOriginalName) .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); admin .MapPost("/{id:guid}/episodes", AddEpisode) @@ -98,6 +101,20 @@ public static class ShowEndpoints return result.ToHttpResult(); } + private static async Task SetAudience( + Guid id, + SetShowAudienceBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowAudienceCommand(id, body.Audience), + cancellationToken + ); + return result.ToHttpResult(); + } + private static async Task DeleteShow( Guid id, ISender sender, @@ -141,3 +158,5 @@ public sealed record AddEpisodeBody(Guid MediaAssetId); public sealed record RenameShowBody(string Name); public sealed record SetShowOriginalNameBody(string? OriginalName); + +public sealed record SetShowAudienceBody(ShowAudience Audience); diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs index dc24606..c402022 100644 --- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommand.cs @@ -8,5 +8,6 @@ public sealed record CreateShowCommand( string Name, ShowKind Kind, string? Description, - string? OriginalName = null + string? OriginalName = null, + ShowAudience Audience = ShowAudience.General ) : ICommand>; diff --git a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs index 06f043c..847221b 100644 --- a/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs +++ b/backend/src/TeleWave.Application/Library/CreateShow/CreateShowCommandHandler.cs @@ -14,7 +14,8 @@ public sealed class CreateShowCommandHandler(IAppDbContext dbContext) command.Name, command.Kind, command.Description, - command.OriginalName + command.OriginalName, + command.Audience ); dbContext.Shows.Add(show); return Task.FromResult(Result.Success(show.Id)); diff --git a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs index 3bc7b3c..77bf6c4 100644 --- a/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/GetShow/GetShowQueryHandler.cs @@ -61,6 +61,7 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext) show.Name, show.OriginalName, show.Kind, + show.Audience, show.Description, show.MetadataProvider, show.MetadataExternalId, diff --git a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs index 4835ba2..12a66e3 100644 --- a/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs +++ b/backend/src/TeleWave.Application/Library/ListShows/ListShowsQueryHandler.cs @@ -46,6 +46,7 @@ public sealed class ListShowsQueryHandler(IAppDbContext dbContext) s.Name, s.OriginalName, s.Kind, + s.Audience, s.Episodes.Count, seasons, s.Year, diff --git a/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs new file mode 100644 index 0000000..18042e7 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommand.cs @@ -0,0 +1,8 @@ +using LiteCqrs; +using TeleWave.Application.Common.Models; +using TeleWave.Domain.Library; + +namespace TeleWave.Application.Library.SetShowAudience; + +/// Задать категорию аудитории шоу (обычное/детское/взрослое). +public sealed record SetShowAudienceCommand(Guid Id, ShowAudience Audience) : ICommand; diff --git a/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommandHandler.cs b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommandHandler.cs new file mode 100644 index 0000000..3989095 --- /dev/null +++ b/backend/src/TeleWave.Application/Library/SetShowAudience/SetShowAudienceCommandHandler.cs @@ -0,0 +1,26 @@ +using LiteCqrs; +using Microsoft.EntityFrameworkCore; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; + +namespace TeleWave.Application.Library.SetShowAudience; + +public sealed class SetShowAudienceCommandHandler(IAppDbContext dbContext) + : ICommandHandler +{ + public async Task Handle( + SetShowAudienceCommand command, + CancellationToken cancellationToken + ) + { + var show = await dbContext.Shows.FirstOrDefaultAsync( + s => s.Id == command.Id, + cancellationToken + ); + if (show is null) + return Result.Failure(ShowErrors.NotFound); + + show.SetAudience(command.Audience); + return Result.Success(); + } +} diff --git a/backend/src/TeleWave.Application/Library/ShowDtos.cs b/backend/src/TeleWave.Application/Library/ShowDtos.cs index c8be819..a544561 100644 --- a/backend/src/TeleWave.Application/Library/ShowDtos.cs +++ b/backend/src/TeleWave.Application/Library/ShowDtos.cs @@ -8,6 +8,7 @@ public sealed record ShowSummaryDto( string Name, string? OriginalName, ShowKind Kind, + ShowAudience Audience, int EpisodeCount, int SeasonCount, int? Year, @@ -35,6 +36,7 @@ public sealed record ShowDto( string Name, string? OriginalName, ShowKind Kind, + ShowAudience Audience, string? Description, string? MetadataProvider, string? MetadataExternalId, diff --git a/backend/src/TeleWave.Domain/Library/Show.cs b/backend/src/TeleWave.Domain/Library/Show.cs index 4505e7b..86687da 100644 --- a/backend/src/TeleWave.Domain/Library/Show.cs +++ b/backend/src/TeleWave.Domain/Library/Show.cs @@ -18,6 +18,10 @@ public class Show public string? Description { get; private set; } public ShowKind Kind { get; private set; } + + /// Категория аудитории (обычное/детское/взрослое) — под будущие фильтры показа. + public ShowAudience Audience { get; private set; } + public DateTimeOffset CreatedAt { get; private set; } // ── Метаданные (TMDb/OMDb/вручную) ── @@ -42,7 +46,8 @@ public class Show string name, ShowKind kind, string? description = null, - string? originalName = null + string? originalName = null, + ShowAudience audience = ShowAudience.General ) => new() { @@ -51,9 +56,13 @@ public class Show OriginalName = Normalize(originalName), Kind = kind, Description = description, + Audience = audience, CreatedAt = DateTimeOffset.UtcNow, }; + /// Задать категорию аудитории (обычное/детское/взрослое). + public void SetAudience(ShowAudience audience) => Audience = audience; + public void Rename(string name, string? description) { Name = name; diff --git a/backend/src/TeleWave.Domain/Library/ShowAudience.cs b/backend/src/TeleWave.Domain/Library/ShowAudience.cs new file mode 100644 index 0000000..111cd8e --- /dev/null +++ b/backend/src/TeleWave.Domain/Library/ShowAudience.cs @@ -0,0 +1,14 @@ +namespace TeleWave.Domain.Library; + +/// Категория аудитории шоу (пригодится для фильтров/разграничения показа). +public enum ShowAudience +{ + /// Обычное — без ограничений (по умолчанию). + General, + + /// Детское. + Kids, + + /// Взрослое. + Adult, +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.Designer.cs new file mode 100644 index 0000000..10b45ff --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.Designer.cs @@ -0,0 +1,1026 @@ +// +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("20260725225737_ShowAudience")] + partial class ShowAudience + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TeleWave.Domain.Auth.RefreshToken", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FromShowId") + .HasColumnType("uuid"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("ToShowId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FromShowId", "ToShowId", "Signature"); + + b.ToTable("BumperAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AccentColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("AudioDurationSeconds") + .HasColumnType("double precision"); + + b.Property("AudioExtension") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("BackgroundColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundColor2") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("BackgroundImageId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Position"); + + b.ToTable("BumperTemplate"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Line1") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Line2") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NextLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("NowLabel") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Trigger") + .HasColumnType("integer"); + + b.Property("Weight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId", "Position"); + + b.ToTable("BumperTextVariants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.Channel", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("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("DayOfWeek") + .HasColumnType("integer"); + + b.Property("EndMinute") + .HasColumnType("integer"); + + b.Property("EndsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Recurrence") + .HasColumnType("integer"); + + b.Property("StartMinute") + .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("Audience") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("MetadataExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("MetadataProvider") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("OriginalName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.Property("Year") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Shows"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.ShowEpisode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AirDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Episode") + .HasColumnType("integer"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("Overview") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("StillImageId") + .HasColumnType("uuid"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("ShowId", "Position"); + + b.ToTable("ShowEpisode"); + }); + + modelBuilder.Entity("TeleWave.Domain.Media.MediaAsset", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AudioCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Duration") + .HasColumnType("interval"); + + b.Property("ErrorMessage") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Height") + .HasColumnType("integer"); + + b.Property("OriginalExtension") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ProcessingDuration") + .HasColumnType("interval"); + + b.Property("ProcessingStartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RelativePath") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SegmentCount") + .HasColumnType("integer"); + + b.Property("SegmentSeconds") + .HasColumnType("integer"); + + b.Property("Source") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VideoCodec") + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Width") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("MediaAssets"); + }); + + modelBuilder.Entity("TeleWave.Domain.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/20260725225737_ShowAudience.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.cs new file mode 100644 index 0000000..7ef487d --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260725225737_ShowAudience.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ShowAudience : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Audience", + table: "Shows", + type: "integer", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Audience", + table: "Shows"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index c738d2e..45c2133 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -587,6 +587,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("Id") .HasColumnType("uuid"); + b.Property("Audience") + .HasColumnType("integer"); + b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md new file mode 100644 index 0000000..63bc89b --- /dev/null +++ b/docs/tv-scheduler-architecture.md @@ -0,0 +1,691 @@ +# Автоматическое построение расписания линейного канала + +Спецификация для разработки. Система эмулирует линейный телеканал: непрерывный общий +эфир, привязанный к календарю, с публикуемой программой передач на N дней вперёд. + +--- + +## 1. Основные принципы + +### 1.1. Четыре слоя правил + +Гибкость достигается не «конструктором расписаний», а разделением на четыре +независимых, переиспользуемых слоя: + +| Слой | Сущность | Отвечает за | +|------|----------|-------------| +| 1 | **Пул** | Что может попасть в эфир | +| 2 | **Сетка** | Когда и в каком порядке | +| 3 | **Стратегия** | Как выбирается конкретный элемент | +| 4 | **Ограничение** | Чего нельзя допускать | + +Слои ссылаются друг на друга, но редактируются отдельно. Один пул используется +многими слотами, одна стратегия — многими каналами. + +### 1.2. Детерминированность + +Генерация — чистая функция: + +``` +schedule(channel_id, date) → [элементы] +``` + +Одинаковые входные данные всегда дают одинаковый результат. Следствия: + +- предпросмотр гарантированно совпадает с эфиром; +- перегенерация не «прыгает» без причины; +- можно посчитать любую дату независимо от соседних; +- нет накопленного состояния, которое рассинхронизируется. + +### 1.3. Модель времени + +- `channel.epoch` — дата запуска канала, точка отсчёта для последовательных стратегий. +- `channel.timezone` — таймзона канала. Эфир общий: в 20:00 все зрители видят одно и то же. +- `now` — единственная жёсткая граница между неизменяемым прошлым и пересчитываемым будущим. + +--- + +## 2. Модель данных + +### 2.1. Канал + +``` +Channel + id + name + slug + epoch date — точка отсчёта + timezone string + era_profile jsonb — профиль эпохи (см. 2.2) + grid_mode enum — hard | elastic + day_start_time time — вещательные сутки, обычно 06:00, не 00:00 + status enum — draft | active | archived + rules_version int — инкремент при любой правке правил +``` + +**`day_start_time`** — важная деталь. У телеканалов сутки начинаются утром, а не в полночь. +Ночной блок с 00:00 до 06:00 относится к предыдущему дню. Это влияет на слои сетки +(«пятничная ночь» = ночь с пятницы на субботу) и на отображение программы. + +**`grid_mode`**: +- `hard` — старты слотов фиксированы (:00, :30). Разница добирается врезками. Правильный + выбор для правдоподобной эмуляции. +- `elastic` — всё встык, времена слотов — цели с допуском дрейфа, выравнивание на якорях. + +### 2.2. Профиль эпохи + +Фильтр верхнего уровня, накрывающий все пулы канала сразу. Один переключатель задаёт +атмосферу и не даёт просочиться современному контенту. + +``` +era_profile + year_max int — ничего новее + year_min int — опционально + aspect_ratio enum — 4:3 | 16:9 | any + ident_pack_ids [uuid] — паки заставок + ad_pack_ids [uuid] — паки рекламных роликов + promo_pack_ids [uuid] — паки анонсов +``` + +Применяется как AND к любому пулу этого канала. + +### 2.3. Пул (слой 1) + +``` +Pool + id + name + kind enum — query | manual | composite + filters jsonb — для kind=query + manual_items [item_ref] — для kind=manual + composite jsonb — для kind=composite: {include:[], exclude:[]} + cached_stats jsonb — {count, total_duration_ms, computed_at} +``` + +`filters` для `kind=query`: + +```json +{ + "content_types": ["show", "movie"], + "genres": { "any_of": ["comedy", "animation"] }, + "tags": { "all_of": ["retro"], "none_of": ["holiday"] }, + "year": { "min": 1989, "max": 1999 }, + "duration_ms": { "min": 1200000, "max": 1500000 }, + "age_rating": { "max": "12+" }, + "languages": ["ru"], + "channels": ["cartoon-network"] +} +``` + +`cached_stats` пересчитывается фоново при изменении каталога — нужно для UI +(«подходит 342 позиции, 118 часов»). + +### 2.4. Сетка и слот (слой 2) + +``` +GridLayer + id + channel_id + name — «Базовая», «Выходные», «Летняя», «Новый год» + priority int — больше = специфичнее, побеждает + applicability jsonb — когда действует + enabled bool +``` + +`applicability`: + +```json +{ + "weekdays": [1,2,3,4,5], + "date_ranges": [{"from": "2026-06-01", "to": "2026-08-31"}], + "specific_dates": ["2026-12-31"], + "recurring_dates": [{"month": 10, "day": 31}] +} +``` + +Разрешение конфликтов: для каждой минуты берётся слот из слоя с наибольшим `priority` +среди применимых. Базовый слой имеет `priority = 0`. + +``` +Slot + id + layer_id + weekday int — 0..6, null если слой привязан к датам + start_time time — в вещательных сутках канала + duration_ms int + title string — отображаемое имя блока («Вечернее кино») + slot_type enum — content | repeat | anchor + pool_id uuid + strategy jsonb — см. 2.5 + junction_template_id uuid + break_load jsonb — плотность врезок, см. 4.3 + is_anchor bool — старт не сдвигается ни при каких условиях + daypart enum — morning | day | prime | night +``` + +**`slot_type = repeat`** — очень узнаваемая примета настоящего ТВ и бесплатное +расширение объёма контента: + +```json +{ + "slot_type": "repeat", + "repeat_source": { "days_ago": 1, "time": "20:00" } +} +``` + +### 2.5. Стратегия (слой 3) + +Хранится внутри слота как JSON с полем `type`. + +**sequential** — сериал по порядку: +```json +{ + "type": "sequential", + "on_season_end": "next_season", // next_season | restart | stop + "start_from": { "season": 1, "episode": 1 } +} +``` + +**marathon** — блок серий подряд: +```json +{ "type": "marathon", "count": 4, "continue_across_days": true } +``` + +**rotation** — случайный выбор с остыванием: +```json +{ "type": "rotation", "cooldown_days": 7 } +``` + +**weighted** — вероятность по весу: +```json +{ + "type": "weighted", + "weight_by": "popularity", // popularity | recency | manual + "cooldown_days": 3 +} +``` + +**fixed** — всегда один и тот же тайтл: +```json +{ "type": "fixed", "item_ref": "movie:12345" } +``` + +**playlist** — жёсткий порядок: +```json +{ "type": "playlist", "items": ["show:1:s1e1", "movie:99"], "loop": true } +``` + +### 2.6. Шаблон стыка + +Последовательность врезок между программами. Достаточно 3–4 шаблонов на канал. + +``` +JunctionTemplate + id + name — «Прайм», «День», «Ночь», «Внутри марафона» + elements jsonb[] +``` + +```json +{ + "name": "Прайм", + "elements": [ + { "kind": "ad", "duration_ms": 120000, "required": true, + "flex": { "min": 60000, "max": 180000 } }, + { "kind": "ident", "required": false, + "condition": "minutes_since_last_ident > 30" }, + { "kind": "bumper_nextup", "required": false, + "condition": "next_slot.slot_type != 'repeat'" }, + { "kind": "promo", "required": false, "fill_remaining": true } + ] +} +``` + +- `required` — элемент нельзя выбросить при нехватке времени. +- `flex` — диапазон, внутри которого элемент поглощает лишнее/недостающее время. +- `fill_remaining` — элемент растягивается на весь остаток слота. +- `condition` — выражение над контекстом (соседние слоты, время суток, счётчики). + +### 2.7. Ограничения (слой 4) + +``` +Constraint + id + channel_id + scope enum — channel | daypart | slot + scope_ref uuid + type enum + params jsonb + severity enum — hard | soft +``` + +`hard` — генератор обязан соблюсти или упасть с ошибкой. +`soft` — старается соблюсти, при невозможности пишет предупреждение. + +Типы: + +| type | params | Смысл | +|------|--------|-------| +| `age_rating_by_time` | `{from, to, max_rating}` | Детское время | +| `max_title_repeats` | `{window_days, max}` | Не чаще N раз за период | +| `min_repeat_gap` | `{hours}` | Минимальный интервал между повторами | +| `max_break_minutes_per_hour` | `{minutes}` | Потолок врезок | +| `max_genre_share` | `{genre, share, window}` | Доля жанра в сутках | +| `forbidden_adjacency` | `{tags}` | Что нельзя ставить встык | + +### 2.8. Результат генерации + +Два представления одного расписания. + +**Программная сетка** — то, что отдаётся клиентам: + +``` +ProgrammeEntry + id + channel_id + start_at timestamptz + end_at timestamptz + title + item_ref — show:id:s1e5 | movie:id + slot_id — что породило + layer_id — из какого слоя сетки + origin enum — generated | manual | fallback + is_frozen bool — прошедшее +``` + +**Плейаут-лента** — реальная последовательность воспроизведения: + +``` +PlayoutItem + id + channel_id + programme_entry_id — null для врезок между программами + start_at timestamptz + duration_ms + kind enum — programme | ad | promo | ident | bumper | filler + asset_id + source_ref — slot_id | junction_template_id +``` + +Плейаут выводится из программной сетки при генерации. Клиентам отдаётся только первое. + +### 2.9. Ручные правки + +Отдельная таблица, переживает перегенерацию. + +``` +Patch + id + channel_id + target_date date + target_time time + op enum — pin | replace | shift | remove | insert + payload jsonb + status enum — active | orphaned + created_by + created_at +``` + +- `pin` — закрепить конкретный элемент в этом времени. +- `replace` — заменить то, что сгенерировалось. +- `shift` — сдвинуть границу слота. +- `orphaned` — правила изменились так, что патч потерял смысл. Не применяется молча + и не выбрасывается молча — показывается админу списком. + +--- + +## 3. Алгоритм генерации + +### 3.1. Пайплайн + +``` +1. RESOLVE GRID — собрать эффективную сетку на дату из слоёв +2. FILL SLOTS — для каждого слота выбрать контент по стратегии +3. APPLY CONSTRAINTS — проверить ограничения, при нарушении — пересобрать слот +4. BUILD PLAYOUT — разложить врезки по шаблонам стыков, подогнать длительности +5. APPLY PATCHES — наложить ручные правки +6. VALIDATE — финальная проверка, собрать предупреждения +7. MATERIALIZE — записать в кэш будущего +``` + +### 3.2. Seed: гранулярность на уровне слота + +**Критично для UX.** Если считать seed на уровне дня, любая мелкая правка +перетасовывает всю неделю, включая слоты, которых правка не касалась. Админ поменял +шаблон стыка в ночном блоке — переехало дневное вещание. Диф становится +бесполезным. + +``` +seed = hash(channel_id, date, slot_id) +``` + +**Правило: seed зависит только от координат (канал + дата + слот), но не от +содержания правил.** Изменилось правило — изменился результат его применения, но не +жребий соседей. Диф читается как «изменилось 4 элемента из 180». + +### 3.3. Последовательные стратегии без состояния + +Курсор в БД не нужен. Номер выхода вычисляется арифметически: + +``` +occurrences = количество срабатываний слота в интервале [channel.epoch, date) +episode_index = (occurrences + start_offset) mod total_episodes +``` + +`occurrences` считается по правилу повторения слоя (например, «будни» → количество +рабочих дней между датами) с вычетом дат, где слот был перекрыт слоем с более высоким +приоритетом. + +Даёт: строго последовательный порядок + мгновенную перемотку на любую дату + нулевое +состояние. + +Для `on_season_end = next_season` — тот же принцип, но по плоскому списку серий всех +сезонов. + +### 3.4. Ротация с остыванием без состояния + +Вместо хранения истории показов — детерминированная колода: + +``` +period = floor(days_since_epoch / cooldown_days) +deck = shuffle(pool_items, seed = hash(channel_id, slot_id, period)) +index = day_within_period +item = deck[index mod len(deck)] +``` + +Колода перетасовывается раз в период, внутри периода повторов нет by design. + +### 3.5. Подгонка длительности — главная рабочая часть + +Слот 30 мин, серия 22 мин → 8 минут добора. Алгоритм: + +``` +gap = slot.duration_ms - content.duration_ms + +if gap > 0: + заполнить по шаблону стыка: + 1. required-элементы (сумма их min) + 2. расширить flex-элементы до max в порядке приоритета + 3. добавить optional-элементы, пока проходят условия + 4. остаток отдать fill_remaining-элементу + 5. если остаток всё ещё > 0 → filler + +if gap < 0: # контент длиннее слота + grid_mode = hard: + а) искать в пуле элемент подходящей длительности (best-fit) + б) сжать врезки до min + в) если не помещается → расширить слот, сдвинув следующий + (но не якорь — перед якорем сжимается эластичная зона) + grid_mode = elastic: + сдвинуть последующие слоты, выровняться на ближайшем якоре +``` + +**Best-fit важнее «лучшего»**: при подборе в пул часто правильнее взять элемент, +ближе всего подходящий по длительности, чем элемент с наивысшим весом. + +**Внутренние разрывы** (реклама внутри программы) ставятся по маркерам из метаданных, +при их отсутствии — через равные интервалы с запретом первых и последних 5 минут. + +### 3.6. Плотность врезок по дейпартам + +Реальный канал не идёт встык, и плотность меняется по времени суток. + +``` +break_load: + morning: { target_ratio: 0.12, max_break_ms: 90000 } + day: { target_ratio: 0.18, max_break_ms: 120000 } + prime: { target_ratio: 0.22, max_break_ms: 180000 } + night: { target_ratio: 0.10, max_break_ms: 240000 } +``` + +Ночью врезок меньше, но они длиннее — это узнаваемо. + +### 3.7. Граница `now` и перегенерация + +``` +past → snapshot, immutable, никогда не пересчитывается +current → текущий элемент не вырезается из-под зрителя; + в grid_mode=hard применение начинается со следующего слота +future → materialized cache, пересобирается по требованию +patches → отдельная таблица, переживает перегенерацию +``` + +Перегенерация = удалить кэш будущего от границы применения → посчитать заново → +наложить патчи. Операция идемпотентна. + +Прошлое замораживается снапшотом обязательно: иначе история будет врать — пользователь +смотрел вчера в 20:00 одно, а в архиве другое. + +Фоновая задача каждую ночь достраивает горизонт (например, держим 14 дней вперёд). + +### 3.8. Отдача клиентам + +- Запрос программы — простой диапазон по `ProgrammeEntry`. +- Возвращать `version` / `ETag`, чтобы клиент не показывал устаревшую сетку из кэша. +- Плейаут-лента наружу не отдаётся. + +--- + +## 4. Валидация и предупреждения + +Считаются до генерации (по правилам) и после (по результату). + +### 4.1. До генерации + +| Проверка | Формула | Сообщение | +|----------|---------|-----------| +| Нехватка контента | `pool.count < выходов_слота_в_неделю` | «В пуле 3 позиции при потребности 7 в неделю — повторы каждые полнедели» | +| Пустой пул | `pool.count == 0` | «Пул пуст, слот будет заполнен филлером» | +| Дыра в сетке | непокрытый интервал | «Не покрыто: вт 03:00–06:00» | +| Пересечение слотов | overlap в одном слое | «Слоты пересекаются» | +| Недостижимое ограничение | hard-constraint vs объём пула | «Ограничение „не чаще 1 раза в неделю" невыполнимо при 3 позициях» | + +**Расчёт достаточности пула:** + +``` +потребность = выходов_в_неделю +запас = pool.count / потребность # в неделях до полного цикла +``` + +Показывать в UI: «полный цикл без повторов — 6.2 недели». + +### 4.2. После генерации + +- частота повторов тайтла (тепловая карта); +- фактическая доля врезок по часам против лимита; +- элементы с `origin = fallback` (не нашлось контента); +- осиротевшие патчи; +- нарушенные soft-constraints. + +--- + +## 5. UI: формы и экраны + +### 5.1. Редактор сетки — основной экран + +**Календарь недели с drag & drop**, как в Google Calendar. Не список форм. + +- ось X — дни недели, ось Y — время вещательных суток (от `day_start_time`); +- слоты тянутся мышкой, изменяются за края; +- копирование дня на другие дни, копирование недели; +- цвет блока = дейпарт или тип слота; +- полупрозрачная штриховка на слотах, перекрытых слоем выше; +- панель слоёв слева: список с чекбоксами видимости и приоритетом, drag для + переупорядочивания. + +**Инспектор слота** (правая панель, открывается по клику): + +``` +Название блока [Вечернее кино ] +Время / длительность [20:00] [90 мин] □ Якорь +Дейпарт (○ утро ○ день ● прайм ○ ночь) +Тип слота (● контент ○ повтор ○ якорь) + +Пул [Фильмы 90-х ▾] 342 поз. · 118 ч · цикл 6.2 нед + [Открыть пул] [Создать новый] + +Стратегия [Ротация с остыванием ▾] + Остывание [7] дней + +Шаблон стыка [Прайм ▾] ~4 мин врезок +Плотность врезок [наследовать от дейпарта ▾] + +Ограничения слота + Добавить +``` + +Ключевое: статистика пула («342 поз. · цикл 6.2 нед») видна прямо здесь, без перехода. + +### 5.2. Редактор пула + +Двухпанельный: слева конструктор фильтров, справа — живой список результатов с +пересчётом на каждое изменение. + +``` +Тип контента [x] Шоу [x] Фильмы +Жанры любой из: [комедия ×] [анимация ×] + +Теги все из: [ретро ×] + + ни одного: [праздничное ×] + +Год от [1989] до [1999] +Длительность от [20] до [25] мин +Возраст не выше [12+ ▾] + +───────────────────────────────── +Найдено: 342 позиции · 118 ч 40 мин +Самое старое: 1989 · Самое новое: 1999 + +[список результатов с превью] +``` + +Профиль эпохи канала показывается как неотключаемый бейдж сверху: «Фильтр канала: +не новее 1999». + +### 5.3. Редактор шаблона стыка + +Визуальная горизонтальная цепочка, перетаскиванием. + +``` +[КОНЕЦ] → [Реклама 60–180с] → [Айдент 10с] → [Далее 15с] → [Промо ⇥] → [НАЧАЛО] + required if >30min if !repeat fill +``` + +Под цепочкой — линейка суммарной длительности: «мин 85с / типично 155с / макс 205с». +Клик по элементу — попап с параметрами (kind, длительность, flex, condition, required). + +### 5.4. Предпросмотр + +Кнопка «Предпросмотр на N дней» доступна из редактора **без сохранения** — считает по +текущему черновику правил. + +Три вкладки: + +**Программа** — список как увидит зритель, по дням. + +**Плейаут** — посекундный таймлайн с цветовыми слоями (программа / реклама / айдент / +анонс / филлер). Сверху — гистограмма нагрузки врезок по часам с горизонтальной линией +лимита; превышения красным. + +**Проблемы** — сгруппированный список предупреждений с переходом к источнику. + +Дополнительно: тепловая карта повторов — матрица «тайтл × день», где яркость = +количество показов. Мгновенно видно, что один фильм крутится четыре раза за неделю. + +### 5.5. «Почему это здесь» + +У каждого элемента расписания — иконка «i», открывающая цепочку происхождения: + +``` +Симпсоны, с5э12 · пн 18:00 + +Слой: Базовая сетка (priority 0) +Слот: Дневная анимация · будни 18:00 · 30 мин +Пул: Мультсериалы 90-х (128 позиций) +Стратегия: Последовательная + выход №247 с 01.01.2024 → серия 247 mod 178 = 69 +Врезки: шаблон «День», 8 мин добора +Патчи: нет +``` + +Это экономит редакторам часы отладки — обязательный, не опциональный элемент. + +### 5.6. Диф перед применением + +Уведомлений клиентам нет, но админу нужен предохранитель. + +``` +Изменения затронут: + 180 элементов всего, из них 12 изменятся + 3 — в ближайшие 24 часа ⚠ + + пн 20:00 «Терминатор» → «Чужой» + пн 21:40 реклама 120с → реклама 180с + вт 18:00 без изменений + ... + + [Применить] [Отмена] +``` + +Отдельно подсвечивать изменения в ближайшие сутки — самая частая причина случайного +ущерба. + +### 5.7. Прочие экраны + +- **Список каналов** с кнопкой «Клонировать» — сделал один канал, скопировал, поменял + пулы, получил второй. Основной способ масштабирования. +- **История версий правил**: кто, когда, что изменил; откат к версии. +- **Патчи**: список ручных правок, фильтр по статусу, массовое снятие осиротевших. +- **Расписание (обзор)**: чтение на любую дату, включая архив прошлого. + +### 5.8. Люк вниз + +Правила хранятся в JSONB со схемой. Визуальный редактор и «сырой» JSON правят одну и ту +же структуру. Для продвинутых пользователей — вкладка с JSON-редактором и валидацией по +схеме. Это же даёт нормальный дифф в истории версий. + +--- + +## 6. Что делает эмуляцию правдоподобной + +Отдельный раздел, потому что это влияет на дефолты и на то, что вынести в UI явно. + +1. **Стабильность сетки.** Настоящее ТВ предсказуемо: одно шоу всегда в одно время. + Слоты прибиты, а не рандомятся каждый день. Дефолт — `grid_mode = hard`. +2. **Характер дейпартов.** Утро — короткие детские; день — повторы и дешёвый контент; + прайм — премьеры и полнометражки; ночь — старое кино, документалки, длинные блоки. + Реализуется просто разными пулами и шаблонами стыков. +3. **Повторы как фича, а не баг.** Вечерний блок повторяется утром следующего дня — + очень узнаваемо. Тип слота `repeat`. +4. **Вещательные сутки с 06:00**, а не с полуночи. +5. **Ритм врезок**: чем ближе к прайму, тем плотнее. Ночью реже и длиннее. +6. **Ротация айдентов с остыванием** — не крутить один и тот же чаще раза в час. +7. **Сезонные слои**: летняя сетка, новогодняя, тематические дни. + +--- + +## 7. Порядок реализации + +**Этап 1 — ядро** +Канал, пул (kind=query), базовый слой сетки, слоты, стратегии sequential и rotation, +генератор без врезок, материализация, отдача программы. + +**Этап 2 — врезки** +Шаблоны стыков, подгонка длительности, плотность по дейпартам, плейаут-лента. + +**Этап 3 — гибкость** +Слои сетки с приоритетами, остальные стратегии, ограничения, тип слота `repeat`. + +**Этап 4 — админка** +Календарь с drag & drop, инспектор слота, редактор пула со статистикой, +предпросмотр, «почему это здесь». + +**Этап 5 — эксплуатация** +Патчи, диф, версионирование и откат, клонирование канала, валидация, тепловые карты. + +--- + +## 8. Открытые вопросы + +- Нужна ли поддержка нескольких таймзон для одного канала (условные «орбиты» +2/+4)? + Если да — это тонкая надстройка над той же лентой со сдвигом, а не отдельный канал. +- Нужны ли «прямые эфиры» / премьеры по расписанию как отдельный тип якоря? +- Хранить ли плейаут-ленту материализованно или считать на лету из программной сетки + при запросе. Зависит от нагрузки на плеер. diff --git a/frontend/src/features/admin/shows/ShowDetail.tsx b/frontend/src/features/admin/shows/ShowDetail.tsx index a02a3db..4dfa882 100644 --- a/frontend/src/features/admin/shows/ShowDetail.tsx +++ b/frontend/src/features/admin/shows/ShowDetail.tsx @@ -4,11 +4,12 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { ChevronLeft } from 'lucide-react' import { HttpError } from '@/shared/api/client' -import type { MediaAssetDto } from '@/shared/api/types' +import type { MediaAssetDto, ShowAudience } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' import { Pager } from '@/shared/ui/pager' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select' import { toast } from '@/shared/ui/toast-store' import { listAllMedia } from '@/features/admin/media/api' import { @@ -20,7 +21,7 @@ import { import { formatDuration } from '@/features/admin/media/MediaPanel' import { ShowMetadataCard } from './ShowMetadataCard' import { imageUrl } from '@/features/admin/images/api' -import { addEpisode, getShow, removeEpisode } from './api' +import { addEpisode, getShow, removeEpisode, setShowAudience } from './api' type Candidate = { asset: MediaAssetDto; parsed: ParsedEpisode } @@ -48,6 +49,12 @@ export function ShowDetail({ showId }: { showId: string }) { const onError = (error: unknown) => toast.error(error instanceof HttpError ? error.detail : t('common.error')) + const audienceMutation = useMutation({ + mutationFn: (audience: ShowAudience) => setShowAudience(showId, audience), + onSuccess: invalidate, + onError, + }) + const removeMutation = useMutation({ mutationFn: (episodeId: string) => removeEpisode(showId, episodeId), onSuccess: invalidate, @@ -141,6 +148,19 @@ export function ShowDetail({ showId }: { showId: string }) {

{show.name}

{t(`admin.shows.kinds.${show.kind}`)} + {show.kind === 'Series' && ( {t('admin.shows.seasons')}: {seasons.length} diff --git a/frontend/src/features/admin/shows/ShowsPanel.tsx b/frontend/src/features/admin/shows/ShowsPanel.tsx index 538ffa7..a126174 100644 --- a/frontend/src/features/admin/shows/ShowsPanel.tsx +++ b/frontend/src/features/admin/shows/ShowsPanel.tsx @@ -3,7 +3,7 @@ import { Link } from '@tanstack/react-router' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { HttpError } from '@/shared/api/client' -import type { ShowKind } from '@/shared/api/types' +import type { ShowAudience, ShowKind } from '@/shared/api/types' import { Badge } from '@/shared/ui/badge' import { Button } from '@/shared/ui/button' import { Input } from '@/shared/ui/input' @@ -21,6 +21,7 @@ export function ShowsPanel() { const [name, setName] = useState('') const [originalName, setOriginalName] = useState('') const [kind, setKind] = useState('Series') + const [audience, setAudience] = useState('General') const [query, setQuery] = useState('') const [page, setPage] = useState(1) const { sort, toggle } = useTableSort('name', false) @@ -44,6 +45,7 @@ export function ShowsPanel() { return sortRows(matched, sort, { name: (s) => s.name.toLowerCase(), kind: (s) => s.kind, + audience: (s) => s.audience, seasons: (s) => s.seasonCount, episodes: (s) => s.episodeCount, }) @@ -56,7 +58,12 @@ export function ShowsPanel() { const createMutation = useMutation({ mutationFn: () => - createShow({ name: name.trim(), kind, originalName: originalName.trim() || undefined }), + createShow({ + name: name.trim(), + kind, + originalName: originalName.trim() || undefined, + audience, + }), onSuccess: () => { setName('') setOriginalName('') @@ -92,6 +99,16 @@ export function ShowsPanel() { {t('admin.shows.kinds.Single')} +