Implement Discontinuity Index for Live Streaming Enhancements
ci / build-backend (push) Successful in 2m17s
ci / build-frontend (push) Successful in 53s
ci / tests (push) Successful in 1m39s
ci / sonar (push) Failing after 1m23s

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.
This commit is contained in:
Leonid Pershin
2026-08-04 12:43:35 +03:00
parent 7a164dbd90
commit 0f609db4e9
16 changed files with 1851 additions and 26 deletions
@@ -188,6 +188,12 @@ public static class StreamingEndpoints
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
$"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n" $"#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) foreach (var segment in playlist.Segments)
{ {
@@ -134,7 +134,12 @@ public sealed class GridScheduleGenerator(
cancellationToken cancellationToken
); );
var added = WriteEntries(channel.Id, result.Items, bumperAssets); var added = await WriteEntriesAsync(
channel.Id,
result.Items,
bumperAssets,
cancellationToken
);
await SaveCursorsAsync(result.Cursors, cancellationToken); await SaveCursorsAsync(result.Cursors, cancellationToken);
@@ -233,13 +238,37 @@ public sealed class GridScheduleGenerator(
/// <summary> /// <summary>
/// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета /// Переносит собранную ленту в записи расписания. Заставка без отрендеренного ассета
/// пропускается: запись без файла стала бы дырой в эфире. /// пропускается: запись без файла стала бы дырой в эфире.
///
/// Заодно проставляет накопительный <see cref="ScheduleEntry.DiscontinuityIndex"/>, продолжая счёт
/// от уцелевшего хвоста ленты: из него live-раздача выводит <c>EXT-X-DISCONTINUITY-SEQUENCE</c>.
/// Инкремент считается ровно так же, как склейку видит live-калькулятор — по смене ассета; разрыв
/// во времени (пропущенная заставка, отставший планировщик) даёт +2: вход-в-филлер и выход-из-него.
/// </summary> /// </summary>
private int WriteEntries( private async Task<int> WriteEntriesAsync(
Guid channelId, Guid channelId,
IReadOnlyList<PlannedItem> items, IReadOnlyList<PlannedItem> items,
IReadOnlyDictionary<int, Guid> bumperAssets IReadOnlyDictionary<int, Guid> 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; var added = 0;
for (var index = 0; index < items.Count; index++) for (var index = 0; index < items.Count; index++)
@@ -252,6 +281,14 @@ public sealed class GridScheduleGenerator(
) )
continue; continue;
if (previousAsset is { } prevAsset)
{
if (previousEnd is { } prevEnd && prevEnd < item.StartsAtUtc)
discontinuityIndex += 2; // между записями крутится филлер — две склейки
else if (prevAsset != assetId)
discontinuityIndex += 1;
}
dbContext.ScheduleEntries.Add( dbContext.ScheduleEntries.Add(
ScheduleEntry.FromSlot( ScheduleEntry.FromSlot(
channelId, channelId,
@@ -267,9 +304,12 @@ public sealed class GridScheduleGenerator(
? null ? null
: JsonSerializer.Serialize(item.Trace, TraceJsonOptions), : JsonSerializer.Serialize(item.Trace, TraceJsonOptions),
item.CollectionId item.CollectionId
) ),
discontinuityIndex
) )
); );
previousAsset = assetId;
previousEnd = item.EndsAtUtc;
added++; added++;
} }
@@ -52,6 +52,7 @@ public sealed class GetLivePlaylistQueryHandler(
e.StartsAtUtc, e.StartsAtUtc,
e.EndsAtUtc, e.EndsAtUtc,
e.MediaAssetId, e.MediaAssetId,
e.DiscontinuityIndex,
}) })
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -75,7 +76,8 @@ public sealed class GetLivePlaylistQueryHandler(
e.StartsAtUtc, e.StartsAtUtc,
e.EndsAtUtc, e.EndsAtUtc,
e.MediaAssetId, e.MediaAssetId,
segmentCounts[e.MediaAssetId] segmentCounts[e.MediaAssetId],
e.DiscontinuityIndex
)) ))
.ToList(); .ToList();
@@ -92,6 +94,7 @@ public sealed class GetLivePlaylistQueryHandler(
var dto = new LivePlaylistDto( var dto = new LivePlaylistDto(
playlist.MediaSequence, playlist.MediaSequence,
playlist.DiscontinuitySequence,
playlist.TargetDuration, playlist.TargetDuration,
playlist playlist
.Segments.Select(s => new LiveSegmentDto(s.AssetId, s.LocalIndex, s.Discontinuity)) .Segments.Select(s => new LiveSegmentDto(s.AssetId, s.LocalIndex, s.Discontinuity))
@@ -37,6 +37,7 @@ public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontin
public sealed record LivePlaylistDto( public sealed record LivePlaylistDto(
long MediaSequence, long MediaSequence,
long DiscontinuitySequence,
int TargetDuration, int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments IReadOnlyList<LiveSegmentDto> Segments
); );
@@ -6,6 +6,12 @@ namespace TeleWave.Domain.Broadcast.Live;
/// разных ассетов. Всё выровнено на длину сегмента (длительности ассетов кратны ей), поэтому /// разных ассетов. Всё выровнено на длину сегмента (длительности ассетов кратны ей), поэтому
/// арифметика целочисленная, а `MEDIA-SEQUENCE = floor((now - epoch)/seg)` монотонен по построению. /// арифметика целочисленная, а `MEDIA-SEQUENCE = floor((now - epoch)/seg)` монотонен по построению.
/// ///
/// Помимо MEDIA-SEQUENCE выдаёт DISCONTINUITY-SEQUENCE — абсолютный номер склейки первого сегмента окна.
/// Он берётся из накопительного индекса записи (<see cref="LiveEntry.DiscontinuityIndex"/>) и
/// «отматывается назад» по тегам DISCONTINUITY внутри окна, поэтому один и тот же физический сегмент
/// сообщает один и тот же номер склейки на каждой перезагрузке плейлиста. Без этого ffmpeg-based плееры
/// сбиваются, когда тег DISCONTINUITY уезжает за верхний край окна.
///
/// Без БД и ФС — юнит-тестируемо (см. LiveWindowCalculatorTests). /// Без БД и ФС — юнит-тестируемо (см. LiveWindowCalculatorTests).
/// </summary> /// </summary>
public static class LiveWindowCalculator public static class LiveWindowCalculator
@@ -14,17 +20,19 @@ public static class LiveWindowCalculator
{ {
var seg = input.SegmentSeconds; var seg = input.SegmentSeconds;
if (seg <= 0) 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; var elapsed = (input.Now - input.Epoch).TotalSeconds;
if (elapsed < 0) if (elapsed < 0)
return new LivePlaylist(0, seg, []); // эфир ещё не начался return new LivePlaylist(0, 0, seg, []); // эфир ещё не начался
var currentIndex = (long)Math.Floor(elapsed / seg); var currentIndex = (long)Math.Floor(elapsed / seg);
var windowStart = Math.Max(0, currentIndex - input.WindowSegments + 1); var windowStart = Math.Max(0, currentIndex - input.WindowSegments + 1);
// Резолвим каждый глобальный сегмент; неразрешённые (дыра без филлера) — null. // Резолвим каждый глобальный сегмент; неразрешённые (дыра без филлера) — 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++) for (var g = windowStart; g <= currentIndex; g++)
{ {
var segTime = input.Epoch + TimeSpan.FromSeconds(g * seg); var segTime = input.Epoch + TimeSpan.FromSeconds(g * seg);
@@ -39,23 +47,35 @@ public static class LiveWindowCalculator
startIdx = i + 1; startIdx = i + 1;
if (startIdx >= resolved.Count) if (startIdx >= resolved.Count)
return new LivePlaylist(currentIndex + 1, seg, []); return new LivePlaylist(currentIndex + 1, 0, seg, []);
var mediaSequence = windowStart + startIdx; var mediaSequence = windowStart + startIdx;
var segments = new List<LiveSegment>(); var segments = new List<LiveSegment>();
// DISCONTINUITY-SEQUENCE — номер склейки ПЕРВОГО сегмента окна. Внутри записи склеек нет
// (один ассет), поэтому абсолютный номер сегмента = base + число тегов DISCONTINUITY перед ним
// в окне. Отсюда base = (известный номер первой попавшейся записи) − (тегов до неё): так первый
// сегмент получает базу, согласованную между перезагрузками. У филлера номера нет — база
// восстанавливается от ближайшей записи в окне; чисто-филлерное окно склеек не содержит вовсе.
long? baseSequence = null;
long tagsSoFar = 0;
Guid? previousAsset = null; Guid? previousAsset = null;
for (var i = startIdx; i < resolved.Count; i++) 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; var discontinuity = previousAsset.HasValue && previousAsset.Value != asset;
if (discontinuity)
tagsSoFar++;
segments.Add(new LiveSegment(asset, local, discontinuity)); segments.Add(new LiveSegment(asset, local, discontinuity));
if (baseSequence is null && discont is { } di)
baseSequence = di - tagsSoFar;
previousAsset = asset; 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, DateTimeOffset segTime,
long globalIndex, long globalIndex,
int seg, int seg,
@@ -69,7 +89,7 @@ public static class LiveWindowCalculator
{ {
var local = (int)Math.Floor((segTime - entry.StartsAtUtc).TotalSeconds / seg); var local = (int)Math.Floor((segTime - entry.StartsAtUtc).TotalSeconds / seg);
if (local >= 0 && local < entry.SegmentCount) if (local >= 0 && local < entry.SegmentCount)
return (entry.MediaAssetId, local); return (entry.MediaAssetId, local, entry.DiscontinuityIndex);
} }
if (input.Filler is { SegmentCount: > 0 } filler) if (input.Filler is { SegmentCount: > 0 } filler)
@@ -77,7 +97,7 @@ public static class LiveWindowCalculator
var local = (int)( var local = (int)(
((globalIndex % filler.SegmentCount) + filler.SegmentCount) % filler.SegmentCount ((globalIndex % filler.SegmentCount) + filler.SegmentCount) % filler.SegmentCount
); );
return (filler.AssetId, local); return (filler.AssetId, local, null);
} }
return null; return null;
@@ -1,11 +1,16 @@
namespace TeleWave.Domain.Broadcast.Live; namespace TeleWave.Domain.Broadcast.Live;
/// <summary>Запись расписания в терминах live-калькулятора (только нужное для нарезки окна).</summary> /// <summary>Запись расписания в терминах live-калькулятора (только нужное для нарезки окна).</summary>
/// <param name="DiscontinuityIndex">Накопительный номер склейки этой записи — число разрывов ленты от
/// эпохи канала до её первого сегмента (материализуется планировщиком). Из него выводится
/// <c>EXT-X-DISCONTINUITY-SEQUENCE</c>: без него ffmpeg-based плееры (Jellyfin) теряют счёт склеек,
/// когда тег <c>EXT-X-DISCONTINUITY</c> уезжает за верхний край скользящего окна, и встают на стыке.</param>
public sealed record LiveEntry( public sealed record LiveEntry(
DateTimeOffset StartsAtUtc, DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc, DateTimeOffset EndsAtUtc,
Guid MediaAssetId, Guid MediaAssetId,
int SegmentCount int SegmentCount,
long DiscontinuityIndex
); );
/// <summary>Ассет-заглушка для дыр в расписании (крутится по кругу).</summary> /// <summary>Ассет-заглушка для дыр в расписании (крутится по кругу).</summary>
@@ -25,6 +30,7 @@ public sealed record LiveSegment(Guid AssetId, int LocalIndex, bool Discontinuit
public sealed record LivePlaylist( public sealed record LivePlaylist(
long MediaSequence, long MediaSequence,
long DiscontinuitySequence,
int TargetDuration, int TargetDuration,
IReadOnlyList<LiveSegment> Segments IReadOnlyList<LiveSegment> Segments
); );
@@ -13,6 +13,14 @@ public class ScheduleEntry
public DateTimeOffset StartsAtUtc { get; private set; } public DateTimeOffset StartsAtUtc { get; private set; }
public DateTimeOffset EndsAtUtc { get; private set; } public DateTimeOffset EndsAtUtc { get; private set; }
/// <summary>
/// Накопительный номер склейки: число разрывов эфирной ленты (смен ассета) от эпохи канала до
/// первого сегмента этой записи. Считается при материализации, продолжаясь от предыдущей записи.
/// Из него live-раздача выводит <c>EXT-X-DISCONTINUITY-SEQUENCE</c> — без него ffmpeg-плееры
/// (Jellyfin) встают на стыке серий, когда тег склейки уходит за край скользящего окна.
/// </summary>
public long DiscontinuityIndex { get; private set; }
/// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary> /// <summary>Шоу (для <see cref="ScheduleEntryKind.Program"/>) — для EPG.</summary>
public Guid? ShowId { get; private set; } public Guid? ShowId { get; private set; }
@@ -47,7 +55,8 @@ public class ScheduleEntry
ScheduleEntryKind kind, ScheduleEntryKind kind,
DateTimeOffset startsAtUtc, DateTimeOffset startsAtUtc,
DateTimeOffset endsAtUtc, DateTimeOffset endsAtUtc,
ScheduleEntryOrigin origin ScheduleEntryOrigin origin,
long discontinuityIndex
) => ) =>
new() new()
{ {
@@ -62,6 +71,7 @@ public class ScheduleEntry
SlotId = origin.SlotId, SlotId = origin.SlotId,
TraceJson = origin.TraceJson, TraceJson = origin.TraceJson,
CollectionId = origin.CollectionId, CollectionId = origin.CollectionId,
DiscontinuityIndex = discontinuityIndex,
}; };
public static ScheduleEntry Program( public static ScheduleEntry Program(
@@ -0,0 +1,77 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeleWave.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class ScheduleEntryDiscontinuityIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
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";
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "DiscontinuityIndex", table: "ScheduleEntries");
}
}
}
@@ -380,6 +380,9 @@ namespace TeleWave.Infrastructure.Migrations
b.Property<Guid?>("CollectionId") b.Property<Guid?>("CollectionId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<long>("DiscontinuityIndex")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("EndsAtUtc") b.Property<DateTimeOffset>("EndsAtUtc")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -54,7 +54,8 @@ public class TelegramBotTests
ScheduleEntryKind.Program, ScheduleEntryKind.Program,
start, start,
start.AddMinutes(minutes), start.AddMinutes(minutes),
new ScheduleEntryOrigin(showId, 0, null, null, null) new ScheduleEntryOrigin(showId, 0, null, null, null),
0
); );
[Fact] [Fact]
@@ -49,7 +49,8 @@ public class TelegramNotifierTests
ScheduleEntryKind.Program, ScheduleEntryKind.Program,
start, start,
start.AddMinutes(minutes), start.AddMinutes(minutes),
new ScheduleEntryOrigin(showId, 0, null, null, null) new ScheduleEntryOrigin(showId, 0, null, null, null),
0
); );
[Fact] [Fact]
@@ -92,7 +92,8 @@ public class EntryTraceTests
slot.Id, slot.Id,
JsonSerializer.Serialize(trace, TraceJson), JsonSerializer.Serialize(trace, TraceJson),
collection.Id collection.Id
) ),
0
); );
await using (var seed = fixture.New()) await using (var seed = fixture.New())
@@ -150,7 +151,8 @@ public class EntryTraceTests
ScheduleEntryKind.Program, ScheduleEntryKind.Program,
T0, T0,
T0.AddMinutes(90), T0.AddMinutes(90),
new ScheduleEntryOrigin(show.Id, 1, null, traceJson) new ScheduleEntryOrigin(show.Id, 1, null, traceJson),
0
); );
await using (var seed = fixture.New()) await using (var seed = fixture.New())
@@ -200,7 +202,8 @@ public class EntryTraceTests
ScheduleEntryKind.Program, ScheduleEntryKind.Program,
T0, T0,
T0.AddMinutes(30), 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()) await using (var seed = fixture.New())
@@ -27,7 +27,7 @@ public class LiveWindowCalculatorTests
public void SingleEntry_ProducesWindowWithMonotonicMediaSequence() public void SingleEntry_ProducesWindowWithMonotonicMediaSequence()
{ {
var asset = Guid.NewGuid(); var asset = Guid.NewGuid();
var entries = new List<LiveEntry> { new(Epoch, Epoch.AddSeconds(100), asset, 50) }; var entries = new List<LiveEntry> { new(Epoch, Epoch.AddSeconds(100), asset, 50, 3) };
// now = epoch + 20s → currentIndex = 10, окно [6..10]. // now = epoch + 20s → currentIndex = 10, окно [6..10].
var playlist = LiveWindowCalculator.Build(Input(20, entries)); 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.Equal([6, 7, 8, 9, 10], playlist.Segments.Select(s => s.LocalIndex));
Assert.All(playlist.Segments, s => Assert.Equal(asset, s.AssetId)); Assert.All(playlist.Segments, s => Assert.Equal(asset, s.AssetId));
Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity); Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity);
// Окно целиком внутри записи — DISCONTINUITY-SEQUENCE равен её накопительному номеру.
Assert.Equal(3, playlist.DiscontinuitySequence);
} }
[Fact] [Fact]
@@ -47,8 +49,8 @@ public class LiveWindowCalculatorTests
b = Guid.NewGuid(); b = Guid.NewGuid();
var entries = new List<LiveEntry> var entries = new List<LiveEntry>
{ {
new(Epoch, Epoch.AddSeconds(16), a, 8), // g0..7 new(Epoch, Epoch.AddSeconds(16), a, 8, 5), // g0..7
new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12), // g8.. new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12, 6), // g8..
}; };
var playlist = LiveWindowCalculator.Build(Input(20, entries)); var playlist = LiveWindowCalculator.Build(Input(20, entries));
@@ -60,6 +62,8 @@ public class LiveWindowCalculatorTests
[false, false, true, false, false], [false, false, true, false, false],
playlist.Segments.Select(s => s.Discontinuity) playlist.Segments.Select(s => s.Discontinuity)
); );
// Первый сегмент окна принадлежит A — база берётся из его номера.
Assert.Equal(5, playlist.DiscontinuitySequence);
} }
[Fact] [Fact]
@@ -73,6 +77,8 @@ public class LiveWindowCalculatorTests
Assert.Equal(5, playlist.Segments.Count); Assert.Equal(5, playlist.Segments.Count);
Assert.Equal([2, 3, 0, 1, 2], playlist.Segments.Select(s => s.LocalIndex)); Assert.Equal([2, 3, 0, 1, 2], playlist.Segments.Select(s => s.LocalIndex));
Assert.All(playlist.Segments, s => Assert.Equal(filler.AssetId, s.AssetId)); Assert.All(playlist.Segments, s => Assert.Equal(filler.AssetId, s.AssetId));
// Чисто-филлерное окно склеек не содержит — DISCONTINUITY-SEQUENCE равен нулю.
Assert.Equal(0, playlist.DiscontinuitySequence);
} }
[Fact] [Fact]
@@ -81,7 +87,7 @@ public class LiveWindowCalculatorTests
var b = Guid.NewGuid(); var b = Guid.NewGuid();
var entries = new List<LiveEntry> var entries = new List<LiveEntry>
{ {
new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12), new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12, 4),
}; };
// Окно [6..10]: g6,g7 без записи и без филлера → отброшены, старт с g8. // Окно [6..10]: g6,g7 без записи и без филлера → отброшены, старт с g8.
@@ -91,6 +97,35 @@ public class LiveWindowCalculatorTests
Assert.Equal(3, playlist.Segments.Count); Assert.Equal(3, playlist.Segments.Count);
Assert.Equal([0, 1, 2], playlist.Segments.Select(s => s.LocalIndex)); Assert.Equal([0, 1, 2], playlist.Segments.Select(s => s.LocalIndex));
Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity); 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<LiveEntry>
{
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] [Fact]
@@ -211,7 +211,8 @@ public sealed class TemplateOperationsIntegrationTests(PostgresFixture fixture)
ScheduleEntryKind.Program, ScheduleEntryKind.Program,
from, from,
to, to,
new ScheduleEntryOrigin(null, null, null, null, null) new ScheduleEntryOrigin(null, null, null, null, null),
0
) )
); );
+10
View File
@@ -162,6 +162,16 @@ BuildLiveWindow(расписание, филлер, now, windowSegments, segment
UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер UTC; локальный пояс показывает фронтенд. Раздача встык — типичный случай без филлера; филлер
подставляется только при пустом расписании. подставляется только при пустом расписании.
**`EXT-X-DISCONTINUITY-SEQUENCE` обязателен.** Плейлист — скользящее окно, и когда тег
`EXT-X-DISCONTINUITY` уезжает за его верхний край, плеер должен по-прежнему знать абсолютный номер
текущей склейки. Без него ffmpeg-based клиенты (Jellyfin) сбивают счёт и встают на стыке серий (в
браузере hls.js терпим и пересчитывает сам). Считать число склеек от эпохи на каждый запрос нечем —
поэтому накопительный номер склейки `DiscontinuityIndex` **материализуется на каждой записи**
планировщиком (`WriteEntriesAsync`, продолжая счёт от уцелевшего хвоста: смена ассета +1, разрыв во
времени с филлером +2). Калькулятор берёт номер записи первого сегмента окна и «отматывает» его назад
по тегам `EXT-X-DISCONTINUITY` внутри окна — так один и тот же физический сегмент сообщает один и тот
же `DISCONTINUITY-SEQUENCE` на каждой перезагрузке.
**Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам: **Плеер возвращается к живому краю сам.** Окно короткое, а вкладка в фоне живёт по другим правилам:
браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока браузер душит таймеры, hls.js не успевает тянуть сегменты, видео доигрывает буфер и встаёт. Пока
зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу, зритель не смотрит, окно уходит вперёд на сколько угодно — и, вернувшись, он увидел бы программу,