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,164 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using LiteCqrs;
using TeleWave.Api.Common;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Application.Streaming.GetLivePlaylist;
using TeleWave.Application.Streaming.GetPublicEpg;
using TeleWave.Application.Streaming.ListPublicChannels;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Api.Endpoints;
public static class StreamingEndpoints
{
private const string StreamCookieName = "tw_stream";
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
public static IEndpointRouteBuilder MapStreamingEndpoints(this IEndpointRouteBuilder app)
{
// Публичный API канала (Bearer): список, EPG, выдача stream-cookie.
var channels = app.MapGroup("/api/channels").WithTags("Channels").RequireAuthorization();
channels.MapGet("", ListChannels).Produces<IReadOnlyList<PublicChannelDto>>();
channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent);
channels.MapGet("/{slug}/epg", Epg);
// Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит <video>/hls.js.
app.MapGet("/api/channels/{slug}/live.m3u8", LivePlaylist).WithTags("Streaming");
app.MapGet("/api/stream/{assetId:guid}/{file}", Segment).WithTags("Streaming");
return app;
}
private static async Task<IResult> ListChannels(ISender sender, CancellationToken cancellationToken)
{
var result = await sender.Send(new ListPublicChannelsQuery(), cancellationToken);
return Results.Ok(result);
}
private static IResult Watch(
string slug,
ICurrentUser currentUser,
StreamTokenService tokens,
HttpRequest request,
HttpResponse response
)
{
if (currentUser.UserId is not { } userId)
return Results.Unauthorized();
var (token, expiresAt) = tokens.Issue(userId);
response.Cookies.Append(
StreamCookieName,
token,
new CookieOptions
{
HttpOnly = true,
Secure = request.IsHttps,
SameSite = SameSiteMode.Strict,
Path = "/api",
Expires = expiresAt,
}
);
return Results.NoContent();
}
private static async Task<IResult> Epg(
string slug,
DateTimeOffset? from,
DateTimeOffset? to,
ISender sender,
CancellationToken cancellationToken
)
{
var fromUtc = from ?? DateTimeOffset.UtcNow;
var toUtc = to ?? fromUtc.AddHours(12);
var result = await sender.Send(
new GetPublicEpgQuery(slug, fromUtc, toUtc),
cancellationToken
);
return result.ToHttpResult();
}
private static async Task<IResult> LivePlaylist(
string slug,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
ISender sender,
CancellationToken cancellationToken
)
{
if (!tokens.Validate(request.Cookies[StreamCookieName]))
return Results.Unauthorized();
var result = await sender.Send(
new GetLivePlaylistQuery(slug, DateTimeOffset.UtcNow),
cancellationToken
);
if (!result.IsSuccess)
return result.ToHttpResult();
if (result.Value.Segments.Count == 0)
return Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
response.Headers.CacheControl = "no-cache";
return Results.Text(Render(result.Value), "application/vnd.apple.mpegurl");
}
private static IResult Segment(
Guid assetId,
string file,
HttpRequest request,
HttpResponse response,
StreamTokenService tokens,
MediaPathResolver paths
)
{
if (!tokens.Validate(request.Cookies[StreamCookieName]))
return Results.Unauthorized();
if (!SegmentFileName.IsMatch(file))
return Results.NotFound();
string path;
try
{
path = paths.SegmentPath(assetId, file);
}
catch (UnauthorizedAccessException)
{
return Results.NotFound();
}
if (!File.Exists(path))
return Results.NotFound();
response.Headers.CacheControl = "public, max-age=31536000, immutable";
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
}
private static string Render(LivePlaylistDto playlist)
{
var extinf = playlist.TargetDuration.ToString("F6", CultureInfo.InvariantCulture);
var sb = new StringBuilder();
sb.Append("#EXTM3U\n");
sb.Append("#EXT-X-VERSION:3\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXT-X-TARGETDURATION:{playlist.TargetDuration}\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXT-X-MEDIA-SEQUENCE:{playlist.MediaSequence}\n");
foreach (var segment in playlist.Segments)
{
if (segment.Discontinuity)
sb.Append("#EXT-X-DISCONTINUITY\n");
sb.Append(CultureInfo.InvariantCulture, $"#EXTINF:{extinf},\n");
sb.Append(
CultureInfo.InvariantCulture,
$"/api/stream/{segment.AssetId:N}/seg{segment.LocalIndex:D5}.ts\n"
);
}
return sb.ToString();
}
}
+1
View File
@@ -115,6 +115,7 @@ app.MapAdminUserEndpoints();
app.MapMediaEndpoints();
app.MapShowEndpoints();
app.MapChannelEndpoints();
app.MapStreamingEndpoints();
// Раздача статики SPA из wwwroot + fallback на index.html для клиентских маршрутов.
app.UseDefaultFiles();
@@ -16,9 +16,15 @@
"Storage": {
"RootPath": "/media",
"SegmentSeconds": 2,
"LiveWindowSegments": 10,
"KeepOriginals": false,
"MinFreeSpaceBytes": 10737418240
},
"Scheduler": {
"HorizonDays": 3,
"RetentionHours": 24,
"TickMinutes": 30
},
"Media": {
"FfmpegPath": "ffmpeg",
"FfprobePath": "ffprobe",
@@ -0,0 +1,7 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetLivePlaylist;
public sealed record GetLivePlaylistQuery(string Slug, DateTimeOffset Now)
: IQuery<Result<LivePlaylistDto>>;
@@ -0,0 +1,91 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Broadcast.Live;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Streaming.GetLivePlaylist;
public sealed class GetLivePlaylistQueryHandler(
IAppDbContext dbContext,
IOptions<StreamingOptions> options
) : IQueryHandler<GetLivePlaylistQuery, Result<LivePlaylistDto>>
{
private readonly StreamingOptions _options = options.Value;
public async Task<Result<LivePlaylistDto>> Handle(
GetLivePlaylistQuery query,
CancellationToken cancellationToken
)
{
var channel = await dbContext.Channels.AsNoTracking()
.Where(c => c.Slug == query.Slug && c.IsEnabled)
.Select(c => new { c.Id, c.EpochUtc, c.FillerAssetId })
.FirstOrDefaultAsync(cancellationToken);
if (channel is null)
return Result.Failure<LivePlaylistDto>(ChannelErrors.NotFound);
var seg = _options.SegmentSeconds;
var window = _options.LiveWindowSegments;
// Загружаем записи, пересекающиеся с окном (с небольшим запасом назад).
var windowStartTime = query.Now.AddSeconds(-(window + 2) * seg);
var rawEntries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channel.Id
&& e.StartsAtUtc < query.Now
&& e.EndsAtUtc > windowStartTime
)
.OrderBy(e => e.StartsAtUtc)
.Select(e => new
{
e.StartsAtUtc,
e.EndsAtUtc,
e.MediaAssetId,
})
.ToListAsync(cancellationToken);
var assetIds = rawEntries.Select(e => e.MediaAssetId).ToList();
if (channel.FillerAssetId is { } fillerId)
assetIds.Add(fillerId);
var segmentCounts = await dbContext.MediaAssets.AsNoTracking()
.Where(a =>
assetIds.Contains(a.Id)
&& a.Status == MediaAssetStatus.Ready
&& a.SegmentCount != null
)
.Select(a => new { a.Id, a.SegmentCount })
.ToDictionaryAsync(a => a.Id, a => a.SegmentCount!.Value, cancellationToken);
var entries = rawEntries
.Where(e => segmentCounts.ContainsKey(e.MediaAssetId))
.Select(e => new LiveEntry(
e.StartsAtUtc,
e.EndsAtUtc,
e.MediaAssetId,
segmentCounts[e.MediaAssetId]
))
.ToList();
LiveFiller? filler = null;
if (channel.FillerAssetId is { } fid && segmentCounts.TryGetValue(fid, out var fillerSegments))
filler = new LiveFiller(fid, fillerSegments);
var playlist = LiveWindowCalculator.Build(
new LiveInput(query.Now, channel.EpochUtc, seg, window, entries, filler)
);
var dto = new LivePlaylistDto(
playlist.MediaSequence,
playlist.TargetDuration,
playlist.Segments
.Select(s => new LiveSegmentDto(s.AssetId, s.LocalIndex, s.Discontinuity))
.ToList()
);
return Result.Success(dto);
}
}
@@ -0,0 +1,8 @@
using LiteCqrs;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed record GetPublicEpgQuery(string Slug, DateTimeOffset FromUtc, DateTimeOffset ToUtc)
: IQuery<Result<IReadOnlyList<ScheduleEntryDto>>>;
@@ -0,0 +1,54 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Broadcast;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Streaming.GetPublicEpg;
public sealed class GetPublicEpgQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetPublicEpgQuery, Result<IReadOnlyList<ScheduleEntryDto>>>
{
public async Task<Result<IReadOnlyList<ScheduleEntryDto>>> Handle(
GetPublicEpgQuery query,
CancellationToken cancellationToken
)
{
var channelId = await dbContext.Channels.AsNoTracking()
.Where(c => c.Slug == query.Slug && c.IsEnabled)
.Select(c => (Guid?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
if (channelId is null)
return Result.Failure<IReadOnlyList<ScheduleEntryDto>>(ChannelErrors.NotFound);
var entries = await dbContext.ScheduleEntries.AsNoTracking()
.Where(e =>
e.ChannelId == channelId
&& e.StartsAtUtc < query.ToUtc
&& e.EndsAtUtc > query.FromUtc
)
.OrderBy(e => e.StartsAtUtc)
.ToListAsync(cancellationToken);
var showIds = entries.Where(e => e.ShowId != null).Select(e => e.ShowId!.Value).Distinct().ToList();
var showNames = await dbContext.Shows.AsNoTracking()
.Where(s => showIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToDictionaryAsync(s => s.Id, s => s.Name, cancellationToken);
var dtos = entries
.Select(e => new ScheduleEntryDto(
e.Id,
e.Kind,
e.MediaAssetId,
e.StartsAtUtc,
e.EndsAtUtc,
e.ShowId,
e.ShowId is { } sid ? showNames.GetValueOrDefault(sid) : null,
e.EpisodeIndex
))
.ToList();
return Result.Success<IReadOnlyList<ScheduleEntryDto>>(dtos);
}
}
@@ -0,0 +1,5 @@
using LiteCqrs;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed record ListPublicChannelsQuery : IQuery<IReadOnlyList<PublicChannelDto>>;
@@ -0,0 +1,21 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
namespace TeleWave.Application.Streaming.ListPublicChannels;
public sealed class ListPublicChannelsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListPublicChannelsQuery, IReadOnlyList<PublicChannelDto>>
{
public async Task<IReadOnlyList<PublicChannelDto>> Handle(
ListPublicChannelsQuery query,
CancellationToken cancellationToken
)
{
return await dbContext.Channels.AsNoTracking()
.Where(c => c.IsEnabled)
.OrderBy(c => c.Name)
.Select(c => new PublicChannelDto(c.Id, c.Slug, c.Name))
.ToListAsync(cancellationToken);
}
}
@@ -0,0 +1,11 @@
namespace TeleWave.Application.Streaming;
public sealed record PublicChannelDto(Guid Id, string Slug, string Name);
public sealed record LiveSegmentDto(Guid AssetId, int LocalIndex, bool Discontinuity);
public sealed record LivePlaylistDto(
long MediaSequence,
int TargetDuration,
IReadOnlyList<LiveSegmentDto> Segments
);
@@ -0,0 +1,12 @@
namespace TeleWave.Application.Streaming;
/// <summary>Параметры раздачи эфира. Биндится к секции «Storage» (общая с обработкой медиа).</summary>
public sealed class StreamingOptions
{
public const string SectionName = "Storage";
public int SegmentSeconds { get; init; } = 2;
/// <summary>Сколько сегментов держать в скользящем окне live-плейлиста.</summary>
public int LiveWindowSegments { get; init; } = 10;
}
@@ -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
);
@@ -7,11 +7,13 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.IdentityModel.Tokens;
using TeleWave.Application.Broadcast.Scheduling;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Streaming;
using TeleWave.Domain.Broadcast.Scheduling;
using TeleWave.Infrastructure.Broadcast;
using TeleWave.Infrastructure.Identity;
using TeleWave.Infrastructure.Media;
using TeleWave.Infrastructure.Persistence;
using TeleWave.Infrastructure.Streaming;
namespace TeleWave.Infrastructure;
@@ -99,8 +101,10 @@ public static class DependencyInjection
private static void AddBroadcast(IServiceCollection services, IConfiguration configuration)
{
services.Configure<SchedulerOptions>(configuration.GetSection(SchedulerOptions.SectionName));
services.Configure<StreamingOptions>(configuration.GetSection(StreamingOptions.SectionName));
services.AddSingleton<IRandomSource, SystemRandomSource>();
services.AddSingleton<StreamTokenService>();
services.AddScoped<ScheduleGenerator>();
services.AddHostedService<SchedulingBackgroundService>();
}
@@ -40,6 +40,13 @@ public sealed class MediaPathResolver
public string AssetRelativePath(Guid assetId) => $"assets/{assetId:N}";
/// <summary>Путь к файлу сегмента внутри каталога ассета (имя файла проверяется на traversal).</summary>
public string SegmentPath(Guid assetId, string fileName)
{
var assetDir = AssetDir(assetId);
return EnsureWithin(assetDir, Path.Combine(assetDir, fileName));
}
/// <summary>Резолвит имя файла внутри uploads/ (токен загрузки), проверяя выход за пределы каталога.</summary>
public string UploadPath(string token) =>
EnsureWithin(UploadsDir, Path.Combine(UploadsDir, token));
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using TeleWave.Infrastructure.Identity;
namespace TeleWave.Infrastructure.Streaming;
/// <summary>
/// Короткоживущий подписанный токен для cookie <c>tw_stream</c>: выдаётся авторизованному зрителю,
/// проверяется на запросах плейлиста и сегментов (работает и с нативным HLS, где заголовок не
/// поставить). Подпись — HMAC-SHA256 на том же ключе, что и JWT.
/// </summary>
public sealed class StreamTokenService(IOptions<JwtOptions> jwtOptions)
{
private static readonly TimeSpan Ttl = TimeSpan.FromHours(6);
private readonly byte[] _key = Encoding.UTF8.GetBytes(jwtOptions.Value.SigningKey);
public (string Token, DateTimeOffset ExpiresAt) Issue(Guid userId)
{
var expiresAt = DateTimeOffset.UtcNow.Add(Ttl);
var payload = $"{userId:N}.{expiresAt.ToUnixTimeSeconds()}";
var token = $"{Base64Url(Encoding.UTF8.GetBytes(payload))}.{Base64Url(Sign(payload))}";
return (token, expiresAt);
}
public bool Validate(string? token)
{
if (string.IsNullOrEmpty(token))
return false;
var parts = token.Split('.');
if (parts.Length != 2)
return false;
byte[] payloadBytes;
byte[] signature;
try
{
payloadBytes = FromBase64Url(parts[0]);
signature = FromBase64Url(parts[1]);
}
catch (FormatException)
{
return false;
}
var payload = Encoding.UTF8.GetString(payloadBytes);
var expected = Sign(payload);
if (!CryptographicOperations.FixedTimeEquals(signature, expected))
return false;
var dot = payload.LastIndexOf('.');
if (dot < 0 || !long.TryParse(payload.AsSpan(dot + 1), out var expiresUnix))
return false;
return DateTimeOffset.FromUnixTimeSeconds(expiresUnix) > DateTimeOffset.UtcNow;
}
private byte[] Sign(string payload) =>
HMACSHA256.HashData(_key, Encoding.UTF8.GetBytes(payload));
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
private static byte[] FromBase64Url(string value)
{
var padded = value.Replace('-', '+').Replace('_', '/');
padded += (padded.Length % 4) switch
{
2 => "==",
3 => "=",
_ => "",
};
return Convert.FromBase64String(padded);
}
}
@@ -0,0 +1,100 @@
using TeleWave.Domain.Broadcast.Live;
using Xunit;
namespace TeleWave.Domain.Tests.Broadcast;
public class LiveWindowCalculatorTests
{
private static readonly DateTimeOffset Epoch = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
private const int Seg = 2;
private const int Window = 5;
private static LiveInput Input(
double nowOffsetSeconds,
IReadOnlyList<LiveEntry> entries,
LiveFiller? filler = null
) =>
new(
Now: Epoch.AddSeconds(nowOffsetSeconds),
Epoch: Epoch,
SegmentSeconds: Seg,
WindowSegments: Window,
Entries: entries,
Filler: filler
);
[Fact]
public void SingleEntry_ProducesWindowWithMonotonicMediaSequence()
{
var asset = Guid.NewGuid();
var entries = new List<LiveEntry> { new(Epoch, Epoch.AddSeconds(100), asset, 50) };
// now = epoch + 20s → currentIndex = 10, окно [6..10].
var playlist = LiveWindowCalculator.Build(Input(20, entries));
Assert.Equal(6, playlist.MediaSequence);
Assert.Equal(Seg, playlist.TargetDuration);
Assert.Equal(5, playlist.Segments.Count);
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);
}
[Fact]
public void EntryBoundary_InsertsDiscontinuity()
{
Guid a = Guid.NewGuid(),
b = Guid.NewGuid();
var entries = new List<LiveEntry>
{
new(Epoch, Epoch.AddSeconds(16), a, 8), // g0..7
new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12), // g8..
};
var playlist = LiveWindowCalculator.Build(Input(20, entries));
Assert.Equal(6, playlist.MediaSequence);
Assert.Equal([a, a, b, b, b], playlist.Segments.Select(s => s.AssetId));
// DISCONTINUITY только перед первым сегментом B (индекс 2 в окне).
Assert.Equal(
[false, false, true, false, false],
playlist.Segments.Select(s => s.Discontinuity)
);
}
[Fact]
public void Gap_WithFiller_LoopsFiller()
{
var filler = new LiveFiller(Guid.NewGuid(), 4);
var playlist = LiveWindowCalculator.Build(Input(20, [], filler));
Assert.Equal(6, playlist.MediaSequence);
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));
}
[Fact]
public void LeadingGap_WithoutFiller_TrimmedFromWindowStart()
{
var b = Guid.NewGuid();
var entries = new List<LiveEntry> { new(Epoch.AddSeconds(16), Epoch.AddSeconds(40), b, 12) };
// Окно [6..10]: g6,g7 без записи и без филлера → отброшены, старт с g8.
var playlist = LiveWindowCalculator.Build(Input(20, entries));
Assert.Equal(8, playlist.MediaSequence);
Assert.Equal(3, playlist.Segments.Count);
Assert.Equal([0, 1, 2], playlist.Segments.Select(s => s.LocalIndex));
Assert.DoesNotContain(playlist.Segments, s => s.Discontinuity);
}
[Fact]
public void BeforeEpoch_ReturnsEmpty()
{
var playlist = LiveWindowCalculator.Build(Input(-10, []));
Assert.Empty(playlist.Segments);
}
}