Enhance channel and settings functionalities: introduce viewer settings in ChannelEndpoints, update SiteSettings to include channel number toggling, and refactor related data structures. Implement new endpoints for validating templates and diffing scheduling changes, improving overall user experience and configuration management.
build / backend (push) Successful in 1m54s
build / frontend (push) Successful in 43s
tests / backend-tests (push) Successful in 2m4s

This commit is contained in:
Leonid Pershin
2026-07-26 14:50:32 +03:00
parent 8494eb5e6a
commit f699576582
54 changed files with 5857 additions and 1631 deletions
@@ -1,184 +1,197 @@
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();
}
}
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>>();
// Что включено на стороне зрителя: сейчас только переключение по номерам (см. 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))
);
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();
}
}
/// <summary>Опции зрительской части, включённые глобально.</summary>
public sealed record ViewerFeaturesDto(bool ChannelNumbersEnabled);