Implement media storage and processing features: update configuration in .env.example and appsettings files, enhance Docker setup for media storage, and add media-related services and database entities. Include ffmpeg for media handling and adjust Kestrel settings for large file uploads.

This commit is contained in:
Leonid Pershin
2026-07-24 08:33:11 +03:00
parent 1dd6991174
commit e15ecbdb29
47 changed files with 2599 additions and 1 deletions
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.Delete;
public sealed record DeleteMediaAssetCommand(Guid Id) : ICommand<Result>;
@@ -0,0 +1,29 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.Delete;
public sealed class DeleteMediaAssetCommandHandler(IAppDbContext dbContext, IMediaStorage storage)
: ICommandHandler<DeleteMediaAssetCommand, Result>
{
public async Task<Result> Handle(
DeleteMediaAssetCommand command,
CancellationToken cancellationToken
)
{
var asset = await dbContext.MediaAssets.FirstOrDefaultAsync(
x => x.Id == command.Id,
cancellationToken
);
if (asset is null)
return Result.Failure(MediaErrors.NotFound);
// TODO(этап 2): запретить удаление, пока ассет используется в расписании/пуле канала.
storage.DeleteAssetArtifacts(asset.Id, asset.OriginalExtension);
dbContext.MediaAssets.Remove(asset);
return Result.Success();
}
}
@@ -0,0 +1,6 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.GetMedia;
public sealed record GetMediaAssetQuery(Guid Id) : IQuery<Result<MediaAssetDto>>;
@@ -0,0 +1,23 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.GetMedia;
public sealed class GetMediaAssetQueryHandler(IAppDbContext dbContext)
: IQueryHandler<GetMediaAssetQuery, Result<MediaAssetDto>>
{
public async Task<Result<MediaAssetDto>> Handle(
GetMediaAssetQuery query,
CancellationToken cancellationToken
)
{
var asset = await dbContext.MediaAssets.AsNoTracking()
.FirstOrDefaultAsync(x => x.Id == query.Id, cancellationToken);
return asset is null
? Result.Failure<MediaAssetDto>(MediaErrors.NotFound)
: Result.Success(MediaAssetDto.From(asset));
}
}
@@ -0,0 +1,12 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Media.ListMedia;
public sealed record ListMediaAssetsQuery(
int Page,
int PageSize,
MediaAssetStatus? Status,
string? Search
) : IQuery<PagedList<MediaAssetDto>>;
@@ -0,0 +1,41 @@
using LiteCqrs;
using Microsoft.EntityFrameworkCore;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media.ListMedia;
public sealed class ListMediaAssetsQueryHandler(IAppDbContext dbContext)
: IQueryHandler<ListMediaAssetsQuery, PagedList<MediaAssetDto>>
{
public async Task<PagedList<MediaAssetDto>> Handle(
ListMediaAssetsQuery query,
CancellationToken cancellationToken
)
{
var q = dbContext.MediaAssets.AsNoTracking();
if (query.Status is { } status)
q = q.Where(x => x.Status == status);
if (!string.IsNullOrWhiteSpace(query.Search))
{
// ToLower().Contains переводится в LIKE lower(...) — регистронезависимо и без привязки к
// Npgsql-специфичному ILike (Application не ссылается на провайдер).
var term = query.Search.Trim().ToLower();
q = q.Where(x => x.OriginalFileName.ToLower().Contains(term));
}
var total = await q.CountAsync(cancellationToken);
// Маппинг в памяти: MediaAssetDto.From обращается к TimeSpan.TotalSeconds, который EF в SQL
// не переводит. Страница ограничена pageSize, поэтому материализация сущностей безопасна.
var entities = await q.OrderByDescending(x => x.CreatedAt)
.Skip((query.Page - 1) * query.PageSize)
.Take(query.PageSize)
.ToListAsync(cancellationToken);
var items = entities.Select(MediaAssetDto.From).ToList();
return new PagedList<MediaAssetDto>(items, total, query.Page, query.PageSize);
}
}
@@ -0,0 +1,35 @@
using TeleWave.Domain.Media;
namespace TeleWave.Application.Media;
public sealed record MediaAssetDto(
Guid Id,
string OriginalFileName,
MediaSource Source,
MediaAssetStatus Status,
double? DurationSeconds,
int? SegmentCount,
int? Width,
int? Height,
string? VideoCodec,
string? AudioCodec,
string? ErrorMessage,
DateTimeOffset CreatedAt
)
{
public static MediaAssetDto From(MediaAsset asset) =>
new(
asset.Id,
asset.OriginalFileName,
asset.Source,
asset.Status,
asset.Duration?.TotalSeconds,
asset.SegmentCount,
asset.Width,
asset.Height,
asset.VideoCodec,
asset.AudioCodec,
asset.ErrorMessage,
asset.CreatedAt
);
}
@@ -0,0 +1,36 @@
using TeleWave.Application.Common.Models;
namespace TeleWave.Application.Media;
public static class MediaErrors
{
public static readonly Error NotFound = Error.NotFound(
"Media.NotFound",
"Медиа-ассет не найден."
);
public static readonly Error UnsupportedFormat = Error.Validation(
"Media.UnsupportedFormat",
"Неподдерживаемое расширение файла."
);
public static readonly Error EmptyFileName = Error.Validation(
"Media.EmptyFileName",
"Имя файла не задано."
);
public static readonly Error FileTooLarge = Error.Validation(
"Media.FileTooLarge",
"Файл превышает допустимый размер загрузки."
);
public static readonly Error InsufficientStorage = Error.Conflict(
"Media.InsufficientStorage",
"Недостаточно свободного места в хранилище."
);
public static readonly Error SourceNotFound = Error.NotFound(
"Media.SourceNotFound",
"Исходный файл не найден в хранилище."
);
}
@@ -0,0 +1,29 @@
namespace TeleWave.Application.Media;
/// <summary>Допустимые контейнеры исходников. Кодеки нормализуются при обработке, поэтому список
/// широкий — важно лишь, что ffmpeg сможет это прочитать.</summary>
public static class MediaFormats
{
public static readonly IReadOnlySet<string> AllowedExtensions = new HashSet<string>(
StringComparer.OrdinalIgnoreCase
)
{
".mp4",
".mkv",
".avi",
".mov",
".m4v",
".webm",
".ts",
".mpg",
".mpeg",
".wmv",
".flv",
};
public static bool IsAllowed(string fileNameOrExtension)
{
var ext = Path.GetExtension(fileNameOrExtension);
return !string.IsNullOrEmpty(ext) && AllowedExtensions.Contains(ext);
}
}
@@ -0,0 +1,16 @@
using LiteCqrs;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Media.Register;
/// <summary>
/// Регистрирует уже лежащий в хранилище исходник (<paramref name="SourceToken"/> — временный файл в
/// uploads/ или имя файла в inbox/) как <see cref="MediaAsset"/> в статусе Pending и переносит его в
/// originals/. Постановку в очередь обработки делает вызывающая сторона после коммита.
/// </summary>
public sealed record RegisterMediaAssetCommand(
string SourceToken,
MediaSource Source,
string OriginalFileName
) : ICommand<Result<Guid>>;
@@ -0,0 +1,32 @@
using LiteCqrs;
using TeleWave.Application.Common.Interfaces;
using TeleWave.Application.Common.Models;
using TeleWave.Domain.Media;
namespace TeleWave.Application.Media.Register;
public sealed class RegisterMediaAssetCommandHandler(
IAppDbContext dbContext,
IMediaStorage storage
) : ICommandHandler<RegisterMediaAssetCommand, Result<Guid>>
{
public async Task<Result<Guid>> Handle(
RegisterMediaAssetCommand command,
CancellationToken cancellationToken
)
{
var extension = Path.GetExtension(command.OriginalFileName).ToLowerInvariant();
var asset = MediaAsset.Register(command.OriginalFileName, extension, command.Source);
await storage.PromoteToOriginalAsync(
command.Source,
command.SourceToken,
asset.Id,
extension,
cancellationToken
);
dbContext.MediaAssets.Add(asset);
return Result.Success(asset.Id);
}
}
@@ -0,0 +1,16 @@
using FluentValidation;
namespace TeleWave.Application.Media.Register;
public sealed class RegisterMediaAssetCommandValidator : AbstractValidator<RegisterMediaAssetCommand>
{
public RegisterMediaAssetCommandValidator()
{
RuleFor(x => x.SourceToken).NotEmpty();
RuleFor(x => x.OriginalFileName)
.NotEmpty()
.MaximumLength(512)
.Must(MediaFormats.IsAllowed)
.WithMessage("Неподдерживаемое расширение файла.");
}
}