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.
215 lines
9.2 KiB
C#
215 lines
9.2 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using LiteCqrs;
|
|
using Microsoft.Extensions.Hosting;
|
|
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";
|
|
|
|
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>>();
|
|
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8).
|
|
channels.MapGet("/features", ViewerFeatures).Produces<ViewerFeaturesDto>();
|
|
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 async Task<IResult> ViewerFeatures(
|
|
ISiteSettings siteSettings,
|
|
CancellationToken cancellationToken
|
|
) =>
|
|
Results.Ok(
|
|
new ViewerFeaturesDto(
|
|
await siteSettings.AreChannelNumbersEnabledAsync(cancellationToken)
|
|
)
|
|
);
|
|
|
|
/// <summary>
|
|
/// Выдаёт cookie доступа к эфиру. Канал в маршруте есть для симметрии с остальными
|
|
/// эндпоинтами, но токен не привязан к каналу — он подтверждает зрителя, а не подписку на
|
|
/// конкретную ленту, поэтому в сигнатуре slug не нужен.
|
|
/// </summary>
|
|
private static IResult Watch(
|
|
ICurrentUser currentUser,
|
|
StreamTokenService tokens,
|
|
HttpRequest request,
|
|
HttpResponse response,
|
|
IHostEnvironment env
|
|
)
|
|
{
|
|
if (currentUser.UserId is not { } userId)
|
|
return Results.Unauthorized();
|
|
|
|
var (token, expiresAt) = tokens.Issue(userId);
|
|
response.Cookies.Append(
|
|
StreamCookieName,
|
|
token,
|
|
new CookieOptions
|
|
{
|
|
HttpOnly = true,
|
|
// Вне Development — всегда Secure (прод за внешним TLS-прокси; request.IsHttps ненадёжен
|
|
// при неполной настройке ForwardedHeaders). См. UseSecureCookie в AuthEndpoints.
|
|
Secure = !env.IsDevelopment() || 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,
|
|
IIdentityService identity,
|
|
ISender sender,
|
|
CancellationToken cancellationToken
|
|
)
|
|
{
|
|
// Плейлист hls.js перезагружает регулярно — здесь дёшево (1 запрос на перезагрузку) сверить,
|
|
// что зритель из токена ещё существует и не заблокирован. Так блокировка отражается почти сразу,
|
|
// не дожидаясь истечения короткого TTL cookie; сегменты этой проверки не делают (слишком часто).
|
|
// Для IPTV это единственная точка, где блокировка вообще может сработать: у внешнего плеера
|
|
// пропуск долгий и перевыпуску не подлежит.
|
|
if (Viewer(request, tokens) is not { } userId)
|
|
return Results.Unauthorized();
|
|
var profile = await identity.GetProfileAsync(userId, cancellationToken);
|
|
if (profile is null || profile.IsBlocked)
|
|
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);
|
|
|
|
// Токен из ссылки тянется дальше в адреса сегментов: плеер разрешает их относительно
|
|
// плейлиста и query при этом теряет, а cookie у него нет — без этого сегменты дадут 401.
|
|
var token = request.Query["token"].ToString();
|
|
var suffix = string.IsNullOrEmpty(token)
|
|
? string.Empty
|
|
: $"?token={Uri.EscapeDataString(token)}";
|
|
|
|
response.Headers.CacheControl = "no-cache";
|
|
return Results.Text(Render(result.Value, suffix), "application/vnd.apple.mpegurl");
|
|
}
|
|
|
|
private static IResult Segment(
|
|
Guid assetId,
|
|
string file,
|
|
HttpRequest request,
|
|
HttpResponse response,
|
|
StreamTokenService tokens,
|
|
MediaPathResolver paths
|
|
)
|
|
{
|
|
if (Viewer(request, tokens) is null)
|
|
return Results.Unauthorized();
|
|
if (!SegmentFiles.IsSegmentName(file))
|
|
return Results.NotFound();
|
|
|
|
if (SegmentFiles.TryResolveExisting(paths, assetId, file) is not { } path)
|
|
return Results.NotFound();
|
|
|
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
|
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Кто смотрит: cookie — для браузера, токен в ссылке — для внешнего IPTV-плеера, который
|
|
/// cookie не носит. Токен один и тот же по формату, различается только сроком выдачи.
|
|
/// </summary>
|
|
private static Guid? Viewer(HttpRequest request, StreamTokenService tokens) =>
|
|
tokens.Validate(request.Cookies[StreamCookieName])
|
|
?? tokens.Validate(request.Query["token"]);
|
|
|
|
private static string Render(LivePlaylistDto playlist, string segmentSuffix)
|
|
{
|
|
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"
|
|
);
|
|
// Без DISCONTINUITY-SEQUENCE ffmpeg-плееры (Jellyfin) теряют счёт склеек, когда тег
|
|
// EXT-X-DISCONTINUITY уезжает за верхний край окна, и встают на стыке серий.
|
|
sb.Append(
|
|
CultureInfo.InvariantCulture,
|
|
$"#EXT-X-DISCONTINUITY-SEQUENCE:{playlist.DiscontinuitySequence}\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{segmentSuffix}\n"
|
|
);
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
}
|
|
|
|
/// <summary>Опции зрительской части, включённые глобально.</summary>
|
|
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);
|