diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index fa9fedd..2c2fb57 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -188,6 +188,12 @@ public static class StreamingEndpoints CultureInfo.InvariantCulture, $"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n" ); + // Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег + // EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий. + sb.Append( + CultureInfo.InvariantCulture, + $"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\n" + ); foreach (var segment in playlist.Segments) { diff --git a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs index aac4747..025695f 100644 --- a/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs +++ b/backend/src/TeleWave.Application/Programming/Planning/GridScheduleGenerator.cs @@ -134,7 +134,12 @@ public sealed class GridScheduleGenerator( cancellationToken ); - var added = WriteEntries(channel.Id, result.Items, bumperAssets); + var added = await WriteEntriesAsync( + channel.Id, + result.Items, + bumperAssets, + cancellationToken + ); await SaveCursorsAsync(result.Cursors, cancellationToken); @@ -233,13 +238,37 @@ public sealed class GridScheduleGenerator( /// /// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета /// пропускается: запись без файла стала бы дырой в эфире. + /// + /// Заодно проставляет накопительный , продолжая счёт + /// от уцелевшего хвоста ленты: из него live-раздача выводит EXT-X-DISCONTINUITY-SEQUENCE. + /// Инкремент считается ровно так же, как склейку видит live-калькулятор — по смене ассета; разрыв + /// во времени (пропущенная заставка, отставший планировщик) даёт +2: вход-в-филлер и выход-из-него. /// - private int WriteEntries( + private async Task WriteEntriesAsync( Guid channelId, IReadOnlyList items, - IReadOnlyDictionary bumperAssets + IReadOnlyDictionary bumperAssets, + CancellationToken cancellationToken ) { + // Затравка — последняя уцелевшая запись канала: с неё продолжается счёт склеек. Будущее к этому + // моменту уже снесено (при пересборке), поэтому она указывает на границу неизменяемого прошлого. + var seed = await dbContext + .ScheduleEntries.AsNoTracking() + .Where(e => e.ChannelId == channelId) + .OrderByDescending(e => e.EndsAtUtc) + .Select(e => new + { + e.MediaAssetId, + e.EndsAtUtc, + e.DiscontinuityIndex, + }) + .FirstOrDefaultAsync(cancellationToken); + + Guid? previousAsset = seed?.MediaAssetId; + DateTimeOffset? previousEnd = seed?.EndsAtUtc; + var discontinuityIndex = seed?.DiscontinuityIndex ?? 0; + var added = 0; for (var index = 0; index < items.Count; index++) @@ -252,6 +281,14 @@ public sealed class GridScheduleGenerator( ) continue; + if (previousAsset is { } prevAsset) + { + if (previousEnd is { } prevEnd && prevEnd < item.StartsAtUtc) + discontinuityIndex += 2; // между записями крутится филлер — две склейки + else if (prevAsset != assetId) + discontinuityIndex += 1; + } + dbContext.ScheduleEntries.Add( ScheduleEntry.FromSlot( channelId, @@ -267,9 +304,12 @@ public sealed class GridScheduleGenerator( ? null : JsonSerializer.Serialize(item.Trace, TraceJsonOptions), item.CollectionId - ) + ), + discontinuityIndex ) ); + previousAsset = assetId; + previousEnd = item.EndsAtUtc; added++; } diff --git a/backend/src/TeleWave.Application/Streaming/GetLivePlaylist/GetLivePlaylistQueryHandler.cs b/backend/src/TeleWave.Application/Streaming/GetLivePlaylist/GetLivePlaylistQueryHandler.cs index a72f5ee..826151a 100644 --- a/backend/src/TeleWave.Application/Streaming/GetLivePlaylist/GetLivePlaylistQueryHandler.cs +++ b/backend/src/TeleWave.Application/Streaming/GetLivePlaylist/GetLivePlaylistQueryHandler.cs @@ -52,6 +52,7 @@ public sealed class GetLivePlaylistQueryHandler( e.StartsAtUtc, e.EndsAtUtc, e.MediaAssetId, + e.DiscontinuityIndex, }) .ToListAsync(cancellationToken); @@ -75,7 +76,8 @@ public sealed class GetLivePlaylistQueryHandler( e.StartsAtUtc, e.EndsAtUtc, e.MediaAssetId, - segmentCounts[e.MediaAssetId] + segmentCounts[e.MediaAssetId], + e.DiscontinuityIndex )) .ToList(); @@ -92,6 +94,7 @@ public sealed class GetLivePlaylistQueryHandler( var dto = new LivePlaylistDto( playlist.MediaSequence, + playlist.DiscontinuitySequence, playlist.TargetDuration, playlist .Segments.Select(s => new LiveSegmentDto(s.AssetId, s.LocalIndex, s.Discontinuity)) diff --git a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs index e055a10..7b5dde2 100644 --- a/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs +++ b/backend/src/TeleWave.Application/Streaming/StreamingDtos.cs @@ -37,6 +37,7 @@ public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontin public sealed record LivePlaylistDto( long MediaSequence, + long DiscontinuitySequence, int TargetDuration, IReadOnlyList Segments ); diff --git a/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowCalculator.cs b/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowCalculator.cs index 2a5767a..b0ba06f 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowCalculator.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowCalculator.cs @@ -6,6 +6,12 @@ namespace TeleWave.Domain.Broadcast.Live; /// разных ассетов. Всё выровнено на длину сегмента (длительности ассетов кратны ей), поэтому /// арифметика целочисленная, а `MEDIA-SEQUENCE = floor((now - epoch)/seg)` монотонен по построению. /// +/// Помимо MEDIA-SEQUENCE выдаёт DISCONTINUITY-SEQUENCE — абсолютный номер склейки первого сегмента окна. +/// Он берётся из накопительного индекса записи () и +/// «отматывается назад» по тегам DISCONTINUITY внутри окна, поэтому один и тот же физический сегмент +/// сообщает один и тот же номер склейки на каждой перезагрузке плейлиста. Без этого ffmpeg-based плееры +/// сбиваются, когда тег DISCONTINUITY уезжает за верхний край окна. +/// /// Без БД и ФС — юнит-тестируемо (см. LiveWindowCalculatorTests). /// public static class LiveWindowCalculator @@ -14,17 +20,19 @@ public static class LiveWindowCalculator { var seg = input.SegmentSeconds; if (seg <= 0) - return new LivePlaylist(0, Math.Max(seg, 1), []); + return new LivePlaylist(0, 0, Math.Max(seg, 1), []); var elapsed = (input.Now - input.Epoch).TotalSeconds; if (elapsed < 0) - return new LivePlaylist(0, seg, []); // эфир ещё не начался + return new LivePlaylist(0, 0, seg, []); // эфир ещё не начался var currentIndex = (long)Math.Floor(elapsed / seg); var windowStart = Math.Max(0, currentIndex - input.WindowSegments + 1); // Резолвим каждый глобальный сегмент; неразрешённые (дыра без филлера) — null. - var resolved = new List<(Guid Asset, int Local)?>(); + // Discont — накопительный номер склейки записи, которой принадлежит сегмент (null у филлера: + // его сегменты якорятся к соседним записям при подсчёте DISCONTINUITY-SEQUENCE). + var resolved = new List<(Guid Asset, int Local, long? Discont)?>(); for (var g = windowStart; g <= currentIndex; g++) { var segTime = input.Epoch + TimeSpan.FromSeconds(g * seg); @@ -39,23 +47,35 @@ public static class LiveWindowCalculator startIdx = i + 1; if (startIdx >= resolved.Count) - return new LivePlaylist(currentIndex + 1, seg, []); + return new LivePlaylist(currentIndex + 1, 0, seg, []); var mediaSequence = windowStart + startIdx; var segments = new List(); + + // DISCONTINUITY-SEQUENCE — номер склейки ПЕРВОГО сегмента окна. Внутри записи склеек нет + // (один ассет), поэтому абсолютный номер сегмента = base + число тегов DISCONTINUITY перед ним + // в окне. Отсюда base = (известный номер первой попавшейся записи) − (тегов до неё): так первый + // сегмент получает базу, согласованную между перезагрузками. У филлера номера нет — база + // восстанавливается от ближайшей записи в окне; чисто-филлерное окно склеек не содержит вовсе. + long? baseSequence = null; + long tagsSoFar = 0; Guid? previousAsset = null; for (var i = startIdx; i < resolved.Count; i++) { - var (asset, local) = resolved[i]!.Value; + var (asset, local, discont) = resolved[i]!.Value; var discontinuity = previousAsset.HasValue && previousAsset.Value != asset; + if (discontinuity) + tagsSoFar++; segments.Add(new LiveSegment(asset, local, discontinuity)); + if (baseSequence is null && discont is { } di) + baseSequence = di - tagsSoFar; previousAsset = asset; } - return new LivePlaylist(mediaSequence, seg, segments); + return new LivePlaylist(mediaSequence, Math.Max(0, baseSequence ?? 0), seg, segments); } - private static (Guid Asset, int Local)? Resolve( + private static (Guid Asset, int Local, long? Discont)? Resolve( DateTimeOffset segTime, long globalIndex, int seg, @@ -69,7 +89,7 @@ public static class LiveWindowCalculator { var local = (int)Math.Floor((segTime - entry.StartsAtUtc).TotalSeconds / seg); if (local >= 0 && local < entry.SegmentCount) - return (entry.MediaAssetId, local); + return (entry.MediaAssetId, local, entry.DiscontinuityIndex); } if (input.Filler is { SegmentCount: > 0 } filler) @@ -77,7 +97,7 @@ public static class LiveWindowCalculator var local = (int)( ((globalIndex % filler.SegmentCount) + filler.SegmentCount) % filler.SegmentCount ); - return (filler.AssetId, local); + return (filler.AssetId, local, null); } return null; diff --git a/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowModels.cs b/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowModels.cs index 1cf5cab..1a76d51 100644 --- a/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowModels.cs +++ b/backend/src/TeleWave.Domain/Broadcast/Live/LiveWindowModels.cs @@ -1,11 +1,16 @@ namespace TeleWave.Domain.Broadcast.Live; /// Запись расписания в терминах live-калькулятора (только нужное для нарезки окна). +/// Накопительный номер склейки этой записи — число разрывов ленты от +/// эпохи канала до её первого сегмента (материализуется планировщиком). Из него выводится +/// EXT-X-DISCONTINUITY-SEQUENCE: без него ffmpeg-based плееры (Jellyfin) теряют счёт склеек, +/// когда тег EXT-X-DISCONTINUITY уезжает за верхний край скользящего окна, и встают на стыке. public sealed record LiveEntry( DateTimeOffset StartsAtUtc, DateTimeOffset EndsAtUtc, Guid MediaAssetId, - int SegmentCount + int SegmentCount, + long DiscontinuityIndex ); /// Ассет-заглушка для дыр в расписании (крутится по кругу). @@ -25,6 +30,7 @@ public sealed record LiveSegment(Guid AssetId, int LocalIndex, bool Discontinuit public sealed record LivePlaylist( long MediaSequence, + long DiscontinuitySequence, int TargetDuration, IReadOnlyList Segments ); diff --git a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs index b10b817..6589f84 100644 --- a/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs +++ b/backend/src/TeleWave.Domain/Broadcast/ScheduleEntry.cs @@ -13,6 +13,14 @@ public class ScheduleEntry public DateTimeOffset StartsAtUtc { get; private set; } public DateTimeOffset EndsAtUtc { get; private set; } + /// + /// Накопительный номер склейки: число разрывов эфирной ленты (смен ассета) от эпохи канала до + /// первого сегмента этой записи. Считается при материализации, продолжаясь от предыдущей записи. + /// Из него live-раздача выводит EXT-X-DISCONTINUITY-SEQUENCE — без него ffmpeg-плееры + /// (Jellyfin) встают на стыке серий, когда тег склейки уходит за край скользящего окна. + /// + public long DiscontinuityIndex { get; private set; } + /// Шоу (для ) — для EPG. public Guid? ShowId { get; private set; } @@ -47,7 +55,8 @@ public class ScheduleEntry ScheduleEntryKind kind, DateTimeOffset startsAtUtc, DateTimeOffset endsAtUtc, - ScheduleEntryOrigin origin + ScheduleEntryOrigin origin, + long discontinuityIndex ) => new() { @@ -62,6 +71,7 @@ public class ScheduleEntry SlotId = origin.SlotId, TraceJson = origin.TraceJson, CollectionId = origin.CollectionId, + DiscontinuityIndex = discontinuityIndex, }; public static ScheduleEntry Program( diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.Designer.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.Designer.cs new file mode 100644 index 0000000..22b6cfc --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.Designer.cs @@ -0,0 +1,1608 @@ +// +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("20260804084450_ScheduleEntryDiscontinuityIndex")] + partial class ScheduleEntryDiscontinuityIndex + { + /// + 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("BackgroundAssetId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaAssetId") + .HasColumnType("uuid"); + + b.Property("PosterShowId") + .HasColumnType("uuid"); + + b.Property("RenderedLinesJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Signature") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("VariantId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MediaAssetId"); + + b.HasIndex("Signature") + .IsUnique(); + + 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("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Font") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("TextColor") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("BumperTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTextVariant", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Background") + .HasColumnType("integer"); + + b.Property("BackgroundClipShowId") + .HasColumnType("uuid"); + + b.Property("BumperTemplateId") + .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("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("AnalogFilterStrength") + .HasColumnType("double precision"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DayStartTime") + .HasColumnType("time without time zone"); + + b.Property("EpochUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FillerAssetId") + .HasColumnType("uuid"); + + b.Property("IconImageId") + .HasColumnType("uuid"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LogoCorner") + .HasColumnType("integer"); + + b.Property("LogoImageId") + .HasColumnType("uuid"); + + b.Property("LogoOpacity") + .HasColumnType("double precision"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("ShowClock") + .HasColumnType("boolean"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UtcOffsetMinutes") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Number") + .IsUnique() + .HasFilter("\"Number\" IS NOT NULL"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.ScheduleEntry", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("DiscontinuityIndex") + .HasColumnType("bigint"); + + 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("SlotId") + .HasColumnType("uuid"); + + b.Property("StartsAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TraceJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "EndsAtUtc"); + + b.HasIndex("ChannelId", "StartsAtUtc"); + + b.HasIndex("ChannelId", "ShowId", "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.Collection", 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("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PosterImageId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CollectionId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("ShowId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ShowId"); + + b.HasIndex("CollectionId", "Position"); + + b.HasIndex("CollectionId", "ShowId") + .IsUnique(); + + b.ToTable("CollectionItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("GenreId"); + + b.HasIndex("Value") + .IsUnique(); + + b.ToTable("GenreAliases"); + }); + + 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("FranchiseExternalId") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("FranchiseName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + 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.Library.ShowGenre", b => + { + b.Property("ShowId") + .HasColumnType("uuid"); + + b.Property("GenreId") + .HasColumnType("uuid"); + + b.Property("IsPrimary") + .HasColumnType("boolean"); + + b.HasKey("ShowId", "GenreId"); + + b.HasIndex("GenreId"); + + b.ToTable("ShowGenres"); + }); + + 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.Notifications.TelegramLinkCode", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("TelegramLinkCodes"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSettings", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BotToken") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("BotUsername") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastContactAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("LastUpdateId") + .HasColumnType("bigint"); + + b.Property("NotifiedUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("ProxyHost") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProxyKind") + .HasColumnType("integer"); + + b.Property("ProxyPassword") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ProxyPort") + .HasColumnType("integer"); + + b.Property("ProxyUsername") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Transport") + .HasColumnType("integer"); + + b.Property("WebhookSecret") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("WebhookUrl") + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.HasKey("Id"); + + b.ToTable("TelegramSettings"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AlertMessageId") + .HasColumnType("bigint"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("IsStopped") + .HasColumnType("boolean"); + + b.Property("LastSeenAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MenuMessageId") + .HasColumnType("bigint"); + + b.Property("NoticeMessageId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChatId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("TelegramSubscribers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("SubscriberId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId", "Kind"); + + b.HasIndex("SubscriberId", "ChannelId", "Kind") + .IsUnique(); + + b.ToTable("TelegramSubscriptions"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ApplicabilityJson") + .HasColumnType("jsonb"); + + b.Property("IsBackground") + .HasColumnType("boolean"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Priority") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId", "Priority"); + + b.ToTable("GridLayers"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", 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("FilterJson") + .HasColumnType("jsonb"); + + b.Property("ItemCount") + .HasColumnType("integer"); + + b.Property("Mode") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("StatsComputedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalDuration") + .HasColumnType("interval"); + + b.Property("UnitCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ElementId") + .HasColumnType("uuid"); + + b.Property("ElementKind") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Role") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Weight") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ElementKind", "ElementId"); + + b.HasIndex("GroupId", "Position"); + + b.HasIndex("GroupId", "ElementKind", "ElementId") + .IsUnique(); + + b.ToTable("GroupItems"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AmountMode") + .HasColumnType("integer"); + + b.Property("AmountValue") + .HasColumnType("integer"); + + b.Property("BumperTemplateId") + .HasColumnType("uuid"); + + b.Property("BumperVariantId") + .HasColumnType("uuid"); + + b.Property("ChoiceKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ChoiceWeight") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ConditionsJson") + .HasColumnType("jsonb"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("JunctionTemplateId") + .HasColumnType("uuid"); + + b.Property("Kind") + .HasColumnType("integer"); + + b.Property("Position") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BumperTemplateId"); + + b.HasIndex("BumperVariantId"); + + b.HasIndex("GroupId"); + + b.HasIndex("JunctionTemplateId", "Position"); + + b.ToTable("JunctionElements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MaxTotalSeconds") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("JunctionTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AppliedRevision") + .HasColumnType("integer"); + + b.Property("AppliedSnapshotJson") + .HasColumnType("jsonb"); + + b.Property("ChannelId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultJunctionId") + .HasColumnType("uuid"); + + b.Property("FallbackGroupId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Revision") + .HasColumnType("integer"); + + b.Property("RulesJson") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.ToTable("ScheduleTemplates"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("BlockMode") + .HasColumnType("integer"); + + b.Property("BlockValue") + .HasColumnType("integer"); + + b.Property("Daypart") + .HasColumnType("integer"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("IsAnchor") + .HasColumnType("boolean"); + + b.Property("JunctionAfterId") + .HasColumnType("uuid"); + + b.Property("JunctionBetweenId") + .HasColumnType("uuid"); + + b.Property("LayerId") + .HasColumnType("uuid"); + + b.Property("MaxDriftMinutes") + .HasColumnType("integer"); + + b.Property("OverflowPolicy") + .HasColumnType("integer"); + + b.Property("RepeatSourceJson") + .HasColumnType("jsonb"); + + b.Property("SlotKind") + .HasColumnType("integer"); + + b.Property("SnapToMinutes") + .HasColumnType("integer"); + + b.Property("StrategyJson") + .HasColumnType("jsonb"); + + b.Property("TargetDurationMinutes") + .HasColumnType("integer"); + + b.Property("TargetStart") + .HasColumnType("time without time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Weekday") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("LayerId", "TargetStart"); + + b.ToTable("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.Property("SlotId") + .HasColumnType("uuid"); + + b.Property("CurrentElementId") + .HasColumnType("uuid"); + + b.Property("CurrentElementKind") + .HasColumnType("integer"); + + b.Property("NextUnitIndex") + .HasColumnType("integer"); + + b.HasKey("SlotId"); + + b.ToTable("SlotStates"); + }); + + 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.BumperTextVariant", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany("Variants") + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsMany("TeleWave.Domain.Broadcast.BumperLine", "Lines", b1 => + { + b1.Property("BumperTextVariantId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAdd(); + + b1.Property("Color"); + + b1.Property("Position"); + + b1.Property("Style"); + + b1.Property("Text") + .IsRequired() + .HasMaxLength(120); + + b1.HasKey("BumperTextVariantId", "__synthesizedOrdinal"); + + b1.ToTable("BumperTextVariants"); + + b1 + .ToJson("Lines") + .HasColumnType("jsonb"); + + b1.WithOwner() + .HasForeignKey("BumperTextVariantId"); + }); + + b.Navigation("Lines"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.CollectionItem", b => + { + b.HasOne("TeleWave.Domain.Library.Collection", null) + .WithMany("Items") + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany() + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.GenreAlias", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany("Aliases") + .HasForeignKey("GenreId") + .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.Library.ShowGenre", b => + { + b.HasOne("TeleWave.Domain.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("TeleWave.Domain.Library.Show", null) + .WithMany("Genres") + .HasForeignKey("ShowId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscription", b => + { + b.HasOne("TeleWave.Domain.Notifications.TelegramSubscriber", null) + .WithMany("Subscriptions") + .HasForeignKey("SubscriberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.HasOne("TeleWave.Domain.Programming.ScheduleTemplate", null) + .WithMany("Layers") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GroupItem", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany("Items") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionElement", b => + { + b.HasOne("TeleWave.Domain.Broadcast.BumperTemplate", null) + .WithMany() + .HasForeignKey("BumperTemplateId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Broadcast.BumperTextVariant", null) + .WithMany() + .HasForeignKey("BumperVariantId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.JunctionTemplate", null) + .WithMany("Elements") + .HasForeignKey("JunctionTemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Slot", b => + { + b.HasOne("TeleWave.Domain.Programming.Group", null) + .WithMany() + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("TeleWave.Domain.Programming.GridLayer", null) + .WithMany("Slots") + .HasForeignKey("LayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.SlotState", b => + { + b.HasOne("TeleWave.Domain.Programming.Slot", null) + .WithOne() + .HasForeignKey("TeleWave.Domain.Programming.SlotState", "SlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeleWave.Domain.Broadcast.BumperTemplate", b => + { + b.Navigation("Variants"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Collection", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Genre", b => + { + b.Navigation("Aliases"); + }); + + modelBuilder.Entity("TeleWave.Domain.Library.Show", b => + { + b.Navigation("Episodes"); + + b.Navigation("Genres"); + }); + + modelBuilder.Entity("TeleWave.Domain.Notifications.TelegramSubscriber", b => + { + b.Navigation("Subscriptions"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.GridLayer", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.Group", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.JunctionTemplate", b => + { + b.Navigation("Elements"); + }); + + modelBuilder.Entity("TeleWave.Domain.Programming.ScheduleTemplate", b => + { + b.Navigation("Layers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.cs b/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.cs new file mode 100644 index 0000000..bf7202f --- /dev/null +++ b/backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.cs @@ -0,0 +1,77 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeleWave.Infrastructure.Migrations +{ + /// + public partial class ScheduleEntryDiscontinuityIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DiscontinuityIndex", + table: "ScheduleEntries", + type: "bigint", + nullable: false, + defaultValue: 0L + ); + + // Пересчёт накопительного номера склейки для уже материализованной ленты: без него будущие + // записи остались бы с нулём, и live-раздача давала бы неверный DISCONTINUITY-SEQUENCE, пока + // хвост не пересоберётся. Правило один в один с WriteEntriesAsync: смена ассета +1, разрыв во + // времени (крутится филлер: вход и выход) +2, первая запись канала — 0. + migrationBuilder.Sql( + """ + WITH ordered AS ( + SELECT + "Id", + "MediaAssetId", + LAG("MediaAssetId") OVER w AS prev_asset, + LAG("EndsAtUtc") OVER w AS prev_end, + "StartsAtUtc", + "EndsAtUtc", + "ChannelId" + FROM "ScheduleEntries" + WINDOW w AS (PARTITION BY "ChannelId" ORDER BY "StartsAtUtc", "EndsAtUtc") + ), + bumped AS ( + SELECT + "Id", + "ChannelId", + "StartsAtUtc", + "EndsAtUtc", + CASE + WHEN prev_asset IS NULL THEN 0 + WHEN prev_end < "StartsAtUtc" THEN 2 + WHEN prev_asset <> "MediaAssetId" THEN 1 + ELSE 0 + END AS bump + FROM ordered + ), + indexed AS ( + SELECT + "Id", + SUM(bump) OVER ( + PARTITION BY "ChannelId" + ORDER BY "StartsAtUtc", "EndsAtUtc" + ROWS UNBOUNDED PRECEDING + ) AS idx + FROM bumped + ) + UPDATE "ScheduleEntries" e + SET "DiscontinuityIndex" = i.idx + FROM indexed i + WHERE e."Id" = i."Id"; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "DiscontinuityIndex", table: "ScheduleEntries"); + } + } +} diff --git a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 5e278c2..e168959 100644 --- a/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/TeleWave.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -380,6 +380,9 @@ namespace TeleWave.Infrastructure.Migrations b.Property("CollectionId") .HasColumnType("uuid"); + b.Property("DiscontinuityIndex") + .HasColumnType("bigint"); + b.Property("EndsAtUtc") .HasColumnType("timestamp with time zone"); diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs index 2f273cc..2e6cab2 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramBotTests.cs @@ -54,7 +54,8 @@ public class TelegramBotTests ScheduleEntryKind.Program, start, start.AddMinutes(minutes), - new ScheduleEntryOrigin(showId, 0, null, null, null) + new ScheduleEntryOrigin(showId, 0, null, null, null), + 0 ); [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs index 0edb2b3..cf7b82e 100644 --- a/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Notifications/TelegramNotifierTests.cs @@ -49,7 +49,8 @@ public class TelegramNotifierTests ScheduleEntryKind.Program, start, start.AddMinutes(minutes), - new ScheduleEntryOrigin(showId, 0, null, null, null) + new ScheduleEntryOrigin(showId, 0, null, null, null), + 0 ); [Fact] diff --git a/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs index 296fe4a..441f656 100644 --- a/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs +++ b/backend/tests/TeleWave.Application.Tests/Programming/EntryTraceTests.cs @@ -92,7 +92,8 @@ public class EntryTraceTests slot.Id, JsonSerializer.Serialize(trace, TraceJson), collection.Id - ) + ), + 0 ); await using (var seed = fixture.New()) @@ -150,7 +151,8 @@ public class EntryTraceTests ScheduleEntryKind.Program, T0, T0.AddMinutes(90), - new ScheduleEntryOrigin(show.Id, 1, null, traceJson) + new ScheduleEntryOrigin(show.Id, 1, null, traceJson), + 0 ); await using (var seed = fixture.New()) @@ -200,7 +202,8 @@ public class EntryTraceTests ScheduleEntryKind.Program, T0, T0.AddMinutes(30), - new ScheduleEntryOrigin(null, null, null, JsonSerializer.Serialize(trace, TraceJson)) + new ScheduleEntryOrigin(null, null, null, JsonSerializer.Serialize(trace, TraceJson)), + 0 ); await using (var seed = fixture.New()) diff --git a/backend/tests/TeleWave.Domain.Tests/Broadcast/LiveWindowCalculatorTests.cs b/backend/tests/TeleWave.Domain.Tests/Broadcast/LiveWindowCalculatorTests.cs index d72e31a..2d22756 100644 --- a/backend/tests/TeleWave.Domain.Tests/Broadcast/LiveWindowCalculatorTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Broadcast/LiveWindowCalculatorTests.cs @@ -27,7 +27,7 @@ public class LiveWindowCalculatorTests public void SingleEntry_ProducesWindowWithMonotonicMediaSequence() { var asset = Guid.NewGuid(); - var entries = new List { new(Epoch, Epoch.AddSeconds(100), asset, 50) }; + var entries = new List { new(Epoch, Epoch.AddSeconds(100), asset, 50, 3) }; // now = epoch + 20s → currentIndex = 10, окно [6..10]. var playlist = LiveWindowCalculator.Build(Input(20, entries)); @@ -38,6 +38,8 @@ public class LiveWindowCalculatorTests Assert.Equal([6, 7, 8, 9, 10], playlist.Segments.Select(s => s.LocalIndex)); Assert.All(playlist.Segments, s => Assert.Equal(asset, s.AssetId)); Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity); + // Окно целиком внутри записи — DISCONTINUITY-SEQUENCE равен её накопительному номеру. + Assert.Equal(3, playlist.DiscontinuitySequence); } [Fact] @@ -47,8 +49,8 @@ public class LiveWindowCalculatorTests b = Guid.NewGuid(); var entries = new List { - new(Epoch, Epoch.AddSeconds(16), a, 8), // g0..7 - new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12), // g8.. + new(Epoch, Epoch.AddSeconds(16), a, 8, 5), // g0..7 + new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12, 6), // g8.. }; var playlist = LiveWindowCalculator.Build(Input(20, entries)); @@ -60,6 +62,8 @@ public class LiveWindowCalculatorTests [false, false, true, false, false], playlist.Segments.Select(s => s.Discontinuity) ); + // Первый сегмент окна принадлежит A — база берётся из его номера. + Assert.Equal(5, playlist.DiscontinuitySequence); } [Fact] @@ -73,6 +77,8 @@ public class LiveWindowCalculatorTests Assert.Equal(5, playlist.Segments.Count); Assert.Equal([2, 3, 0, 1, 2], playlist.Segments.Select(s => s.LocalIndex)); Assert.All(playlist.Segments, s => Assert.Equal(filler.AssetId, s.AssetId)); + // Чисто-филлерное окно склеек не содержит — DISCONTINUITY-SEQUENCE равен нулю. + Assert.Equal(0, playlist.DiscontinuitySequence); } [Fact] @@ -81,7 +87,7 @@ public class LiveWindowCalculatorTests var b = Guid.NewGuid(); var entries = new List { - new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12), + new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12, 4), }; // Окно [6..10]: g6,g7 без записи и без филлера → отброшены, старт с g8. @@ -91,6 +97,35 @@ public class LiveWindowCalculatorTests Assert.Equal(3, playlist.Segments.Count); Assert.Equal([0, 1, 2], playlist.Segments.Select(s => s.LocalIndex)); Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity); + Assert.Equal(4, playlist.DiscontinuitySequence); + } + + [Fact] + public void FillerBetweenEntries_KeepsDiscontinuitySequenceConsistentAsWindowSlides() + { + // A g0..4, разрыв g5 (крутится филлер), B с g6. Номера записей отстоят на 2 (вход-в-филлер + + // выход-из-него) — ровно столько склеек и вставит калькулятор между ними. + Guid a = Guid.NewGuid(), + b = Guid.NewGuid(); + var entries = new List + { + new(Epoch, Epoch.AddSeconds(10), a, 5, 10), // g0..4 + new(Epoch.AddSeconds(12), Epoch.AddSeconds(40), b, 14, 12), // g6.. + }; + var filler = new LiveFiller(Guid.NewGuid(), 4); + + // Окно [4..8]: хвост A, филлер, голова B — база отматывается к номеру A. + var mixed = LiveWindowCalculator.Build(Input(16, entries, filler)); + Assert.Equal(10, mixed.DiscontinuitySequence); + Assert.Equal( + [false, true, true, false, false], + mixed.Segments.Select(s => s.Discontinuity) + ); + + // Окно [6..10]: только B — обе склейки (A→филлер, филлер→B) ушли за край, +2 к номеру. + var afterwards = LiveWindowCalculator.Build(Input(20, entries, filler)); + Assert.Equal(12, afterwards.DiscontinuitySequence); + Assert.All(afterwards.Segments, s => Assert.Equal(b, s.AssetId)); } [Fact] diff --git a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs index 4dabc80..05fb4c2 100644 --- a/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs +++ b/backend/tests/TeleWave.Integration.Tests/TemplateOperationsIntegrationTests.cs @@ -211,7 +211,8 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture) ScheduleEntryKind.Program, from, to, - new ScheduleEntryOrigin(null, null, null, null, null) + new ScheduleEntryOrigin(null, null, null, null, null), + 0 ) ); diff --git a/docs/media-storage-and-streaming.md b/docs/media-storage-and-streaming.md index 9555209..174cef7 100644 --- a/docs/media-storage-and-streaming.md +++ b/docs/media-storage-and-streaming.md @@ -162,6 +162,16 @@ BuildLiveWindow(расписание, филлер, now, windowSegments, segment UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер подставляется только при пустом расписании. +**`EXT-X-DISCONTINUITY-SEQUENCE` обязателен.** Плейлист — скользящее окно, и когда тег +`EXT-X-DISCONTINUITY` уезжает за его верхний край, плеер должен по-прежнему знать абсолютный номер +текущей склейки. Без него ffmpeg-based клиенты (Jellyfin) сбивают счёт и встают на стыке серий (в +браузере hls.js терпим и пересчитывает сам). Считать число склеек от эпохи на каждый запрос нечем — +поэтому накопительный номер склейки `DiscontinuityIndex` **материализуется на каждой записи** +планировщиком (`WriteEntriesAsync`, продолжая счёт от уцелевшего хвоста: смена ассета +1, разрыв во +времени с филлером +2). Калькулятор берёт номер записи первого сегмента окна и «отматывает» его назад +по тегам `EXT-X-DISCONTINUITY` внутри окна — так один и тот же физический сегмент сообщает один и тот +же `DISCONTINUITY-SEQUENCE` на каждой перезагрузке. + **Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам: браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу,