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:
@@ -1,11 +1,13 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Domain.Auth;
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
public interface IAppDbContext
|
||||
{
|
||||
DbSet<RefreshToken> RefreshTokens { get; }
|
||||
DbSet<MediaAsset> MediaAssets { get; }
|
||||
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Очередь фоновой обработки ассетов. Продюсеры (endpoint загрузки, inbox-сканер) ставят id ассета,
|
||||
/// единственный фоновый потребитель обрабатывает по одному за раз.
|
||||
/// </summary>
|
||||
public interface IMediaProcessingQueue
|
||||
{
|
||||
void Enqueue(Guid assetId);
|
||||
|
||||
IAsyncEnumerable<Guid> DequeueAllAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>Итог нормализации и нарезки исходника в HLS-сегменты.</summary>
|
||||
public sealed record MediaProcessingResult(
|
||||
TimeSpan Duration,
|
||||
int SegmentSeconds,
|
||||
int SegmentCount,
|
||||
int Width,
|
||||
int Height,
|
||||
string VideoCodec,
|
||||
string AudioCodec,
|
||||
string RelativePath
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Порт обработки медиа (ffprobe + ffmpeg). Реализация читает исходник по <paramref name="assetId"/>
|
||||
/// и <paramref name="extension"/> из хранилища, нормализует в H.264/AAC и режет на сегменты
|
||||
/// фиксированной длины, добивая длительность до кратности сегменту.
|
||||
/// </summary>
|
||||
public interface IMediaProcessor
|
||||
{
|
||||
Task<MediaProcessingResult> ProcessAsync(
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using TeleWave.Domain.Media;
|
||||
|
||||
namespace TeleWave.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Порт файлового хранилища медиа. Все относительные пути резолвятся строго внутри корня
|
||||
/// (<c>Storage:RootPath</c>) — защита от path traversal лежит на реализации.
|
||||
/// </summary>
|
||||
public interface IMediaStorage
|
||||
{
|
||||
/// <summary>Свободное место на томе хранилища, байт.</summary>
|
||||
long GetAvailableFreeSpaceBytes();
|
||||
|
||||
/// <summary>
|
||||
/// Стримит загружаемый контент во временный файл в <c>uploads/</c> без буферизации в память.
|
||||
/// Возвращает непрозрачный токен (имя временного файла) для последующего <see cref="PromoteToOriginalAsync"/>.
|
||||
/// </summary>
|
||||
Task<string> SaveUploadAsync(Stream content, string extension, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Удаляет временный файл загрузки (откат при ошибке до регистрации ассета).</summary>
|
||||
void DeleteUpload(string uploadToken);
|
||||
|
||||
/// <summary>
|
||||
/// Переносит исходник из источника (<c>uploads/</c> для <see cref="MediaSource.Upload"/> либо
|
||||
/// <c>inbox/</c> для <see cref="MediaSource.Inbox"/>) в <c>originals/{assetId}{ext}</c>.
|
||||
/// </summary>
|
||||
Task PromoteToOriginalAsync(
|
||||
MediaSource source,
|
||||
string sourceToken,
|
||||
Guid assetId,
|
||||
string extension,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
|
||||
/// <summary>Удаляет все артефакты ассета: исходник в <c>originals/</c> и каталог сегментов <c>assets/{id}/</c>.</summary>
|
||||
void DeleteAssetArtifacts(Guid assetId, string extension);
|
||||
}
|
||||
@@ -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("Неподдерживаемое расширение файла.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user