Enhance configuration and routing for streaming features: update .env.example and appsettings.json to include new scheduler and streaming options, add streaming endpoint mapping in Program.cs, and integrate streaming services in DependencyInjection. Introduce segment path resolution in MediaPathResolver for asset management.

This commit is contained in:
Leonid Pershin
2026-07-24 16:07:37 +03:00
parent 4fa9dae37f
commit 1bbfd15907
18 changed files with 686 additions and 0 deletions
@@ -0,0 +1,81 @@
namespace TeleWave.Domain.Broadcast.Live;
/// <summary>
/// Чистая математика скользящего окна HLS-live. По текущему времени, эпохе канала и материализованному
/// расписанию собирает последние N сегментов до «живого края», расставляя DISCONTINUITY на стыках
/// разных ассетов. Всё выровнено на длину сегмента (длительности ассетов кратны ей), поэтому
/// арифметика целочисленная, а `MEDIA-SEQUENCE = floor((now - epoch)/seg)` монотонен по построению.
///
/// Без БД и ФС — юнит-тестируемо (см. LiveWindowCalculatorTests).
/// </summary>
public static class LiveWindowCalculator
{
public static LivePlaylist Build(LiveInput input)
{
var seg = input.SegmentSeconds;
if (seg <= 0)
return new LivePlaylist(0, Math.Max(seg, 1), []);
var elapsed = (input.Now - input.Epoch).TotalSeconds;
if (elapsed < 0)
return new LivePlaylist(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)?>();
for (var g = windowStart; g <= currentIndex; g++)
{
var segTime = input.Epoch + TimeSpan.FromSeconds(g * seg);
resolved.Add(Resolve(segTime, g, seg, input));
}
// Оставляем самый длинный суффикс подряд разрешённых сегментов у живого края
// (ведущие дыры отбрасываем, сдвигая MEDIA-SEQUENCE).
var startIdx = 0;
for (var i = 0; i < resolved.Count; i++)
if (resolved[i] is null)
startIdx = i + 1;
if (startIdx >= resolved.Count)
return new LivePlaylist(currentIndex + 1, seg, []);
var mediaSequence = windowStart + startIdx;
var segments = new List<LiveSegment>();
Guid? previousAsset = null;
for (var i = startIdx; i < resolved.Count; i++)
{
var (asset, local) = resolved[i]!.Value;
var discontinuity = previousAsset.HasValue && previousAsset.Value != asset;
segments.Add(new LiveSegment(asset, local, discontinuity));
previousAsset = asset;
}
return new LivePlaylist(mediaSequence, seg, segments);
}
private static (Guid Asset, int Local)? Resolve(
DateTimeOffset segTime,
long globalIndex,
int seg,
LiveInput input
)
{
var entry = input.Entries.FirstOrDefault(e => segTime >= e.StartsAtUtc && segTime < e.EndsAtUtc);
if (entry is not null)
{
var local = (int)Math.Floor((segTime - entry.StartsAtUtc).TotalSeconds / seg);
if (local >= 0 && local < entry.SegmentCount)
return (entry.MediaAssetId, local);
}
if (input.Filler is { SegmentCount: > 0 } filler)
{
var local = (int)(((globalIndex % filler.SegmentCount) + filler.SegmentCount) % filler.SegmentCount);
return (filler.AssetId, local);
}
return null;
}
}
@@ -0,0 +1,30 @@
namespace TeleWave.Domain.Broadcast.Live;
/// <summary>Запись расписания в терминах live-калькулятора (только нужное для нарезки окна).</summary>
public sealed record LiveEntry(
DateTimeOffset StartsAtUtc,
DateTimeOffset EndsAtUtc,
Guid MediaAssetId,
int SegmentCount
);
/// <summary>Ассет-заглушка для дыр в расписании (крутится по кругу).</summary>
public sealed record LiveFiller(Guid AssetId, int SegmentCount);
public sealed record LiveInput(
DateTimeOffset Now,
DateTimeOffset Epoch,
int SegmentSeconds,
int WindowSegments,
IReadOnlyList<LiveEntry> Entries,
LiveFiller? Filler
);
/// <summary>Сегмент в окне: какой ассет, локальный индекс сегмента, нужен ли перед ним DISCONTINUITY.</summary>
public sealed record LiveSegment(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylist(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegment> Segments
);