From 0f609db4e9af5e8042752b303a3d127a3f1de21f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Tue, 4 Aug 2026 12:43:35 +0300 Subject: [PATCH] Implement Discontinuity Index for Live Streaming Enhancements Added a DiscontinuityIndex property to ScheduleEntry and related DTOs to track the cumulative number of discontinuities in live streaming. Updated the LiveWindowCalculator to utilize this index for generating accurate EXT-X-DISCONTINUITY-SEQUENCE values, ensuring compatibility with ffmpeg-based players. Enhanced the WriteEntriesAsync method to calculate and persist the discontinuity index during entry creation. Updated relevant tests and documentation to reflect these changes, improving the reliability of live streaming functionality. --- .../Endpoints/StreamingEndpoints.cs | 6 + .../Planning/GridScheduleGenerator.cs | 48 +- .../GetLivePlaylistQueryHandler.cs | 5 +- .../Streaming/StreamingDtos.cs | 1 + .../Broadcast/Live/LiveWindowCalculator.cs | 38 +- .../Broadcast/Live/LiveWindowModels.cs | 8 +- .../Broadcast/ScheduleEntry.cs | 12 +- ...cheduleEntryDiscontinuityIndex.Designer.cs | 1608 +++++++++++++++++ ...4084450_ScheduleEntryDiscontinuityIndex.cs | 77 + .../Migrations/AppDbContextModelSnapshot.cs | 3 + .../Notifications/TelegramBotTests.cs | 3 +- .../Notifications/TelegramNotifierTests.cs | 3 +- .../Programming/EntryTraceTests.cs | 9 +- .../Broadcast/LiveWindowCalculatorTests.cs | 43 +- .../TemplateOperationsIntegrationTests.cs | 3 +- docs/media-storage-and-streaming.md | 10 + 16 files changed, 1851 insertions(+), 26 deletions(-) create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.Designer.cs create mode 100644 backend/src/TeleWave.Infrastructure/Migrations/20260804084450_ScheduleEntryDiscontinuityIndex.cs 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 не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу,