185 lines
6.7 KiB
C#
185 lines
6.7 KiB
C#
using System.Globalization;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
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";
|
|
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,
|
|
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; сегменты этой проверки не делают (слишком часто).
|
|
if (tokens.Validate(request.Cookies[StreamCookieName]) 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);
|
|
|
|
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]) is null)
|
|
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();
|
|
}
|
|
}
|