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