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.MovieImport; using TeleWave.Application.Media.Register; using TeleWave.Application.Media.Retry; 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); // Перезапуск упавшей обработки: файл на месте, повторить нарезку — обычное дело. admin.MapPost("/{id:guid}/retry", Retry).Produces(); admin.MapPost("/retry-failed", RetryFailed).Produces(); // Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу. admin.MapGet("/manual", ListManual).Produces(); admin.MapPost("/manual/import", ImportManual).Produces(); // Разбор фильмов: имена приходят с клиента (каталог manual/ либо выбранные в браузере // файлы), сервер разбирает их и ищет в источнике. Импорт заводит шоу на файл. admin.MapPost("/movies/match", MatchMovies).Produces>(); admin.MapPost("/movies/match-one", MatchMovie).Produces(); admin.MapPost("/movies/import", ImportMovies).Produces(); // Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт // по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer. admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist); admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment); return app; } private static async Task Retry( Guid id, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send(new RetryMediaCommand(id), cancellationToken); return result.ToHttpResult(); } private static async Task RetryFailed( ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send(new RetryMediaCommand(), cancellationToken); return result.ToHttpResult(); } /// /// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в 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); // Файл уже на диске в uploads/, но в БД его ещё нет и ссылок на токен нигде не остаётся: // любой исход, кроме успешной регистрации, обязан за собой убрать. Удаляем и при исключении // (оборванный запрос, сбой БД) — иначе гигабайты остаются в uploads/ до уборщика. // Токен отмены для уборки не пробрасываем: при обрыве запроса он уже сработал. Result result; try { result = await sender.Send( new RegisterMediaAssetCommand(token, MediaSource.Upload, fileName), cancellationToken ); } catch { await storage.DeleteUploadAsync(token, CancellationToken.None); throw; } if (!result.IsSuccess) { await storage.DeleteUploadAsync(token, CancellationToken.None); 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 MatchMovies( MatchMoviesBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( new MatchMoviesQuery(body.Names, body.Provider), cancellationToken ); return Results.Ok(result); } private static async Task MatchMovie( MatchMovieBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( new MatchMovieQuery(body.Name, body.Title, body.Year, body.Provider), cancellationToken ); return Results.Ok(result); } private static async Task ImportMovies( ImportMoviesBody body, ISender sender, CancellationToken cancellationToken ) { var result = await sender.Send( new ImportMoviesCommand(body.Items, body.Provider), cancellationToken ); return result.ToHttpResult(); } 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); public sealed record MatchMoviesBody(IReadOnlyList Names, string Provider); public sealed record MatchMovieBody(string Name, string Title, int? Year, string Provider); public sealed record ImportMoviesBody(IReadOnlyList Items, string Provider);