Normalize line endings to LF via .gitattributes
Репозиторий хранил фронтенд в CRLF, а часть бэкенда — вперемешку, хотя CI и Docker-сборка работают под Linux. Прибиваем LF атрибутом `* text=auto eol=lf` и разово нормализуем дерево, чтобы форматтеры не переписывали файлы целиком на каждом прогоне. Коммит чисто механический: изменений содержимого нет, только концы строк. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9d1c6d2fc3
commit
0442056367
@@ -1,191 +1,191 @@
|
||||
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);
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user