diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ea00aea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Концы строк в репозитории — всегда LF. Бэкенд и так лежал в LF, фронтенд — в CRLF; Prettier +# (endOfLine: lf по умолчанию) выровнял его, и этот файл не даёт разъехаться обратно: без него +# при core.autocrlf=true у другого разработчика CRLF вернулись бы в коммит. +* text=auto eol=lf + +# Бинарники не трогаем. +*.png binary +*.jpg binary +*.ico binary +*.woff2 binary diff --git a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs index a244360..b0e143a 100644 --- a/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ChannelEndpoints.cs @@ -1,247 +1,247 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Broadcast; -using TeleWave.Application.Broadcast.CreateChannel; -using TeleWave.Application.Broadcast.GetChannel; -using TeleWave.Application.Broadcast.GetSchedule; -using TeleWave.Application.Broadcast.ListChannels; -using TeleWave.Application.Broadcast.UpdateChannelSettings; -using TeleWave.Application.Broadcast.UpdateChannelTime; -using TeleWave.Application.Broadcast.UpdateViewerSettings; -using TeleWave.Application.Programming.Planning.Trace; -using TeleWave.Domain.Broadcast; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Api.Endpoints; - -/// -/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки — -/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки -/// (TemplateEndpoints). -/// -public static partial class ChannelEndpoints -{ - public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/channels") - .WithTags("Admin.Channels") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created); - admin.MapGet("", ListChannels).Produces>(); - admin.MapGet("/{id:guid}", GetChannel).Produces(); - admin - .MapPut("/{id:guid}/settings", UpdateSettings) - .Produces(StatusCodes.Status204NoContent); - admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) - .Produces(StatusCodes.Status201Created); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) - .Produces(StatusCodes.Status204NoContent); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - SetTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/background", - ClearTemplateBackground - ) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) - .Produces(StatusCodes.Status204NoContent); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", - PreviewPlaylist - ); - admin.MapGet( - "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", - PreviewSegment - ); - - admin - .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) - .Produces(StatusCodes.Status201Created); - admin - .MapPut( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - UpdateBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - admin - .MapDelete( - "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", - RemoveBumperVariant - ) - .Produces(StatusCodes.Status204NoContent); - - admin - .MapGet("/{id:guid}/schedule", GetSchedule) - .Produces>(); - admin - .MapPut("/{id:guid}/viewer", UpdateViewerSettings) - .Produces(StatusCodes.Status204NoContent); - - // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации. - admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces(); - - return app; - } - - private static async Task UpdateViewerSettings( - Guid id, - UpdateViewerSettingsBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateViewerSettingsCommand( - id, - body.LogoImageId, - body.LogoCorner, - body.LogoOpacity, - body.ShowClock, - body.AnalogFilterStrength - ), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task GetEntryTrace( - Guid entryId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task CreateChannel( - CreateChannelCommand command, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(command, cancellationToken); - return result.IsSuccess - ? Results.Created( - $"/api/admin/channels/{result.Value}", - new CreatedIdResponse(result.Value) - ) - : result.ToHttpResult(); - } - - private static async Task ListChannels( - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ListChannelsQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task GetChannel( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new GetChannelQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task UpdateTime( - Guid id, - UpdateChannelTimeBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task UpdateSettings( - Guid id, - UpdateChannelSettingsBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateChannelSettingsCommand( - id, - body.Name, - body.IsEnabled, - body.BumpersEnabled, - body.Bumper, - body.FillerAssetId - ), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task GetSchedule( - Guid id, - DateTimeOffset? from, - DateTimeOffset? to, - ISender sender, - CancellationToken cancellationToken - ) - { - var fromUtc = from ?? DateTimeOffset.UtcNow; - var toUtc = to ?? fromUtc.AddDays(1); - var result = await sender.Send( - new GetChannelScheduleQuery(id, fromUtc, toUtc), - cancellationToken - ); - return result.ToHttpResult(); - } -} - -/// Номер канала и его время: смещение от UTC и начало вещательных суток. -public sealed record UpdateChannelTimeBody( - int? Number, - int UtcOffsetMinutes, - TimeOnly DayStartTime -); - -public sealed record UpdateChannelSettingsBody( - string Name, - bool IsEnabled, - bool BumpersEnabled, - BumperSettingsInput Bumper, - Guid? FillerAssetId -); - -/// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8). -public sealed record UpdateViewerSettingsBody( - Guid? LogoImageId, - LogoCorner LogoCorner, - double LogoOpacity, - bool ShowClock, - double AnalogFilterStrength -); +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Broadcast; +using TeleWave.Application.Broadcast.CreateChannel; +using TeleWave.Application.Broadcast.GetChannel; +using TeleWave.Application.Broadcast.GetSchedule; +using TeleWave.Application.Broadcast.ListChannels; +using TeleWave.Application.Broadcast.UpdateChannelSettings; +using TeleWave.Application.Broadcast.UpdateChannelTime; +using TeleWave.Application.Broadcast.UpdateViewerSettings; +using TeleWave.Application.Programming.Planning.Trace; +using TeleWave.Domain.Broadcast; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +/// +/// Админ-эндпоинты канала: создание, список, настройки, время и чтение расписания. Заставки — +/// в ChannelEndpoints.Bumpers.cs. Что и когда идёт в эфире, задаёт шаблон сетки +/// (TemplateEndpoints). +/// +public static partial class ChannelEndpoints +{ + public static IEndpointRouteBuilder MapChannelEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/channels") + .WithTags("Admin.Channels") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", CreateChannel).Produces(StatusCodes.Status201Created); + admin.MapGet("", ListChannels).Produces>(); + admin.MapGet("/{id:guid}", GetChannel).Produces(); + admin + .MapPut("/{id:guid}/settings", UpdateSettings) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/time", UpdateTime).Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/bumper/templates", AddBumperTemplate) + .Produces(StatusCodes.Status201Created); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}", UpdateBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}", RemoveBumperTemplate) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/bumper/templates/{templateId:guid}/audio", UploadTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete("/{id:guid}/bumper/templates/{templateId:guid}/audio", ClearTemplateAudio) + .Produces(StatusCodes.Status204NoContent); + admin + .MapPut( + "/{id:guid}/bumper/templates/{templateId:guid}/background", + SetTemplateBackground + ) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete( + "/{id:guid}/bumper/templates/{templateId:guid}/background", + ClearTemplateBackground + ) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/preview", RenderPreview) + .Produces(StatusCodes.Status204NoContent); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/index.m3u8", + PreviewPlaylist + ); + admin.MapGet( + "/{id:guid}/bumper/templates/{templateId:guid}/preview/{variantId:guid}/{file}", + PreviewSegment + ); + + admin + .MapPost("/{id:guid}/bumper/templates/{templateId:guid}/variants", AddBumperVariant) + .Produces(StatusCodes.Status201Created); + admin + .MapPut( + "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", + UpdateBumperVariant + ) + .Produces(StatusCodes.Status204NoContent); + admin + .MapDelete( + "/{id:guid}/bumper/templates/{templateId:guid}/variants/{variantId:guid}", + RemoveBumperVariant + ) + .Produces(StatusCodes.Status204NoContent); + + admin + .MapGet("/{id:guid}/schedule", GetSchedule) + .Produces>(); + admin + .MapPut("/{id:guid}/viewer", UpdateViewerSettings) + .Produces(StatusCodes.Status204NoContent); + + // «Почему это здесь»: цепочка происхождения записи, записанная в момент генерации. + admin.MapGet("/entries/{entryId:guid}/trace", GetEntryTrace).Produces(); + + return app; + } + + private static async Task UpdateViewerSettings( + Guid id, + UpdateViewerSettingsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateViewerSettingsCommand( + id, + body.LogoImageId, + body.LogoCorner, + body.LogoOpacity, + body.ShowClock, + body.AnalogFilterStrength + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task GetEntryTrace( + Guid entryId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetEntryTraceQuery(entryId), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task CreateChannel( + CreateChannelCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/channels/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task ListChannels( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListChannelsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task GetChannel( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetChannelQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task UpdateTime( + Guid id, + UpdateChannelTimeBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateChannelTimeCommand(id, body.Number, body.UtcOffsetMinutes, body.DayStartTime), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task UpdateSettings( + Guid id, + UpdateChannelSettingsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateChannelSettingsCommand( + id, + body.Name, + body.IsEnabled, + body.BumpersEnabled, + body.Bumper, + body.FillerAssetId + ), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task GetSchedule( + Guid id, + DateTimeOffset? from, + DateTimeOffset? to, + ISender sender, + CancellationToken cancellationToken + ) + { + var fromUtc = from ?? DateTimeOffset.UtcNow; + var toUtc = to ?? fromUtc.AddDays(1); + var result = await sender.Send( + new GetChannelScheduleQuery(id, fromUtc, toUtc), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +/// Номер канала и его время: смещение от UTC и начало вещательных суток. +public sealed record UpdateChannelTimeBody( + int? Number, + int UtcOffsetMinutes, + TimeOnly DayStartTime +); + +public sealed record UpdateChannelSettingsBody( + string Name, + bool IsEnabled, + bool BumpersEnabled, + BumperSettingsInput Bumper, + Guid? FillerAssetId +); + +/// Оверлеи и аналоговый фильтр — как канал выглядит у зрителя (см. 6.8). +public sealed record UpdateViewerSettingsBody( + Guid? LogoImageId, + LogoCorner LogoCorner, + double LogoOpacity, + bool ShowClock, + double AnalogFilterStrength +); diff --git a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs index 4a40d50..5d71fc4 100644 --- a/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/MediaEndpoints.cs @@ -1,216 +1,216 @@ -using System.Text; -using LiteCqrs; -using Microsoft.Extensions.Options; -using TeleWave.Api.Common; -using TeleWave.Application.Common.Interfaces; -using TeleWave.Application.Common.Models; -using TeleWave.Application.Media; -using TeleWave.Application.Media.Delete; -using TeleWave.Application.Media.ListMedia; -using TeleWave.Application.Media.ManualInbox; -using TeleWave.Application.Media.Register; -using TeleWave.Application.Media.Stats; -using TeleWave.Domain.Media; -using TeleWave.Infrastructure.Identity; -using TeleWave.Infrastructure.Media; - -namespace TeleWave.Api.Endpoints; - -public static class MediaEndpoints -{ - public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/media") - .WithTags("Admin.Media") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", Upload).Produces(StatusCodes.Status201Created); - admin.MapGet("", List).Produces>(); - admin.MapGet("/stats", Stats).Produces(); - admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); - - // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. - admin.MapGet("/manual", ListManual).Produces(); - admin.MapPost("/manual/import", ImportManual).Produces(); - - // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт - // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. - admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); - admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); - - return app; - } - - /// - /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». - /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. - /// - private static async Task Upload( - string fileName, - HttpRequest request, - IMediaStorage storage, - IMediaProcessingQueue queue, - ISender sender, - UploadLimits limits, - CancellationToken cancellationToken - ) - { - if (string.IsNullOrWhiteSpace(fileName)) - return Results.Problem( - title: MediaErrors.EmptyFileName.Code, - detail: MediaErrors.EmptyFileName.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - if (!MediaFormats.IsAllowed(fileName)) - return Results.Problem( - title: MediaErrors.UnsupportedFormat.Code, - detail: MediaErrors.UnsupportedFormat.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - var contentLength = request.ContentLength ?? 0; - if (contentLength > limits.MaxUploadBytes) - return Results.Problem( - title: MediaErrors.FileTooLarge.Code, - detail: MediaErrors.FileTooLarge.Message, - statusCode: StatusCodes.Status400BadRequest - ); - - var free = storage.GetAvailableFreeSpaceBytes(); - if (free - contentLength < limits.MinFreeSpaceBytes) - return Results.Problem( - title: MediaErrors.InsufficientStorage.Code, - detail: MediaErrors.InsufficientStorage.Message, - statusCode: StatusCodes.Status409Conflict - ); - - var extension = Path.GetExtension(fileName); - var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); - - var result = await sender.Send( - new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), - cancellationToken - ); - if (!result.IsSuccess) - { - await storage.DeleteUploadAsync(token, cancellationToken); - return result.ToHttpResult(); - } - - queue.Enqueue(result.Value); - return Results.Created( - $"/api/admin/media/{result.Value}", - new UploadMediaResponse(result.Value) - ); - } - - private static async Task List( - [AsParameters] ListMediaFilter filter, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new ListMediaAssetsQuery( - filter.Page is > 0 ? filter.Page.Value : 1, - filter.PageSize is > 0 ? filter.PageSize.Value : 20, - filter.Status ?? [], - filter.Search, - filter.Sort, - filter.Desc ?? false - ), - cancellationToken - ); - return Results.Ok(result); - } - - private static async Task Stats(ISender sender, CancellationToken cancellationToken) - { - var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task Delete( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task ListManual( - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new ListManualInboxQuery(), cancellationToken); - return Results.Ok(result); - } - - private static async Task ImportManual( - ImportManualInboxBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new ImportManualInboxCommand(body.Items, body.ShowId), - cancellationToken - ); - return result.ToHttpResult(); - } - - /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. - private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) - { - if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath) - return Results.NotFound(); - - var baseUrl = $"/api/admin/media/{id}/preview/"; - var sb = new StringBuilder(); - foreach (var line in File.ReadLines(indexPath)) - { - var trimmed = line.Trim(); - if (trimmed.Length == 0) - continue; - // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. - sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) - .Append('\n'); - } - - return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); - } - - private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) - { - if (!SegmentFiles.IsSegmentName(file)) - return Results.NotFound(); - - if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path) - return Results.NotFound(); - - return Results.File(path, "video/mp2t", enableRangeProcessing: true); - } -} - -/// -/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query -/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые -/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при -/// AsParameters обязательность определяется nullable-типом, а не значением по умолчанию. -/// -public sealed record ListMediaFilter( - int? Page, - int? PageSize, - MediaAssetStatus[]? Status, - string? Search, - string? Sort, - bool? Desc -); - -public sealed record UploadMediaResponse(Guid Id); - -public sealed record ImportManualInboxBody(IReadOnlyList Items, Guid ShowId); +using System.Text; +using LiteCqrs; +using Microsoft.Extensions.Options; +using TeleWave.Api.Common; +using TeleWave.Application.Common.Interfaces; +using TeleWave.Application.Common.Models; +using TeleWave.Application.Media; +using TeleWave.Application.Media.Delete; +using TeleWave.Application.Media.ListMedia; +using TeleWave.Application.Media.ManualInbox; +using TeleWave.Application.Media.Register; +using TeleWave.Application.Media.Stats; +using TeleWave.Domain.Media; +using TeleWave.Infrastructure.Identity; +using TeleWave.Infrastructure.Media; + +namespace TeleWave.Api.Endpoints; + +public static class MediaEndpoints +{ + public static IEndpointRouteBuilder MapMediaEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/media") + .WithTags("Admin.Media") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", Upload).Produces(StatusCodes.Status201Created); + admin.MapGet("", List).Produces>(); + admin.MapGet("/stats", Stats).Produces(); + admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent); + + // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. + admin.MapGet("/manual", ListManual).Produces(); + admin.MapPost("/manual/import", ImportManual).Produces(); + + // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт + // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. + admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); + admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); + + return app; + } + + /// + /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName». + /// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку. + /// + private static async Task Upload( + string fileName, + HttpRequest request, + IMediaStorage storage, + IMediaProcessingQueue queue, + ISender sender, + UploadLimits limits, + CancellationToken cancellationToken + ) + { + if (string.IsNullOrWhiteSpace(fileName)) + return Results.Problem( + title: MediaErrors.EmptyFileName.Code, + detail: MediaErrors.EmptyFileName.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + if (!MediaFormats.IsAllowed(fileName)) + return Results.Problem( + title: MediaErrors.UnsupportedFormat.Code, + detail: MediaErrors.UnsupportedFormat.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + var contentLength = request.ContentLength ?? 0; + if (contentLength > limits.MaxUploadBytes) + return Results.Problem( + title: MediaErrors.FileTooLarge.Code, + detail: MediaErrors.FileTooLarge.Message, + statusCode: StatusCodes.Status400BadRequest + ); + + var free = storage.GetAvailableFreeSpaceBytes(); + if (free - contentLength < limits.MinFreeSpaceBytes) + return Results.Problem( + title: MediaErrors.InsufficientStorage.Code, + detail: MediaErrors.InsufficientStorage.Message, + statusCode: StatusCodes.Status409Conflict + ); + + var extension = Path.GetExtension(fileName); + var token = await storage.SaveUploadAsync(request.Body, extension, cancellationToken); + + var result = await sender.Send( + new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), + cancellationToken + ); + if (!result.IsSuccess) + { + await storage.DeleteUploadAsync(token, cancellationToken); + return result.ToHttpResult(); + } + + queue.Enqueue(result.Value); + return Results.Created( + $"/api/admin/media/{result.Value}", + new UploadMediaResponse(result.Value) + ); + } + + private static async Task List( + [AsParameters] ListMediaFilter filter, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ListMediaAssetsQuery( + filter.Page is > 0 ? filter.Page.Value : 1, + filter.PageSize is > 0 ? filter.PageSize.Value : 20, + filter.Status ?? [], + filter.Search, + filter.Sort, + filter.Desc ?? false + ), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task Stats(ISender sender, CancellationToken cancellationToken) + { + var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task Delete( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task ListManual( + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new ListManualInboxQuery(), cancellationToken); + return Results.Ok(result); + } + + private static async Task ImportManual( + ImportManualInboxBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new ImportManualInboxCommand(body.Items, body.ShowId), + cancellationToken + ); + return result.ToHttpResult(); + } + + /// Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут. + private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths) + { + if (SegmentFiles.TryResolveExisting(paths, id, "index.m3u8") is not { } indexPath) + return Results.NotFound(); + + var baseUrl = $"/api/admin/media/{id}/preview/"; + var sb = new StringBuilder(); + foreach (var line in File.ReadLines(indexPath)) + { + var trimmed = line.Trim(); + if (trimmed.Length == 0) + continue; + // Директивы — как есть; строки-сегменты (абсолютный путь от ffmpeg) → admin-URL. + sb.Append(trimmed.StartsWith('#') ? trimmed : baseUrl + Path.GetFileName(trimmed)) + .Append('\n'); + } + + return Results.Text(sb.ToString(), "application/vnd.apple.mpegurl"); + } + + private static IResult PreviewSegment(Guid id, string file, MediaPathResolver paths) + { + if (!SegmentFiles.IsSegmentName(file)) + return Results.NotFound(); + + if (SegmentFiles.TryResolveExisting(paths, id, file) is not { } path) + return Results.NotFound(); + + return Results.File(path, "video/mp2t", enableRangeProcessing: true); + } +} + +/// +/// Фильтр и страница списка медиа. Все поля nullable намеренно: обязательный параметр в query +/// заставлял минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые +/// ассеты», которым сортировка не нужна. Умолчания подставляет хендлер, а не объявление: при +/// AsParameters обязательность определяется nullable-типом, а не значением по умолчанию. +/// +public sealed record ListMediaFilter( + int? Page, + int? PageSize, + MediaAssetStatus[]? Status, + string? Search, + string? Sort, + bool? Desc +); + +public sealed record UploadMediaResponse(Guid Id); + +public sealed record ImportManualInboxBody(IReadOnlyList Items, Guid ShowId); diff --git a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs index d26bb62..f669e2f 100644 --- a/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/SettingsEndpoints.cs @@ -1,55 +1,55 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Settings; -using TeleWave.Application.Settings.GetSiteSettings; -using TeleWave.Application.Settings.UpdateSiteSettings; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Api.Endpoints; - -public static class SettingsEndpoints -{ - public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/settings") - .WithTags("Admin.Settings") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapGet("", GetSettings).Produces(); - admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent); - - return app; - } - - private static async Task GetSettings( - ISender sender, - CancellationToken cancellationToken - ) - { - var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); - return Results.Ok(settings); - } - - private static async Task UpdateSettings( - UpdateSiteSettingsBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new UpdateSiteSettingsCommand( - body.RegistrationEnabled, - body.PreferredAudioLanguages ?? "", - body.ChannelNumbersEnabled - ), - cancellationToken - ); - return result.ToHttpResult(); - } -} - -public sealed record UpdateSiteSettingsBody( - bool RegistrationEnabled, - string? PreferredAudioLanguages, - bool ChannelNumbersEnabled -); +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Settings; +using TeleWave.Application.Settings.GetSiteSettings; +using TeleWave.Application.Settings.UpdateSiteSettings; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class SettingsEndpoints +{ + public static IEndpointRouteBuilder MapSettingsEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/settings") + .WithTags("Admin.Settings") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapGet("", GetSettings).Produces(); + admin.MapPut("", UpdateSettings).Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task GetSettings( + ISender sender, + CancellationToken cancellationToken + ) + { + var settings = await sender.Send(new GetSiteSettingsQuery(), cancellationToken); + return Results.Ok(settings); + } + + private static async Task UpdateSettings( + UpdateSiteSettingsBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new UpdateSiteSettingsCommand( + body.RegistrationEnabled, + body.PreferredAudioLanguages ?? "", + body.ChannelNumbersEnabled + ), + cancellationToken + ); + return result.ToHttpResult(); + } +} + +public sealed record UpdateSiteSettingsBody( + bool RegistrationEnabled, + string? PreferredAudioLanguages, + bool ChannelNumbersEnabled +); diff --git a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs index b676c59..f44d391 100644 --- a/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/ShowEndpoints.cs @@ -1,186 +1,186 @@ -using LiteCqrs; -using TeleWave.Api.Common; -using TeleWave.Application.Library; -using TeleWave.Application.Library.AddEpisode; -using TeleWave.Application.Library.CreateShow; -using TeleWave.Application.Library.DeleteShow; -using TeleWave.Application.Library.GetShow; -using TeleWave.Application.Library.ListShows; -using TeleWave.Application.Library.RemoveEpisode; -using TeleWave.Application.Library.RenameShow; -using TeleWave.Application.Library.SetShowAudience; -using TeleWave.Application.Library.SetShowGenres; -using TeleWave.Application.Library.SetShowOriginalName; -using TeleWave.Domain.Library; -using TeleWave.Infrastructure.Identity; - -namespace TeleWave.Api.Endpoints; - -public static class ShowEndpoints -{ - public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) - { - var admin = app.MapGroup("/api/admin/shows") - .WithTags("Admin.Shows") - .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); - - admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created); - admin.MapGet("", ListShows).Produces>(); - admin.MapGet("/{id:guid}", GetShow).Produces(); - admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); - admin - .MapPut("/{id:guid}/original-name", SetOriginalName) - .Produces(StatusCodes.Status204NoContent); - admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); - admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); - admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); - admin - .MapPost("/{id:guid}/episodes", AddEpisode) - .Produces(StatusCodes.Status201Created); - admin - .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) - .Produces(StatusCodes.Status204NoContent); - - return app; - } - - private static async Task CreateShow( - CreateShowCommand command, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(command, cancellationToken); - return result.IsSuccess - ? Results.Created( - $"/api/admin/shows/{result.Value}", - new CreatedIdResponse(result.Value) - ) - : result.ToHttpResult(); - } - - private static async Task ListShows( - ISender sender, - CancellationToken cancellationToken, - Guid? genreId = null, - bool interstitials = false - ) - { - var result = await sender.Send( - new ListShowsQuery(genreId, interstitials), - cancellationToken - ); - return Results.Ok(result); - } - - private static async Task GetShow( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new GetShowQuery(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task Rename( - Guid id, - RenameShowBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task SetOriginalName( - Guid id, - SetShowOriginalNameBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new SetShowOriginalNameCommand(id, body.OriginalName), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task SetAudience( - Guid id, - SetShowAudienceBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new SetShowAudienceCommand(id, body.Audience), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task SetGenres( - Guid id, - SetShowGenresBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId), - cancellationToken - ); - return result.ToHttpResult(); - } - - private static async Task DeleteShow( - Guid id, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); - return result.ToHttpResult(); - } - - private static async Task AddEpisode( - Guid id, - AddEpisodeBody body, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send( - new AddEpisodeCommand(id, body.MediaAssetId), - cancellationToken - ); - return result.IsSuccess - ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) - : result.ToHttpResult(); - } - - private static async Task RemoveEpisode( - Guid id, - Guid episodeId, - ISender sender, - CancellationToken cancellationToken - ) - { - var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); - return result.ToHttpResult(); - } -} - -public sealed record AddEpisodeBody(Guid MediaAssetId); - -public sealed record RenameShowBody(string Name); - -public sealed record SetShowOriginalNameBody(string? OriginalName); - -/// Рейтинг шоу; null — снять проставленный. -public sealed record SetShowAudienceBody(ShowAudience? Audience); - -public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId); +using LiteCqrs; +using TeleWave.Api.Common; +using TeleWave.Application.Library; +using TeleWave.Application.Library.AddEpisode; +using TeleWave.Application.Library.CreateShow; +using TeleWave.Application.Library.DeleteShow; +using TeleWave.Application.Library.GetShow; +using TeleWave.Application.Library.ListShows; +using TeleWave.Application.Library.RemoveEpisode; +using TeleWave.Application.Library.RenameShow; +using TeleWave.Application.Library.SetShowAudience; +using TeleWave.Application.Library.SetShowGenres; +using TeleWave.Application.Library.SetShowOriginalName; +using TeleWave.Domain.Library; +using TeleWave.Infrastructure.Identity; + +namespace TeleWave.Api.Endpoints; + +public static class ShowEndpoints +{ + public static IEndpointRouteBuilder MapShowEndpoints(this IEndpointRouteBuilder app) + { + var admin = app.MapGroup("/api/admin/shows") + .WithTags("Admin.Shows") + .RequireAuthorization(policy => policy.RequireRole(RoleNames.Admin)); + + admin.MapPost("", CreateShow).Produces(StatusCodes.Status201Created); + admin.MapGet("", ListShows).Produces>(); + admin.MapGet("/{id:guid}", GetShow).Produces(); + admin.MapPut("/{id:guid}/name", Rename).Produces(StatusCodes.Status204NoContent); + admin + .MapPut("/{id:guid}/original-name", SetOriginalName) + .Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/audience", SetAudience).Produces(StatusCodes.Status204NoContent); + admin.MapPut("/{id:guid}/genres", SetGenres).Produces(StatusCodes.Status204NoContent); + admin.MapDelete("/{id:guid}", DeleteShow).Produces(StatusCodes.Status204NoContent); + admin + .MapPost("/{id:guid}/episodes", AddEpisode) + .Produces(StatusCodes.Status201Created); + admin + .MapDelete("/{id:guid}/episodes/{episodeId:guid}", RemoveEpisode) + .Produces(StatusCodes.Status204NoContent); + + return app; + } + + private static async Task CreateShow( + CreateShowCommand command, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(command, cancellationToken); + return result.IsSuccess + ? Results.Created( + $"/api/admin/shows/{result.Value}", + new CreatedIdResponse(result.Value) + ) + : result.ToHttpResult(); + } + + private static async Task ListShows( + ISender sender, + CancellationToken cancellationToken, + Guid? genreId = null, + bool interstitials = false + ) + { + var result = await sender.Send( + new ListShowsQuery(genreId, interstitials), + cancellationToken + ); + return Results.Ok(result); + } + + private static async Task GetShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new GetShowQuery(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task Rename( + Guid id, + RenameShowBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RenameShowCommand(id, body.Name), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task SetOriginalName( + Guid id, + SetShowOriginalNameBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowOriginalNameCommand(id, body.OriginalName), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task SetAudience( + Guid id, + SetShowAudienceBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowAudienceCommand(id, body.Audience), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task SetGenres( + Guid id, + SetShowGenresBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new SetShowGenresCommand(id, body.GenreIds, body.PrimaryGenreId), + cancellationToken + ); + return result.ToHttpResult(); + } + + private static async Task DeleteShow( + Guid id, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new DeleteShowCommand(id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task AddEpisode( + Guid id, + AddEpisodeBody body, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send( + new AddEpisodeCommand(id, body.MediaAssetId), + cancellationToken + ); + return result.IsSuccess + ? Results.Created($"/api/admin/shows/{id}", new CreatedIdResponse(result.Value)) + : result.ToHttpResult(); + } + + private static async Task RemoveEpisode( + Guid id, + Guid episodeId, + ISender sender, + CancellationToken cancellationToken + ) + { + var result = await sender.Send(new RemoveEpisodeCommand(id, episodeId), cancellationToken); + return result.ToHttpResult(); + } +} + +public sealed record AddEpisodeBody(Guid MediaAssetId); + +public sealed record RenameShowBody(string Name); + +public sealed record SetShowOriginalNameBody(string? OriginalName); + +/// Рейтинг шоу; null — снять проставленный. +public sealed record SetShowAudienceBody(ShowAudience? Audience); + +public sealed record SetShowGenresBody(IReadOnlyList GenreIds, Guid? PrimaryGenreId); diff --git a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs index 7cb55cd..8a13440 100644 --- a/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs +++ b/backend/src/TeleWave.Api/Endpoints/StreamingEndpoints.cs @@ -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>(); - // Что включено на стороне зрителя: сейчас только переключение по номерам (см. 6.8). - channels.MapGet("/features", ViewerFeatures).Produces(); - channels.MapPost("/{slug}/watch", Watch).Produces(StatusCodes.Status204NoContent); - channels.MapGet("/{slug}/epg", Epg); - - // Раздача эфира (cookie tw_stream): плейлист и сегменты — их грузит