Refactor media storage and management functionalities: enhance IMediaStorage interface with manual inbox handling, update FileSystemMediaStorage to support manual file imports, and improve MediaPathResolver for better path management. Extend MediaEndpoints to include new manual inbox features and update frontend components for improved media management experience.
This commit is contained in:
@@ -1,199 +1,225 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
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.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
|
||||
{
|
||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
||||
|
||||
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<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||
/// </summary>
|
||||
private static async Task<IResult> Upload(
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IMediaStorage storage,
|
||||
IMediaProcessingQueue queue,
|
||||
ISender sender,
|
||||
IOptions<MediaOptions> mediaOptions,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
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 > mediaOptions.Value.MaxUploadBytes)
|
||||
return Results.Problem(
|
||||
title: MediaErrors.FileTooLarge.Code,
|
||||
detail: MediaErrors.FileTooLarge.Message,
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
|
||||
var free = storage.GetAvailableFreeSpaceBytes();
|
||||
if (free - contentLength < storageOptions.Value.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<IResult> List(
|
||||
int page,
|
||||
int pageSize,
|
||||
MediaAssetStatus[]? status,
|
||||
string? search,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListMediaAssetsQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
status ?? [],
|
||||
search,
|
||||
sort,
|
||||
desc
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||
{
|
||||
string indexPath;
|
||||
try
|
||||
{
|
||||
indexPath = paths.SegmentPath(id, "index.m3u8");
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(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 (!SegmentFileName.IsMatch(file))
|
||||
return Results.NotFound();
|
||||
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(id, file);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(path))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UploadMediaResponse(Guid Id);
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
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
|
||||
{
|
||||
private static readonly Regex SegmentFileName = new(@"^seg\d{1,6}\.ts$", RegexOptions.Compiled);
|
||||
|
||||
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<UploadMediaResponse>(StatusCodes.Status201Created);
|
||||
admin.MapGet("", List).Produces<PagedList<MediaAssetDto>>();
|
||||
admin.MapGet("/stats", Stats).Produces<MediaStatsDto>();
|
||||
admin.MapDelete("/{id:guid}", Delete).Produces(StatusCodes.Status204NoContent);
|
||||
|
||||
// Ручной inbox: сканером не разбирается — файлы выбирает админ и сразу указывает шоу.
|
||||
admin.MapGet("/manual", ListManual).Produces<ManualInboxListDto>();
|
||||
admin.MapPost("/manual/import", ImportManual).Produces<ImportManualInboxResultDto>();
|
||||
|
||||
// Просмотр обработанного ассета в админке (ролики, проверка серии). Публичная раздача идёт
|
||||
// по stream-куке, здесь роут под JWT — плейлист и сегменты грузит hls.js с Bearer.
|
||||
admin.MapGet("/{id:guid}/preview/index.m3u8", PreviewPlaylist);
|
||||
admin.MapGet("/{id:guid}/preview/{file}", PreviewSegment);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Потоковая загрузка: тело запроса — сырые байты файла, имя передаётся в query «fileName».
|
||||
/// Файл стримится на диск без буферизации в память, затем регистрируется и уходит в обработку.
|
||||
/// </summary>
|
||||
private static async Task<IResult> Upload(
|
||||
string fileName,
|
||||
HttpRequest request,
|
||||
IMediaStorage storage,
|
||||
IMediaProcessingQueue queue,
|
||||
ISender sender,
|
||||
IOptions<MediaOptions> mediaOptions,
|
||||
IOptions<StorageOptions> storageOptions,
|
||||
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 > mediaOptions.Value.MaxUploadBytes)
|
||||
return Results.Problem(
|
||||
title: MediaErrors.FileTooLarge.Code,
|
||||
detail: MediaErrors.FileTooLarge.Message,
|
||||
statusCode: StatusCodes.Status400BadRequest
|
||||
);
|
||||
|
||||
var free = storage.GetAvailableFreeSpaceBytes();
|
||||
if (free - contentLength < storageOptions.Value.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<IResult> List(
|
||||
int page,
|
||||
int pageSize,
|
||||
MediaAssetStatus[]? status,
|
||||
string? search,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ListMediaAssetsQuery(
|
||||
page <= 0 ? 1 : page,
|
||||
pageSize <= 0 ? 20 : pageSize,
|
||||
status ?? [],
|
||||
search,
|
||||
sort,
|
||||
desc
|
||||
),
|
||||
cancellationToken
|
||||
);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Stats(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new GetMediaStatsQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Delete(
|
||||
Guid id,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(new DeleteMediaAssetCommand(id), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> ListManual(ISender sender, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await sender.Send(new ListManualInboxQuery(), cancellationToken);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
|
||||
private static async Task<IResult> ImportManual(
|
||||
ImportManualInboxBody body,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ImportManualInboxCommand(body.RelativePaths, body.ShowId),
|
||||
cancellationToken
|
||||
);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
/// <summary>Плейлист ассета: переписываем ffmpeg-index.m3u8, направляя сегменты на admin-роут.</summary>
|
||||
private static IResult PreviewPlaylist(Guid id, MediaPathResolver paths)
|
||||
{
|
||||
string indexPath;
|
||||
try
|
||||
{
|
||||
indexPath = paths.SegmentPath(id, "index.m3u8");
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(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 (!SegmentFileName.IsMatch(file))
|
||||
return Results.NotFound();
|
||||
|
||||
string path;
|
||||
try
|
||||
{
|
||||
path = paths.SegmentPath(id, file);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
if (!File.Exists(path))
|
||||
return Results.NotFound();
|
||||
|
||||
return Results.File(path, "video/mp2t", enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UploadMediaResponse(Guid Id);
|
||||
|
||||
public sealed record ImportManualInboxBody(IReadOnlyList<string> RelativePaths, Guid ShowId);
|
||||
|
||||
Reference in New Issue
Block a user