Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
192 lines
7.5 KiB
C#
192 lines
7.5 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; сегменты этой проверки не делают (слишком часто).
|
|
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 (!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);
|
|
}
|
|
|
|
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);
|